18 KiB
CLINK on Quartz + Amethyst
Status: implemented (Phases 0–3 + 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)
- Scope: implement all three CLINK specs (Offers, Debits, Manage).
- Sidedness: Quartz implements both client and server for every spec
(so
amyand interop tests can drive both ends). Amethyst is consume-only — it never hosts offers or approves incoming debits. - Wallet model: CLINK plugs into Amethyst's wallet layer like NWC. The
only spec that backs a spendable wallet is Debits — a stored
ndebitpointer is the CLINK analogue of an NWC connection string. It lives in the same wallet list and adds a payment route toZapPaymentHandler.noffer(pay others) andnmanage(offer admin) are not wallet connections. - Pointer parsing:
noffer/ndebit/nmanageare parsed by a dedicatedClinkPointerParser, NOT folded intoNip19Parser. We do not loosen the NIP-19 regexes app-wide for these prefixes.
What CLINK is
"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.
Pointer TLV fields (verify against @shocknet/clink-sdk in Phase 0)
- 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
k1session 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.
- TLV constants — CLINK-local TLV indices (do not overload NIP-19
TlvTypes, which is NIP-19-specific). - Pointers —
NOffer,NDebit,NManagedata classes withparse(bytes)/create(...)built likeNProfile.kt(Tlv.parse+TlvBuilder). A standaloneClinkPointerParserdecodes/encodes the three prefixes — NOT wired intoNip19Parser. Verify HRP + checksum and the TLV namespace against@shocknet/clink-sdkwith a round-trip test. - Events —
OfferEvent(21001),DebitEvent(21002),ManageEvent(21003), eachisContentEncoded() = true, withcreateRequest()/createResponse()companions that NIP-44-encrypt JSON and setp/clink_version/etags (direct analogue ofLnZapPaymentRequestEvent). Register inEventFactory. - DTOs + errors — Jackson request/response classes per spec, shared
GfyError(code, message, range?, retryAfter?, delta?), Offers error model (code1..5,range,latest). - ClinkClient / ClinkServer — high-level:
decode(pointer),buildRequest(...),responseFilter(reqId),parseResponse(...); server side validates freshness,k1single-use, app-scoped offer ownership. - Tests — quartz unit tests with spec TLV vectors + round-trip against
clink-sdk fixtures;
amy clink decode|offer-pay|debit|manageverbs (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):
RichTextParserrecognizes anoffer1…token; render a "⚡ Pay" card next to the existing BOLT-11InvoicePreview. Tap reuses the same decode→21001→pay path. - Profile pay button: read
nofferfrom 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
ndebitpointer sits alongside NWC connections (AccountSettings, parallel tonwcWallets; selectable as default funding source). - Payment route:
ZapPaymentHandlergains a CLINK-debit route — send a 21002 request, await{"res":"ok",preimage}, handle GFY. Always behind an explicit confirmation; honork1single-use; never auto-approve. - Out of scope (server side): receiving/approving incoming 21002 requests
and session-
k1scan-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-sdksource, 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 dedicatedClinkPointerParser(bech32 + TLV), not wired intoNip19Parser. 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 inEventFactory.
- tag-class DSL (
- High-level clients
OfferClient/DebitClient/ManageClientbuild the request event, expose aresponseFilter(filtered by bothe=reqIdandp=self), and parse the NIP-44-decrypted response DTO. - Shared
clink_versionis its own tag class (tags/ClinkVersionTag.kt,CURRENT="1") reused by all three events, with aclinkVersion()builder extension. The oldClink.ktconstants 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.priceisLong?; decode reads the 4 bytes as unsigned (SDK doesparseInt(hex)), encode writes the low 32 bits. The earlierInttyping produced a negative price for any amount ≥ 2^31. Regression:ClinkPointerTest.offerLargePriceRoundTripIsUnsigned(3_000_000_000L). - Decrypt guard.
OfferEvent/DebitEvent/ManageEventreplaced the old self-fallbacktalkingWith()withconversationPeer(myPubKey)that returnsnullwhen the signer is neither author nor recipient;decryptContentthen throwsUnauthorizedDecryptionException. Regression:ClinkEventTest.cannotDecryptAuthoredEventMissingRecipient. - Payer hang fix.
ClinkOfferPayer/ClinkDebitPayerwrapparseResponsein try/catch and returnnullon a decode failure — an uncaughtSerializationExceptionpreviously hung the UI waiting on a coroutine that never completed.payInvoiceViaClinkDebitnow deliversonResultonDispatchers.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:
- Offer moved → GFY
code 3carrieslatest(a fresh pointer). The client follows it. The SDK omits this; the spec defines it. Keep the follow logic inClinkOfferPreview/OfferClient. - ndebit session
k1lives 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.fieldsshape (above), not a flat object. ManageResponse.detailsis parsed as a single object, not an array — Jackson'sACCEPT_SINGLE_VALUE_AS_ARRAYis OFF in this repo, and the reference service returns one object. Documented as a known limitation inManageMessages.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:
- Manage
detailssingle-object responses parse (JacksonACCEPT_SINGLE_VALUE_AS_ARRAY) — Lightning.Pub returns a bare object for create/update/get, an array only forlist. 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.Nip05Parser.parseClinkOfferaccepts bridgelet's flat-string shape as well as the spec's per-name map.- Offer receipts are a parseable primitive (
OfferEvent.createReceipt/decryptReceipt,OfferClient.parseReceipt,OfferReceipt.isOk). ClinkOfferPayersigns 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:
addIntwrites the low 32 bits, which are bit-identical to the unsigned 4-byte BE (proven byofferLargePriceRoundTripIsUnsigned, 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 (ourencode()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.fieldsfor 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).