Two-of-three escrow — a buyer, a seller, and an arbiter who can't cheat each other

← all sites | generated 2026-11-02T05:52:08.024Z

Site info

titleTwo-of-three escrow — a buyer, a seller, and an arbiter who can't cheat each other
descriptionTwo-of-three escrow on Nostr A single HTML file that lets a buyer, a seller, and an arbiter hold money in escrow without trusting each other, without trusting a company, and without trusting a blockchain — just three keypairs, a set of Nostr relays, and a bit of cryptography that enforces the rule: any two of the three can move the money, and no single one can. There is no server, no build step, and no signup. You open the file, you pick a role, and you're an escrow participant. WHY THIS EXISTS --------------- Normal escrow requires a company in the middle: a payment processor, a bank, a marketplace. That company holds the money, decides who's right in a dispute, and can freeze you out whenever it wants. It's also a single point of failure and a single point of censorship. This project replaces that company with three things. 1. A 2-of-3 threshold signature (FROST on secp256k1). The three participants each hold one share of a single public key. Two shares together produce a valid signature; one share alone produces nothing. This is the "escrow address" — funds sent to it can only leave when two of the three parties agree. 2. Nostr relays as the message bus. All coordination — proposing the deal, handing out shares, signing a release, raising a dispute, resolving it — travels as ordinary signed Nostr events over public relays. There is no coordination server. If a relay goes down, you switch relays. 3. A client-side state machine that shows the current phase of the escrow and only offers the actions that are valid right now. The rules are enforced by the cryptography, not by the UI; the UI just makes them visible. The result: a buyer, a seller, and an arbiter can agree on terms, lock funds, and settle up — with no middleman and no way for any single participant to walk off with the money. WHAT EACH ROLE CAN AND CANNOT DO -------------------------------- Role Holds Can do Cannot do -------- --------------- --------------------------------- --------------------------------- Buyer one FROST share fund the escrow; co-sign release move funds alone; force a refund alone Seller one FROST share receive funds on delivery move funds alone Arbiter one FROST share co-sign with whichever side is right move funds alone; act without a counterparty Any two of the three together can authorize a release or a refund. No one acting alone can do anything. THE LIFECYCLE ------------- idle -> proposed -> keygen -> funded -> in_progress -> delivered -> release_pending -> released (happy path) -> disputed -> resolving -> resolved (dispute path) -> refund_pending -> refunded (timeout path) -> cancelled (mutual cancel) -> failed (terminal error) Every transition is a signed Nostr event. Public coordination uses kind 30078 (app-specific data) keyed by d = "escrow:<uuid>". Private coordination — handing out FROST shares, dispute notices — uses NIP-17/NIP-59 gift wrap (a kind 13 seal inside a kind 1059 wrap). The relay only sees a random pubkey and an opaque blob. QUICK START ----------- 1. Open the file. There is no build step; it's a single .html document. Modern browsers run it as-is, pulling nostr-tools, @frostr/bifrost, and @frostr/igloo-core from an ESM CDN. 2. If you have a NIP-07 browser extension (Alby, nos2x, etc.), the app uses it automatically for signing. If not, it falls back to an ephemeral in-memory key that's clearly labelled "demo only". 3. Press "Demo mode". This spawns three local actors in the page so you can walk the entire lifecycle — propose, keygen, fund, deliver, dispute, resolve — without real counterparties and without real funds. The real FROST math and the real relay message flow still run. If you have DevTools open, you can watch the WebSocket traffic. 4. On first login, a guided tour runs. It spotlights each major panel — your identity, the relay status, the relay preferences panel, the wizard, the escrow detail view, the timeline, diagnostics — and can be skipped, resumed, or restarted from the header or the Diagnostics panel. YOUR RELAYS ARE YOURS --------------------- The app never overwrites the relay list you choose. Once you save a list — whether you fetched it from your own NIP-65 (kind:10002) event or typed it in manually — it lives under escrownostr.userRelays.v1 in localStorage and the boot sequence will not touch it again. Only an explicit user action changes it: - Fetch my NIP-65 relays — pulls your kind:10002 event and saves the result with source: 'nip65'. - Edit manually — a textarea, one URL per line, ws:// rejected outside localhost. - Reset to default — clears the preference entirely. The relay pool opens at most 4 concurrent WebSocket connections at once, no matter how long your list is. The rest are kept as standbys and swapped in automatically if an active relay fails repeatedly or drops its socket. The full list is preserved in storage; the pool only decides which subset to activate at any given moment. This matters because the alternative — opening a WebSocket per relay — turns a long NIP-65 list into a battery and bandwidth problem, and a single flapping relay into a reconnect storm. NOSTR EVENTS USED ----------------- Kind Purpose ----- ------------------------------------------------------------- 30078 Escrow proposal, dispute, and resolution (addressable) 1 Funding notice, delivery confirmation 13 Inner seal — signed by the real sender 1059 Outer gift wrap — signed by a random one-time key 9735 Zap receipt (validated when funding via Lightning) 10002 NIP-65 relay list (fetched once; never overwritten) 27235 NIP-98 HTTP auth (used only when a relay demands AUTH) FROST CEREMONY -------------- Each role instantiates a BifrostNode (from @frostr/bifrost) with its own FROST share. To release or refund: 1. The initiator calls node.req.sign(payload, { type: 'message', peers: [counterparty] }). 2. Bifrost exchanges /sign/sender/req and /sign/handler/req messages over relays using NIP-44-encrypted, gift-wrapped transport. 3. The returned aggregate signature is the escrow release proof. Peer policies limit which counterparties a given role can send to or accept from at any point in the state machine, so the network itself can't trick a node into co-signing something it shouldn't. SECURITY NOTE — READ THIS BEFORE YOU SEND REAL MONEY ---------------------------------------------------- This is a demonstration. Real custody requires: 1. An audited Bitcoin transaction builder that constructs a real 2-of-3 P2WSH script and signs its BIP-340 sighash. This app exercises the FROST threshold-signature math and the Nostr coordination layer, not the on-chain transaction construction. 2. Hardware-backed share storage. An in-browser FROST share is only as safe as the browser tab it lives in. 3. Careful key hygiene — never paste an nsec into a web page, never reuse identity keys across roles, never run this on a machine you don't trust. 4. Independent audit of the FROST implementation and of the escrow logic. Do not send real funds to any address produced by this page. HOW THE FILE IS ORGANIZED ------------------------- Everything lives in one HTML file, split into // #region markers so you can jump between them in any editor: #region config relays, timeouts, retry policy, feature flags, connection cap #region types every type, every discriminated union, every branded ID #region schemas Zod schemas for every external payload #region errors structured error classes #region utils logging, timeouts, retry, URL normalization #region state the escrow state machine (exhaustive reducer + never assertion) #region persistence versioned localStorage #region relay-prefs NIP-65 fetch, user relay preference, cap logic #region nostr-client RelayPool, NIP-17/NIP-59 gift wrap, signers #region frost wrappers around @frostr/igloo-core #region escrow-orchestrator ties the reducer, relay pool, FROST nodes, and persistence together #region ui render-on-state-change UI (no framework, no virtual DOM) #region tour the first-login guided wizard #region demo three-actor demo mode #region boot startup CODING STANDARD --------------- This file follows a strict internal checklist, applied to every line: - Zod validation at every trust boundary — network, disk, DOM. - Exhaustive discriminated-union state with never assertions so the compiler catches unhandled cases. - No floating promises; every async path has a try/catch or an explicit void with a justification. - Bounded retry with jitter on every network call. - Every user-facing string and number goes through a formatter that handles undefined, NaN, and out-of-range values. - Stable list keys — never array indices. - Versioned persistence with a schema check; corrupted data resets cleanly. - No any, no !, no as without a runtime guard. CONTRIBUTING ------------ DMs about project are welcome. Please keep the single-file constraint — the whole point is that a person can open one HTML document and use the app. If you're adding a feature, do it inside the existing #region blocks rather than introducing a build step. For bug reports, include: - What you did - What you expected - What happened - The output of window.__escrowDebug (paste it as JSON) - Your browser and version LICENSE ------- MIT. See LICENSE.
author
ColdJuno_f96b - Juno Sterling
npub1ctc6knn22a7a3xqt2ls4nphdj2psh9dffum5ry3r2434x26u4ssqhukdtw
identifierescrow-nostr
hostname4uwyr5edrehxffqo5y1txt08qucnkqctjkylu1gd2rav4brqxsescrow-nostr.pages.gittr.space
clientnsite.run
updated22d ago
hits13

Relays

Blossom servers

Manifest servers

User servers

Paths (3)

pathsha256cached server
/__MACOSX/._index.htmlab8d280ad918—
/404.html1893e39b821fnsite.run
/index.html1893e39b821fnsite.run

Raw manifest event

{
  "id": "fd84c75ce6b4bbc7ccdc7b6412536250fcdf64fe3fb566e71105b7032657f07a",
  "pubkey": "c2f1ab4e6a577dd8980b57e15986ed92830b95a94f374192235563532b5cac20",
  "created_at": 1791613208,
  "kind": 35128,
  "tags": [
    [
      "path",
      "/index.html",
      "1893e39b821f2e98409569c0952d10f9e11b72247cd66e791045238f20a35a70"
    ],
    [
      "path",
      "/__MACOSX/._index.html",
      "ab8d280ad91805ff59bf228ab8fbe4c1ac4a81e8b5619cf37b0049d9961864f1"
    ],
    [
      "path",
      "/404.html",
      "1893e39b821f2e98409569c0952d10f9e11b72247cd66e791045238f20a35a70"
    ],
    [
      "server",
      "https://nsite.run"
    ],
    [
      "server",
      "https://nostr.download"
    ],
    [
      "server",
      "https://blssm.us"
    ],
    [
      "server",
      "https://blossom.yakihonne.com"
    ],
    [
      "server",
      "https://zencrown.relay2aeon.quest/"
    ],
    [
      "relay",
      "wss://nsite.run"
    ],
    [
      "relay",
      "was://zencrown.relay2aeon.quest"
    ],
    [
      "relay",
      "wss://nostr.bitcoiner.social"
    ],
    [
      "relay",
      "wss://purplepag.es"
    ],
    [
      "relay",
      "wss://relay.primal.net"
    ],
    [
      "relay",
      "wss://zencrown.relay2aeon.quest"
    ],
    [
      "d",
      "escrow-nostr"
    ],
    [
      "title",
      "Two-of-three escrow — a buyer, a seller, and an arbiter who can't cheat each other"
    ],
    [
      "description",
      "Two-of-three escrow on Nostr\n\nA single HTML file that lets a buyer, a seller, and an arbiter hold money in\nescrow without trusting each other, without trusting a company, and without\ntrusting a blockchain — just three keypairs, a set of Nostr relays, and a bit\nof cryptography that enforces the rule: any two of the three can move the\nmoney, and no single one can.\n\nThere is no server, no build step, and no signup. You open the file, you pick\na role, and you're an escrow participant.\n\n\nWHY THIS EXISTS\n---------------\n\nNormal escrow requires a company in the middle: a payment processor, a bank, a\nmarketplace. That company holds the money, decides who's right in a dispute,\nand can freeze you out whenever it wants. It's also a single point of failure\nand a single point of censorship.\n\nThis project replaces that company with three things.\n\n1. A 2-of-3 threshold signature (FROST on secp256k1). The three participants\n   each hold one share of a single public key. Two shares together produce a\n   valid signature; one share alone produces nothing. This is the \"escrow\n   address\" — funds sent to it can only leave when two of the three parties\n   agree.\n\n2. Nostr relays as the message bus. All coordination — proposing the deal,\n   handing out shares, signing a release, raising a dispute, resolving it —\n   travels as ordinary signed Nostr events over public relays. There is no\n   coordination server. If a relay goes down, you switch relays.\n\n3. A client-side state machine that shows the current phase of the escrow and\n   only offers the actions that are valid right now. The rules are enforced by\n   the cryptography, not by the UI; the UI just makes them visible.\n\nThe result: a buyer, a seller, and an arbiter can agree on terms, lock funds,\nand settle up — with no middleman and no way for any single participant to\nwalk off with the money.\n\n\nWHAT EACH ROLE CAN AND CANNOT DO\n--------------------------------\n\nRole       Holds             Can do                              Cannot do\n--------   ---------------   ---------------------------------   ---------------------------------\nBuyer      one FROST share   fund the escrow; co-sign release    move funds alone; force a refund alone\nSeller     one FROST share   receive funds on delivery           move funds alone\nArbiter    one FROST share   co-sign with whichever side is right move funds alone; act without a counterparty\n\nAny two of the three together can authorize a release or a refund. No one\nacting alone can do anything.\n\n\nTHE LIFECYCLE\n-------------\n\nidle -> proposed -> keygen -> funded -> in_progress -> delivered\n     -> release_pending -> released                     (happy path)\n\n     -> disputed -> resolving -> resolved                (dispute path)\n\n     -> refund_pending -> refunded                       (timeout path)\n\n     -> cancelled                                        (mutual cancel)\n     -> failed                                           (terminal error)\n\nEvery transition is a signed Nostr event. Public coordination uses kind 30078\n(app-specific data) keyed by d = \"escrow:<uuid>\". Private coordination —\nhanding out FROST shares, dispute notices — uses NIP-17/NIP-59 gift wrap (a\nkind 13 seal inside a kind 1059 wrap). The relay only sees a random pubkey\nand an opaque blob.\n\n\nQUICK START\n-----------\n\n1. Open the file. There is no build step; it's a single .html document. Modern\n   browsers run it as-is, pulling nostr-tools, @frostr/bifrost, and\n   @frostr/igloo-core from an ESM CDN.\n\n2. If you have a NIP-07 browser extension (Alby, nos2x, etc.), the app uses it\n   automatically for signing. If not, it falls back to an ephemeral in-memory\n   key that's clearly labelled \"demo only\".\n\n3. Press \"Demo mode\". This spawns three local actors in the page so you can\n   walk the entire lifecycle — propose, keygen, fund, deliver, dispute,\n   resolve — without real counterparties and without real funds. The real\n   FROST math and the real relay message flow still run. If you have DevTools\n   open, you can watch the WebSocket traffic.\n\n4. On first login, a guided tour runs. It spotlights each major panel — your\n   identity, the relay status, the relay preferences panel, the wizard, the\n   escrow detail view, the timeline, diagnostics — and can be skipped,\n   resumed, or restarted from the header or the Diagnostics panel.\n\n\nYOUR RELAYS ARE YOURS\n---------------------\n\nThe app never overwrites the relay list you choose. Once you save a list —\nwhether you fetched it from your own NIP-65 (kind:10002) event or typed it in\nmanually — it lives under escrownostr.userRelays.v1 in localStorage and the\nboot sequence will not touch it again. Only an explicit user action changes it:\n\n  - Fetch my NIP-65 relays — pulls your kind:10002 event and saves the result\n    with source: 'nip65'.\n  - Edit manually — a textarea, one URL per line, ws:// rejected outside\n    localhost.\n  - Reset to default — clears the preference entirely.\n\nThe relay pool opens at most 4 concurrent WebSocket connections at once, no\nmatter how long your list is. The rest are kept as standbys and swapped in\nautomatically if an active relay fails repeatedly or drops its socket. The\nfull list is preserved in storage; the pool only decides which subset to\nactivate at any given moment.\n\nThis matters because the alternative — opening a WebSocket per relay — turns\na long NIP-65 list into a battery and bandwidth problem, and a single\nflapping relay into a reconnect storm.\n\n\nNOSTR EVENTS USED\n-----------------\n\nKind   Purpose\n-----  -------------------------------------------------------------\n30078  Escrow proposal, dispute, and resolution (addressable)\n1      Funding notice, delivery confirmation\n13     Inner seal — signed by the real sender\n1059   Outer gift wrap — signed by a random one-time key\n9735   Zap receipt (validated when funding via Lightning)\n10002  NIP-65 relay list (fetched once; never overwritten)\n27235  NIP-98 HTTP auth (used only when a relay demands AUTH)\n\n\nFROST CEREMONY\n--------------\n\nEach role instantiates a BifrostNode (from @frostr/bifrost) with its own FROST\nshare. To release or refund:\n\n1. The initiator calls node.req.sign(payload, { type: 'message',\n   peers: [counterparty] }).\n2. Bifrost exchanges /sign/sender/req and /sign/handler/req messages over\n   relays using NIP-44-encrypted, gift-wrapped transport.\n3. The returned aggregate signature is the escrow release proof.\n\nPeer policies limit which counterparties a given role can send to or accept\nfrom at any point in the state machine, so the network itself can't trick a\nnode into co-signing something it shouldn't.\n\n\nSECURITY NOTE — READ THIS BEFORE YOU SEND REAL MONEY\n----------------------------------------------------\n\nThis is a demonstration. Real custody requires:\n\n1. An audited Bitcoin transaction builder that constructs a real 2-of-3 P2WSH\n   script and signs its BIP-340 sighash. This app exercises the FROST\n   threshold-signature math and the Nostr coordination layer, not the on-chain\n   transaction construction.\n\n2. Hardware-backed share storage. An in-browser FROST share is only as safe as\n   the browser tab it lives in.\n\n3. Careful key hygiene — never paste an nsec into a web page, never reuse\n   identity keys across roles, never run this on a machine you don't trust.\n\n4. Independent audit of the FROST implementation and of the escrow logic.\n\nDo not send real funds to any address produced by this page.\n\n\nHOW THE FILE IS ORGANIZED\n-------------------------\n\nEverything lives in one HTML file, split into // #region markers so you can\njump between them in any editor:\n\n  #region config              relays, timeouts, retry policy, feature flags, connection cap\n  #region types               every type, every discriminated union, every branded ID\n  #region schemas             Zod schemas for every external payload\n  #region errors              structured error classes\n  #region utils               logging, timeouts, retry, URL normalization\n  #region state               the escrow state machine (exhaustive reducer + never assertion)\n  #region persistence         versioned localStorage\n  #region relay-prefs         NIP-65 fetch, user relay preference, cap logic\n  #region nostr-client        RelayPool, NIP-17/NIP-59 gift wrap, signers\n  #region frost               wrappers around @frostr/igloo-core\n  #region escrow-orchestrator ties the reducer, relay pool, FROST nodes, and persistence together\n  #region ui                  render-on-state-change UI (no framework, no virtual DOM)\n  #region tour                the first-login guided wizard\n  #region demo                three-actor demo mode\n  #region boot                startup\n\n\nCODING STANDARD\n---------------\n\nThis file follows a strict internal checklist, applied to every line:\n\n  - Zod validation at every trust boundary — network, disk, DOM.\n  - Exhaustive discriminated-union state with never assertions so the compiler\n    catches unhandled cases.\n  - No floating promises; every async path has a try/catch or an explicit void\n    with a justification.\n  - Bounded retry with jitter on every network call.\n  - Every user-facing string and number goes through a formatter that handles\n    undefined, NaN, and out-of-range values.\n  - Stable list keys — never array indices.\n  - Versioned persistence with a schema check; corrupted data resets cleanly.\n  - No any, no !, no as without a runtime guard.\n\n\nCONTRIBUTING\n------------\n\nDMs about project are welcome. Please keep the single-file constraint — the whole\npoint is that a person can open one HTML document and use the app. If you're\nadding a feature, do it inside the existing #region blocks rather than\nintroducing a build step.\n\nFor bug reports, include:\n\n  - What you did\n  - What you expected\n  - What happened\n  - The output of window.__escrowDebug (paste it as JSON)\n  - Your browser and version\n\n\nLICENSE\n-------\n\nMIT. See LICENSE."
    ],
    [
      "client",
      "nsite.run"
    ]
  ],
  "content": "",
  "sig": "d9513964067b4aa2b7a725aa51eade6765426c78d98fe3152b1b6c9aa8b0df6957a7c065705ca5ebbf5cba78ce651db616214adb3cdd56a36e0c5cdf775fddfc"
}