# CLINK on Quartz + Amethyst

> **Status:** shipped — `experimental/clink/` ships pointers (NOffer/NDebit/NManage), `ClinkPointerParser`, events, and serialization tests; the plan header itself records the implementation as landed.
> _Audited 2026-06-30._

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)

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.

## 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 `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/NwcRequestEvent.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. **Pointers** — `NOffer`, `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. **Events** — `OfferEvent(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 `NwcRequestEvent`). 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).
