Files
amethyst/quartz/plans/2026-06-09-clink.md
T

18 KiB
Raw Blame History

CLINK on Quartz + Amethyst

Status: implemented (Phases 03 + receive side + CLI) — see "Final implementation state" at the bottom for what actually shipped, the audit results, and the spec-vs-SDK gotchas. The body below is the original (proposed) design and is kept for context; where it disagrees with the final state, the final state wins. Date: 2026-06-09 Owner: TBD

Decisions (locked)

  1. Scope: implement all three CLINK specs (Offers, Debits, Manage).
  2. Sidedness: Quartz implements both client and server for every spec (so amy and interop tests can drive both ends). Amethyst is consume-only — it never hosts offers or approves incoming debits.
  3. Wallet model: CLINK plugs into Amethyst's wallet layer like NWC. The only spec that backs a spendable wallet is Debits — a stored ndebit pointer is the CLINK analogue of an NWC connection string. It lives in the same wallet list and adds a payment route to ZapPaymentHandler. noffer (pay others) and nmanage (offer admin) are not wallet connections.
  4. Pointer parsing: noffer / ndebit / nmanage are parsed by a dedicated ClinkPointerParser, NOT folded into Nip19Parser. We do not loosen the NIP-19 regexes app-wide for these prefixes.

"Common Lightning Interface for Nostr Keys" — three Nostr-native Lightning protocols from ShockNet. Where NWC (NIP-47) is RPC-style remote control of your own wallet, CLINK is peer-to-peer / app-to-service: no HTTPS callbacks, no LNURL web server, no onion messages. Each interaction is a NIP-44-encrypted ephemeral request→response event over a relay, addressed by a bech32 pointer.

Spec Kind Pointer Purpose LN analogue
Offers 21001 noffer1… Static code → request a fresh BOLT-11 over Nostr LNURL-Pay / BOLT-12
Debits 21002 ndebit1… Authorize a counterparty to pull a payment LNURL-withdraw
Manage 21003 nmanage1… Delegate CRUD of offers to an external app

Shared envelope: ephemeral kind, ["p", recipient] + ["clink_version","1"] tags, ["e", reqId] on responses, NIP-44 JSON content, GFY error codes ({"res":"GFY","code":1..6,...}), 30s freshness window.

  • noffer: 0=receiver pubkey, 1=relay, 2=offer-id, 3=price-type (0 fixed / 1 variable / 2 spontaneous), 4=price sats, 5=currency.
  • ndebit: 0=service pubkey, 1=relay, 2=pointer-id (opt), 3=32-byte k1 session id (opt; single-use).
  • nmanage: 0=server pubkey, 1=relay, 2=pointer-id (opt).

Reuse matrix

Component Status Location Action
NIP-44 encrypt/decrypt Reuse quartz/.../nip44Encryption/ + signer.nip44Encrypt/Decrypt Use as-is
Bech32 + TLV codec Reuse nip19Bech32/bech32/, tlv/TlvBuilder.kt, tlv/Tlv.kt Use the codec; do NOT reuse Nip19Parser
Entity TLV pattern (reference) 📦 Mirror nip19Bech32/entities/NProfile.kt Copy shape into new Clink entities
Encrypted req/resp event pattern 📦 Mirror nip47WalletConnect/events/LnZapPaymentRequestEvent.kt (kind 23194) Clone for kinds 21001/2/3, client + server
Request/response over relay Reuse Nip47Client.responseFilter(), relayClient/reqCommand/nwc/ Same filter-by-e-tag pattern
BOLT-11 parsing Reuse quartz/.../lightning/LnInvoiceUtil.kt Use as-is
LN payment execution Reuse amethyst/.../service/ZapPaymentHandler.kt, NwcSignerState Add CLINK routes
Wallet/zap account settings 📦 Extend AccountSettings.kt (nwcWallets, defaultNwcWalletId) Add CLINK debit pointers to the wallet list
Rich-text inline detect/render 📦 Extend commons/.../richtext/RichTextParser.kt, RichTextViewer.kt, InvoicePreview.kt Add noffer1… token → pay card
EventFactory registry 📦 Extend quartz/.../utils/EventFactory.kt Register 21001/21002/21003

Genuinely new: 3 event classes, 3 bech32 entities + ClinkPointerParser, JSON DTOs, ClinkClient + ClinkServer, the debit-pointer wallet store, UI surfaces.

Phase 0 — Quartz foundation (client + server, all three)

New package quartz/.../nipClink/ (mirroring the single-package NWC layout), with events/, pointers/, rpc/ subfolders.

  1. TLV constants — CLINK-local TLV indices (do not overload NIP-19 TlvTypes, which is NIP-19-specific).
  2. PointersNOffer, NDebit, NManage data classes with parse(bytes) / create(...) built like NProfile.kt (Tlv.parse + TlvBuilder). A standalone ClinkPointerParser decodes/encodes the three prefixes — NOT wired into Nip19Parser. Verify HRP + checksum and the TLV namespace against @shocknet/clink-sdk with a round-trip test.
  3. EventsOfferEvent(21001), DebitEvent(21002), ManageEvent(21003), each isContentEncoded() = true, with createRequest() / createResponse() companions that NIP-44-encrypt JSON and set p / clink_version / e tags (direct analogue of LnZapPaymentRequestEvent). Register in EventFactory.
  4. DTOs + errors — Jackson request/response classes per spec, shared GfyError(code, message, range?, retryAfter?, delta?), Offers error model (code 1..5, range, latest).
  5. ClinkClient / ClinkServer — high-level: decode(pointer), buildRequest(...), responseFilter(reqId), parseResponse(...); server side validates freshness, k1 single-use, app-scoped offer ownership.
  6. Tests — quartz unit tests with spec TLV vectors + round-trip against clink-sdk fixtures; amy clink decode|offer-pay|debit|manage verbs (thin assembly only).

Phase 1 — Offers consume (Amethyst)

Ship order: scan/paste → inline card → profile button.

  • Scan/paste-to-pay: QR scanner + clipboard handle noffer1… → decode → send 21001 → await BOLT-11 → existing send sheet → ZapPaymentHandler. Smallest correct slice; proves the client end-to-end.
  • Inline feed card (headline): RichTextParser recognizes a noffer1… token; render a " Pay" card next to the existing BOLT-11 InvoicePreview. Tap reuses the same decode→21001→pay path.
  • Profile pay button: read noffer from kind-0 metadata; profile Zap button prefers CLINK offer over LNURL when present.

Phase 2 — Debits as a wallet (Amethyst, consume-only)

This is the "add a wallet via CLINK" answer.

  • Account store: extend the wallet list so a saved ndebit pointer sits alongside NWC connections (AccountSettings, parallel to nwcWallets; selectable as default funding source).
  • Payment route: ZapPaymentHandler gains a CLINK-debit route — send a 21002 request, await {"res":"ok",preimage}, handle GFY. Always behind an explicit confirmation; honor k1 single-use; never auto-approve.
  • Out of scope (server side): receiving/approving incoming 21002 requests and session-k1 scan-to-pull. Quartz has the server code; Amethyst does not expose it (consume-only decision).

Phase 3 — Manage (Quartz only)

Full client + server in Quartz + amy clink manage … (create/update/get/ list/delete, app-scoped ownership, GFY errors). No Amethyst UI — Amethyst is an offer consumer, not a host. Revisit an in-app "my offers" minter only if users want to mint offers from inside the app later.

Risks / to verify in Phase 0

  • Exact bech32 HRP + whether CLINK uses its own TLV namespace vs NIP-19's — pin against @shocknet/clink-sdk source, not the client-rendered docs.
  • Relay selection for ephemeral req/resp (pointer relay vs account relays).
  • Response-timeout + retry UX (30s freshness window, GFY code 3 deltas).

Final implementation state

Everything below reflects what is on the branch now, not the proposal above. Read this section first if you're touching CLINK.

What shipped

Phase 0 — Quartz foundation (quartz/.../experimental/clink/)

  • Pointers (pointers/NOffer.kt, NDebit.kt, NManage.kt) decoded/encoded via a dedicated ClinkPointerParser (bech32 + TLV), not wired into Nip19Parser. Round-trip tested against the canonical interop vectors (ClinkPointerTest, ClinkInteropTest).
  • Events OfferEvent(21001), DebitEvent(21002), ManageEvent(21003), refactored to the codebase convention: eventTemplate(KIND, content, …){…}
    • tag-class DSL (pTag, eTag, alt, clinkVersion) and typed accessors (PTag::parseKey, ETag::parseId). Registered in EventFactory.
  • High-level clients OfferClient / DebitClient / ManageClient build the request event, expose a responseFilter (filtered by both e=reqId and p=self), and parse the NIP-44-decrypted response DTO.
  • Shared clink_version is its own tag class (tags/ClinkVersionTag.kt, CURRENT="1") reused by all three events, with a clinkVersion() builder extension. The old Clink.kt constants object was retired.

Phase 1 — Offers consume (Amethyst): inline feed card (ClinkOfferPreview.kt) renders a payable "" card for a noffer1… token, with a variable-amount field for SPONTANEOUS offers, moved-offer follow (GFY code 3 latest), and the resolved activeOffer price. Payment routes through the shared InvoicePaymentDispatcher (confirm-then-pay) into ZapPaymentHandler. Zappable offers were explicitly reverted — do not re-add NIP-57 zaps to offer payment.

Phase 2 — Debits as a wallet (Amethyst, consume-only): a stored ndebit pointer is a first-class payment source alongside NWC. PaymentSource (sealed: Nwc / ClinkDebit), PaymentSourceResolver, and AccountSettings.defaultPaymentSource() unify default selection; defaultNwcWalletId was migrated to defaultPaymentSourceId. ZapPaymentHandler dispatches on the resolved source. Budgets/recurring via DebitClient.requestBudget + DebitFrequency units (day/week/month).

Phase 3 — Manage (Quartz only, no Amethyst UI): nested request/response shape — ManageRequest(resource, action, pointer, offer: ManageOffer?), ManageOffer(id, fields: OfferFields), OfferData(...), ManageResponse(...).

Receive side (advertise your own noffer): kind-0 clink_offer field (UserMetadata.clinkOffer + dual-written clink_offer tag via ClinkOfferTag, NIP-1770 pattern) and NIP-05 .well-known discovery (Nip05Parser.parseClinkOffer, INip05Client.loadClinkOffer). The profile header (DisplayClinkOffer) prefers whichever is present, with a 256-entry LruCache over the NIP-05 lookups.

CLI (amy): offer info|request, debit info|pay|budget (thin assembly only). New Context.requestResponse(...) does subscribe→publish→await-first- matching-live-reply (vs drain, which returns at EOSE).

Audit findings & resolutions

  • Offer price is an UNSIGNED 4-byte BE integer. NOffer.price is Long?; decode reads the 4 bytes as unsigned (SDK does parseInt(hex)), encode writes the low 32 bits. The earlier Int typing produced a negative price for any amount ≥ 2^31. Regression: ClinkPointerTest.offerLargePriceRoundTripIsUnsigned (3_000_000_000L).
  • Decrypt guard. OfferEvent/DebitEvent/ManageEvent replaced the old self-fallback talkingWith() with conversationPeer(myPubKey) that returns null when the signer is neither author nor recipient; decryptContent then throws UnauthorizedDecryptionException. Regression: ClinkEventTest.cannotDecryptAuthoredEventMissingRecipient.
  • Payer hang fix. ClinkOfferPayer / ClinkDebitPayer wrap parseResponse in try/catch and return null on a decode failure — an uncaught SerializationException previously hung the UI waiting on a coroutine that never completed. payInvoiceViaClinkDebit now delivers onResult on Dispatchers.Main.
  • NIP-05 cache correctness. The offer cache distinguishes a cache-miss from a cached-null (explicit presence check), so a profile with no offer isn't re-fetched on every recomposition.

CRITICAL — spec vs SDK (do NOT "fix" these)

The @shocknet/clink-sdk (1.5.5) lags the published spec. Two things look like bugs against the SDK but are correct against the spec (raw.githubusercontent.com/shocknet/CLINK/main/specs/clink-*.md) and were verified there directly:

  1. Offer moved → GFY code 3 carries latest (a fresh pointer). The client follows it. The SDK omits this; the spec defines it. Keep the follow logic in ClinkOfferPreview / OfferClient.
  2. ndebit session k1 lives at TLV index 3. The SDK doesn't read it; the spec defines it as the optional single-use session id. Keep decoding it.

Other shape notes for future maintainers:

  • Manage uses the nested offer.fields shape (above), not a flat object.
  • ManageResponse.details is parsed as a single object, not an array — Jackson's ACCEPT_SINGLE_VALUE_AS_ARRAY is OFF in this repo, and the reference service returns one object. Documented as a known limitation in ManageMessages.kt; revisit if a service returns a list.

Verification matrix

Level What it covers Status
JVM unit tests pointer codecs (incl. unsigned price), decrypt guard, Manage nested shape, DTOs, metadata clink_offer, NIP-05 discovery, PaymentSourceResolver green
amy CLI offer info/request, debit info/pay/budget — local decode verified end-to-end green
Shell harness cli/tests/clink/clink-headless.sh — decode of canonical vectors + arg-error paths 12/12

Remaining gap: the live NIP-44 request→response round-trip over a relay against a real CLINK service is not automated (needs a device or a mock/live service). Feasible later via quartz's in-process relay server (nip01Core/relay/server/) plus a mock CLINK responder; flagged but not built.


Interop review & spec-conformance pass (2026-06-10)

Reviewed against the whole shocknet/CLINK ecosystem (Lightning.Pub, clink-sdk, ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) and re-audited every spec file line-by-line against the code. Verdict: the consume-only payer/requestor role is conformant and interoperable; no real correctness bugs.

Interop fixes shipped:

  1. Manage details single-object responses parse (Jackson ACCEPT_SINGLE_VALUE_AS_ARRAY) — Lightning.Pub returns a bare object for create/update/get, an array only for list.
  2. NOffer.encode() always emits the price-type TLV (3); decode defaults an absent/unknown type to SPONTANEOUS. The SDK + bridgelet decoders throw on a missing TLV 3, so omitting it made our pointers undecodable by JS consumers.
  3. Nip05Parser.parseClinkOffer accepts bridgelet's flat-string shape as well as the spec's per-name map.
  4. Offer receipts are a parseable primitive (OfferEvent.createReceipt / decryptReceipt, OfferClient.parseReceipt, OfferReceipt.isOk).
  5. ClinkOfferPayer signs offer requests with an ephemeral key (privacy parity with the SDK/Zeus/Stacker News). Debits keep the persistent account key (budgets need a stable app identity).

Conformance hardening shipped: NDebit.parse rejects a non-32-byte k1; DebitClient.requestBudget validates frequency.unit ∈ {day,week,month}; OfferClient caps description at 100 chars; DebitResponse.failureDetail() surfaces GFY range/retry_after in the debit zap error path.

New tests: ClinkWireShapeTest (golden JSON payloads from the public-domain specs), a clink-demo/SDK canonical DEFAULT_NOFFER vector, plus regressions for each fix above.

Three "CRITICAL" review findings were verified FALSE — do NOT re-chase them:

  • Offer price encoding for ≥2³¹ sats is correct: addInt writes the low 32 bits, which are bit-identical to the unsigned 4-byte BE (proven by offerLargePriceRoundTripIsUnsigned, 3e9 sats). Only ≥2³² loses data, which the spec's 4-byte field can't represent anyway.
  • Parser "wrongly accepts nostr:/lightning: wrappers" is not a violation: the MUST-NOT-wrap rule governs producing QR (our encode() is bare); lenient decode is Postel-legal.
  • Manage "create shape deviation" — the spec's inline-create example is the outlier; Lightning.Pub/SDK/clink-demo all use nested offer.fields for create, which we match.

Remaining non-bugs (intentional / out-of-role): we don't reject responses lacking clink_version (Lightning.Pub omits it — rejecting breaks interop); the offer Payment Receipt UI subscription is unwired (primitive ships); clink_debit discovery and the unrestricted-access debit request are creditor/app-side features outside the consume-only role; noffer TLV 5 currency is dormant (unimplemented by the SDK and Lightning.Pub too).