DEV Community

Denver Mtange
Denver Mtange

Posted on

Building Pontmore: From Protocol Spec to Working Standalone Escrow POC

When I opened PR #12 on the Pontmore protocol repo, I was trying to answer one question: can we define a standard way for applications to invoke an escrow service directly, without routing through a swap state machine? The answer was yes , but the path from spec to working POC to simplified protocol taught me more than I expected about designing interoperable financial infrastructure.

The Problem: PIP-01 Was Discovery-Only

PIP-01 (the Pontmore Escrow Descriptor) initially served a narrow purpose: allow agents to discover compatible escrow mechanisms for fiat-to-Bitcoin swaps. It was a discovery tool, not an execution engine.

A pre-PR #12 descriptor looked like this:

{
  "version": 1,
  "escrow_type": "lightning_hold_invoice",
  "networks": ["bitcoin", "lightning"],
  "funding_rules": { "required_confirmation": "invoice_held" },
  "release_rules": { "release_trigger": "counterparty_fiat_payment_confirmed" },
  "dispute_rules": { "policy": "operator_resolved" }
}
Enter fullscreen mode Exit fullscreen mode

This told an agent "this escrow exists and works with Lightning hold invoices." But it didn't tell a standalone application how to create an escrow, fund it, release it, or cancel it. Those details were implicit in PIP-02's swap state machine , you needed a swap to use an escrow. There was no path for an application to say "I need an escrow between two people, let me create one."

PR #12: The Standalone Service Interface

The driving force was Issue #11: "Define escrow service invocation in PIP-01." The solution was an optional service block in the descriptor that tells applications how to talk to the escrow directly ; endpoints, authentication, operations, funding models, and release decision formats.

The resulting spec defined:

  • Transport: https as the canonical transport, with room for additional transports
  • Authentication: nostr_http_auth (NIP-98) , your Nostr pubkey IS your identity, no bearer tokens
  • Canonical operations: create, funding_instructions, fund_status, release, refund, split, cancel
  • Funding models: single_funder, two_party, m_of_n
  • Release decisions: mutual_consent, operator_decision, oracle_signature, application_signed_result, threshold_participant_signatures, split_decision
  • Wire contract: a schema_url pointing to a normative OpenAPI document

A standalone-sufficient descriptor now carried a full service contract:

{
  "version": 1,
  "escrow_type": "custodial_escrow",
  "networks": ["lightning"],
  "funding_rules": { "required_confirmation": "invoice_paid", "funding_timeout": "86400_seconds" },
  "release_rules": { "release_trigger": "application_signed_result", "refund_trigger": "timeout_or_dispute_refund_decision" },
  "dispute_rules": { "policy": "operator_resolved" },
  "service": {
    "transport": ["https"],
    "interface": "pontmore_escrow_http_v1",
    "endpoint": "https://escrow.example.com/pontmore/v1",
    "auth": ["nostr_http_auth"],
    "operations": ["create", "funding_instructions", "fund_status", "release", "refund", "cancel"],
    "funding_model": ["single_funder", "two_party", "m_of_n"],
    "release_decisions": ["mutual_consent", "operator_decision", "application_signed_result"],
    "schema_url": "https://escrow.example.com/pontmore/v1/openapi/v1.0.0.json"
  }
}
Enter fullscreen mode Exit fullscreen mode

The PR also patched structural loopholes identified during implementation: cross-instance replay protection (oracle/threshold signatures now commit to the stable escrow ID), funding-phase timeout enforcement (cancelling a partially-funded escrow must refund all funded sides), and deadlock prevention (any timeout path using mutual consent must declare a non-consent fallback).

PR #12 was merged on August 11, 2026 , with review feedback that would prove significant.

Review Feedback That Seeded PR #17

During review, okjodom left several comments that revealed a deeper design tension:

"We could simplify this definition by deferring the service, transport and interface version to the declared OpenAPI schema doc."

"Please remove the escrow state machine definition. Implementation details must not be part of the generic PIP-01 spec."

"I'm quite wary of having to define these operational semantics in the escrow descriptor."

The tension was clear: PR #12 had made PIP-01 the source of truth for service behavior ; endpoints, operations, state machines, decision formats. But the reviewer wanted to pull all of that back into the referenced schema, leaving PIP-01 as a lightweight compatibility object. He approved the PR anyway , "this has provably moved us forward by a margin, let's land and iterate" — and opened PR #17 within hours to begin the simplification.

The POC: Building Against PR #12

While the spec was being reviewed, I had to build a working implementation to prove the concept was viable. The result is pontmore-lightning-escrow — a custodial escrow service running on Render with Blink Lightning custody and Supabase persistence. It's live at standalone-escrow.onrender.com and has processed real Lightning escrows during testing.

Architecture

                   Nostr Relays               HTTPS Clients
                  (nos.lol etc)           (rollpot, curl, apps)
                       │                         │
                       │ kind 30361              │ NIP-98 auth header
                       │ descriptor              │ (signed kind 27235 event)
                       ▼                         ▼
              ┌─────────────────────────────────────────┐
              │           Express Server                │
              │           (Render, port 3000)           │
              │                                         │
              │  ┌──────────────┐  ┌────────────────┐   │
              │  │  NIP-98 Auth │  │  Escrow Engine │   │
              │  │  Middleware  │  │   + 5 release  │   │
              │  │              │  │  decision types│   │
              │  └──────────────┘  └────────────────┘   │
              │                                         │
              │            ┌────────────────────────┐   │
              │            │                        │   │
              │            ▼                        ▼   │
              │   ┌──────────────┐           ┌──────────────┐
              │   │   Supabase   │           │    Blink     │
              │   │   (Postgres) │           │  (Lightning) │
              │   │              │           │              │
              │   │ · escrow     │           │ · invoices   │
              │   │   instances  │           │ · payments   │
              │   │ · funders    │           │ · LN address │
              │   │ · state RPC  │           │   payouts    │
              │   └──────────────┘           └──────────────┘
              │
              └─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Express Server handles all PIP-01 operations via NIP-98 authenticated endpoints. Every mutating request carries a signed kind 27235 Nostr event in the Authorization header , the authenticated pubkey IS the participant's identity. No user registration, no session management, no password resets.

Escrow Engine validates state transitions, verifies release/refund Schnorr signatures across five decision formats, manages opaque single-use enrollment tokens, and orchestrates multi-party funding. All state mutations go through a Postgres RPC that atomically checks the current state before transitioning , no application-level locking required.

Supabase provides durable Postgres storage with two tables (escrow_instances for escrow metadata, escrow_funders for per-participant funding state) and a transition_escrow_state RPC that prevents race conditions. If two requests race to transition the same escrow, exactly one succeeds and the other gets a clean conflict error.

Blink is the Lightning custody backend , creates BOLT11 invoices per participant (with per-invoice platform fees), detects payments via polling, and executes Lightning Address payouts on release. Payout idempotency is guaranteed via deterministic keys scoped to the escrow, purpose, and recipient.

The Test Suite and Universal Tester

No automated tests existed when I started , manual curl commands were the only validation. I built a 50-test integration suite targeting the live Render deployment, covering descriptor discovery, NIP-98 auth edge cases (5 failure modes), open enrollment with single-use enforcement, multi-party funding visibility, release decision verification, cancel authorization, idempotency across creators, state machine correctness, and OpenAPI schema compliance against live responses.

All tests use ephemeral secp256k1 keypairs with no shared state between tests. Cleanup is automated via afterAll hooks that cancel any created escrows.

I also built a separate tool , the Escrow Descriptor Tester ; a web UI that queries Nostr relays for kind 30361 events, runs 40+ PIP-01 spec validation checks per descriptor, and executes live service tests against standalone endpoints. It found 18 published descriptors on nos.lol, most of them custodial_escrow with operator_resolved dispute policy. Several had subtle spec violations: empty required_confirmation strings, non-standard funding models, or missing decision signer blocks.

PR #17: The Simplification

While the POC was running against the PR #12 spec, okjodom opened PR #17 — a cleanup that recasts PIP-01 from a "descriptor-defined standalone service interface" into a "compatibility/discovery object with a schema pointer." The motivation, in his words: "PIP-01 had started to carry too many responsibilities."

The key changes:

Before (PR #12) After (PR #17)
10+ service fields: transport, interface, endpoint, auth, operations, funding_model, release_decisions, decision_signers, schema_url One field: service.schema { type, url }
Named funding models: single_funder, two_party, m_of_n funding_threshold / participant_count cardinality (m-of-n)
Descriptor-level release_rules with triggers and fallbacks Removed; release and refund behavior belongs to schema_url
Canonical state machine defined in PIP-01 Removed; swap lifecycle to PIP-02, service behavior to schema
Subtype-specific field lists (implementations, custody_authority, release_authority, refund_authority) Trimmed to purpose, compatibility invariants, and public/private boundary

The new minimal descriptor, as defined in the upstream spec:

{
  "version": 1,
  "escrow_type": "custodial_escrow",
  "networks": ["bitcoin", "lightning"],
  "funding_rules": {
    "funding_threshold": 1,
    "participant_count": 1,
    "required_confirmation": "invoice_paid",
    "funding_timeout": "funding timeout"
  },
  "dispute_rules": {
    "policy": "operator_resolved",
    "timeout_fallback": "operator_decision"
  },
  "reference_format": "bolt11_or_custodial_escrow_reference",
  "service": {
    "schema": {
      "type": "openapi",
      "url": "https://escrow.example.com/pontmore-escrow.openapi.json"
    }
  },
  "updated_at": 1775559028
}
Enter fullscreen mode Exit fullscreen mode

The migration path is clear but disruptive. The live standalone-escrow.onrender.com deployment still serves the PR #12 descriptor shape. Rollpot (the reference client) currently reads descriptor.service.endpoint and descriptor.service.funding_model directly. Under PR #17, it should instead fetch service.schema.url, validate the referenced OpenAPI document, and discover servers, paths, security schemes, and operation metadata from the schema. The OpenAPI document already carries most of the behavior PR #17 removes from PIP-01.

What I Learned

Protocol design is iterative. The spec went from discovery-only (pre-PR #12) to rich service interface (PR #12) to minimal compatibility object (PR #17) in the span of three weeks. Building while the spec evolves means accepting that some code will be thrown away but the discarded code teaches you what the protocol actually needs. Every field we removed from the descriptor was a field we learned didn't belong there.

The descriptor is a commitment, not documentation. Publishing a kind 30361 event on Nostr relays is a public declaration. Changing it means broadcasting a kind 5 deletion event and republishing. This creates healthy pressure to get the descriptor right before publishing and makes the published event a useful signal for clients who want to verify an operator keeps their promises.

Multi-party accounting compounds. Per-participant invoices, per-funder funding status, aggregate payout calculation across funded sides, proportional refunds on timeout cancellation , the edge cases multiply with each additional participant. A separate escrow_funders table was essential; the alternative of cramming everything into a single escrow row would have been unmaintainable.

The public/private boundary is the protocol's backbone. Wallet IDs, custody backend identifiers, API keys, and bearer secrets do not belong in a public Nostr event. PR #17 enforces this boundary by making PIP-01 a compatibility object whose only service field is a schema pointer. The schema_url is where implementation details live and where they stay out of the public relay.


The POC: github.com/mk-Denver/pontmore-lightning-escrow

The tester: github.com/mk-Denver/escrow-tester

The protocol: github.com/pontmore/protocol ,website

PR #12 (merged): pontmore/protocol#12

PR #17 (simplification): pontmore/protocol#17

Protocol discussion: Open Bitcoin Africa

Top comments (0)