docs(clink): record final implementation state, audit results, spec-vs-SDK notes

Append a 'Final implementation state' section to the CLINK plan capturing
what shipped across Phases 0-3 + the receive side + CLI, the audit findings
and their fixes, the three-level verification matrix, and the critical
spec-vs-SDK gotchas (offer 'latest' at GFY code 3 and ndebit k1 at TLV-3 are
spec-defined and must not be removed). Flip the doc status to implemented.

https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
This commit is contained in:
Claude
2026-06-10 07:18:02 +00:00
parent 545018a6a3
commit 33b3eb5b6f
+116 -1
View File
@@ -1,6 +1,10 @@
# CLINK on Quartz + Amethyst
Status: proposed
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
@@ -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.