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.
fixMissingSpaces runs on the main thread once per rendered note. The scan
introduced in the previous commit re-tested every detected URL at every
character position (and allocated an iterator per position via firstOrNull),
i.e. O(N*U*L) for a note with U URLs — noticeable on large notes that carry
many links.
Bucket the URLs by their first character once up front and only attempt a
match at positions whose character can actually start a URL; every other
character now costs a single map lookup, keeping the pass linear in the text
length for typical content. Buckets stay longest-first so prefix URLs still
don't shadow longer ones, so the output is identical (verified against the
commons richtext JVM corpus).
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.
RichTextParser.fixMissingSpaces used a Regex of the form
`([^ \n])?(urls)([^ \n])?` to insert spaces around URLs glued to
neighbouring text. Kotlin/Native's regex engine fails to backtrack the
optional `([^ \n])?` capture groups to zero width, so on iOS every URL was
corrupted (e.g. "https://x" became "h https://x"). That broke the
downstream segmenter, which is why :commons:iosSimulatorArm64Test reported
19 failures across the RichText/Gallery/Pdf/F4a parsers once the test binary
finally linked.
Replace the regex with a direct left-to-right scan that inserts a single
space wherever a detected URL touches a non-space/non-newline neighbour. The
scan is engine-independent, so it behaves identically on JVM and Native.
Verified equivalent to the old behaviour across the full commons richtext
JVM corpus, and the new FixMissingSpacesTest pins the cases on every target
(including iosSimulatorArm64).
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.
The :commons:linkDebugTestIosSimulatorArm64 CI step fails under Xcode 16.4
with 'Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion'. The symbol is
referenced by the prebuilt Kotlin/Native cache of Compose Multiplatform's
org.jetbrains.compose.ui:ui-uikit (CMPLayoutRegion), which was built against
a newer simulator SDK (18.5) than the test binary is linked for (14.0).
Disable the native compiler cache for the iOS test binaries so ui-uikit
recompiles against the active SDK, where UIViewLayoutRegion resolves. The
DisableCacheInKotlinVersion guard re-surfaces the workaround once we move
past Kotlin 2.3.21 so it can be removed when the cache is fixed upstream.
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.
Backs out the reactive list-refresh plumbing added in the prior commit:
removes the `changes` SharedFlow from the shared `ChatroomList` (restoring
it to its original form) and reverts Desktop `ChatroomListState` to its
original 2s poll. Room assembly is expected to move to a LocalCache.observe
approach on both platforms later, which would supersede this.
Keeps the independent Desktop list improvements (per-room unread tracking
and the mute/acceptable filter), which don't depend on the flow.
https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
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).
The test-quartz-ios job only compiled :commons production code for iOS
(compileKotlinIos*), never the commonTest source set, so a test using a
JVM-only API compiled fine on JVM/Android and shipped green while silently
breaking compileTestKotlinIosSimulatorArm64.
Mirror the quartz step: run :commons:iosSimulatorArm64Test (compiles +
runs the shared commonTest suite on the simulator) and
:commons:compileTestKotlinIosArm64 (device-only compile drift). This keeps
commonTest KMP-clean going forward.
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).
Makes the Desktop DM client a first-class group participant and tightens
the shared/Desktop DM paths so they match Android behavior.
- commons ChatNewMessageState: actually attach the composed NIP-14 subject
to sent messages (the field was previously collected but dropped).
- commons ChatroomList: emit a `changes` SharedFlow on add/remove so list
UIs can refresh reactively; dedupe the User overloads onto the room ones.
- Desktop NewDmDialog: multi-recipient selection (chips + confirm button) so
a Desktop user can start a group, not only a 1:1.
- Desktop ChatPane/ChatroomHeader: show a group's NIP-14 subject in the
header and add a rename dialog that broadcasts a subject change to all
members.
- Desktop Main.kt DM ingest: route any ChatroomKeyable inner event into the
room (covers kind 14/15 and future variants) and store self-authored
NIP-37 drafts instead of dropping them.
- Desktop ChatroomListState: refresh reactively off ChatroomList.changes
(with a slower safety poll), track real per-room unread via a last-seen
mark, and hide rooms whose latest message isn't acceptable (mute/filter).
https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
A kind:1 note carrying a `q` tag is a quote-repost of the quoted note,
but Amethyst's reaction-row repost counter reads `Note.boosts`, which
only collected kind:6/kind:16 reposts. Quote-reposts were treated purely
as inline citations (stripped by `tagsWithoutCitations()`), so they never
appeared in the quoted note's repost count.
LocalCache now adds a `q`-tagged note as a boost of each quoted note when
consuming text notes/comments, and detaches it on deletion. The quoted
note is deliberately kept out of `replyTo` so the quote still renders as a
root post in the home feed (`Note.isNewThread`). The same wiring is added
to DesktopLocalCache for parity, with tests pinning the behavior using the
exact event reported.
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).
The commons commonTest source set is shared across all KMP targets,
including the iosArm64/iosSimulatorArm64 spike, but several tests still
reached for JVM-only APIs that don't resolve on Kotlin/Native:
- JUnit (`org.junit.*`, `junit.framework.TestCase`) → kotlin.test, with
message arguments moved from first (JUnit) to last (kotlin.test).
- `assertArrayEquals` → `assertContentEquals`.
- `@JvmStatic` on the `android.util.Log` test stub → removed (it only
affects JVM bytecode; companion calls work without it).
- `seg.javaClass.simpleName` → `seg::class.simpleName!!`.
- A test function name containing `()` (illegal on Native) → renamed.
- `String(CharArray, offset, count)` → `CharArray.concatToString`.
CliffDetectorTest exercises `computeStalledSpeakers`/`defaultCliffBackoffMs`,
which live in the jvmAndroid-only NestViewModel and are invisible to iOS,
so it moves to jvmTest alongside NestViewModelTest.