From 3968790db112ddd565bdb71a0599df9c749067b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 04:20:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(clink):=20audit=20fixes=20=E2=80=94=20unsig?= =?UTF-8?q?ned=20offer=20price,=20Manage=20shape,=20NIP-05=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../creators/invoice/ClinkOfferPreview.kt | 2 +- .../profile/header/DrawAdditionalInfo.kt | 34 +++++++++-- .../commons/richtext/ClinkOfferSegmentTest.kt | 2 +- .../experimental/clink/client/ManageClient.kt | 21 +++---- .../experimental/clink/client/OfferClient.kt | 2 +- .../clink/manage/ManageMessages.kt | 58 +++++++++++++------ .../experimental/clink/pointers/NOffer.kt | 21 +++++-- .../clink/pointers/ClinkInteropTest.kt | 4 +- 8 files changed, 99 insertions(+), 45 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index 0af1691a46..c46185bf78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -193,7 +193,7 @@ fun ClinkOfferPreview( useOffer: NOffer, followMoved: Boolean, ) { - val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price?.toLong() + val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 9fbb21933d..342f0afe6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.ClipData +import android.util.LruCache import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -388,10 +389,23 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = else -> R.string.github } +/** + * Process-wide cache of NIP-05 `.well-known` `clink_offer` lookups, keyed by the nip05 + * address. Without it, every profile visit (and every relay-pushed kind-0 refresh while a + * profile is open) would re-fetch the domain's nostr.json. Caches "no offer" results too so + * profiles without one aren't re-hit. A [ResolvedClinkOffer] wrapper holds the nullable result + * (LruCache can't store nulls); absence means "not fetched yet". + */ +private class ResolvedClinkOffer( + val noffer: String?, +) + +private val clinkOfferNip05Cache = LruCache(256) + /** * Shows a payable CLINK Offer card when the profile advertises one, preferring the * kind-0 `clink_offer` field and falling back to the user's NIP-05 `.well-known` - * `clink_offer`. Paying zaps this profile (see [ClinkOfferPreview]). + * `clink_offer` (cached). Paying pays the advertised offer (see [ClinkOfferPreview]). */ @Composable private fun DisplayClinkOffer( @@ -411,13 +425,21 @@ private fun DisplayClinkOffer( offer = kind0Offer return@LaunchedEffect } - // Fall back to the NIP-05 .well-known clink_offer. + // Fall back to the NIP-05 .well-known clink_offer (cached per address). val id = nip05?.let { Nip05Id.parse(it) } offer = - if (id != null) { - withContext(Dispatchers.IO) { - accountViewModel.nip05ClientBuilder().loadClinkOffer(id)?.let { ClinkPointerParser.parse(it) as? NOffer } - } + if (id != null && nip05 != null) { + // Distinguish "cache miss" from a cached "no offer" (null) so we don't refetch. + val cached = clinkOfferNip05Cache.get(nip05) + val nofferStr = + if (cached != null) { + cached.noffer + } else { + val fetched = withContext(Dispatchers.IO) { accountViewModel.nip05ClientBuilder().loadClinkOffer(id) } + clinkOfferNip05Cache.put(nip05, ResolvedClinkOffer(fetched)) + fetched + } + nofferStr?.let { ClinkPointerParser.parse(it) as? NOffer } } else { null } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt index 9925906bd8..a10963ede4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt @@ -47,7 +47,7 @@ class ClinkOfferSegmentTest { assertEquals(offerFixed, segment.segmentText) assertEquals("offer-id", segment.offer.pointer) assertEquals(OfferPriceType.FIXED, segment.offer.priceType) - assertEquals(21000, segment.offer.price) + assertEquals(21000L, segment.offer.price) } @Test diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt index 056b2997b1..8f9693ac10 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.quartz.experimental.clink.client import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields import com.vitorpamplona.quartz.experimental.clink.pointers.NManage import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -45,18 +47,15 @@ class ManageClient( label: String? = null, priceSats: Long? = null, callbackUrl: String? = null, - payerData: Map? = null, + payerData: List? = null, createdAt: Long = TimeUtils.now(), ): ManageEvent = send( ManageRequest( resource = ManageRequest.RESOURCE_OFFER, action = ManageRequest.ACTION_CREATE, - label = label, - price_sats = priceSats, - callback_url = callbackUrl, - payer_data = payerData, pointer = pointer.pointer, + offer = ManageOffer(fields = OfferFields(label, priceSats, callbackUrl, payerData)), ), createdAt, ) @@ -66,18 +65,14 @@ class ManageClient( label: String? = null, priceSats: Long? = null, callbackUrl: String? = null, - payerData: Map? = null, + payerData: List? = null, createdAt: Long = TimeUtils.now(), ): ManageEvent = send( ManageRequest( resource = ManageRequest.RESOURCE_OFFER, action = ManageRequest.ACTION_UPDATE, - id = id, - label = label, - price_sats = priceSats, - callback_url = callbackUrl, - payer_data = payerData, + offer = ManageOffer(id = id, fields = OfferFields(label, priceSats, callbackUrl, payerData)), ), createdAt, ) @@ -85,7 +80,7 @@ class ManageClient( suspend fun getOffer( id: String, createdAt: Long = TimeUtils.now(), - ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_GET, id = id), createdAt) + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_GET, offer = ManageOffer(id = id)), createdAt) suspend fun listOffers(createdAt: Long = TimeUtils.now()): ManageEvent = send( @@ -96,7 +91,7 @@ class ManageClient( suspend fun deleteOffer( id: String, createdAt: Long = TimeUtils.now(), - ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_DELETE, id = id), createdAt) + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_DELETE, offer = ManageOffer(id = id)), createdAt) private suspend fun send( request: ManageRequest, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt index f9481760d4..68ca6472c6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt @@ -66,7 +66,7 @@ class OfferClient( val request = OfferRequest( offer = pointer.pointer, - amount_sats = amountSats ?: pointer.price?.toLong(), + amount_sats = amountSats ?: pointer.price, payer_data = payerData, zap = zap, expires_in_seconds = expiresInSeconds, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt index 2ee2ee036a..455f3487ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt @@ -24,21 +24,39 @@ import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta import com.vitorpamplona.quartz.experimental.clink.common.SatRange import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +/** + * The editable fields of a managed offer. `payer_data` is a list of field NAMES the offer + * requests from the payer (e.g. `["email", "shipping_address"]`), per the CLINK Manage spec. + */ +class OfferFields( + var label: String? = null, + var price_sats: Long? = null, + var callback_url: String? = null, + var payer_data: List? = null, +) : OptimizedSerializable + +/** + * The `offer` envelope on a Manage request: [id] identifies the target for update/get/delete, + * [fields] carries the values for create/update. + */ +class ManageOffer( + var id: String? = null, + var fields: OfferFields? = null, +) : OptimizedSerializable + /** * Decrypted request to a CLINK Manage service (kind 21003) for delegated offer CRUD. - * [resource] is always `"offer"`; [action] is one of create/update/get/list/delete. - * `create` omits [id] (the server generates it); update/get/delete require [id]; - * list filters by the requesting app. + * The offer data is NESTED under [offer] (not flat): `create` sets `offer.fields`, `update` + * sets `offer.id` + `offer.fields`, `get`/`delete` set `offer.id`, and `list` filters by + * [pointer]. (The published spec shows `create` with fields directly under `offer`; the + * reference SDK nests both create and update under `offer.fields`, which we follow for + * interop with SDK-built services.) */ class ManageRequest( var resource: String? = null, var action: String? = null, - var id: String? = null, - var label: String? = null, - var price_sats: Long? = null, - var callback_url: String? = null, - var payer_data: Map? = null, var pointer: String? = null, + var offer: ManageOffer? = null, ) : OptimizedSerializable { companion object { const val RESOURCE_OFFER = "offer" @@ -50,28 +68,34 @@ class ManageRequest( } } -/** A managed offer as returned by the service, including the server-generated `noffer` pointer. */ -class ManageOffer( +/** A managed offer as returned by the service: [OfferFields] plus its [id] and server-generated [noffer]. */ +class OfferData( var id: String? = null, + var noffer: String? = null, var label: String? = null, var price_sats: Long? = null, var callback_url: String? = null, - var payer_data: Map? = null, - var noffer: String? = null, + var payer_data: List? = null, ) : OptimizedSerializable /** - * Decrypted response from a CLINK Manage service. On success [res] is `"ok"` with - * either [offer] (create/update/get) or [offers] (list) populated; on failure [res] - * is `"GFY"` with [code]/[error] set. + * Decrypted response from a CLINK Manage service. On success [res] is `"ok"` and [details] + * carries the affected offer(s); on failure [res] is `"GFY"` with [code]/[error] (and [field] + * naming the invalid input for validation errors). + * + * NOTE: the spec types `details` as `OfferData | OfferData[]` (a single object for + * create/update/get, an array for list). We model it as a list; a single-object response is + * only fully parsed when the JSON mapper coerces single values to arrays. Amethyst is + * consume-only for Manage (it never drives offer CRUD), so this is a documented limitation + * rather than a live path. */ class ManageResponse( var res: String? = null, var resource: String? = null, - var offer: ManageOffer? = null, - var offers: List? = null, + var details: List? = null, var code: Int? = null, var error: String? = null, + var field: String? = null, var range: SatRange? = null, var retry_after: Long? = null, var delta: GfyDelta? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt index 58cafb972d..eb35516ace 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt @@ -40,8 +40,8 @@ data class NOffer( override val pointer: String?, /** TLV 3 — how the offer is priced. Absent means [OfferPriceType.SPONTANEOUS]. */ val priceType: OfferPriceType?, - /** TLV 4 — price in sats (display/fixed offers), 4-byte big-endian per the SDK. */ - val price: Int?, + /** TLV 4 — price in sats (display/fixed offers), 4-byte big-endian *unsigned* per the SDK. */ + val price: Long?, ) : ClinkPointer { override fun encode(): String = TlvBuilder() @@ -50,7 +50,9 @@ data class NOffer( relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } addStringIfNotNull(ClinkTlv.POINTER, pointer) priceType?.let { addHex(ClinkTlv.PRICE_TYPE, it.code.toSingleByteHex()) } - price?.let { addInt(ClinkTlv.PRICE, it) } + // addInt writes the low 32 bits big-endian; for an unsigned price up to + // 2^32-1 that is the correct 4-byte field even when it overflows a signed Int. + price?.let { addInt(ClinkTlv.PRICE, it.toInt()) } }.build() .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } @@ -71,7 +73,18 @@ data class NOffer( tlv.data[ClinkTlv.PRICE_TYPE]?.firstOrNull()?.firstOrNull()?.let { OfferPriceType.fromCode(it.toInt() and 0xFF) } - val price = tlv.firstAsInt(ClinkTlv.PRICE) + // The SDK decodes price as an UNSIGNED big-endian integer (parseInt of the hex); + // reading it as a signed Int would turn prices >= 2^31 sats into negative amounts. + val price = + tlv.data[ClinkTlv.PRICE] + ?.firstOrNull() + ?.takeIf { it.size == 4 } + ?.let { + ((it[0].toLong() and 0xFF) shl 24) or + ((it[1].toLong() and 0xFF) shl 16) or + ((it[2].toLong() and 0xFF) shl 8) or + (it[3].toLong() and 0xFF) + } return NOffer(pubKey, relays, pointer, priceType, price) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt index 030dbedbd9..6f11a7b5a1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt @@ -60,7 +60,7 @@ class ClinkInteropTest { assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), offer.relays.single()) assertEquals("offer-id", offer.pointer) assertEquals(OfferPriceType.FIXED, offer.priceType) - assertEquals(21000, offer.price) + assertEquals(21000L, offer.price) assertEquals(offer, ClinkPointerParser.parse(offer.encode())) } @@ -78,7 +78,7 @@ class ClinkInteropTest { val offer = ClinkPointerParser.parse(offerVariable) as NOffer assertEquals("v", offer.pointer) assertEquals(OfferPriceType.VARIABLE, offer.priceType) - assertEquals(500, offer.price) + assertEquals(500L, offer.price) assertEquals(offer, ClinkPointerParser.parse(offer.encode())) }