From 57f95cf15491a4d8494a500e37d3cd174aa0b828 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 00:22:42 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli):=20add=20`amy=20bolt12`=20=E2=80=94?= =?UTF-8?q?=20decode,=20verify,=20offers,=20and=20two-step=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- cli/README.md | 18 ++ cli/ROADMAP.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 12 + .../amethyst/cli/commands/Bolt12Commands.kt | 209 ++++++++++++++++++ .../cli/commands/Bolt12SendCommands.kt | 145 ++++++++++++ .../commons/actions/Bolt12ZapActions.kt | 125 +++++++++++ .../commons/actions/Bolt12ZapActionsTest.kt | 84 +++++++ 7 files changed, 594 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt diff --git a/cli/README.md b/cli/README.md index cd0c728594..00dd84a862 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 4fbe280968..e785b1e2e0 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 1c3354ac84..5880237780 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt new file mode 100644 index 0000000000..3623bc26cd --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt @@ -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 ` — decode an offer or payer proof to its fields. + * * `bolt12 verify ` — validate a kind:9736 zap in the local store. + * * `bolt12 offer get ` — fetch + show a user's kind:10058 offers. + * * `bolt12 offer set …` — 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 --proof ` 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, + ): Int = + route( + "bolt12", + tail, + "bolt12 …", + 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): Int { + if (rest.isEmpty()) return Output.error("bad_args", "bolt12 decode ") + 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, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "bolt12 verify ") + 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(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, + ): Int = + route( + "bolt12 offer", + tail, + "bolt12 offer …", + help = USAGE, + routes = + mapOf( + "get" to { rest -> offerGet(dataDir, rest) }, + "set" to { rest -> offerSet(dataDir, rest) }, + ), + ) + + private suspend fun offerGet( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer get [--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(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, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer set [ …]") + 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 = 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 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt new file mode 100644 index 0000000000..fa8acb2f58 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt @@ -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, + ): 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] --offer [--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 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(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, + ): Int { + val args = Args(rest) + val intentJson = args.flag("intent") ?: return Output.error("bad_args", "bolt12 zap --intent --proof ") + val proofArg = args.flag("proof") ?: return Output.error("bad_args", "--proof 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 = + 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 + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt new file mode 100644 index 0000000000..79b9227b8d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt @@ -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? { + 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? { + 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, + ): Bolt12OfferListEvent = Bolt12OfferListEvent.create(offers, signer) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt new file mode 100644 index 0000000000..8df8767bd5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt @@ -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) + } +}