amy zap printed the invoice but never paid it. With --with <ndebit> it now
settles the fetched BOLT-11 in-place through a CLINK debit pointer (kind-21002,
reusing DebitCommands.settle), mirroring how the app routes a zap through its
default payment source. Works for both single-recipient (zap user) and
split zaps (zap event) — each recipient reports paid + preimage (or pay_error).
Adds a --with validation case to the headless harness; 16/16 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Brings amy's CLINK surface closer to the app's:
- profile edit --clink-offer <noffer|"">: set/clear the kind-0 clink_offer
(validated as a real noffer; "" clears). MetadataEvent already carried the field.
- offer request --follow: chase an 'Expired or Moved' (code 3) reply to its
'latest' pointer (bounded hops), mirroring the app; the error output now also
carries code/latest/range so a script can follow or correct manually.
- offer pay <noffer> --with <ndebit> [--amount]: end-to-end — fetch the invoice
(21001) and settle it through a debit pointer (21002), reusing DebitCommands.settle.
- Structured GFY detail (code, range, retry_after, delta) in debit/offer errors,
via a new Output.error(extra=) overload.
Adds local-validation cases to the headless harness (offer pay --with, profile
edit --clink-offer); 15/15 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The profile CLINK offer showed the full ClinkOfferPreview payment card up front.
Render it instead as a compact payment-target-style chip (Bolt icon + 'Lightning
Offer' label, matching the PaymentTargetChip look); tapping it expands the
payable card, collapsed by default — same expand-on-click idiom as the lightning
address row.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
progressAllPayments was a non-atomic Float var incremented from the concurrent
mapNotNullAsync bodies AND the async response callbacks (NWC onResponse / the
CLINK launched coroutine), so parallel zap splits raced and could leave the
progress bar below 100%. Replace it with a shared PaymentProgress(AtomicInteger
over 2*N half-steps) used by both payViaNWC and payViaClinkDebit, which also
removes the duplicated half-step arithmetic.
Note: NWC's response half-step still won't fire if a wallet never replies within
its 60s window (sendZapPaymentRequestFor doesn't signal onResponse on timeout);
that progress-stall is pre-existing and separate from this race fix.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
From the audit of this session's changes:
- Error surfacing: the budget (WalletScreen) and offer/invoice card
(InvoicePaymentDispatcher) paths now use DebitResponse.failureDetail() like the
zap path, so a GFY code-5/code-4 surfaces its range/retry_after instead of just
the bare error string.
- NOffer.priceType is now non-null: decode already defaults an absent TLV 3 to
SPONTANEOUS, so the nullable type was misleading and the '?: SPONTANEOUS'
fallbacks in ClinkOfferPreview were dead. Drops them and the now-redundant
always-emit-TLV3 test (covered by the spontaneous round-trip).
- WalletViewModel.requestDebitBudget catches the budget-validation
IllegalArgumentException so a malformed frequency dismisses the dialog instead
of hanging the spinner.
- Document why ClinkDebitPayer signs with the persistent account key (stable
identity for budgets) while ClinkOfferPayer uses an ephemeral key.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Advance half the per-payable progress on dispatch and the other half when the
async debit response arrives, exactly like payViaNWC, instead of jumping the
full share on dispatch.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
payViaClinkDebit blocked the zap on the debit service's res:ok/GFY reply (up to
30s) before reporting a result. Mirror the NWC rail instead: dispatch the debit
on the account scope and report each payable paid optimistically so the zap UI
completes promptly; a GFY/failure (or no reply) surfaces asynchronously through
onError rather than blocking. The programmatic App Functions debit path is
unchanged (it still awaits the real result).
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Follow-ups from the line-by-line spec audit, scoped to the consume-only client:
- NDebit.parse rejects a TLV-3 session id that isn't exactly 32 bytes (64 hex),
per clink-debits: a wrong-length k1 is a malformed session pointer.
- DebitClient.requestBudget validates frequency.unit is one of day/week/month
(DebitFrequency.VALID_UNITS) instead of sending a unit a node service will GFY.
- OfferClient caps the invoice description at 100 chars per clink-offers.
- DebitResponse.failureDetail() composes the GFY error with its actionable extra
(allowed range for code 5, retry_after for code 4); the debit zap path now
surfaces that instead of the bare error string.
Adds regression tests for each (malformed-k1 rejection, invalid-unit throw,
description truncation, failureDetail range/retry_after).
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The vector is the canonical @shocknet/clink-sdk example — both its MIT README
usage snippet and clink-demo's public-domain DEFAULT_NOFFER are the same string.
Confirmed the published npm tarball ships only build output (no test vectors), so
this is the one real codec vector the ecosystem exposes.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Adds ClinkWireShapeTest: the literal decrypted JSON payload bodies documented in
shocknet/CLINK/specs/clink-{offers,debits,manage}.md (public domain) must
deserialize into our DTOs with the right fields. Covers the encrypted-content
half the bech32 pointer vectors don't: offer request + success/error codes 1-5
(incl. code-3 latest, code-5 range) + receipts; debit direct/budget requests,
success, and GFY 1-6 (incl. delta, retry_after, range); manage nested
offer.fields requests and responses — including the single-object 'details'
coercing to a list, which exercises the Manage list/single interop fix.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The clinkme.dev demo (shocknet/clink-demo, public domain) hard-codes a live
default noffer. Adds it as an 8th cross-impl vector — a real-world spontaneous,
relay-bearing, no-price offer with a 64-char-hex offer-id — decoded and
round-tripped through our parser.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Interop review against the shocknet/CLINK ecosystem (Lightning.Pub, clink-sdk,
ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) surfaced five fixes:
1. Manage `details` single-object responses now parse. Lightning.Pub returns a
bare OfferData object for create/update/get and an array only for list; enable
Jackson ACCEPT_SINGLE_VALUE_AS_ARRAY so both shapes coerce into the list field.
2. NOffer.encode() always emits the price-type TLV (3), even for spontaneous
offers — the reference SDK and bridgelet decoders throw on a missing TLV 3, so
an absent field made our pointers undecodable by every JS consumer. Decode now
defaults an absent/unknown price-type to SPONTANEOUS, per the spec.
3. Nip05Parser.parseClinkOffer accepts bridgelet's flat top-level
`"clink_offer":"noffer1…"` string in addition to the spec's per-name map.
4. Offer payment receipts: OfferEvent.createReceipt/decryptReceipt +
OfferClient.parseReceipt + OfferReceipt.isOk() make the post-settlement receipt
(the SDK's onReceipt) a parseable primitive instead of a dead DTO.
5. ClinkOfferPayer signs offer requests with an ephemeral key, like the SDK / Zeus
/ Stacker News, so paying an offer no longer reveals the user's Nostr identity
to the service. Debits keep the persistent account key (budgets need a stable
app identity).
Adds regression tests for each: always-emit TLV3, flat-string NIP-05 discovery,
and a receipt round-trip.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The profile-header NIP-05 clink_offer cache stored the raw noffer string,
re-running ClinkPointerParser on every cache hit; it now stores the parsed
NOffer. The cache key is lowercased since NIP-05 identifiers are
case-insensitive, so casing variants no longer trigger duplicate fetches.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
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
Local-only shell suite (no relay): asserts amy decodes the canonical CLINK
interop vectors (same fixtures as quartz ClinkInteropTest) to the right fields
for 'offer info' and 'debit info', plus the argument-error paths (bad pointer,
unknown budget frequency, missing --amount). The round-trip verbs need a live
service and stay out of scope.
12/12 assertions pass locally (amy init -> decode, no network).
Completes amy's CLINK coverage alongside 'amy offer', reusing the
Context.requestResponse round-trip primitive:
- debit info NDEBIT: local decode of an ndebit1… pointer (pubkey, relays,
pointer id, session flag), no network.
- debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS]: kind-21002 round
trip asking the wallet to pay the invoice; prints preimage or GFY error.
- debit budget NDEBIT --amount SATS [--frequency day|week|month] [--timeout MS]:
authorize a one-time or recurring spending budget.
Thin-assembly: ClinkPointerParser + DebitClient (quartz) do the protocol; the
command shares one roundTrip helper. Verified: 'debit info' decodes an interop
vector correctly (text + --json), and budget arg validation returns exit 1.
pay/budget need a live debit service to exercise fully.
Adds headless CLINK Offers support to amy for interop testing against real
offer services:
- offer info NOFFER: local decode of a noffer1… pointer (pubkey, relays,
pointer id, price type/amount), no network.
- offer request NOFFER [--amount SATS] [--timeout MS]: the kind-21001 round
trip — publishes the request to the pointer's relays and prints the returned
BOLT11, or the service's error.
Thin-assembly per the CLI contract: pointer decode + request/response events
live in quartz (ClinkPointerParser, OfferClient); the round-trip uses a new
Context.requestResponse primitive (publish then await the first matching live
reply — unlike drain, which returns at EOSE).
Verified: 'offer info' runs end-to-end against an interop vector (correct
pubkey/relays/price-type, text + --json modes, bad-pointer error contract +
exit 1). The request round-trip needs a live service to exercise fully.
Locks in the protocol-layer fixes that were previously only compile-checked:
- offerLargePriceRoundTripIsUnsigned: a price > Int.MAX_VALUE round-trips as a
positive Long (guards the unsigned-decode fix).
- cannotDecryptAuthoredEventMissingRecipient: an authored event with no p tag
can't be decrypted by anyone (guards the no-self-fallback conversationPeer).
- manageCreateRequestSerializesNested + manageFailureResponseParsesField: the
Manage request nests under offer.fields, payer_data is a string list, and the
failure response carries field (guards the 21003 shape fix).
All CLINK tests pass.
Two robustness fixes from the audit:
- OfferEvent/DebitEvent/ManageEvent: replace talkingWith() (which fell back to
self when an authored event lacked its p tag, deriving a NIP-44 key with
myself) with conversationPeer(), which returns null when I'm neither the
author nor the addressed recipient; decryptContent then fails cleanly with
UnauthorizedDecryptionException. canDecrypt() is now exactly 'a valid peer
exists'.
- OfferClient/DebitClient/ManageClient responseFilter now also requires
#p == my pubkey, so a service reply that e-tags my request but is addressed
to a different payer no longer matches my subscription.
Valid request/response round-trips are unchanged (CLINK tests pass).
From a spec/SDK audit (verified against the CLINK spec, not just SDK 1.5.5):
- NOffer.price: decode as UNSIGNED 4-byte big-endian (now Long) — the SDK reads
price via parseInt(hex); reading it signed turned prices >= 2^31 sats negative
and broke encode/decode idempotency for high-bit prices.
- Manage (21003) messages corrected to the nested spec shape: request nests offer
data under offer{id,fields}, payer_data is a string list (not a map), and the
response uses details + field (was offer/offers). Documented the single-object
details limitation (Manage is consume-unused).
- DisplayClinkOffer: cache NIP-05 .well-known clink_offer lookups (incl. negative
results) so profile visits / kind-0 refreshes don't refetch nostr.json.
Deliberately NOT changed: the offer 'latest' (code 3) field and ndebit k1 at
TLV-3 — both are SPEC-defined; the SDK 1.5.5 merely lags, as the code comments
already noted. CLINK tests pass; app compiles.
Resolves two review findings:
- App Functions (Assistant) pay path gated on hasWalletConnectSetup() and so
ignored a debit-only user's chosen default. payViaNwcOrNull is generalized to
payViaDefaultSourceOrNull, routing through account.settings.defaultPaymentSource()
(NWC wallet or CLINK debit), matching the rest of the app; NwcOutcome -> PayOutcome.
- AccountViewModel.payInvoiceViaClinkDebit now delivers onResult on Dispatchers.Main
(was Dispatchers.IO via launchSigner), consistent with requestDebitBudget and safe
for UI callbacks (toasts/dialogs).
:amethyst (play) compiles.
From a correctness review of the CLINK branch:
- ClinkOfferPayer/ClinkDebitPayer now catch decrypt/parse failures from
parseResponse and treat an undecryptable reply as no response (return null)
instead of throwing. An uncaught SerializationException/NIP-44 failure
escaped launchSigner (which only catches signer exceptions), hanging the UI:
the offer card stuck on 'Requesting…', the DVM status stuck, the budget toast
never shown, and a split zap silently cancelling sibling payments.
- ClinkOfferPreview now renders the active (possibly moved) offer's price/type,
not the original pointer's, after an Expired-or-Moved redirect.
- WalletViewModel.setDefaultWallet only updates local state when the persist
actually succeeds, so the default star can't diverge from the stored value.
:amethyst compiles.
Exposes the CLINK Debits budget capability (requestBudget) the spec describes:
- ClinkDebitPayer.requestBudget publishes the kind-21002 budget request and
awaits the reply; the publish/await machinery is factored out of payInvoice
into a shared sendAndAwait helper.
- DebitFrequency gains UNIT_DAY/WEEK/MONTH constants.
- WalletViewModel.requestDebitBudget resolves the debit pointer and runs it.
- A 'Budget' action on CLINK debit rows opens ClinkBudgetDialog (amount +
one-time/daily/weekly/monthly cadence); the result is surfaced as a toast.
:amethyst compiles. The 21002 budget round-trip is untested end-to-end.
Models the protocol-version tag the way other tags are modeled, instead of a
loose helper on the Clink object:
- New ClinkVersionTag (TAG_NAME/CURRENT/assemble/parse) under clink/tags, with
a clinkVersion() TagArrayBuilder DSL extension, reused by all three events.
- OfferEvent/DebitEvent/ManageEvent read version() via ClinkVersionTag::parse
and build via clinkVersion() in their templates.
- Retires the now-empty Clink object (its KDoc moved to the tag class).
Behavior-preserving: assemble() emits the identical ["clink_version", "1"]
tag in the same position. All CLINK tests pass.
Brings OfferEvent/DebitEvent/ManageEvent (21001-3) in line with the codebase
tag conventions, replacing raw inline tags:
- Build via eventTemplate(KIND, content) { pTag(...); eTag/add; alt(...) } and
signer.sign(template), instead of hand-rolled arrayOf("p"/"e", ...) + sign().
- Accessors use PTag.parseKey / ETag.parseId instead of matching "p"/"e" literals.
Behavior-preserving: PTag.assemble(x, null) yields the identical ["p", x] bytes
and tag order is unchanged, so signed events are byte-identical. All CLINK tests
pass (ClinkEventTest, ClinkClientServerTest, pointer/interop).
Note: these are NIP-44-encrypted request/response events, so create*() stays a
suspend factory that encrypts then signs the template — matching NIP-47; a pure
pre-signing template isn't possible without the signer.
Brings the kind-0 clink_offer field in line with the sibling fields' structure
instead of a raw string constant written to content only:
- New ClinkOfferTag (TAG_NAME/assemble/parse) under nip01Core/metadata/tags.
- clinkOffer() TagArrayBuilder DSL extension in TagArrayBuilderExt.
- MetadataEvent uses ClinkOfferTag.TAG_NAME and dual-writes it as a kind-0 tag
in updateOrDeleteTagNames (NIP-1770 pattern), like lud16/nip05; drops the
ad-hoc CLINK_OFFER_PROPERTY constant.
UpdateMetadataTest now also asserts the tag is emitted. quartz tests pass.
Paying a CLINK offer is already a direct payment to the recipient; wrapping it
in a NIP-57 zap request conflated two different things. Removes the zappable-
offers and post-level-zap behavior:
- ClinkOfferPreview no longer builds a zap request or takes authorPubKey/zapEvent;
it just pays the fetched invoice via the default source.
- ClinkOfferPayer.requestInvoice drops the zap param.
- Unwinds the zapEvent threading through RichTextViewer / ExpandableRichTextViewer
/ TranslatableRichTextViewer (both flavors) and reverts Text.kt.
Keeps the rest of the offer card (variable amount, moved-offer follow, default
payment-source dispatch) and the profile receive side intact. Both flavors compile.
Threads the note's Event through the rich-text render chain
(TranslatableRichTextViewer [play+fdroid] -> ExpandableRichTextViewer ->
RichTextViewer -> word renderers -> ClinkOfferPreview) as a default-null
zapEvent. The offer card now prefers an e-tag zap on the post when the event
is present, falling back to the author (p-tag) zap, then a plain invoice.
Also fixes coverage: the main note body (Text.kt) didn't pass author context
at all, so offer zaps previously only fired in chat. It now passes both
authorPubKey and the note event, so paying a noffer in a feed post lands as a
zap on that post.
Both flavors compile. Zap round-trip/receipt untested end-to-end.
When the offer service replies EXPIRED_OR_MOVED with a replacement noffer in
'latest', the card now parses it, swaps to the new pointer, and retries the
request once (paying the relocated offer) instead of dead-ending on an error.
Request handling is refactored into a single helper so the amount field and
zap-request attachment apply to the retry too.
:amethyst compiles; offer round-trip remains untested end-to-end.
Completes the receive side: a payable CLINK Offer card now appears on a
profile that advertises one, preferring the kind-0 clink_offer and falling
back to the NIP-05 .well-known clink_offer.
- Nip05Parser.parseClinkOffer + INip05Client.loadClinkOffer fetch/parse the
well-known clink_offer (keyed by local name, mirroring the names map; exact
shape isn't a finalized spec so a mismatch yields null). JVM-tested.
- DrawAdditionalInfo.DisplayClinkOffer resolves kind-0 first, else fetches
NIP-05 on IO, parses the noffer, and renders ClinkOfferPreview zapping the
profile.
quartz tests pass; :amethyst compiles. Network fetch + card render untested
end-to-end.
Adds a 'CLINK Offer (noffer)' field to the profile editor so users can advertise
a payment offer in their kind-0 metadata:
- UserMetadataState.sendNewUserMetadata threads clinkOffer into the kind-0 build.
- NewUserMetadataViewModel loads/saves/clears the clinkOffer field.
- NewUserMetadataScreen renders the input (placeholder noffer1…).
:amethyst compiles. The read side (NIP-05 clink_offer discovery + preferring it)
is the next step.
Adds the CLINK Offers discovery pointer to profile metadata, mirroring the
NIP-05 `clink_offer` key:
- UserMetadata.clinkOffer (@SerialName clink_offer) + clinkOffer() accessor,
with trim/blank cleanup alongside the other fields.
- MetadataEvent.createNew/updateFromPast gain a clinkOffer param written into
kind-0 content via the new CLINK_OFFER_PROPERTY key.
Covered by UpdateMetadataTest (write + parse round-trip) on JVM.
When a noffer is rendered in someone's note, paying it now attaches a NIP-57
zap request so the offer service issues a zappable invoice and publishes a zap
receipt — turning the payment into a real zap on the author instead of a silent
invoice:
- ClinkOfferPayer.requestInvoice forwards a serialized zap request via
OfferClient's existing zap field.
- ClinkOfferPreview builds the 9734 from the threaded authorPubKey using the
account's default zap type (skipped for NONZAP), at the resolved amount.
- RichTextViewer threads authorPubKey into the offer-segment renderers; the
markdown/secret paths default to null (plain invoice, no target).
Falls back to a plain invoice when there's no author or the user opted out of
zaps. :amethyst compiles; the round-trip and receipt remain untested end-to-end.
The offer card no longer assumes a fixed price:
- FIXED offers show their preset price (unchanged).
- SPONTANEOUS offers (and the spec default when the pointer omits a price
type) now render an amount field; Pay is disabled until a positive amount
is entered, and that amount is sent as amount_sats.
- An INVALID_AMOUNT (code 5) response reveals/refines the amount field and
shows the service's allowed range, so variable offers recover gracefully
even when the price type was ambiguous.
:amethyst compiles; the offer round-trip remains untested end-to-end.
In-post payment cards now route their 'Pay' button through the selected
default payment source instead of always opening an external wallet:
- New shared InvoicePaymentDispatcher: resolves defaultPaymentSource() for a
bolt11. External-wallet path fires the intent (the wallet app confirms);
NWC and CLINK-debit paths show a ConfirmPaymentDialog first, because a card
pay (unlike a deliberate small zap tap) can be a larger/variable amount.
- ClinkOfferPreview and InvoicePreview both drive it via a pending-invoice
state; InvoicePreview now takes accountViewModel.
NWC-only and intent-only users are unaffected. :amethyst compiles; the in-app
pay paths remain untested end-to-end.
Surfaces debits in the existing wallet list and add flow:
- WalletViewModel reads clinkDebitWallets and emits them as spend-only rows
(canShowBalance=false) in the unified walletInfoList; the default radio
spans both types via setDefaultPaymentSource; remove/rename route by type.
- WalletScreen renders debit rows with a Pay only badge instead of a balance
and disables the NWC-only detail navigation for them.
- AddWalletScreen offers a CLINK Debit type; AddClinkDebitWalletScreen pastes
or scans an ndebit1 pointer (no secret) and saves it as a payment source.
- New Route.WalletAddClinkDebit + AppNavigation registration.
:amethyst compiles. UI rendering and the end-to-end debit payout remain
untested on device.
ZapPaymentHandler now dispatches on account.settings.defaultPaymentSource():
a CLINK debit default pays each zap invoice via the new payViaClinkDebit
(kind-21002 round-trip through ClinkDebitPayer, surfacing the service's GFY
error text); an NWC default keeps the existing payViaNWC path; no configured
source falls back to the external wallet intent. NWC-only users are unaffected.
The two secondary single-invoice sites (profile LN-address pay, DVM pay) still
use NWC/intent and are left as follow-ups.
:amethyst compiles. The debit payout path is untested end-to-end — it needs a
live debit service to verify a real payment.
Wires the payment-source model into AccountSettings + LocalPreferences:
- AccountSettings gains clinkDebitWallets and add/remove/rename methods
mirroring the NWC ones, plus defaultPaymentSource() resolving the unified
default via PaymentSourceResolver.
- Renames defaultNwcWalletId -> defaultPaymentSourceId: one default id spans
both NWC wallets and CLINK debits. First configured source of any kind
auto-defaults; adding more never silently changes it; removing the default
falls back to the first remaining source.
- LocalPreferences persists clinkDebitWallets and defaultPaymentSourceId,
migrating the legacy defaultNwcWalletId key on load.
- NwcSignerState/WalletViewModel read the unified default; NWC zap routing is
unchanged for NWC-only users (falls back to first NWC wallet).
:amethyst compiles. The Wallet-screen rows/confirm dialog and routing the zap
button through ClinkDebitPayer are the next (compile-only) step.
Adds the verifiable core for using a CLINK debit pointer as a spend rail
alongside NWC:
- ClinkDebitWalletEntry (commons): a saved ndebit pointer, the spend-only
counterpart of NwcWalletEntry (no secret, no balance/history)
- PaymentSource + PaymentSourceResolver (commons): unifies NWC wallets and
CLINK debits into one list with a single default id spanning both types;
no explicit default falls back to first (NWC before debits), preserving
today's behavior. canShowBalance marks NWC vs debit honestly.
- ClinkDebitPayer (amethyst): publishes the kind-21002 pay request and awaits
the preimage via a one-shot subscription, mirroring ClinkOfferPayer.
Resolver logic covered by PaymentSourceResolverTest on JVM (7 cases incl.
cross-type default + stale-id fallback); amethyst compiles. Persisting the
new fields in AccountSettings and the Wallet-screen rows/confirm dialog are
the next (compile-only) step.
Wires the ClinkOfferSegment into RichTextViewer with a ClinkOfferPreview
card (modeled on InvoicePreview): shows the offer + price and a Pay
button. Pay runs ClinkOfferPayer, which publishes the kind-21001 request
to the offer's relays and awaits the encrypted reply via a one-shot
subscription, then hands the returned bolt11 to the existing
payViaIntent wallet flow. Consume-only; Amethyst never answers offers.
Compiles (:amethyst:compilePlayDebugKotlin); visual rendering and a live
offer round-trip still need on-device verification.
Teaches the commons RichTextParser to recognize an inline noffer1...
token and emit a ClinkOfferSegment carrying the decoded NOffer, so a
GUI front end can render a 'Pay' card in the note body (the feed-offer
feature). Bare tokens only for now; nostr:/lightning: prefixed forms
fall through. Covered by ClinkOfferSegmentTest on JVM.
Adds ClinkInteropTest with bech32 pointer strings generated by the
reference TypeScript SDK (clink-sdk 1.5.5) for noffer/ndebit/nmanage.
Asserts our parser decodes the SDK's bytes into the expected fields and
that re-encoding round-trips. TLV is order-independent on decode, so
interop is functional (not byte-identical: we emit fields ascending,
the SDK descending); the reverse direction (SDK decoding our output)
was verified out-of-band against decodeBech32.
Adds the high-level request/response orchestration over the CLINK
pointers and event kinds (experimental/clink):
- OfferClient / DebitClient / ManageClient: build the kind-21001/2/3
request from a decoded pointer, expose the relays to publish on, the
response filter (kind + author + #e=requestId), and the response parser
- ClinkServer: per-kind request filters (#p=service), 30s freshness
check, plus K1Tracker for single-use debit session enforcement
Filter construction, freshness window and k1 single-use covered by
ClinkClientServerTest on JVM; request-building encryption round-trips
will be added under androidDeviceTest (lazysodium constraint).
Adds the three CLINK message kinds to quartz (experimental/clink):
- OfferEvent (21001), DebitEvent (21002), ManageEvent (21003), each
carrying both request and response over one kind, NIP-44 encrypted,
with p + clink_version tags and an e tag on responses
- Request/response DTOs per spec (offers, debits, manage) plus shared
SatRange/GfyDelta and GFY/offer error-code constants
- Registers all three kinds in EventFactory
Pure-logic + JSON (de)serialization covered by ClinkEventTest on JVM;
the NIP-44 encrypt/decrypt round-trip will live in androidDeviceTest
(lazysodium is unavailable in JVM unit tests).
Implements the noffer/ndebit/nmanage pointers (CLINK Offers/Debits/Manage)
as standard-bech32 TLV codes, with a dedicated ClinkPointerParser kept
separate from NIP-19. Wire format (HRPs, TLV indices, single-byte priceType,
4-byte big-endian price) verified against @shocknet/clink-sdk 1.5.5.
Adds round-trip + dispatch + reject tests in commonTest.
Plan to implement CLINK (Offers 21001 / Debits 21002 / Manage 21003) on
Quartz (client + server) and Amethyst (consume-only), reusing NIP-44,
bech32/TLV, the NWC encrypted-event pattern, and ZapPaymentHandler.
Pointers parsed by a dedicated ClinkPointerParser (separate from NIP-19).