feat(cli): add amy bolt12 — decode, verify, offers, and two-step send

Adds a BOLT12 zap (NIP-XX) command group over a new shared commons
Bolt12ZapActions (assembly-only, mirrors ZapActions):

  bolt12 decode LNO1|LNP1        decode an offer or payer proof
  bolt12 verify EVENT-ID         validate a kind:9736 in the local store
  bolt12 offer get/set           read/publish a kind:10058 offer list
  bolt12 intent … / zap …        two-step out-of-band send (amy has no NWC
                                  rail): intent prints the payer_note; zap
                                  wraps the signed intent + settled proof
                                  into a validated kind:9736 and publishes

Keeps cli a thin assembly layer — all logic is quartz's Bolt12ZapBuilder/
Validator/codecs via commons Bolt12ZapActions. Adds Bolt12ZapActionsTest;
updates README + ROADMAP. Interop harness and NWC-fetched proofs remain TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
Claude
2026-07-25 00:22:42 +00:00
parent 79fee9492e
commit 57f95cf154
7 changed files with 594 additions and 0 deletions
+18
View File
@@ -672,6 +672,24 @@ output then also reports `paid` + the preimage.
| `amy zap user USER SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Profile zap: build the zap request and fetch a BOLT11 from USER's LN service. |
| `amy zap event EVENT-ID SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Same, attributed to a specific event (must be in the local store). Zap splits are honored — one invoice per recipient. |
### BOLT12 zaps (NIP-XX)
BOLT12 zaps (kinds 9736/9737, offers in kind:10058). amy has no NWC payment
rail, so sending is a **two-step, out-of-band** flow: `bolt12 intent` signs the
kind:9737 and prints its `payer_note`; you pay the offer elsewhere putting that
note in the invoice request's `invreq_payer_note`; then `bolt12 zap` wraps the
same signed intent and the settled `lnp` proof into a kind:9736 (validated
before publishing).
| Command | What it does |
|---|---|
| `amy bolt12 decode LNO1\|LNP1` | Decode a BOLT12 offer or payer proof to its fields (offline). |
| `amy bolt12 verify EVENT-ID` | Validate a kind:9736 zap in the local store — reports `valid`, `crypto_verified`, recipient, amount, payment hash. |
| `amy bolt12 offer get USER [--timeout SECS]` | Fetch + show a user's kind:10058 BOLT12 offers. |
| `amy bolt12 offer set LNO1 [LNO1 …]` | Publish your own kind:10058 offer list. |
| `amy bolt12 intent [event] TARGET SATS --offer LNO1 [--comment X]` | Sign a kind:9737 intent for a user (or event); prints its `intent_id`, `payer_note`, and `intent_json`. |
| `amy bolt12 zap --intent JSON --proof LNP1` | Wrap a signed intent + settled payer proof into a kind:9736, validate, and publish. |
### CLINK Offers
| Command | What it does |
+1
View File
@@ -62,6 +62,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| Buzz workspaces (`amy buzz`) | ✅ | `BuzzCommands` — post/read the kind:40002 stream timeline, `attest` (offline NIP-OA), `console` (decrypt+aggregate kind:44200 turn metrics via the shared `AgentFleetAggregator`), `personas` (kind:30175). Join/leave reuse `amy relaygroup` (Buzz workspaces are NIP-29 groups). |
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
| NIP-57 zaps (send) | ✅ partial | `ZapCommand``zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. |
| BOLT12 zaps (NIP-XX, kinds 9736/9737/10058) | ✅ partial | `Bolt12Commands` + `Bolt12SendCommands` over shared `commons` `Bolt12ZapActions``bolt12 decode` (offer/proof), `verify` (validate a kind:9736), `offer get/set` (kind:10058), and the two-step send `intent``zap` (out-of-band proof, since amy has no NWC rail). Interop harness + NWC-fetched proofs still 🆕. |
| NIP-65 outbox model queries | ✅ | `OutboxCommand``amy outbox USER [--refresh]`, cache-first. |
| CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. |
| Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. |
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.cli
import com.vitorpamplona.amethyst.cli.commands.AdminCommand
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands
import com.vitorpamplona.amethyst.cli.commands.BlossomCommands
import com.vitorpamplona.amethyst.cli.commands.Bolt12Commands
import com.vitorpamplona.amethyst.cli.commands.BunkerCommand
import com.vitorpamplona.amethyst.cli.commands.BuzzCommands
import com.vitorpamplona.amethyst.cli.commands.ConcordCommands
@@ -300,6 +301,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"graperank" -> GrapeRankCommand.dispatch(dataDir, tail)
"search" -> SearchCommand.dispatch(dataDir, tail)
"zap" -> ZapCommand.dispatch(dataDir, tail)
"bolt12" -> Bolt12Commands.dispatch(dataDir, tail)
"offer" -> OfferCommands.dispatch(dataDir, tail)
"debit" -> DebitCommands.dispatch(dataDir, tail)
"event" -> EventCommand.run(dataDir, tail)
@@ -748,6 +750,16 @@ private fun printUsage() {
| [--comment X] [--anon|--private] event (must be in local store)
| [--timeout SECS]
|
|BOLT12 zaps (NIP-XX):
| bolt12 decode LNO1|LNP1 decode a BOLT12 offer or payer proof
| bolt12 verify EVENT-ID validate a kind:9736 zap in the local store
| bolt12 offer get USER fetch + show a user's kind:10058 offers
| bolt12 offer set LNO1 [LNO1 …] publish your own kind:10058 offer list
| bolt12 intent [event] TARGET SATS sign a 9737 intent; prints its payer_note
| --offer LNO1 [--comment X] (pay the offer out-of-band with that note)
| bolt12 zap --intent JSON wrap a signed intent + settled proof into a
| --proof LNP1 kind:9736, validate, and publish
|
|CLINK Offers:
| offer info NOFFER decode a noffer1… pointer (local, no network)
| offer discover USER find a user's published offers
@@ -0,0 +1,209 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
/**
* `amy bolt12 …` — NIP-XX BOLT12 zaps from the command line, for headless interop
* testing of the quartz BOLT12 stack.
*
* Sub-verbs:
* * `bolt12 decode <lno1…|lnp1…>` — decode an offer or payer proof to its fields.
* * `bolt12 verify <event-id>` — validate a kind:9736 zap in the local store.
* * `bolt12 offer get <user>` — fetch + show a user's kind:10058 offers.
* * `bolt12 offer set <lno1…>…` — publish your own kind:10058 offer list.
* * `bolt12 zap|intent …` — send-side assembly (see [Bolt12SendCommands]).
*
* amy has no NIP-47/NWC payment rail, so BOLT12 sending is a two-step, out-of-band
* flow: `bolt12 intent` signs the 9737 and prints its `payer_note`; you pay the offer
* elsewhere putting that note in the invoice request's `invreq_payer_note`; then
* `bolt12 zap --intent <json> --proof <lnp1>` wraps that same signed intent and the
* settled proof into a kind:9736. (The intent must be reused verbatim — re-deriving it
* would change its id, and the `payer_note` you paid with wouldn't match.)
*/
object Bolt12Commands {
val USAGE: String =
"""
|BOLT12 zaps (NIP-XX):
| bolt12 decode LNO1|LNP1 decode an offer or payer proof to fields
| bolt12 verify EVENT-ID validate a kind:9736 zap in the local store
| bolt12 offer get USER fetch + show a user's kind:10058 offers
| [--timeout SECS]
| bolt12 offer set LNO1 [LNO1 …] publish your own kind:10058 offer list
| bolt12 intent USER SATS --offer LNO1 [--comment X]
| bolt12 intent event EVENT-ID SATS --offer LNO1 [--comment X]
| sign a 9737 intent; prints its id + payer_note
| bolt12 zap --intent JSON --proof LNP1
| wrap a signed intent + settled proof into a
| kind:9736, validate, and publish
""".trimMargin()
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"bolt12",
tail,
"bolt12 <decode|verify|offer|intent|zap> …",
help = USAGE,
routes =
mapOf(
"decode" to { rest -> decode(rest) },
"verify" to { rest -> verify(dataDir, rest) },
"offer" to { rest -> offerDispatch(dataDir, rest) },
"intent" to { rest -> Bolt12SendCommands.intent(dataDir, rest) },
"zap" to { rest -> Bolt12SendCommands.zap(dataDir, rest) },
),
)
private fun decode(rest: Array<String>): Int {
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 decode <lno1…|lnp1…>")
val raw = rest[0]
val canonical = Bolt12Bech32.canonicalize(raw)
when {
Bolt12Bech32.isOffer(canonical) -> {
val fields = Bolt12ZapActions.decodeOffer(raw) ?: return Output.error("decode_failed", "not a parseable BOLT12 offer")
Output.emit(mapOf("type" to "offer") + fields)
}
Bolt12Bech32.isPayerProof(canonical) -> {
val fields = Bolt12ZapActions.decodeProof(raw) ?: return Output.error("decode_failed", "not a parseable BOLT12 payer proof")
Output.emit(mapOf("type" to "payer_proof") + fields)
}
else -> return Output.error("bad_args", "not a BOLT12 offer (lno1…) or payer proof (lnp1…)")
}
return 0
}
private suspend fun verify(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 verify <event-id>")
val eventId = rest[0]
if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val zap =
ctx.store.query<Bolt12ZapEvent>(Filter(ids = listOf(eventId), limit = 1)).firstOrNull()
?: return Output.error("not_found", "no kind:9736 event $eventId in local store; sync or fetch first")
when (val result = Bolt12ZapActions.validate(zap)) {
is Bolt12ZapValidation.Valid ->
Output.emit(
buildMap {
put("event_id", zap.id)
put("valid", true)
put("crypto_verified", result.proofCryptoVerified)
put("recipient", result.recipient)
result.payer?.let { put("payer", it) }
put("anonymous", result.payer == null)
put("amount_msat", result.amountMillisats)
put("payment_hash", result.paymentHashHex)
result.zappedEventId?.let { put("zapped_event_id", it) }
},
)
is Bolt12ZapValidation.Invalid ->
Output.emit(mapOf("event_id" to zap.id, "valid" to false, "reason" to result.reason.name))
}
return 0
}
}
private suspend fun offerDispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"bolt12 offer",
tail,
"bolt12 offer <get|set> …",
help = USAGE,
routes =
mapOf(
"get" to { rest -> offerGet(dataDir, rest) },
"set" to { rest -> offerSet(dataDir, rest) },
),
)
private suspend fun offerGet(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer get <user> [--timeout SECS]")
val args = Args(rest.drop(1).toTypedArray())
val timeoutMs = args.timeoutMs(8)
args.rejectUnknown()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val user = ctx.requireUserHex(rest[0])
val relays = ctx.bootstrapRelays()
val filter = Filter(kinds = listOf(Bolt12OfferListEvent.KIND), authors = listOf(user), limit = 1)
val latest =
(ctx.store.query<Bolt12OfferListEvent>(filter) + ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs).mapNotNull { it.second as? Bolt12OfferListEvent })
.filter { it.pubKey == user }
.maxByOrNull { it.createdAt }
Output.emit(mapOf("user" to user, "offers" to (latest?.offers() ?: emptyList())))
return 0
}
}
private suspend fun offerSet(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer set <lno1…> [<lno1…> …]")
val canonical =
rest.map { raw ->
Bolt12ZapActions.canonicalOfferOrNull(raw) ?: return Output.error("bad_args", "not a valid BOLT12 offer: $raw")
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event = Bolt12ZapActions.buildOfferList(ctx.signer, canonical)
val relays: Set<NormalizedRelayUrl> = ctx.outboxRelays()
val results = ctx.publish(event, relays)
Output.emit(
mapOf(
"event_id" to event.id,
"offers" to canonical,
"published_to" to results.keys.map { it.url },
"accepted_by" to results.filterValues { it.accepted }.keys.map { it.url },
),
)
return 0
}
}
}
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
/**
* The BOLT12 send-side sub-verbs of `amy bolt12` (split from [Bolt12Commands] to keep
* each file focused). Two steps because amy has no NWC rail to fetch the proof itself:
* [intent] signs the 9737 and prints the `payer_note`; the caller pays the offer
* out-of-band with that note; [zap] wraps the same intent + settled proof into a 9736.
*/
object Bolt12SendCommands {
suspend fun intent(
dataDir: DataDir,
rest: Array<String>,
): Int {
val eventMode = rest.firstOrNull() == "event"
val body = if (eventMode) rest.drop(1).toTypedArray() else rest
if (body.size < 2) {
return Output.error("bad_args", "bolt12 intent [event] <user|event-id> <sats> --offer <lno1> [--comment X]")
}
val target = body[0]
val sats =
body[1].toLongOrNull()?.takeIf { it > 0 }
?: return Output.error("bad_args", "sats must be a positive integer (got '${body[1]}')")
val args = Args(body.drop(2).toTypedArray())
val offerArg = args.flag("offer") ?: return Output.error("bad_args", "--offer <lno1…> is required")
val offer = Bolt12ZapActions.canonicalOfferOrNull(offerArg) ?: return Output.error("bad_args", "--offer is not a valid BOLT12 offer")
val comment = args.flag("comment") ?: ""
args.rejectUnknown()
val amountMsat = Bolt12ZapActions.satsToMillisats(sats)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val intent =
if (eventMode) {
if (target.length != 64) return Output.error("bad_args", "event-id must be 64-hex")
val zappedEvent =
ctx.store.query<Event>(Filter(ids = listOf(target), limit = 1)).firstOrNull()
?: return Output.error("not_found", "event $target not in local store; sync or fetch first")
val recipient = zappedEvent.pubKey
Bolt12ZapActions.buildEventIntent(ctx.signer, recipient, amountMsat, offer, zappedEvent, comment)
} else {
val recipient = ctx.requireUserHex(target)
Bolt12ZapActions.buildProfileIntent(ctx.signer, recipient, amountMsat, offer, comment)
}
Output.emit(
mapOf(
"intent_id" to intent.id,
"recipient" to intent.recipient(),
"amount_msat" to amountMsat,
"offer" to offer,
// Put this in the BOLT12 invoice request's invreq_payer_note when paying.
"payer_note" to Bolt12ZapActions.payerNote(intent),
// Feed this verbatim back to `bolt12 zap --intent` once you have the proof.
"intent_json" to intent.toJson(),
),
)
return 0
}
}
suspend fun zap(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val intentJson = args.flag("intent") ?: return Output.error("bad_args", "bolt12 zap --intent <json> --proof <lnp1>")
val proofArg = args.flag("proof") ?: return Output.error("bad_args", "--proof <lnp1…> is required")
args.rejectUnknown()
val proof = Bolt12Bech32.canonicalize(proofArg)
if (!Bolt12Bech32.isPayerProof(proof)) return Output.error("bad_args", "--proof is not a BOLT12 payer proof (lnp1…)")
val intent =
runCatching { Event.fromJson(intentJson) as? Bolt12ZapIntentEvent }.getOrNull()
?: return Output.error("bad_args", "--intent is not a valid kind:9737 zap-intent event")
Context.open(dataDir).use { ctx ->
ctx.prepare()
// Sign the 9736 with our account key. The validator requires the zap and the
// embedded intent to share a pubkey, so an intent signed by another key is
// rejected below and nothing is published.
val zap = Bolt12ZapActions.buildZap(ctx.signer, intent, proof, anonymous = false)
when (val result = Bolt12ZapActions.validate(zap)) {
is Bolt12ZapValidation.Invalid ->
return Output.error("invalid_zap", "assembled zap failed validation: ${result.reason.name}; nothing published")
is Bolt12ZapValidation.Valid -> {
val recipientInbox: Set<NormalizedRelayUrl> =
ctx
.relaysOf(result.recipient)
?.readRelaysNorm()
?.toSet()
.orEmpty()
val relays = ctx.outboxRelays() + recipientInbox
val results = ctx.publish(zap, relays)
Output.emit(
mapOf(
"event_id" to zap.id,
"recipient" to result.recipient,
"amount_msat" to result.amountMillisats,
"payment_hash" to result.paymentHashHex,
"crypto_verified" to result.proofCryptoVerified,
"published_to" to results.keys.map { it.url },
"accepted_by" to results.filterValues { it.accepted }.keys.map { it.url },
),
)
return 0
}
}
}
}
}
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.utils.Hex
/**
* NIP-XX BOLT12 zap building, decoding, and validation — the shared, UI-free
* surface a non-Android caller (amy CLI, interop harnesses) drives.
*
* Assembly-only: it re-exposes [Bolt12ZapBuilder] / [Bolt12ZapValidator] and the
* BOLT12 codecs. Obtaining a settled `lnp` payer proof (the wallet's job over
* NIP-47/nwc#2) lives outside these builders — a caller supplies the proof.
* Pattern matches [ZapActions].
*/
object Bolt12ZapActions {
fun satsToMillisats(sats: Long): Long = sats * 1000L
/** The canonical raw offer if [raw] is a well-formed BOLT12 offer (`lno1…`), else null. */
fun canonicalOfferOrNull(raw: String): String? {
val canonical = Bolt12Bech32.canonicalize(raw)
return if (Bolt12Bech32.isOffer(canonical)) canonical else null
}
/** Decode a BOLT12 offer (`lno1…`) to its interesting fields, or null when unparseable. */
fun decodeOffer(raw: String): Map<String, Any?>? {
val offer = Bolt12Offer.parse(raw) ?: return null
return buildMap {
put("canonical", Bolt12Bech32.canonicalize(raw))
offer.amount()?.let { put("amount_msat", it) }
offer.currency()?.let { put("currency", it) }
offer.description()?.let { put("description", it) }
offer.issuerId()?.let { put("issuer_id", Hex.encode(it)) }
put("has_paths", offer.hasPaths())
}
}
/** Decode a BOLT12 payer proof (`lnp1…`) to its interesting fields, or null when unparseable. */
fun decodeProof(raw: String): Map<String, Any?>? {
val proof = Bolt12PayerProof.parse(raw) ?: return null
return buildMap {
put("has_all_required_fields", proof.hasAllRequiredFields())
put("compressed", proof.isCompressed())
proof.invreqPayerNote()?.let { put("invreq_payer_note", it) }
proof.invreqPayerId()?.let { put("invreq_payer_id", Hex.encode(it)) }
proof.invoiceAmount()?.let { put("invoice_amount_msat", it) }
proof.invoicePaymentHash()?.let { put("invoice_payment_hash", Hex.encode(it)) }
proof.invoiceNodeId()?.let { put("invoice_node_id", Hex.encode(it)) }
proof.offerIssuerId()?.let { put("offer_issuer_id", Hex.encode(it)) }
}
}
/** The value a payer MUST put in the BOLT12 `invreq_payer_note` to bind a payment to [intent]. */
fun payerNote(intent: Bolt12ZapIntentEvent): String = Bolt12ZapBuilder.payerNote(intent)
/** Sign a kind:9737 zap intent targeting [recipientPubKey]'s profile. */
suspend fun buildProfileIntent(
signer: NostrSigner,
recipientPubKey: HexKey,
amountMillisats: Long,
offer: String,
comment: String = "",
): Bolt12ZapIntentEvent = Bolt12ZapBuilder.buildProfileIntent(signer, recipientPubKey, amountMillisats, offer, comment)
/** Sign a kind:9737 zap intent targeting a specific [zappedEvent]. */
suspend fun buildEventIntent(
signer: NostrSigner,
recipientPubKey: HexKey,
amountMillisats: Long,
offer: String,
zappedEvent: Event,
comment: String = "",
): Bolt12ZapIntentEvent = Bolt12ZapBuilder.buildIntent(signer, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), comment)
/**
* Wrap a signed [intent] and a settled [payerProof] into a signed kind:9736 zap.
* When [anonymous], [signer] MUST be the same ephemeral key that signed [intent].
*/
suspend fun buildZap(
signer: NostrSigner,
intent: Bolt12ZapIntentEvent,
payerProof: String,
anonymous: Boolean = false,
): Bolt12ZapEvent = Bolt12ZapBuilder.buildZap(signer, intent, payerProof, anonymous)
/** Validate a kind:9736 BOLT12 zap (structure + intent match + payer-proof binding + crypto). */
fun validate(zap: Bolt12ZapEvent): Bolt12ZapValidation = Bolt12ZapValidator().validate(zap)
/** Sign a kind:10058 BOLT12 offer list publishing [offers] (canonical `lno1…` strings). */
suspend fun buildOfferList(
signer: NostrSigner,
offers: List<String>,
): Bolt12OfferListEvent = Bolt12OfferListEvent.create(offers, signer)
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Values
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvStream
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Locks the shared BOLT12 assembly surface amy drives: offer canonicalization,
* offer decoding, and the kind:10058 offer-list round-trip.
*/
class Bolt12ZapActionsTest {
// A minimal, well-formed offer: amount(8) + description(10), TLV-ascending.
private fun sampleOffer(amountMsat: Long): String {
val records =
listOf(
TlvRecord(Bolt12Offer.TYPE_AMOUNT, Bolt12Values.tu64ToBytes(amountMsat)),
TlvRecord(Bolt12Offer.TYPE_DESCRIPTION, "coffee".encodeToByteArray()),
)
return Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, TlvStream(records).encode())
}
@Test
fun canonicalOfferAcceptsAValidOfferAndRejectsJunk() {
val offer = sampleOffer(21_000L)
assertEquals(offer.lowercase(), Bolt12ZapActions.canonicalOfferOrNull(offer))
assertNull(Bolt12ZapActions.canonicalOfferOrNull("not-an-offer"))
assertNull(Bolt12ZapActions.canonicalOfferOrNull("lnbc10n1xxx")) // bolt11, not bolt12
}
@Test
fun decodeOfferReadsAmountAndDescription() {
val fields = Bolt12ZapActions.decodeOffer(sampleOffer(21_000L))
assertTrue(fields != null)
assertEquals(21_000L, fields["amount_msat"])
assertEquals("coffee", fields["description"])
assertEquals(false, fields["has_paths"])
}
@Test
fun decodeOfferReturnsNullForAProof() {
assertNull(Bolt12ZapActions.decodeOffer("lnp1garbage"))
}
@Test
fun offerListRoundTrips() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val offers = listOf(sampleOffer(21_000L).lowercase(), sampleOffer(5_000L).lowercase())
val event = Bolt12ZapActions.buildOfferList(signer, offers)
assertEquals(10058, event.kind)
assertEquals(offers, event.offers())
assertEquals(signer.pubKey, event.pubKey)
}
}