mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
+1
-1
@@ -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)
|
||||
|
||||
|
||||
+28
-6
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+8
-13
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+41
-17
@@ -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,
|
||||
|
||||
+17
-4
@@ -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)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user