diff --git a/quartz/plans/2026-06-09-clink.md b/quartz/plans/2026-06-09-clink.md index 51a8a7a4dc..93d65a7878 100644 --- a/quartz/plans/2026-06-09-clink.md +++ b/quartz/plans/2026-06-09-clink.md @@ -1,6 +1,10 @@ # CLINK on Quartz + Amethyst -Status: proposed +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 @@ -129,3 +133,114 @@ if users want to mint offers from inside the app later. 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.