mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -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)
|
||||
}
|
||||
+84
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user