fix(clink): audit fixes — unsigned offer price, Manage shape, NIP-05 cache

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.
This commit is contained in:
Claude
2026-06-10 04:20:47 +00:00
parent b74d769f60
commit 3968790db1
8 changed files with 99 additions and 45 deletions
@@ -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)
@@ -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<String, ResolvedClinkOffer>(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
}
@@ -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
@@ -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<String, Any?>? = null,
payerData: List<String>? = 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<String, Any?>? = null,
payerData: List<String>? = 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,
@@ -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,
@@ -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<String>? = 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<String, Any?>? = 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<String, Any?>? = null,
var noffer: String? = null,
var payer_data: List<String>? = 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<ManageOffer>? = null,
var details: List<OfferData>? = 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,
@@ -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)
}
@@ -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()))
}