From d095dc790d149df29ec35936266a44f42ae1a413 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 03:25:44 +0000 Subject: [PATCH 01/19] feat(quartz): add NIP-BC onchain zaps (kind 8333) Implements NIP-BC onchain zap events analogous to NIP-57 lightning zap receipts but for Bitcoin transactions. The recipient's Nostr pubkey is used directly as the internal key of a BIP-341 P2TR output. Mirrors the nip88Polls package structure with a single `zap/` subpackage: - OnchainZapEvent (kind 8333) with profile / event / addressable-event zap builders - Tags: BitcoinTxIdTag (i = bitcoin:tx:), AmountTag (sats), BlockTag (hash + height), ProofTag (raw-tx + merkle proof) for optional SPV verification - TagArrayExt / TagArrayBuilderExt for parsing and assembly - EventFactory wired so kind 8333 deserializes into OnchainZapEvent Reuses standard ETag/ATag/PTag/KindTag from nip01Core. Does not yet implement client-side transaction verification or PSBT signer methods. --- .../nipBCOnchainZaps/zap/OnchainZapEvent.kt | 144 ++++++++++++++++++ .../zap/TagArrayBuilderExt.kt | 58 +++++++ .../nipBCOnchainZaps/zap/TagArrayExt.kt | 35 +++++ .../nipBCOnchainZaps/zap/tags/AmountTag.kt | 41 +++++ .../zap/tags/BitcoinTxIdTag.kt | 68 +++++++++ .../nipBCOnchainZaps/zap/tags/BlockTag.kt | 65 ++++++++ .../nipBCOnchainZaps/zap/tags/ProofTag.kt | 60 ++++++++ .../quartz/utils/EventFactory.kt | 2 + 8 files changed, 473 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/AmountTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BitcoinTxIdTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BlockTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/ProofTag.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt new file mode 100644 index 0000000000..63dc0cf279 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt @@ -0,0 +1,144 @@ +/* + * 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.quartz.nipBCOnchainZaps.zap + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.toETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-BC: Onchain Zaps. + * + * Kind 8333 event that attributes a Bitcoin onchain payment to a Nostr event or profile. + * The recipient's Nostr pubkey is used directly as the internal key of a BIP-341 P2TR + * output, so every Nostr pubkey has exactly one corresponding mainnet Taproot address. + * + * Mainnet only. + */ +@Immutable +class OnchainZapEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + /** The Bitcoin transaction id (64-char lowercase hex) parsed from the `i` tag. */ + fun txid() = tags.txid() + + /** Sender-claimed amount in satoshis. Must be verified against the on-chain transaction. */ + fun claimedAmountInSats() = tags.amountInSats() + + /** The hex-encoded pubkey of the recipient (the author being paid). */ + fun recipient() = tags.firstNotNullOfOrNull(PTag::parseKey) + + /** The event being zapped, if any. */ + fun zappedEvent() = tags.firstNotNullOfOrNull(ETag::parseId) + + /** The addressable event being zapped, if any. */ + fun zappedAddress() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + /** Optional block tag with hash + height enabling SPV verification. */ + fun block() = tags.block() + + /** Optional inline SPV proof (raw tx hex + merkle proof hex). */ + fun proof() = tags.proof() + + /** True when neither `e` nor `a` is present — the zap targets the recipient's profile. */ + fun isProfileZap() = zappedEvent() == null && zappedAddress() == null + + companion object { + const val KIND = 8333 + const val ALT_DESCRIPTION = "Onchain Zap" + + /** + * Build an onchain zap that targets a specific event. + */ + fun build( + txid: String, + recipientPubKey: HexKey, + amountInSats: Long, + zappedEvent: EventHintBundle, + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + txid(txid) + recipient(recipientPubKey) + amountInSats(amountInSats) + if (zappedEvent.event is AddressableEvent) { + zappedAddress(zappedEvent.toATag()) + } + zappedEvent(zappedEvent.toETag()) + zappedKind(zappedEvent.event.kind) + initializer() + } + + /** + * Build an onchain zap that targets a recipient's profile (no event / address). + */ + fun buildProfileZap( + txid: String, + recipientPubKey: HexKey, + amountInSats: Long, + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + txid(txid) + recipient(recipientPubKey) + amountInSats(amountInSats) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..f07b56a51d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayBuilderExt.kt @@ -0,0 +1,58 @@ +/* + * 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.quartz.nipBCOnchainZaps.zap + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.AmountTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BitcoinTxIdTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BlockTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.ProofTag + +fun TagArrayBuilder.txid(txid: String) = addUnique(BitcoinTxIdTag.assemble(txid)) + +fun TagArrayBuilder.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null)) + +fun TagArrayBuilder.amountInSats(amountInSats: Long) = addUnique(AmountTag.assemble(amountInSats)) + +fun TagArrayBuilder.zappedEvent(tag: ETag) = addUnique(tag.toTagArray()) + +fun TagArrayBuilder.zappedAddress(tag: ATag) = addUnique(tag.toATagArray()) + +fun TagArrayBuilder.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind)) + +fun TagArrayBuilder.block( + blockHashHex: String, + height: Long, +) = addUnique(BlockTag.assemble(blockHashHex, height)) + +fun TagArrayBuilder.block(block: BlockTag) = addUnique(block.toTagArray()) + +fun TagArrayBuilder.proof( + rawTxHex: String, + merkleProofHex: String, +) = addUnique(ProofTag.assemble(rawTxHex, merkleProofHex)) + +fun TagArrayBuilder.proof(proof: ProofTag) = addUnique(proof.toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayExt.kt new file mode 100644 index 0000000000..78c3a2efae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayExt.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.nipBCOnchainZaps.zap + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.AmountTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BitcoinTxIdTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BlockTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.ProofTag + +fun TagArray.txid() = firstNotNullOfOrNull(BitcoinTxIdTag::parse) + +fun TagArray.amountInSats() = firstNotNullOfOrNull(AmountTag::parse) + +fun TagArray.block() = firstNotNullOfOrNull(BlockTag::parse) + +fun TagArray.proof() = firstNotNullOfOrNull(ProofTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/AmountTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/AmountTag.kt new file mode 100644 index 0000000000..cff5db482f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/AmountTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.nipBCOnchainZaps.zap.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class AmountTag { + companion object { + const val TAG_NAME = "amount" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(amountInSats: Long) = arrayOf(TAG_NAME, amountInSats.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BitcoinTxIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BitcoinTxIdTag.kt new file mode 100644 index 0000000000..b4972fc327 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BitcoinTxIdTag.kt @@ -0,0 +1,68 @@ +/* + * 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.quartz.nipBCOnchainZaps.zap.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +class BitcoinTxIdTag { + companion object { + const val TAG_NAME = "i" + const val PREFIX = "bitcoin:tx:" + const val TXID_LENGTH = 64 + + fun isTag(tag: Array) = + tag.has(1) && + tag[0] == TAG_NAME && + tag[1].startsWith(PREFIX) && + tag[1].length == PREFIX.length + TXID_LENGTH + + fun isTagged( + tag: Array, + txid: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == assembleScope(txid) + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].startsWith(PREFIX)) { return null } + + val txid = tag[1].substring(PREFIX.length) + ensure(txid.length == TXID_LENGTH) { return null } + ensure(txid == txid.lowercase()) { return null } + ensure(Hex.isHex(txid)) { return null } + + return txid + } + + fun parseScope(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].startsWith(PREFIX)) { return null } + return tag[1] + } + + fun assembleScope(txid: String) = PREFIX + txid.lowercase() + + fun assemble(txid: String) = arrayOf(TAG_NAME, assembleScope(txid)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BlockTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BlockTag.kt new file mode 100644 index 0000000000..a2e5fec2a9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BlockTag.kt @@ -0,0 +1,65 @@ +/* + * 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.quartz.nipBCOnchainZaps.zap.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +data class BlockTag( + val blockHashHex: String, + val height: Long, +) { + fun toTagArray() = assemble(blockHashHex, height) + + companion object { + const val TAG_NAME = "block" + const val BLOCK_HASH_LENGTH = 64 + + fun isTag(tag: Array) = + tag.has(2) && + tag[0] == TAG_NAME && + tag[1].length == BLOCK_HASH_LENGTH && + tag[2].isNotEmpty() + + fun parse(tag: Array): BlockTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == BLOCK_HASH_LENGTH) { return null } + ensure(Hex.isHex(tag[1])) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val height = tag[2].toLongOrNull() ?: return null + ensure(height >= 0) { return null } + + return BlockTag(tag[1].lowercase(), height) + } + + fun assemble( + blockHashHex: String, + height: Long, + ) = arrayOf(TAG_NAME, blockHashHex.lowercase(), height.toString()) + + fun assemble(block: BlockTag) = assemble(block.blockHashHex, block.height) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/ProofTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/ProofTag.kt new file mode 100644 index 0000000000..8586fc8836 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/ProofTag.kt @@ -0,0 +1,60 @@ +/* + * 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.quartz.nipBCOnchainZaps.zap.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +data class ProofTag( + val rawTxHex: String, + val merkleProofHex: String, +) { + fun toTagArray() = assemble(rawTxHex, merkleProofHex) + + companion object { + const val TAG_NAME = "proof" + + fun isTag(tag: Array) = + tag.has(2) && + tag[0] == TAG_NAME && + tag[1].isNotEmpty() + + fun parse(tag: Array): ProofTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(Hex.isHex(tag[1])) { return null } + ensure(Hex.isHex(tag[2])) { return null } + + return ProofTag(tag[1].lowercase(), tag[2].lowercase()) + } + + fun assemble( + rawTxHex: String, + merkleProofHex: String, + ) = arrayOf(TAG_NAME, rawTxHex.lowercase(), merkleProofHex.lowercase()) + + fun assemble(proof: ProofTag) = assemble(proof.rawTxHex, proof.merkleProofHex) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index d3b80ffe76..993c414635 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -286,6 +286,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -517,6 +518,7 @@ class EventFactory { NIP90EventPublishScheduleResponseEvent.KIND -> NIP90EventPublishScheduleResponseEvent(id, pubKey, createdAt, tags, content, sig) NIP90EventPowDelegationRequestEvent.KIND -> NIP90EventPowDelegationRequestEvent(id, pubKey, createdAt, tags, content, sig) NIP90EventPowDelegationResponseEvent.KIND -> NIP90EventPowDelegationResponseEvent(id, pubKey, createdAt, tags, content, sig) + OnchainZapEvent.KIND -> OnchainZapEvent(id, pubKey, createdAt, tags, content, sig) OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) PaymentTargetsEvent.KIND -> PaymentTargetsEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) From 79f1d43581274a64751d4e9afd2fdd2c7c86f58d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 04:34:30 +0000 Subject: [PATCH 02/19] feat(quartz): NIP-BC onchain zap receive-side foundation Phase A.1 of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md): the verify/receive path of NIP-BC. Send-side (PSBT, builder, signPsbt) is the next phase. Adds: - nipBCOnchainZaps/taproot/SegwitAddress: BIP-173 / BIP-350 native segwit address encoder/decoder, witness v0..v16, on top of the existing Bech32 util. - nipBCOnchainZaps/taproot/TaprootAddress: BIP-341 key-path-only derivation from a Nostr pubkey. Computes the TapTweak tagged hash, applies the additive tweak via Secp256k1Instance.pubKeyTweakAdd, and encodes as bc1p... bech32m. - Secp256k1Instance: new pubKeyTweakAdd primitive wired through commonMain expect + jvm / android / native actuals. - nipBCOnchainZaps/chain: pluggable OnchainBackend interface plus BitcoinTx, BitcoinTxOutput, Utxo, FeeEstimates data models. - nipBCOnchainZaps/chain/EsploraBackend (jvmAndroid): OkHttp-based Esplora-compatible client (mempool.space / blockstream.info) covering /tx, /address/{addr}/utxo, /tx broadcast, /blocks/tip/height, and /v1/fees/recommended. - nipBCOnchainZaps/verify/OnchainZapVerifier: enforces every NIP-BC client rule -- reject self-zap, fetch tx, derive recipient Taproot scriptPubKey, sum only outputs paying the recipient (excluding change back to sender), cap claimed amount at verified amount, mark unconfirmed as Pending. Tests (commonTest, runs on jvmTest): - SegwitAddressTest: BIP-350 known-good vectors for v0/v16 + round-trip for v1 (P2TR). - TaprootAddressTest: BIP-341 wallet-test-vectors tagged hash + tweaked output key + scriptPubKey, plus address round-trip and prefix/length sanity. - OnchainZapVerifierTest: confirmed / pending / self-zap / missing tx / zero-verified-amount / multi-output-sum / amount-capping with an in-memory FakeBackend wired to real TaprootAddress-derived scripts. Plan: amethyst/plans/2026-05-14-onchain-zaps.md documents the full v1 scope, the merge into the existing NIP-47 wallet UI, the subscription kind-list edits, and the Note.zapsAmount fold-in approach for display. --- amethyst/plans/2026-05-14-onchain-zaps.md | 170 +++++++++++++ .../quartz/utils/Secp256k1Instance.android.kt | 8 + .../nipBCOnchainZaps/chain/BitcoinTx.kt | 82 +++++++ .../nipBCOnchainZaps/chain/OnchainBackend.kt | 53 ++++ .../nipBCOnchainZaps/taproot/SegwitAddress.kt | 131 ++++++++++ .../taproot/TaprootAddress.kt | 114 +++++++++ .../verify/OnchainZapVerifier.kt | 115 +++++++++ .../verify/VerifiedOnchainZap.kt | 82 +++++++ .../quartz/utils/Secp256k1Instance.kt | 15 ++ .../taproot/SegwitAddressTest.kt | 114 +++++++++ .../taproot/TaprootAddressTest.kt | 89 +++++++ .../verify/OnchainZapVerifierTest.kt | 210 ++++++++++++++++ .../nipBCOnchainZaps/chain/EsploraBackend.kt | 228 ++++++++++++++++++ .../quartz/utils/Secp256k1Instance.jvm.kt | 8 + .../quartz/utils/Secp256k1Instance.native.kt | 8 + 15 files changed, 1427 insertions(+) create mode 100644 amethyst/plans/2026-05-14-onchain-zaps.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddress.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/VerifiedOnchainZap.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt diff --git a/amethyst/plans/2026-05-14-onchain-zaps.md b/amethyst/plans/2026-05-14-onchain-zaps.md new file mode 100644 index 0000000000..38c13af28e --- /dev/null +++ b/amethyst/plans/2026-05-14-onchain-zaps.md @@ -0,0 +1,170 @@ +# Onchain Zaps in Amethyst + +**Date:** 2026-05-14 +**Status:** Active + +Implementation plan for NIP-BC (kind 8333) onchain Bitcoin zaps in Amethyst Android. +Quartz now ships the `OnchainZapEvent` data model; this document describes the +rest of the system. + +## Design decisions + +- **Wallet model.** Non-custodial. The user's Nostr pubkey IS the BIP-341 + internal key of a P2TR output, so every account has exactly one onchain + address derived from its identity. No seed phrase, no separate wallet + creation. +- **Signers.** v1 supports `NostrSignerInternal` (local keypair) and + `NostrSignerExternal` (NIP-55 Android external signer). The NIP-55 path is + broken until Amber implements `sign_psbt`; surface "update your signer" + there. `NostrSignerRemote` (NIP-46) is deferred. +- **Chain backend.** User-configured Esplora-compatible API + (mempool.space, blockstream.info, self-hosted). The configured server sees + the user's UTXO queries — accepted tradeoff for v1. Header-only SPV mode is + a future-phase add. +- **Scope.** Full send + receive + display loop on Android. Desktop is out of + scope for v1. + +## Architecture + +| Layer | Concerns | Location | +|---|---|---| +| Quartz | Address derivation, PSBT codec, Esplora client, NIP-BC verifier, `signPsbt` on signer hierarchy | `quartz/.../nipBCOnchainZaps/{taproot,psbt,chain,build,verify}/` | +| Commons | Send/wallet ViewModels, shared composables | `commons/.../onchain/` | +| Amethyst | `OnchainSection` in existing `WalletScreen`, `LocalCache.consume(OnchainZapEvent)`, kind list edits in 8 filter files, NIP-55 intent plumbing | `amethyst/.../ui/onchain/`, `amethyst/.../model/LocalCache.kt`, existing filter files | + +## Merging into the existing wallet UI + +The existing `WalletScreen` is a NIP-47 NWC multi-wallet manager. Onchain wallet +fits as a separate top section, since it has different semantics (single +deterministic wallet per account, no NWC URI, chain-backed). + +``` +WalletScreen +├── TopAppBar +├── BitcoinSection ← NEW, single card +│ └── OnchainWalletCard (balance, bc1p address, tap → OnchainWalletDetailScreen) +└── LightningSection ← existing MultiWalletHomeContent + ├── NoWalletSetup + └── NwcWalletCard × N +``` + +The "+" `Add wallet` icon still adds NWC entries only — onchain is implicit. +Section labels: "Bitcoin" and "Lightning". + +## Subscription edits — extend existing kind lists + +No new assemblers. Add `OnchainZapEvent.KIND` (8333) to the existing +`LnZapEvent.KIND` (9735) sites: + +| File | Edit | +|---|---| +| `amethyst/.../FilterRepliesAndReactionsToNotes.kt:48-60` | Add to `RepliesAndReactionsKinds` (note `#e`) | +| `amethyst/.../FilterUserProfileZapReceived.kt:30` | Add to `UserProfileZapReceiverKinds` (profile `#p`) | +| `amethyst/.../zaps/dal/UserProfileZapsViewModel.kt:55` | Add to inline `kinds = listOf(...)` | +| `amethyst/.../FilterNotificationsToPubkey.kt:58-65` | Add to `SummaryKinds` (notifications `#p`) | +| `amethyst/.../NotificationFeedFilter.kt:123` | Add to `NOTIFICATION_KINDS` | +| `amethyst/.../NotificationDispatcher.kt:98` | Add to `NOTIFICATION_KINDS` | +| `amethyst/.../FilterMessagesToLiveStream.kt:46` | Add to live-activity zap kinds (`#a`) | +| `amethyst/.../FilterGoalForLiveActivity.kt:58` | Add to goal-zap kinds (`#e`) | + +## Display path — fold into `Note.zapsAmount` + +Today: `LocalCache.consume(LnZapEvent)` → `Note.addZap()` → `Note.updateZapTotal()` +sums lightning amounts into `Note.zapsAmount`, which `ReactionsRow` / +`ObserveZapAmountText` / `SlidingAnimationAmount` render. We add onchain zap +sats to the same `Note.zapsAmount` — no UI changes required. + +- `commons/.../model/Note.kt:154-157, 621-632` + - Add `var onchainZaps = mapOf<...>()` (separate map from `zaps`). + - Extend `updateZapTotal()` to add **verified** onchain sats. Unverified or + pending tx amounts are NOT counted. +- `amethyst/.../model/LocalCache.kt` (after the `consume(LnZapEvent)` block ~line 1667) + - New `consume(event: OnchainZapEvent)` handler. + - Reject self-zap. + - Enqueue verification against the configured `OnchainBackend`. + - On success: `Note.addOnchainZap(event, verifiedSats)` on each `repliesTo`. + - On failure or zero verified amount: discard. + - Dedupe by `(txid, target)`. + +## Quartz additions + +- `nipBCOnchainZaps/taproot/` + - `SegwitAddress.kt` — bech32m segwit address encoder/decoder + - `TaprootAddress.kt` — Nostr pubkey → bc1p P2TR address via BIP-341 + key-path-only tweak (uses `Secp256k1Instance.pubKeyTweakAdd`) +- `nipBCOnchainZaps/chain/` + - `OnchainBackend` interface — `getTx`, `getUtxos`, `broadcast`, + `tipHeight`, `feeEstimates` + - `BitcoinTx`, `BitcoinTxOutput`, `Utxo` data models + - `BitcoinTxParser` — minimal raw-tx parser (just enough for verification) + - `EsploraBackend` (jvmAndroid) — OkHttp impl +- `nipBCOnchainZaps/verify/` + - `OnchainZapVerifier` — implements all spec rules: reject self-zap, sum + only outputs paying the derived recipient address, dedupe `(txid, target)`, + cap claimed amount at verified amount +- `nipBCOnchainZaps/psbt/` (Phase A.2) + - Minimal BIP-174 codec + - `PsbtTaprootKeyPathSigner` — BIP-341 TapTweak + Schnorr sign +- `nipBCOnchainZaps/build/` (Phase A.2) + - `OnchainZapBuilder` — given (sender, recipient, sats, feeRate, utxos), + build a PSBT with change back to sender's Taproot +- `NostrSigner.signPsbt` (Phase A.2) + - Internal: signs directly + - External (NIP-55): launches `sign_psbt` intent — needs Amber update + - Remote (NIP-46): `NotSupportedException` stub + +## Commons additions + +- `OnchainWalletViewModel` — derived address, balance, UTXO list +- `OnchainZapSendViewModel` — build → sign → broadcast → publish kind 8333 +- `OnchainZapSendDialog`, `OnchainAddressQrCard`, `FeeRatePicker` + +## Account state + +In `AccountSettings.kt` next to `nwcWallets`: + +```kotlin +val onchainEsploraEndpoint: MutableStateFlow // default mempool.space +val onchainDefaultFeeTier: MutableStateFlow // SLOW / NORMAL / FAST +``` + +Derived `Account.onchainBalance: StateFlow` populated by +`OnchainWalletViewModel`. + +## Send-from-note merge + +Existing `ZapAmountChoicePopup` (`ReactionsRow.kt:1877-1977`) stays as the +Lightning fast path. Two minimal hooks: + +1. Append `[ ⛓ Onchain… ]` row to the popup. Tapping it opens + `OnchainZapSendDialog` (fee picker + confirmation), separate from the + instant-tap Lightning UX. +2. Optional settings toggle "Show onchain zap option" — defaults off until a + balance is observed. + +## Phased delivery + +| Phase | Deliverable | Status | +|---|---|---| +| **A.1** | Quartz foundation (receive side): taproot address, bech32m segwit, Esplora client, tx parser, verifier | In progress | +| **A.2** | Quartz foundation (send side): PSBT codec, builder, `signPsbt` on signer hierarchy | Pending | +| **B** | NIP-55 `sign_psbt` intent contract + `NostrSignerExternal.signPsbt` | Pending | +| **C** | Receive + display: `OnchainSection` in `WalletScreen`, Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 8 filter files | Pending | +| **D** | Send: dialog in zap menu, UTXO selection, build/sign/broadcast, publish | Pending | + +## Risks / open questions + +- **secp256k1-kmp tweak coverage** — pubKeyTweakAdd exists on JVM/Android/JNI + but not on the pure-Kotlin native impl. iOS support deferred until upstream + ships it or we contribute. +- **PSBT correctness** — single-key-path-only is a small surface; still needs + thorough testing against BIP-174 test vectors before any user funds move. +- **Esplora privacy** — the configured server sees user UTXO queries. Default + to a reputable provider, make user-configurable, consider future Tor option. +- **NIP-55 ecosystem** — Amber must implement `sign_psbt` for external signer + accounts to use this feature. +- **`Note.zaps` shape** — separate `onchainZaps` map vs sealed-type fold-in. + Leaning separate map for v1. +- **Verification network calls from `LocalCache`** — `consume` runs on the + relay thread; verification needs a coroutine scope + `OnchainBackend` + instance injected from `Account` on app start. diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt index 725c8fedfc..f142e506bd 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt @@ -64,4 +64,12 @@ actual object Secp256k1Instance { pubKey: ByteArray, privateKey: ByteArray, ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + + actual fun pubKeyTweakAdd( + pubKey: ByteArray, + tweak: ByteArray, + ): ByteArray { + val full = if (pubKey.size == 32) h02 + pubKey else pubKey + return secp256k1.pubKeyCompress(secp256k1.pubKeyTweakAdd(full, tweak)) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt new file mode 100644 index 0000000000..f492bfee83 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt @@ -0,0 +1,82 @@ +/* + * 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.quartz.nipBCOnchainZaps.chain + +import androidx.compose.runtime.Immutable + +/** + * Minimal view of a confirmed or mempool Bitcoin transaction — just the + * fields NIP-BC verification needs. + * + * @property txid 64-char lowercase hex transaction id + * @property outputs Output list (index = vout) + * @property confirmations 0 if unconfirmed; height-based when known + * @property blockHashHex Hash of the block that confirmed this tx, if any + * @property blockHeight Height of the block that confirmed this tx, if any + */ +@Immutable +data class BitcoinTx( + val txid: String, + val outputs: List, + val confirmations: Int, + val blockHashHex: String? = null, + val blockHeight: Long? = null, +) + +/** + * One output of a Bitcoin transaction. + * + * @property index vout index + * @property valueSats output value in satoshis + * @property scriptPubKeyHex lowercase-hex scriptPubKey bytes + */ +@Immutable +data class BitcoinTxOutput( + val index: Int, + val valueSats: Long, + val scriptPubKeyHex: String, +) + +/** + * An unspent transaction output the wallet can spend. + * + * @property txid 64-char lowercase hex of the funding transaction + * @property vout output index in the funding transaction + * @property valueSats value in satoshis + * @property confirmations confirmation count (0 for mempool) + */ +@Immutable +data class Utxo( + val txid: String, + val vout: Int, + val valueSats: Long, + val confirmations: Int, +) + +/** + * Recommended fee rates in sats per vbyte, as reported by the backend. + */ +@Immutable +data class FeeEstimates( + val fastSatPerVbyte: Double, + val normalSatPerVbyte: Double, + val slowSatPerVbyte: Double, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt new file mode 100644 index 0000000000..8610ea146a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt @@ -0,0 +1,53 @@ +/* + * 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.quartz.nipBCOnchainZaps.chain + +/** + * Pluggable Bitcoin chain data source. + * + * Implementations talk to an Esplora-compatible HTTP API, a Bitcoin Core + * node, an Electrum server, or a local SPV verifier. + */ +interface OnchainBackend { + /** Fetch a transaction by txid. Returns null if the backend has no record of it. */ + suspend fun getTx(txid: String): BitcoinTx? + + /** Fetch the spendable UTXOs paying `address` (bech32m taproot for NIP-BC). */ + suspend fun getUtxosForAddress(address: String): List + + /** Broadcast a fully signed transaction (lowercase hex). Returns the txid. */ + suspend fun broadcast(rawTxHex: String): String + + /** Current chain tip height. */ + suspend fun tipHeight(): Long + + /** Recommended fee rates from the backend. */ + suspend fun feeEstimates(): FeeEstimates +} + +/** + * Thrown by [OnchainBackend] implementations when the underlying network or + * remote API fails. Wraps the original cause where useful. + */ +class OnchainBackendException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddress.kt new file mode 100644 index 0000000000..057508d365 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddress.kt @@ -0,0 +1,131 @@ +/* + * 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.quartz.nipBCOnchainZaps.taproot + +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 + +/** + * BIP-173 / BIP-350 segwit native address encoder/decoder. + * + * NIP-BC uses witness version 1 (taproot) with a 32-byte program, encoded as + * bech32m with HRP `bc` (Bitcoin mainnet only). + */ +object SegwitAddress { + /** Mainnet human-readable prefix. */ + const val HRP_MAINNET = "bc" + + /** Taproot witness version. */ + const val TAPROOT_WITNESS_VERSION = 1 + + /** + * Encode a witness program to a segwit address. + * + * @param hrp Human-readable prefix (`bc` for mainnet). + * @param witnessVersion Witness version (0 for v0, 1 for taproot). + * @param program Witness program bytes (32 bytes for v1 taproot). + */ + fun encode( + hrp: String, + witnessVersion: Int, + program: ByteArray, + ): String { + require(witnessVersion in 0..16) { "invalid witness version $witnessVersion" } + require(program.size in 2..40) { "invalid witness program length ${program.size}" } + if (witnessVersion == 0) { + require(program.size == 20 || program.size == 32) { + "witness v0 program must be 20 or 32 bytes (got ${program.size})" + } + } + + val data = ArrayList(1 + program.size * 2) + data.add(witnessVersion.toByte()) + data.addAll(Bech32.eight2five(program)) + + val encoding = + if (witnessVersion == 0) Bech32.Encoding.Bech32 else Bech32.Encoding.Bech32m + + return Bech32.encode(hrp, ArrayList(data), encoding) + } + + /** Encode a taproot (witness v1) output key as a `bc1p...` address. */ + fun encodeP2TR( + outputKey: ByteArray, + hrp: String = HRP_MAINNET, + ): String { + require(outputKey.size == 32) { + "taproot output key must be 32 bytes (got ${outputKey.size})" + } + return encode(hrp, TAPROOT_WITNESS_VERSION, outputKey) + } + + /** + * Decoded segwit address. + * + * @property hrp Human-readable prefix. + * @property witnessVersion Witness version (0-16). + * @property program Witness program bytes. + */ + data class Decoded( + val hrp: String, + val witnessVersion: Int, + val program: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Decoded) return false + return hrp == other.hrp && + witnessVersion == other.witnessVersion && + program.contentEquals(other.program) + } + + override fun hashCode(): Int { + var result = hrp.hashCode() + result = 31 * result + witnessVersion + result = 31 * result + program.contentHashCode() + return result + } + } + + /** Decode a segwit address, validating HRP, version, encoding, and program length. */ + fun decode(address: String): Decoded { + val (hrp, data, encoding) = Bech32.decode(address) + require(data.isNotEmpty()) { "empty data" } + + val witnessVersion = data[0].toInt() and 0x1f + require(witnessVersion in 0..16) { "invalid witness version $witnessVersion" } + + val expectedEncoding = + if (witnessVersion == 0) Bech32.Encoding.Bech32 else Bech32.Encoding.Bech32m + require(encoding == expectedEncoding) { + "wrong checksum encoding for witness version $witnessVersion" + } + + val program = Bech32.five2eight(data, 1) + require(program.size in 2..40) { "invalid witness program length ${program.size}" } + if (witnessVersion == 0) { + require(program.size == 20 || program.size == 32) { + "witness v0 program must be 20 or 32 bytes" + } + } + + return Decoded(hrp, witnessVersion, program) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt new file mode 100644 index 0000000000..1f0cbffe12 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt @@ -0,0 +1,114 @@ +/* + * 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.quartz.nipBCOnchainZaps.taproot + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * BIP-341 key-path-only taproot address derivation from a Nostr public key. + * + * NIP-BC uses the Nostr pubkey directly as the BIP-341 internal key with no + * script tree. The output key is the tweaked internal key, encoded as a + * bech32m P2TR address on Bitcoin mainnet. + * + * `Q = lift_x(P) + int(hashTapTweak(bytes(P)))·G` + * + * where `bytes(P)` is the 32-byte x-only Nostr pubkey and `hashTapTweak` is + * the BIP-340 tagged hash with tag `"TapTweak"`. + */ +object TaprootAddress { + private const val TAP_TWEAK_TAG = "TapTweak" + + private val tapTweakTagHash: ByteArray by lazy { + sha256(TAP_TWEAK_TAG.encodeToByteArray()) + } + + /** + * Compute the BIP-341 tagged hash `tagged_hash("TapTweak", data)`. + * + * Tagged hash is defined as + * `SHA256(SHA256(tag) || SHA256(tag) || data)`. + */ + fun tapTweakHash(internalKey: ByteArray): ByteArray { + require(internalKey.size == 32) { + "internal key must be 32 bytes (got ${internalKey.size})" + } + val buf = ByteArray(64 + internalKey.size) + tapTweakTagHash.copyInto(buf, 0) + tapTweakTagHash.copyInto(buf, 32) + internalKey.copyInto(buf, 64) + return sha256(buf) + } + + /** + * Apply the BIP-341 key-path-only tweak to an internal key. + * + * @return the 32-byte x-only output key (Q.x). + */ + fun tweakOutputKey(internalKey: ByteArray): ByteArray { + require(internalKey.size == 32) { + "internal key must be 32 bytes (got ${internalKey.size})" + } + val tweak = tapTweakHash(internalKey) + // Returns compressed (33-byte) point; drop the parity byte for the x-only output. + val tweaked = Secp256k1Instance.pubKeyTweakAdd(internalKey, tweak) + require(tweaked.size == 33) { "expected compressed tweaked point" } + return tweaked.copyOfRange(1, 33) + } + + /** + * Derive the Bitcoin mainnet taproot address (`bc1p...`) for a Nostr + * public key. The pubkey is used directly as the BIP-341 internal key. + */ + fun fromPubKey(pubKey: HexKey): String { + val bytes = pubKey.hexToByteArray() + require(bytes.size == 32) { "Nostr pubkey must be 32 bytes" } + return SegwitAddress.encodeP2TR(tweakOutputKey(bytes)) + } + + /** Derive the Bitcoin mainnet taproot address from a 32-byte x-only key. */ + fun fromPubKey(pubKey: ByteArray): String { + require(pubKey.size == 32) { "x-only pubkey must be 32 bytes" } + return SegwitAddress.encodeP2TR(tweakOutputKey(pubKey)) + } + + /** Produce the BIP-341 P2TR scriptPubKey for the given output key: `OP_1 <32-byte-x-only>`. */ + fun outputKeyToScriptPubKey(outputKey: ByteArray): ByteArray { + require(outputKey.size == 32) { + "taproot output key must be 32 bytes (got ${outputKey.size})" + } + val out = ByteArray(34) + out[0] = 0x51 // OP_1 + out[1] = 0x20 // push 32 bytes + outputKey.copyInto(out, 2) + return out + } + + /** Produce the BIP-341 scriptPubKey for the recipient of a Nostr pubkey. */ + fun scriptPubKeyForRecipient(pubKey: HexKey): ByteArray = outputKeyToScriptPubKey(tweakOutputKey(pubKey.hexToByteArray())) + + /** Hex of the BIP-341 scriptPubKey for the recipient — convenient for tx-output matching. */ + fun scriptPubKeyHexForRecipient(pubKey: HexKey): String = scriptPubKeyForRecipient(pubKey).toHexKey() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt new file mode 100644 index 0000000000..2e8185fac7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt @@ -0,0 +1,115 @@ +/* + * 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.quartz.nipBCOnchainZaps.verify + +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent + +/** + * NIP-BC verifier — validates a [OnchainZapEvent] against the configured + * chain backend and returns a [VerifiedOnchainZap] result. + * + * Implements the spec's verification rules: + * 1. Parse the txid from the `i` tag. + * 2. Fetch the transaction from the backend. + * 3. Derive the recipient's expected Taproot scriptPubKey from the `p` tag. + * 4. Sum the values of all outputs matching that scriptPubKey. Change outputs + * paying back to the sender's own derived script MUST NOT be counted. + * 5. If verified amount is 0 → discard. + * 6. Self-zaps (sender == recipient) → discard. + * 7. Pending (unconfirmed) txs are returned as [VerifiedOnchainZap.Pending]; + * callers SHOULD exclude them from aggregate totals. + * + * Deduplication by `(txid, target)` is the caller's responsibility — typically + * done in `LocalCache`. + */ +class OnchainZapVerifier( + private val backend: OnchainBackend, +) { + suspend fun verify(event: OnchainZapEvent): VerifiedOnchainZap { + val txid = + event.txid() + ?: return VerifiedOnchainZap.Rejected("", VerifiedOnchainZap.Rejected.Reason.MISSING_TXID) + + val recipientPubKey = + event.recipient() + ?: return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.MISSING_RECIPIENT) + + // Anti-spoofing rule: self-zaps contribute nothing meaningful. + if (event.pubKey.equals(recipientPubKey, ignoreCase = true)) { + return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.SELF_ZAP) + } + + val tx = + backend.getTx(txid) + ?: return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND) + + val recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientPubKey).lowercase() + val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(event.pubKey).lowercase() + + val verifiedSats = sumOutputsToRecipient(tx, recipientScriptHex, senderScriptHex) + + if (verifiedSats == 0L) { + return VerifiedOnchainZap.Rejected( + txid, + VerifiedOnchainZap.Rejected.Reason.ZERO_VERIFIED_AMOUNT, + ) + } + + return if (tx.confirmations > 0) { + VerifiedOnchainZap.Confirmed( + txid = txid, + recipientPubKey = recipientPubKey, + verifiedSats = verifiedSats, + confirmations = tx.confirmations, + blockHeight = tx.blockHeight, + blockHashHex = tx.blockHashHex, + ) + } else { + VerifiedOnchainZap.Pending( + txid = txid, + recipientPubKey = recipientPubKey, + verifiedSats = verifiedSats, + ) + } + } + + /** + * Sum the value of outputs paying [recipientScriptHex]. Outputs paying + * back to [senderScriptHex] are change and MUST NOT be counted. + */ + private fun sumOutputsToRecipient( + tx: BitcoinTx, + recipientScriptHex: String, + senderScriptHex: String, + ): Long { + var sum = 0L + for (out in tx.outputs) { + val script = out.scriptPubKeyHex.lowercase() + if (script == recipientScriptHex && script != senderScriptHex) { + sum += out.valueSats + } + } + return sum + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/VerifiedOnchainZap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/VerifiedOnchainZap.kt new file mode 100644 index 0000000000..1549321b45 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/VerifiedOnchainZap.kt @@ -0,0 +1,82 @@ +/* + * 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.quartz.nipBCOnchainZaps.verify + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Result of verifying a NIP-BC onchain zap event against the chain. + * + * Clients SHOULD display [verifiedSats], not the sender-claimed amount. + */ +@Immutable +sealed interface VerifiedOnchainZap { + val txid: String + + /** The event is valid and the on-chain transaction pays the recipient. */ + @Immutable + data class Confirmed( + override val txid: String, + val recipientPubKey: HexKey, + val verifiedSats: Long, + val confirmations: Int, + val blockHeight: Long?, + val blockHashHex: String?, + ) : VerifiedOnchainZap + + /** The transaction exists but is not yet confirmed. */ + @Immutable + data class Pending( + override val txid: String, + val recipientPubKey: HexKey, + val verifiedSats: Long, + ) : VerifiedOnchainZap + + /** + * The event failed verification and SHOULD be discarded. + * + * @property reason Why the event was rejected; useful for debugging only — + * do not surface to users. + */ + @Immutable + data class Rejected( + override val txid: String, + val reason: Reason, + ) : VerifiedOnchainZap { + enum class Reason { + /** Sender equals recipient (self-zap). */ + SELF_ZAP, + + /** Transaction does not exist on the configured backend. */ + TX_NOT_FOUND, + + /** Transaction exists but pays the recipient zero satoshis. */ + ZERO_VERIFIED_AMOUNT, + + /** Event has no `i` tag or it's malformed. */ + MISSING_TXID, + + /** Event has no `p` tag identifying the recipient. */ + MISSING_RECIPIENT, + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt index 49c435f260..d3d5378047 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt @@ -58,4 +58,19 @@ expect object Secp256k1Instance { pubKey: ByteArray, privateKey: ByteArray, ): ByteArray + + /** + * BIP-341 / BIP-32 style additive tweak. + * + * Returns `pubKey + tweak·G` as a 33-byte compressed point. + * + * @param pubKey 32-byte x-only public key (the input is assumed to have the + * implicit even-y parity used by BIP-341 internal keys), or a + * 33-byte compressed public key. + * @param tweak 32-byte scalar. + */ + fun pubKeyTweakAdd( + pubKey: ByteArray, + tweak: ByteArray, + ): ByteArray } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt new file mode 100644 index 0000000000..54ef5de44c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt @@ -0,0 +1,114 @@ +/* + * 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.quartz.nipBCOnchainZaps.taproot + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Tests for the segwit (BIP-173 / BIP-350) address encoder. + * + * Vectors come from BIP-350 and BIP-341. + */ +class SegwitAddressTest { + // ============================================================ + // BIP-350 / BIP-173 known-good vectors + // ============================================================ + + @Test + fun encodeMainnetWitnessV0_20Byte() { + // bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 + val program = "751e76e8199196d454941c45d1b3a323f1433bd6".hexToByteArray() + val address = SegwitAddress.encode("bc", 0, program) + assertEquals("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", address) + } + + @Test + fun encodeMainnetWitnessV16_2Byte() { + // BIP-350 short address: BC1SW50QGDZ25J (lowercased) + val program = "751e".hexToByteArray() + val address = SegwitAddress.encode("bc", 16, program) + assertEquals("bc1sw50qgdz25j", address) + } + + @Test + fun encodedTaprootIsLowercaseAndCorrectLength() { + // P2TR encoded address must be 62 chars, lowercase, with 'bc1p' prefix. + val outputKey = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray() + val address = SegwitAddress.encodeP2TR(outputKey) + assertEquals(62, address.length) + assertEquals(address, address.lowercase()) + assertEquals("bc1p", address.substring(0, 4)) + } + + // ============================================================ + // Round-trip + // ============================================================ + + @Test + fun roundTripTaproot() { + val outputKey = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray() + val address = SegwitAddress.encodeP2TR(outputKey) + val decoded = SegwitAddress.decode(address) + assertEquals("bc", decoded.hrp) + assertEquals(1, decoded.witnessVersion) + assertEquals(outputKey.toHexKey(), decoded.program.toHexKey()) + } + + @Test + fun roundTripV0_20Byte() { + val program = "751e76e8199196d454941c45d1b3a323f1433bd6".hexToByteArray() + val address = SegwitAddress.encode("bc", 0, program) + val decoded = SegwitAddress.decode(address) + assertEquals(0, decoded.witnessVersion) + assertEquals(program.toHexKey(), decoded.program.toHexKey()) + } + + // ============================================================ + // Validation + // ============================================================ + + @Test + fun rejectsTaprootProgramOfWrongLength() { + val tooShort = ByteArray(31) + assertFailsWith { + SegwitAddress.encodeP2TR(tooShort) + } + } + + @Test + fun rejectsInvalidWitnessVersion() { + assertFailsWith { + SegwitAddress.encode("bc", 17, ByteArray(32)) + } + } + + @Test + fun rejectsV0WrongProgramLength() { + assertFailsWith { + // v0 must be 20 or 32 bytes — 21 is invalid. + SegwitAddress.encode("bc", 0, ByteArray(21)) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt new file mode 100644 index 0000000000..4999795eed --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt @@ -0,0 +1,89 @@ +/* + * 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.quartz.nipBCOnchainZaps.taproot + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * BIP-341 key-path-only taproot derivation tests. + * + * Vectors come from the BIP-341 wallet-test-vectors (`keyPathSpending` cases + * with `scriptTree=null`). + */ +class TaprootAddressTest { + // ============================================================ + // BIP-341 key-path-only (no script tree) vector + // ============================================================ + + private val internalKey = "d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d" + private val expectedTweak = "b86e7be8f39bab32a6f2c0443abbc210f0edac0e2c53d501b36b64437d9c6c70" + private val expectedOutputKey = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" + private val expectedScriptPubKey = + "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" + + @Test + fun computesTaggedHash() { + val tweak = TaprootAddress.tapTweakHash(internalKey.hexToByteArray()) + assertEquals(expectedTweak, tweak.toHexKey()) + } + + @Test + fun tweaksToOutputKey() { + val outputKey = TaprootAddress.tweakOutputKey(internalKey.hexToByteArray()) + assertEquals(expectedOutputKey, outputKey.toHexKey()) + } + + @Test + fun computesScriptPubKey() { + val script = TaprootAddress.scriptPubKeyForRecipient(internalKey) + assertEquals(expectedScriptPubKey, script.toHexKey()) + } + + @Test + fun derivedAddressRoundTripsToOutputKey() { + // The address itself is the bech32m encoding of (v1, output_key). We + // don't hard-code a specific bech32m string here because some + // historical BIP-341 wallet vectors used post-Taproot-Schnorr key + // adjustments — instead, verify the address decodes back to the + // expected output key. + val address = TaprootAddress.fromPubKey(internalKey) + val decoded = SegwitAddress.decode(address) + assertEquals(1, decoded.witnessVersion) + assertEquals(expectedOutputKey, decoded.program.toHexKey()) + } + + // ============================================================ + // Sanity: address prefix + length + // ============================================================ + + @Test + fun derivedAddressHasTaprootPrefix() { + // Use a different x-only key; output should still be a bc1p taproot address. + val pubKey = "8b34731183a85a4fc1a3ea9e8caa14e72ff31716bf6dd0d2f8c93f5b14e44f5d" + val address = TaprootAddress.fromPubKey(pubKey) + assertTrue(address.startsWith("bc1p"), "expected bc1p..., got $address") + assertEquals(62, address.length, "P2TR address must be 62 chars") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt new file mode 100644 index 0000000000..8537a97591 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt @@ -0,0 +1,210 @@ +/* + * 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.quartz.nipBCOnchainZaps.verify + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTxOutput +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.AmountTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BitcoinTxIdTag +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Verification logic tests using an in-memory fake [OnchainBackend]. + * + * Crypto-derived recipient scripts come from the real `TaprootAddress` + * implementation so the tests cover the full sum-matching-outputs path. + */ +class OnchainZapVerifierTest { + // Two arbitrary x-only Nostr pubkeys. + private val senderHex = "d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d" + private val recipientHex = "8b34731183a85a4fc1a3ea9e8caa14e72ff31716bf6dd0d2f8c93f5b14e44f5d" + + private val recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientHex).lowercase() + private val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(senderHex).lowercase() + + private val txid = "a".repeat(64) + + private fun mkEvent( + sender: HexKey = senderHex, + recipient: HexKey = recipientHex, + amountSats: Long = 25000L, + txidValue: String = txid, + ): OnchainZapEvent { + val tags = + arrayOf( + BitcoinTxIdTag.assemble(txidValue), + arrayOf("p", recipient), + AmountTag.assemble(amountSats), + arrayOf("alt", "Onchain zap"), + ) + return OnchainZapEvent( + id = "b".repeat(64), + pubKey = sender, + createdAt = 0L, + tags = tags, + content = "", + sig = "c".repeat(128), + ) + } + + private class FakeBackend( + private val tx: BitcoinTx?, + ) : OnchainBackend { + override suspend fun getTx(txid: String): BitcoinTx? = if (tx?.txid == txid) tx else null + + override suspend fun getUtxosForAddress(address: String): List = emptyList() + + override suspend fun broadcast(rawTxHex: String): String = throw UnsupportedOperationException() + + override suspend fun tipHeight(): Long = 0L + + override suspend fun feeEstimates(): FeeEstimates = FeeEstimates(20.0, 10.0, 5.0) + } + + @Test + fun confirmedZap() = + runTest { + val tx = + BitcoinTx( + txid = txid, + outputs = + listOf( + BitcoinTxOutput(0, 25000L, recipientScriptHex), + BitcoinTxOutput(1, 99000L, senderScriptHex), // change + ), + confirmations = 3, + blockHashHex = "f".repeat(64), + blockHeight = 800_000L, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent()) + + assertIs(result) + assertEquals(25000L, result.verifiedSats) + assertEquals(recipientHex, result.recipientPubKey) + assertEquals(800_000L, result.blockHeight) + } + + @Test + fun sumsMultipleOutputsToSameRecipient() = + runTest { + val tx = + BitcoinTx( + txid = txid, + outputs = + listOf( + BitcoinTxOutput(0, 10000L, recipientScriptHex), + BitcoinTxOutput(1, 15000L, recipientScriptHex), + BitcoinTxOutput(2, 50000L, senderScriptHex), + ), + confirmations = 1, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent()) + + assertIs(result) + assertEquals(25000L, result.verifiedSats) + } + + @Test + fun rejectsSelfZap() = + runTest { + val verifier = OnchainZapVerifier(FakeBackend(tx = null)) + val result = verifier.verify(mkEvent(sender = recipientHex, recipient = recipientHex)) + + assertIs(result) + assertEquals(VerifiedOnchainZap.Rejected.Reason.SELF_ZAP, result.reason) + } + + @Test + fun rejectsMissingTransaction() = + runTest { + val verifier = OnchainZapVerifier(FakeBackend(tx = null)) + val result = verifier.verify(mkEvent()) + + assertIs(result) + assertEquals(VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND, result.reason) + } + + @Test + fun rejectsZeroVerifiedAmount() = + runTest { + // TX exists but pays no output to the recipient. + val tx = + BitcoinTx( + txid = txid, + outputs = + listOf( + BitcoinTxOutput(0, 99000L, senderScriptHex), + ), + confirmations = 5, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent()) + + assertIs(result) + assertEquals(VerifiedOnchainZap.Rejected.Reason.ZERO_VERIFIED_AMOUNT, result.reason) + } + + @Test + fun pendingWhenUnconfirmed() = + runTest { + val tx = + BitcoinTx( + txid = txid, + outputs = listOf(BitcoinTxOutput(0, 7000L, recipientScriptHex)), + confirmations = 0, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent(amountSats = 7000L)) + + assertIs(result) + assertEquals(7000L, result.verifiedSats) + } + + @Test + fun capsClaimedAmountAtVerified() = + runTest { + // Sender claims 1,000,000 but only 5,000 went to the recipient. + val tx = + BitcoinTx( + txid = txid, + outputs = listOf(BitcoinTxOutput(0, 5000L, recipientScriptHex)), + confirmations = 1, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent(amountSats = 1_000_000L)) + + assertIs(result) + assertEquals(5000L, result.verifiedSats, "verifier must report the on-chain amount, not the claimed amount") + assertTrue(result.verifiedSats < 1_000_000L) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt new file mode 100644 index 0000000000..fc7534d5bc --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt @@ -0,0 +1,228 @@ +/* + * 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.quartz.nipBCOnchainZaps.chain + +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.utils.Log +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync + +/** + * [OnchainBackend] implementation that talks to an Esplora-compatible HTTP + * API (mempool.space, blockstream.info, self-hosted). + * + * The API surface used: + * - `GET /tx/{txid}` — transaction JSON + * - `GET /address/{addr}/utxo` — UTXO list for a derived address + * - `POST /tx` — broadcast (body = raw tx hex) + * - `GET /blocks/tip/height` — chain tip + * - `GET /v1/fees/recommended` — fee tier suggestions (mempool.space variant) + * + * The `baseUrl` is supplied as a function so callers can hot-swap endpoints + * from `AccountSettings` without rebuilding the backend. + * + * @param baseUrl Function returning the base URL with trailing slash stripped + * (e.g. "https://mempool.space/api"). + * @param client Shared OkHttp client. + */ +class EsploraBackend( + private val baseUrl: () -> String, + private val client: OkHttpClient, +) : OnchainBackend { + private val logTag = "EsploraBackend" + + override suspend fun getTx(txid: String): BitcoinTx? { + val url = "${baseUrl()}/tx/$txid" + val request = + Request + .Builder() + .header("Accept", "application/json") + .url(url) + .get() + .build() + return client.newCall(request).executeAsync().use { response -> + when { + response.code == 404 -> null + + response.isSuccessful -> parseTx(response.body.string()) + + else -> throw OnchainBackendException( + "GET $url failed: ${response.code} ${response.message}", + ) + } + } + } + + override suspend fun getUtxosForAddress(address: String): List { + val url = "${baseUrl()}/address/$address/utxo" + val request = + Request + .Builder() + .header("Accept", "application/json") + .url(url) + .get() + .build() + return client.newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw OnchainBackendException( + "GET $url failed: ${response.code} ${response.message}", + ) + } + parseUtxoList(response.body.string()) + } + } + + override suspend fun broadcast(rawTxHex: String): String { + val url = "${baseUrl()}/tx" + val request = + Request + .Builder() + .url(url) + .post(rawTxHex.toRequestBody("text/plain".toMediaType())) + .build() + return client.newCall(request).executeAsync().use { response -> + val body = response.body.string() + if (!response.isSuccessful) { + throw OnchainBackendException( + "POST $url failed: ${response.code} ${response.message} body=$body", + ) + } + body.trim() + } + } + + override suspend fun tipHeight(): Long { + val url = "${baseUrl()}/blocks/tip/height" + val request = + Request + .Builder() + .url(url) + .get() + .build() + return client.newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw OnchainBackendException( + "GET $url failed: ${response.code} ${response.message}", + ) + } + response.body + .string() + .trim() + .toLong() + } + } + + override suspend fun feeEstimates(): FeeEstimates { + // mempool.space-style recommended fees endpoint. + val url = "${baseUrl()}/v1/fees/recommended" + val request = + Request + .Builder() + .header("Accept", "application/json") + .url(url) + .get() + .build() + return client.newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw OnchainBackendException( + "GET $url failed: ${response.code} ${response.message}", + ) + } + parseFees(response.body.string()) + } + } + + private fun parseTx(json: String): BitcoinTx { + val node = JacksonMapper.mapper.readTree(json) + val txid = node["txid"].asText() + val status = node["status"] + val confirmed = status?.get("confirmed")?.asBoolean() == true + val blockHeight = status?.get("block_height")?.asLong() + val blockHash = status?.get("block_hash")?.asText() + + val outputs = + node["vout"].mapIndexed { idx, vout -> + BitcoinTxOutput( + index = idx, + valueSats = vout["value"].asLong(), + scriptPubKeyHex = vout["scriptpubkey"].asText().lowercase(), + ) + } + + val confirmations = + if (confirmed && blockHeight != null) { + // Esplora returns confirmations only via /tx/{txid}/status sometimes; + // we conservatively report 1 here and let callers re-query tip if they + // need a precise count. + 1 + } else { + 0 + } + + return BitcoinTx( + txid = txid, + outputs = outputs, + confirmations = confirmations, + blockHashHex = blockHash, + blockHeight = blockHeight, + ) + } + + private fun parseUtxoList(json: String): List { + val node = JacksonMapper.mapper.readTree(json) + return node.map { utxo -> + val confirmed = utxo["status"]?.get("confirmed")?.asBoolean() == true + Utxo( + txid = utxo["txid"].asText(), + vout = utxo["vout"].asInt(), + valueSats = utxo["value"].asLong(), + confirmations = if (confirmed) 1 else 0, + ) + } + } + + private fun parseFees(json: String): FeeEstimates { + val node = JacksonMapper.mapper.readTree(json) + // mempool.space exposes fastestFee / halfHourFee / hourFee / minimumFee. + val fast = node["fastestFee"]?.asDouble() + val normal = node["halfHourFee"]?.asDouble() ?: fast + val slow = node["hourFee"]?.asDouble() ?: node["minimumFee"]?.asDouble() ?: normal + + if (fast == null) { + Log.w(logTag) { "fee response missing 'fastestFee': $json" } + // Sensible default if the endpoint disagrees with our schema. + return FeeEstimates(20.0, 10.0, 5.0) + } + return FeeEstimates( + fastSatPerVbyte = fast, + normalSatPerVbyte = normal ?: fast, + slowSatPerVbyte = slow ?: fast, + ) + } + + companion object { + const val MEMPOOL_API_URL = "https://mempool.space/api" + const val BLOCKSTREAM_API_URL = "https://blockstream.info/api" + } +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt index 725c8fedfc..f142e506bd 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt @@ -64,4 +64,12 @@ actual object Secp256k1Instance { pubKey: ByteArray, privateKey: ByteArray, ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + + actual fun pubKeyTweakAdd( + pubKey: ByteArray, + tweak: ByteArray, + ): ByteArray { + val full = if (pubKey.size == 32) h02 + pubKey else pubKey + return secp256k1.pubKeyCompress(secp256k1.pubKeyTweakAdd(full, tweak)) + } } diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt index eefd59b567..bf61c5877f 100644 --- a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt @@ -63,4 +63,12 @@ actual object Secp256k1Instance { pubKey: ByteArray, privateKey: ByteArray, ): ByteArray = secp256k1Ref.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + + actual fun pubKeyTweakAdd( + pubKey: ByteArray, + tweak: ByteArray, + ): ByteArray { + val full = if (pubKey.size == 32) h02 + pubKey else pubKey + return secp256k1Ref.pubKeyCompress(secp256k1Ref.pubKeyTweakAdd(full, tweak)) + } } From 031159b3f6a90d4caa35e2e2684fb6ea130a44fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 04:54:40 +0000 Subject: [PATCH 03/19] feat(amethyst): NIP-BC onchain zap receive + display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md). Plumbs the Quartz receive foundation from Phase A.1 into the Android app so incoming kind 8333 zaps show up in note totals and the wallet screen. Subscription / fetch path (kind 8333 rides existing zap filters, no new assemblers per the plan): - FilterRepliesAndReactionsToNotes: OnchainZapEvent.KIND in RepliesAndReactionsKinds (#e on notes) - FilterUserProfileZapReceived: OnchainZapEvent.KIND in UserProfileZapReceiverKinds (#p on profiles) - UserProfileZapsViewModel: kinds = LnZap + Onchain inline - FilterNotificationsToPubkey: OnchainZapEvent.KIND in SummaryKinds - NotificationFeedFilter / NotificationDispatcher: kind 8333 in NOTIFICATION_KINDS - FilterMessagesToLiveStream / FilterGoalForLiveActivity: kind 8333 alongside 9735 Display path (Note.zapsAmount fold-in — no UI changes needed downstream): - commons/model/Note.kt: new `onchainZaps: Map` (keyed by txid for `(txid, target)` dedup); `addOnchainZap(txid, sats, confirmed)` mutator; `updateZapTotal` now also sums confirmed onchain sats into `zapsAmount`. Pending entries are tracked but excluded from the aggregate total per the NIP-BC spec. - commons/model/OnchainZapAmount.kt: new data class for per-tx state. - LocalCache: new `onchainBackend: OnchainBackend?` slot; new `consume(OnchainZapEvent)` handler that runs the same sig-verify and computeReplyTo pipeline as `consume(LnZapEvent)`, then kicks off async verification on `applicationIOScope`. Self-zaps are rejected synchronously per the spec. Verification failures, mempool-only txs, and confirmed txs all route to `Note.addOnchainZap` with the correct flag. Without a backend wired, events are still cached (so subscriptions see them) but skip the total contribution. - LocalCache.computeReplyTo: new `is OnchainZapEvent ->` branch that enumerates `e`/`a` targets — profile-only zaps return an empty list and flow through profile zap queries instead. Wallet UI: - Wallet screen now renders a "Bitcoin" Card above the existing NIP-47 NWC wallet list. Single card (one onchain wallet per Nostr identity, derived from the pubkey). Shows the bc1p taproot address + Copy button. Balance, recent incoming zaps, and tap-to-detail come in later phases. Settings + wiring: - AccountSettings: new `onchainEsploraEndpoint: MutableStateFlow`, default mempool.space, plumbed for a future settings UI. - AppModules: builds a single shared `EsploraBackend` at app init using the role-based money-flavored OkHttp client (Tor-aware), installs it on `LocalCache.onchainBackend` so verification can run. Secp256k1Instance: new `pubKeyTweakAdd` was added in Phase A.1. No PSBT, no signing, no broadcasting — send-side (Phase A.2 + D) is still pending. --- .../com/vitorpamplona/amethyst/AppModules.kt | 15 +++ .../amethyst/model/AccountSettings.kt | 5 + .../amethyst/model/LocalCache.kt | 83 ++++++++++++ .../notifications/NotificationDispatcher.kt | 2 + .../FilterNotificationsToPubkey.kt | 2 + .../FilterRepliesAndReactionsToNotes.kt | 2 + .../FilterGoalForLiveActivity.kt | 3 +- .../FilterMessagesToLiveStream.kt | 2 + .../dal/NotificationFeedFilter.kt | 2 + .../FilterUserProfileZapReceived.kt | 3 +- .../zaps/dal/UserProfileZapsViewModel.kt | 3 +- .../screen/loggedIn/wallet/OnchainSection.kt | 123 ++++++++++++++++++ .../ui/screen/loggedIn/wallet/WalletScreen.kt | 28 ++-- .../amethyst/commons/model/Note.kt | 48 +++++++ .../commons/model/OnchainZapAmount.kt | 40 ++++++ 15 files changed, 347 insertions(+), 14 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 466779ad6c..6675445fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.DEFAULT_ESPLORA_ENDPOINT import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState @@ -99,6 +100,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -330,6 +332,19 @@ class AppModules( // Caches all events in Memory val cache: LocalCache = LocalCache + // NIP-BC onchain zap verification backend. Wired up once at app init so + // LocalCache.consume(OnchainZapEvent) can sum the on-chain output values + // that pay the recipient's derived Taproot address. Endpoint is currently + // a fixed default; per-account override (AccountSettings.onchainEsploraEndpoint) + // is plumbed for a future Settings UI. + init { + cache.onchainBackend = + EsploraBackend( + baseUrl = { DEFAULT_ESPLORA_ENDPOINT }, + client = roleBasedHttpClientBuilder.okHttpClientForMoney(DEFAULT_ESPLORA_ENDPOINT), + ) + } + // Provides a relay pool val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 4a5511df5a..1e6e0135a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -141,6 +141,9 @@ sealed class TopFilter( ) : TopFilter("InterestSet/${address.toValue()}") } +/** Default Esplora-compatible Bitcoin chain backend for NIP-BC onchain zaps. */ +const val DEFAULT_ESPLORA_ENDPOINT = "https://mempool.space/api" + @Stable class AccountSettings( val keyPair: KeyPair, @@ -176,6 +179,8 @@ class AccountSettings( val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), + // NIP-BC onchain zap configuration. + val onchainEsploraEndpoint: MutableStateFlow = MutableStateFlow(DEFAULT_ESPLORA_ENDPOINT), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d50daf6b7d..246b7a9efa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -240,6 +240,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier +import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase @@ -286,6 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider { val paymentTracker = NwcPaymentTracker() + /** + * Bitcoin chain backend used by [consume]`(OnchainZapEvent)` to verify NIP-BC zaps + * against the actual on-chain transaction. `null` disables verification (incoming + * onchain zap events still get cached, but no `Note.zapsAmount` contribution). + * Set during account init. + */ + @Volatile + var onchainBackend: OnchainBackend? = null + val relayHints = HintIndexer() val deletionIndex = DeletionIndex() @@ -904,6 +917,18 @@ object LocalCache : ILocalCache, ICacheProvider { (event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList()) } + is OnchainZapEvent -> { + // NIP-BC zaps can target an event id (e), an addressable event (a), + // or just the recipient profile (p). Profile-only zaps have no + // Note target and are surfaced through profile zap queries. + buildList { + event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } } + event.zappedAddress()?.let { coord -> + Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) } + } + } + } + is LnZapRequestEvent -> { event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } @@ -1712,6 +1737,60 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + fun consume( + event: OnchainZapEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + + if (!(wasVerified || justVerify(event))) return false + + // Anti-spoofing: NIP-BC requires rejecting self-zaps. + val recipient = event.recipient() ?: return false + if (event.pubKey.equals(recipient, ignoreCase = true)) return false + + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) + note.loadEvent(event, author, repliesTo) + refreshNewNoteObservers(note) + + // Verification needs a chain backend. Without one (e.g. before Account + // wires its EsploraBackend) the event is still cached so subscriptions + // and profile zap views see it, but it can't contribute to Note totals. + val backend = onchainBackend ?: return true + val verifier = OnchainZapVerifier(backend) + + Amethyst.instance.applicationIOScope.launch { + try { + when (val result = verifier.verify(event)) { + is VerifiedOnchainZap.Confirmed -> { + repliesTo.forEach { + it.addOnchainZap(result.txid, result.verifiedSats, confirmed = true) + } + } + + is VerifiedOnchainZap.Pending -> { + repliesTo.forEach { + it.addOnchainZap(result.txid, result.verifiedSats, confirmed = false) + } + } + + is VerifiedOnchainZap.Rejected -> { + Log.d("OnchainZap") { + "rejected ${result.txid}: ${result.reason}" + } + } + } + } catch (t: Throwable) { + Log.w("OnchainZap", "verification failed for ${event.id}", t) + } + } + + return true + } + private fun attachZapToLiveActivityChannel( event: LnZapEvent, note: Note, @@ -3142,6 +3221,10 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is OnchainZapEvent -> { + consume(event, relay, wasVerified) + } + is NIP90StatusEvent -> { consumeRegularEvent(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index df17bfe015..cedd7daea0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -96,6 +97,7 @@ class NotificationDispatcher( // Direct-arrival PrivateDmEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, ReactionEvent.KIND, TextNoteEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 941b972faf..10cca27dc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent val SummaryKinds = listOf( @@ -62,6 +63,7 @@ val SummaryKinds = RepostEvent.KIND, GenericRepostEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, ) val NotificationsPerKeyKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 0160962bb0..f9a5052356 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsKinds = @@ -53,6 +54,7 @@ val RepliesAndReactionsKinds = GenericRepostEvent.KIND, ReportEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, OtsEvent.KIND, TextNoteModificationEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt index 427101e5c3..7880c23d9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent /** * Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts @@ -55,7 +56,7 @@ fun filterGoalForLiveActivities( relay = relay, filter = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), tags = mapOf("e" to listOf(goalId)), limit = 200, since = since?.get(relay)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index c1c47325aa..93c9984855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, @@ -44,6 +45,7 @@ fun filterMessagesToLiveActivities( LiveActivitiesRaidEvent.KIND, LiveActivitiesClipEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index c24217b769..d58a7d7c57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -121,6 +122,7 @@ class NotificationFeedFilter( ReactionEvent.KIND, RepostEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, LiveActivitiesChatMessageEvent.KIND, PictureEvent.KIND, PollEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index d03a3d7ac2..2cc37d239b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -26,8 +26,9 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND) +val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND) fun filterUserProfileZapsReceived( user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 33ebc1b200..703f3d7a90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -52,7 +53,7 @@ class UserProfileZapsViewModel( ) : ViewModel() { val zapsToUser = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt new file mode 100644 index 0000000000..b4934bb629 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt @@ -0,0 +1,123 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import kotlinx.coroutines.launch + +/** + * "Bitcoin" section card on the wallet screen, shown above the lightning NWC + * wallet list. Every account has exactly one Taproot address (derived from + * its Nostr pubkey via NIP-BC / BIP-341), so this is always a single card — + * not a list. + * + * Phase C scope: derive + display + copy address. Balance, recent zaps, send, + * and tap-to-detail UI come in later phases. + */ +@Composable +fun OnchainSection( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val pubKey = accountViewModel.account.signer.pubKey + + // Cache the derived address — bech32m + tap-tweak is cheap but pubkey doesn't + // change for the lifetime of the screen. + val address = + remember(pubKey) { + runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull() + } + + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Bitcoin", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (address == null) { + Text( + text = "Address derivation unavailable for this account.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "Your Taproot address", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = address, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + CopyAddressRow(address) + } + } + } +} + +@Composable +private fun CopyAddressRow(address: String) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = { + scope.launch { clipboard.setText(address) } + }) { + Text("Copy address") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index 971628c5ba..1646694365 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -124,18 +124,24 @@ fun WalletScreen( } }, ) { padding -> - if (!hasWallet) { - NoWalletSetup( - modifier = Modifier.padding(padding), - nav = nav, - ) - } else { - MultiWalletHomeContent( - walletViewModel = walletViewModel, - modifier = Modifier.padding(padding), - listState = listState, - nav = nav, + Column(modifier = Modifier.padding(padding)) { + OnchainSection( + accountViewModel = accountViewModel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) + if (!hasWallet) { + NoWalletSetup( + modifier = Modifier, + nav = nav, + ) + } else { + MultiWalletHomeContent( + walletViewModel = walletViewModel, + modifier = Modifier, + listState = listState, + nav = nav, + ) + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 1a2790d087..5a3a16c873 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -156,6 +156,15 @@ open class Note( var zapsAmount: BigDecimal = BigDecimal.ZERO + /** + * NIP-BC verified onchain zaps targeting this note. + * Key: Bitcoin txid (lowercase 64-char hex). Value: verified satoshis paid to the recipient + * (NOT the sender-claimed amount). Confirmed and pending entries live here together; + * `updateZapTotal` only counts confirmed amounts. + */ + var onchainZaps = mapOf() + private set + var zapPayments = mapOf() private set @@ -318,6 +327,7 @@ open class Note( boosts = listOf() reports = mapOf() zaps = mapOf() + onchainZaps = mapOf() zapPayments = mapOf() zapsAmount = BigDecimal.ZERO relays = listOf() @@ -417,6 +427,36 @@ open class Note( } } + @Synchronized + private fun innerAddOnchainZap( + txid: String, + amount: OnchainZapAmount, + ): Boolean { + val existing = onchainZaps[txid] + // Allow upgrading pending → confirmed but never reduce already-stored confirmations. + if (existing != null && existing.confirmed && !amount.confirmed) return false + if (existing == amount) return false + onchainZaps = onchainZaps + Pair(txid, amount) + return true + } + + /** + * Register a NIP-BC verified onchain zap targeting this note. `verifiedSats` MUST come + * from on-chain verification (sum of outputs paying the recipient's derived Taproot + * address), not the sender-claimed `amount` tag. + */ + fun addOnchainZap( + txid: String, + verifiedSats: Long, + confirmed: Boolean, + ) { + val inserted = innerAddOnchainZap(txid, OnchainZapAmount(verifiedSats, confirmed)) + if (inserted) { + updateZapTotal() + flowSet?.zaps?.invalidateData() + } + } + @Synchronized private fun innerAddZapPayment( zapPaymentRequest: Note, @@ -629,6 +669,14 @@ open class Note( } } + // NIP-BC onchain zaps — verified amounts only, confirmed only. + // Pending/unconfirmed entries are tracked but excluded from the total per spec. + onchainZaps.values.forEach { entry -> + if (entry.confirmed) { + sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats) + } + } + zapsAmount = sumOfAmounts } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt new file mode 100644 index 0000000000..d7c4bcb65b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt @@ -0,0 +1,40 @@ +/* + * 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.model + +import androidx.compose.runtime.Immutable + +/** + * Per-(note, txid) verified NIP-BC onchain zap state. + * + * @property verifiedSats Satoshis verified to have paid the recipient's derived Taproot + * address on chain. NEVER the sender-claimed `amount` tag. + * @property confirmed True when the transaction has at least one confirmation. Unconfirmed + * zaps are tracked but excluded from aggregate totals per the NIP-BC + * spec ("Unconfirmed transactions MAY be displayed as pending... + * SHOULD either exclude them from aggregate totals or clearly label + * them as pending"). + */ +@Immutable +data class OnchainZapAmount( + val verifiedSats: Long, + val confirmed: Boolean, +) From 6ce193d46a7bf9585da43c8ba270857bbe0ddcf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 04:58:18 +0000 Subject: [PATCH 04/19] feat(amethyst): show onchain balance on wallet screen Bitcoin section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnchainSection now fetches UTXOs for the derived Taproot address from the configured Esplora backend and displays the sum as the section balance. Loading / unavailable / error states are surfaced inline. No retry on failure — the user refreshes by re-entering the screen. Balance fetch runs in a LaunchedEffect keyed on the derived address (which is keyed on the account pubkey), so it kicks off once per account view. --- .../screen/loggedIn/wallet/OnchainSection.kt | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt index b4934bb629..087c62556a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt @@ -31,18 +31,26 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.NumberFormat /** * "Bitcoin" section card on the wallet screen, shown above the lightning NWC @@ -67,6 +75,32 @@ fun OnchainSection( runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull() } + // Balance fetched from the configured OnchainBackend. null = unknown / loading + // / unconfigured. We intentionally do not retry on failure here — the user can + // refresh by leaving and re-entering the screen. + var balanceSats by remember(pubKey) { mutableStateOf(null) } + var balanceState by remember(pubKey) { mutableStateOf(BalanceState.LOADING) } + + LaunchedEffect(address) { + if (address == null) { + balanceState = BalanceState.UNAVAILABLE + return@LaunchedEffect + } + val backend = LocalCache.onchainBackend + if (backend == null) { + balanceState = BalanceState.UNAVAILABLE + return@LaunchedEffect + } + balanceState = BalanceState.LOADING + try { + val utxos = withContext(Dispatchers.IO) { backend.getUtxosForAddress(address) } + balanceSats = utxos.sumOf { it.valueSats } + balanceState = BalanceState.READY + } catch (t: Throwable) { + balanceState = BalanceState.ERROR + } + } + Card( modifier = modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), @@ -87,6 +121,7 @@ fun OnchainSection( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { + BalanceRow(state = balanceState, sats = balanceSats) Text( text = "Your Taproot address", style = MaterialTheme.typography.labelMedium, @@ -104,6 +139,39 @@ fun OnchainSection( } } +private enum class BalanceState { LOADING, READY, ERROR, UNAVAILABLE } + +@Composable +private fun BalanceRow( + state: BalanceState, + sats: Long?, +) { + val text = + when (state) { + BalanceState.LOADING -> { + "Loading balance…" + } + + BalanceState.READY -> { + val formatted = NumberFormat.getNumberInstance().format(sats ?: 0L) + "$formatted sats" + } + + BalanceState.ERROR -> { + "Balance unavailable" + } + + BalanceState.UNAVAILABLE -> { + "Chain backend not configured" + } + } + Text( + text = text, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) +} + @Composable private fun CopyAddressRow(address: String) { val clipboard = LocalClipboard.current From bb5613ca8bc6caae82b6472130ab548838c44cae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 04:59:23 +0000 Subject: [PATCH 05/19] =?UTF-8?q?docs:=20update=20onchain=20zaps=20plan=20?= =?UTF-8?q?=E2=80=94=20A.1=20+=20C=20shipped,=20A.2/B/D=20pending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks the receive + display phases done and spells out exactly what Phase A.2 (send-side PSBT foundation) needs before send can ship — notably the fund-safety-critical BIP-174 codec and BIP-341 tweaked-key signing path. --- amethyst/plans/2026-05-14-onchain-zaps.md | 28 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/amethyst/plans/2026-05-14-onchain-zaps.md b/amethyst/plans/2026-05-14-onchain-zaps.md index 38c13af28e..551f1eff97 100644 --- a/amethyst/plans/2026-05-14-onchain-zaps.md +++ b/amethyst/plans/2026-05-14-onchain-zaps.md @@ -146,11 +146,29 @@ Lightning fast path. Two minimal hooks: | Phase | Deliverable | Status | |---|---|---| -| **A.1** | Quartz foundation (receive side): taproot address, bech32m segwit, Esplora client, tx parser, verifier | In progress | -| **A.2** | Quartz foundation (send side): PSBT codec, builder, `signPsbt` on signer hierarchy | Pending | -| **B** | NIP-55 `sign_psbt` intent contract + `NostrSignerExternal.signPsbt` | Pending | -| **C** | Receive + display: `OnchainSection` in `WalletScreen`, Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 8 filter files | Pending | -| **D** | Send: dialog in zap menu, UTXO selection, build/sign/broadcast, publish | Pending | +| **A.1** | Quartz foundation (receive side): taproot address, bech32m segwit, Esplora client, verifier + tests | **Shipped** | +| **C** | Receive + display: `OnchainSection` in `WalletScreen` (address + live balance), Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 7 in-scope filter files | **Shipped** | +| **A.2** | Quartz foundation (send side): minimal BIP-174 PSBT codec, `OnchainZapBuilder`, `signPsbt` on `NostrSigner` hierarchy | Pending | +| **B** | NIP-55 `sign_psbt` intent contract + `NostrSignerExternal.signPsbt`. Broken until Amber ships matching support — surface "update your signer" in the UI. | Pending | +| **D** | Send: dialog in zap menu, UTXO selection, build → sign → broadcast → publish kind 8333 | Pending | + +### What's still required before send can ship (Phase A.2) + +1. **PSBT codec.** Hand-rolled BIP-174 reader/writer for the single shape we need + (1+ inputs key-path-only P2TR, 1-2 outputs, no script tree). Needs explicit + test vectors from the BIP-174 / BIP-341 test suites — any bug here can lose + user funds. +2. **TapTweak in signer.** `NostrSignerInternal.signPsbt` applies the BIP-341 + key-path-only tweak to its private key before producing the Schnorr sig + over the BIP-341 sighash. Today `signSchnorr*` always uses the raw + keypair — we need an internal `signWithTweakedKey` variant. Tests must + compare against the libsecp256k1 reference output. +3. **Sighash computation.** BIP-341 default sighash (SIGHASH_DEFAULT) is its + own serialization; not reusable from existing Nostr signing. +4. **Coin selection.** Simple smallest-set-covering-amount is enough for v1 + but needs a fee-vs-dust guard. +5. **Broadcast & publish.** Already plumbed through `EsploraBackend.broadcast`; + just needs the orchestrator in commons. ## Risks / open questions From a42b1e0f3243c5c9114adeee259184635b745c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 11:29:47 +0000 Subject: [PATCH 06/19] feat(quartz): NIP-BC onchain zap send-side foundation (Phase A.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the send half of the onchain zaps plan: a minimal, fund-safe BIP-174 PSBT pipeline plus the OnchainZapBuilder and `NostrSigner.signPsbt`. Every cryptographic step is validated against the BIP-341 wallet test vectors. nipBCOnchainZaps/psbt/: - BitcoinIO: little-endian Bitcoin consensus byte reader/writer (u16/u32/u64, compact-size varint, var-bytes). - BitcoinTransaction: OutPoint / TxIn / TxOut / transaction model with legacy and BIP-144 segwit serialization, segwit-aware parsing, and witness-stripped txid. Validated against the genesis coinbase tx and the BIP-341 9-input unsigned tx. - TaprootSigHash: BIP-341 SigMsg + TapSighash tagged hash for key-path spends. All six base sighash types plus both ANYONECANPAY variants verified against the seven BIP-341 keyPathSpending vectors. - Psbt: BIP-174 container subset (global unsigned-tx, per-input witness-utxo / tap-internal-key / tap-key-sig / sighash-type, per-output tap-internal-key). Unknown records round-trip verbatim. Typed accessor extensions. - PsbtSigner: signs the key-path P2TR inputs a given private key controls — derives the BIP-341 tweaked key, computes the all-inputs sighash, produces a BIP-340 Schnorr signature. Leaves inputs the key does not control untouched. - PsbtFinalizer: moves tap-key-sigs into the witness stack, yields a broadcastable transaction. nipBCOnchainZaps/build/: - OnchainZapBuilder: largest-first coin selection + unsigned-PSBT assembly for a NIP-BC zap. Sender spends from / changes back to the single Taproot address derived from their Nostr pubkey; recipient output pays the recipient's derived address. Dust-aware change handling, InsufficientFundsException, self-zap and dust-amount guards. nipBCOnchainZaps/taproot/: - TaprootAddress.tweakSecretKey: BIP-341 taproot_tweak_seckey (key-path-only), including the odd-y internal-key negation. Validated against the BIP-341 vector's tweakedPrivkey. Secp256k1Instance: new `privKeyNegate` primitive across commonMain expect + jvm / android / native actuals. Signer hierarchy — new `NostrSigner.signPsbt(psbtHex): String`: - NostrSignerSync / NostrSignerInternal: real implementation. - NostrSignerWithClientTag: delegates to the wrapped signer. - NostrSignerRemote (NIP-46) and NostrSignerExternal (NIP-55): throw SignerExceptions.UnsupportedMethodException — the bunker command and the Android Intent contract are Phase B, and depend on signer-app support. Tests: 31 new tests across the psbt/build/signers packages, anchored on the BIP-341 wallet-test-vectors so the tweak, sighash, signature, and finalized transaction are all checked against an authoritative source. Still pending: Phase B (NIP-55 sign_psbt Intent) and Phase D (send UI + broadcast + publish kind 8333). --- .../commons/viewmodels/NestViewModelTest.kt | 2 + .../client/NostrSignerExternal.kt | 12 + .../quartz/utils/Secp256k1Instance.android.kt | 2 + .../quartz/nip01Core/signers/NostrSigner.kt | 13 + .../nip01Core/signers/NostrSignerInternal.kt | 5 + .../nip01Core/signers/NostrSignerSync.kt | 13 + .../nip01Core/signers/SignerExceptions.kt | 6 + .../signer/NostrSignerRemote.kt | 10 + .../clientTag/NostrSignerWithClientTag.kt | 2 + .../quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt | 183 +++++++++++++ .../psbt/BitcoinTransaction.kt | 240 +++++++++++++++++ .../quartz/nipBCOnchainZaps/psbt/Psbt.kt | 242 ++++++++++++++++++ .../nipBCOnchainZaps/psbt/PsbtFinalizer.kt | 62 +++++ .../nipBCOnchainZaps/psbt/PsbtSigner.kt | 105 ++++++++ .../nipBCOnchainZaps/psbt/TaprootSigHash.kt | 167 ++++++++++++ .../taproot/TaprootAddress.kt | 30 +++ .../quartz/utils/Secp256k1Instance.kt | 8 + .../nip01Core/signers/NostrSignerPsbtTest.kt | 120 +++++++++ .../psbt/BitcoinTransactionTest.kt | 106 ++++++++ .../nipBCOnchainZaps/psbt/PsbtSignerTest.kt | 182 +++++++++++++ .../quartz/nipBCOnchainZaps/psbt/PsbtTest.kt | 112 ++++++++ .../psbt/TaprootSigHashTest.kt | 93 +++++++ .../quartz/utils/Secp256k1Instance.jvm.kt | 2 + .../quartz/utils/Secp256k1Instance.native.kt | 2 + 24 files changed, 1719 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt index 59b7a91caa..ceff41e3ff 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt @@ -589,6 +589,8 @@ class NestViewModelTest { override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = error("not used") override suspend fun deriveKey(nonce: String): String = error("not used") + + override suspend fun signPsbt(psbtHex: String): String = error("not used") } companion object { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt index 64551b128b..681e521ee3 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt @@ -174,6 +174,18 @@ class NostrSignerExternal( throw convertExceptions("Could not decrypt private zap", result) } + /** + * NIP-BC `sign_psbt` over NIP-55. The Android external-signer Intent + * contract for PSBT signing is not implemented yet (Phase B); Amber and + * other signer apps must also ship support before this can work. Until + * then, callers should fall back to other signer kinds or surface an + * "update your signer" message. + */ + override suspend fun signPsbt(psbtHex: String): String = + throw SignerExceptions.UnsupportedMethodException( + "This external signer does not support sign_psbt yet", + ) + // always ready override fun hasForegroundSupport() = hasForegroundActivity() diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt index f142e506bd..efc5276dad 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt @@ -72,4 +72,6 @@ actual object Secp256k1Instance { val full = if (pubKey.size == 32) h02 + pubKey else pubKey return secp256k1.pubKeyCompress(secp256k1.pubKeyTweakAdd(full, tweak)) } + + actual fun privKeyNegate(privKey: ByteArray): ByteArray = secp256k1.privKeyNegate(privKey) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt index 6b44ea6f83..fd5fd2e0d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt @@ -64,6 +64,19 @@ abstract class NostrSigner( abstract suspend fun deriveKey(nonce: HexKey): HexKey + /** + * NIP-BC `sign_psbt`: sign the key-path P2TR inputs of [psbtHex] that this + * signer's key controls and return the updated PSBT as lowercase hex. + * + * The signer adds `PSBT_IN_TAP_KEY_SIG` records; it does NOT finalize the + * PSBT — finalization and broadcast are the client's responsibility. + * + * Throws [SignerExceptions.UnsupportedMethodException] for signer kinds + * that have not implemented the method yet (remote NIP-46 bunkers, NIP-55 + * external signers that predate `sign_psbt` support). + */ + abstract suspend fun signPsbt(psbtHex: String): String + abstract fun hasForegroundSupport(): Boolean suspend fun decrypt( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt index ea1b167c80..b308afbd06 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt @@ -101,4 +101,9 @@ class NostrSignerInternal( runWrapErrors { signerSync.deriveKey(nonce) } + + override suspend fun signPsbt(psbtHex: String): String = + runWrapErrors { + signerSync.signPsbt(psbtHex) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt index dc626ee9b5..9ca2935bf5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapRequestBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner class NostrSignerSync( val keyPair: KeyPair = KeyPair(), @@ -129,6 +131,17 @@ class NostrSignerSync( fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = PrivateZapRequestBuilder().decryptZapEvent(event, this) + /** + * NIP-BC `sign_psbt`: sign the key-path P2TR inputs of [psbtHex] this key + * controls and return the updated (not finalized) PSBT as lowercase hex. + */ + fun signPsbt(psbtHex: String): String { + val privKey = keyPair.privKey ?: throw SignerExceptions.ReadOnlyException() + val psbt = Psbt.parse(psbtHex) + PsbtSigner.signKeyPathInputs(psbt, privKey) + return psbt.toHex() + } + fun deriveKey(nonce: HexKey): HexKey { if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt index 1e71ccd92b..f3d14320fa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt @@ -59,4 +59,10 @@ sealed class SignerExceptions( msg: String, cause: Throwable? = null, ) : SignerExceptions(msg) + + /** The signer cannot perform the requested method (e.g. a bunker that has not shipped `sign_psbt`). */ + class UnsupportedMethodException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg, cause) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 2163b42e8f..7c36acbb8d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -278,6 +278,16 @@ class NostrSignerRemote( TODO("Not yet implemented") } + /** + * NIP-BC `sign_psbt` over NIP-46. The bunker-side command is not yet + * standardized/shipped, so this is intentionally unsupported until the + * remote-signer ecosystem catches up. + */ + override suspend fun signPsbt(psbtHex: String): String = + throw SignerExceptions.UnsupportedMethodException( + "Remote (NIP-46) signers do not support sign_psbt yet", + ) + override fun hasForegroundSupport(): Boolean = true fun convertExceptions( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt index 5856a0ebf3..2da2479daa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt @@ -98,6 +98,8 @@ class NostrSignerWithClientTag( override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) + override suspend fun signPsbt(psbtHex: String): String = inner.signPsbt(psbtHex) + override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport() private fun appendClientTag(tags: Array>): Array> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt new file mode 100644 index 0000000000..3e4c3d2777 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt @@ -0,0 +1,183 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.utils.ByteArrayOutputStream + +/** + * Little-endian byte writer for Bitcoin consensus serialization (transactions, + * PSBT maps). All multi-byte integers are written little-endian, matching the + * Bitcoin wire format. + */ +class BitcoinWriter( + initialSize: Int = 256, +) { + private val out = ByteArrayOutputStream(initialSize) + + fun writeByte(value: Int): BitcoinWriter { + out.write((value and 0xFF).toByte()) + return this + } + + fun writeBytes(bytes: ByteArray): BitcoinWriter { + out.write(bytes) + return this + } + + fun writeUInt16LE(value: Int): BitcoinWriter { + out.write((value and 0xFF).toByte()) + out.write(((value ushr 8) and 0xFF).toByte()) + return this + } + + fun writeUInt32LE(value: Long): BitcoinWriter { + out.write((value and 0xFF).toByte()) + out.write(((value ushr 8) and 0xFF).toByte()) + out.write(((value ushr 16) and 0xFF).toByte()) + out.write(((value ushr 24) and 0xFF).toByte()) + return this + } + + fun writeUInt64LE(value: Long): BitcoinWriter { + var v = value + for (i in 0 until 8) { + out.write((v and 0xFF).toByte()) + v = v ushr 8 + } + return this + } + + /** Bitcoin compact-size (varint) encoding. */ + fun writeVarInt(value: Long): BitcoinWriter { + when { + value < 0xFD -> { + writeByte(value.toInt()) + } + + value <= 0xFFFF -> { + writeByte(0xFD) + writeUInt16LE(value.toInt()) + } + + value <= 0xFFFFFFFFL -> { + writeByte(0xFE) + writeUInt32LE(value) + } + + else -> { + writeByte(0xFF) + writeUInt64LE(value) + } + } + return this + } + + /** Length-prefixed (varint) byte string. */ + fun writeVarBytes(bytes: ByteArray): BitcoinWriter { + writeVarInt(bytes.size.toLong()) + writeBytes(bytes) + return this + } + + fun toByteArray(): ByteArray = out.toByteArray() +} + +/** + * Little-endian byte reader, the inverse of [BitcoinWriter]. Throws + * [PsbtParseException] on truncated input. + */ +class BitcoinReader( + private val data: ByteArray, + private var pos: Int = 0, +) { + val remaining: Int get() = data.size - pos + + val isAtEnd: Boolean get() = pos >= data.size + + private fun require(n: Int) { + if (remaining < n) { + throw PsbtParseException("Unexpected end of data: needed $n, have $remaining") + } + } + + fun readByte(): Int { + require(1) + return data[pos++].toInt() and 0xFF + } + + fun readBytes(n: Int): ByteArray { + require(n) + val slice = data.copyOfRange(pos, pos + n) + pos += n + return slice + } + + fun readUInt16LE(): Int { + require(2) + val v = (data[pos].toInt() and 0xFF) or ((data[pos + 1].toInt() and 0xFF) shl 8) + pos += 2 + return v + } + + fun readUInt32LE(): Long { + require(4) + var v = 0L + for (i in 0 until 4) { + v = v or ((data[pos + i].toLong() and 0xFF) shl (8 * i)) + } + pos += 4 + return v + } + + fun readUInt64LE(): Long { + require(8) + var v = 0L + for (i in 0 until 8) { + v = v or ((data[pos + i].toLong() and 0xFF) shl (8 * i)) + } + pos += 8 + return v + } + + fun readVarInt(): Long { + val first = readByte() + return when (first) { + 0xFD -> readUInt16LE().toLong() + 0xFE -> readUInt32LE() + 0xFF -> readUInt64LE() + else -> first.toLong() + } + } + + fun readVarBytes(): ByteArray { + val len = readVarInt() + if (len > Int.MAX_VALUE.toLong()) { + throw PsbtParseException("var-bytes length too large: $len") + } + return readBytes(len.toInt()) + } +} + +/** Thrown when PSBT or transaction bytes cannot be parsed. */ +class PsbtParseException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt new file mode 100644 index 0000000000..3e54203cf1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt @@ -0,0 +1,240 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** Double SHA-256, the Bitcoin hash. */ +internal fun hash256(data: ByteArray): ByteArray = sha256(sha256(data)) + +/** + * A reference to a specific output of a previous transaction. + * + * @property txid Transaction id in display byte order (64-char lowercase hex). + * @property vout Output index within that transaction. + */ +@Immutable +data class OutPoint( + val txid: String, + val vout: Long, +) { + fun write(writer: BitcoinWriter) { + // On the wire the txid is stored in internal (reversed) byte order. + writer.writeBytes(txid.hexToByteArray().reversedArray()) + writer.writeUInt32LE(vout) + } + + companion object { + fun read(reader: BitcoinReader): OutPoint { + val txidInternal = reader.readBytes(32) + val txid = txidInternal.reversedArray().toHexKey() + val vout = reader.readUInt32LE() + return OutPoint(txid, vout) + } + } +} + +/** + * A transaction input. + * + * @property outPoint The previous output being spent. + * @property scriptSig The unlocking script. Empty for segwit inputs. + * @property sequence nSequence value. + * @property witness Witness stack items. Empty for a pre-signing or legacy input. + */ +@Immutable +data class TxIn( + val outPoint: OutPoint, + val scriptSig: ByteArray = ByteArray(0), + val sequence: Long = 0xFFFFFFFFL, + val witness: List = emptyList(), +) { + fun writeWithoutWitness(writer: BitcoinWriter) { + outPoint.write(writer) + writer.writeVarBytes(scriptSig) + writer.writeUInt32LE(sequence) + } + + fun writeWitness(writer: BitcoinWriter) { + writer.writeVarInt(witness.size.toLong()) + witness.forEach { writer.writeVarBytes(it) } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TxIn) return false + return outPoint == other.outPoint && + scriptSig.contentEquals(other.scriptSig) && + sequence == other.sequence && + witness.size == other.witness.size && + witness.indices.all { witness[it].contentEquals(other.witness[it]) } + } + + override fun hashCode(): Int { + var result = outPoint.hashCode() + result = 31 * result + scriptSig.contentHashCode() + result = 31 * result + sequence.hashCode() + result = 31 * result + witness.sumOf { it.contentHashCode() } + return result + } + + companion object { + fun readWithoutWitness(reader: BitcoinReader): TxIn { + val outPoint = OutPoint.read(reader) + val scriptSig = reader.readVarBytes() + val sequence = reader.readUInt32LE() + return TxIn(outPoint, scriptSig, sequence) + } + } +} + +/** + * A transaction output. + * + * @property valueSats Output value in satoshis. + * @property scriptPubKey The locking script. + */ +@Immutable +data class TxOut( + val valueSats: Long, + val scriptPubKey: ByteArray, +) { + fun write(writer: BitcoinWriter) { + writer.writeUInt64LE(valueSats) + writer.writeVarBytes(scriptPubKey) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TxOut) return false + return valueSats == other.valueSats && scriptPubKey.contentEquals(other.scriptPubKey) + } + + override fun hashCode(): Int = 31 * valueSats.hashCode() + scriptPubKey.contentHashCode() + + companion object { + fun read(reader: BitcoinReader): TxOut = TxOut(reader.readUInt64LE(), reader.readVarBytes()) + } +} + +/** + * A Bitcoin transaction. + * + * Supports both legacy and BIP-144 segwit serialization. [txid] is computed + * over the legacy serialization (witness-stripped), per consensus rules. + */ +@Immutable +data class BitcoinTransaction( + val version: Long, + val inputs: List, + val outputs: List, + val lockTime: Long, +) { + val hasWitness: Boolean get() = inputs.any { it.witness.isNotEmpty() } + + /** Legacy (witness-stripped) serialization — the bytes that [txid] hashes. */ + fun serializeForId(): ByteArray { + val w = BitcoinWriter() + w.writeUInt32LE(version) + w.writeVarInt(inputs.size.toLong()) + inputs.forEach { it.writeWithoutWitness(w) } + w.writeVarInt(outputs.size.toLong()) + outputs.forEach { it.write(w) } + w.writeUInt32LE(lockTime) + return w.toByteArray() + } + + /** Full serialization. Uses BIP-144 segwit format when any input carries witness data. */ + fun serialize(): ByteArray { + if (!hasWitness) return serializeForId() + val w = BitcoinWriter() + w.writeUInt32LE(version) + w.writeByte(0x00) // segwit marker + w.writeByte(0x01) // segwit flag + w.writeVarInt(inputs.size.toLong()) + inputs.forEach { it.writeWithoutWitness(w) } + w.writeVarInt(outputs.size.toLong()) + outputs.forEach { it.write(w) } + inputs.forEach { it.writeWitness(w) } + w.writeUInt32LE(lockTime) + return w.toByteArray() + } + + /** Transaction id in display byte order (reversed double-SHA256 of the legacy bytes). */ + fun txid(): String = hash256(serializeForId()).reversedArray().toHexKey() + + companion object { + fun parse(rawHex: String): BitcoinTransaction = parse(rawHex.hexToByteArray()) + + fun parse(bytes: ByteArray): BitcoinTransaction { + val reader = BitcoinReader(bytes) + val version = reader.readUInt32LE() + + // Detect the BIP-144 segwit marker+flag (0x00 0x01). + var isSegwit = false + val firstByte = reader.readByte() + val inputCount: Long + if (firstByte == 0x00) { + val flag = reader.readByte() + if (flag != 0x01) throw PsbtParseException("Invalid segwit flag $flag") + isSegwit = true + inputCount = reader.readVarInt() + } else { + // firstByte was actually the start of the input-count varint. + inputCount = + when (firstByte) { + 0xFD -> reader.readUInt16LE().toLong() + 0xFE -> reader.readUInt32LE() + 0xFF -> reader.readUInt64LE() + else -> firstByte.toLong() + } + } + + val inputs = ArrayList(inputCount.toInt()) + for (i in 0 until inputCount) { + inputs.add(TxIn.readWithoutWitness(reader)) + } + + val outputCount = reader.readVarInt() + val outputs = ArrayList(outputCount.toInt()) + for (i in 0 until outputCount) { + outputs.add(TxOut.read(reader)) + } + + if (isSegwit) { + for (i in inputs.indices) { + val itemCount = reader.readVarInt() + val items = ArrayList(itemCount.toInt()) + for (j in 0 until itemCount) { + items.add(reader.readVarBytes()) + } + inputs[i] = inputs[i].copy(witness = items) + } + } + + val lockTime = reader.readUInt32LE() + return BitcoinTransaction(version, inputs, outputs, lockTime) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt new file mode 100644 index 0000000000..5875b2c5dd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt @@ -0,0 +1,242 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * One key-value record inside a PSBT map. [keyType] is the BIP-174 keytype + * compact-size; [keyData] is whatever follows it (empty for all the field + * types NIP-BC uses). + */ +class PsbtRecord( + val keyType: Int, + val keyData: ByteArray, + val value: ByteArray, +) { + val hasEmptyKeyData: Boolean get() = keyData.isEmpty() +} + +/** + * An ordered PSBT key-value map (global, per-input, or per-output). Unknown + * records are preserved verbatim so serialization round-trips. + */ +class PsbtMap( + val records: MutableList = mutableListOf(), +) { + /** First value for [keyType] with empty key data, or null. */ + fun get(keyType: Int): ByteArray? = records.firstOrNull { it.keyType == keyType && it.hasEmptyKeyData }?.value + + /** Insert or replace the (empty-key-data) record for [keyType]. */ + fun put( + keyType: Int, + value: ByteArray, + ) { + val idx = records.indexOfFirst { it.keyType == keyType && it.hasEmptyKeyData } + val record = PsbtRecord(keyType, ByteArray(0), value) + if (idx >= 0) records[idx] = record else records.add(record) + } + + /** Remove the (empty-key-data) record for [keyType], if present. */ + fun remove(keyType: Int) { + records.removeAll { it.keyType == keyType && it.hasEmptyKeyData } + } + + fun write(writer: BitcoinWriter) { + for (record in records) { + val key = BitcoinWriter() + key.writeVarInt(record.keyType.toLong()) + key.writeBytes(record.keyData) + writer.writeVarBytes(key.toByteArray()) + writer.writeVarBytes(record.value) + } + writer.writeByte(0x00) // map separator + } + + companion object { + fun read(reader: BitcoinReader): PsbtMap { + val map = PsbtMap() + while (true) { + val keyLen = reader.readVarInt() + if (keyLen == 0L) break // separator + val keyBytes = reader.readBytes(keyLen.toInt()) + val keyReader = BitcoinReader(keyBytes) + val keyType = keyReader.readVarInt().toInt() + val keyData = keyReader.readBytes(keyReader.remaining) + val value = reader.readVarBytes() + map.records.add(PsbtRecord(keyType, keyData, value)) + } + return map + } + } +} + +/** + * A Partially Signed Bitcoin Transaction ([BIP-174](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki)). + * + * This is a deliberately small subset — enough to construct, sign, and + * finalize the single-key-path P2TR spends NIP-BC needs. Unknown records are + * preserved verbatim so the container round-trips even when fields aren't + * modeled. + */ +class Psbt( + val global: PsbtMap, + val inputs: MutableList, + val outputs: MutableList, +) { + /** The unsigned transaction from `PSBT_GLOBAL_UNSIGNED_TX`. */ + val unsignedTx: BitcoinTransaction by lazy { + val raw = + global.get(PSBT_GLOBAL_UNSIGNED_TX) + ?: throw PsbtParseException("PSBT has no unsigned transaction") + BitcoinTransaction.parse(raw) + } + + fun serialize(): ByteArray { + val w = BitcoinWriter() + w.writeBytes(MAGIC) + global.write(w) + inputs.forEach { it.write(w) } + outputs.forEach { it.write(w) } + return w.toByteArray() + } + + fun toHex(): String = serialize().toHexKey() + + companion object { + val MAGIC = byteArrayOf(0x70, 0x73, 0x62, 0x74, 0xFF.toByte()) + + // Global keytypes. + const val PSBT_GLOBAL_UNSIGNED_TX = 0x00 + const val PSBT_GLOBAL_VERSION = 0xFB + + // Per-input keytypes. + const val PSBT_IN_NON_WITNESS_UTXO = 0x00 + const val PSBT_IN_WITNESS_UTXO = 0x01 + const val PSBT_IN_SIGHASH_TYPE = 0x03 + const val PSBT_IN_TAP_KEY_SIG = 0x13 + const val PSBT_IN_TAP_INTERNAL_KEY = 0x17 + + // Per-output keytypes. + const val PSBT_OUT_TAP_INTERNAL_KEY = 0x05 + + fun parse(hex: String): Psbt = parse(hex.hexToByteArray()) + + fun parse(bytes: ByteArray): Psbt { + val reader = BitcoinReader(bytes) + val magic = reader.readBytes(5) + if (!magic.contentEquals(MAGIC)) { + throw PsbtParseException("Not a PSBT: bad magic ${magic.toHexKey()}") + } + + val global = PsbtMap.read(reader) + val rawTx = + global.get(PSBT_GLOBAL_UNSIGNED_TX) + ?: throw PsbtParseException("PSBT global map has no unsigned transaction") + val tx = BitcoinTransaction.parse(rawTx) + + val inputs = ArrayList(tx.inputs.size) + for (i in tx.inputs.indices) { + inputs.add(PsbtMap.read(reader)) + } + + val outputs = ArrayList(tx.outputs.size) + for (i in tx.outputs.indices) { + outputs.add(PsbtMap.read(reader)) + } + + return Psbt(global, inputs, outputs) + } + + /** Build an unsigned PSBT shell from a transaction with empty input/output maps. */ + fun fromUnsignedTx(tx: BitcoinTransaction): Psbt { + require(!tx.hasWitness) { "unsigned tx must not carry witness data" } + val global = PsbtMap() + global.put(PSBT_GLOBAL_UNSIGNED_TX, tx.serializeForId()) + val inputs = MutableList(tx.inputs.size) { PsbtMap() } + val outputs = MutableList(tx.outputs.size) { PsbtMap() } + return Psbt(global, inputs, outputs) + } + } +} + +// --------------------------------------------------------------------------- +// Typed accessors for the fields NIP-BC's key-path P2TR flow touches. +// --------------------------------------------------------------------------- + +/** The witness UTXO (`PSBT_IN_WITNESS_UTXO`) being spent by input [index]. */ +fun Psbt.inputWitnessUtxo(index: Int): TxOut? = inputs[index].get(Psbt.PSBT_IN_WITNESS_UTXO)?.let { TxOut.read(BitcoinReader(it)) } + +fun Psbt.setInputWitnessUtxo( + index: Int, + output: TxOut, +) { + val w = BitcoinWriter() + output.write(w) + inputs[index].put(Psbt.PSBT_IN_WITNESS_UTXO, w.toByteArray()) +} + +/** The 32-byte x-only taproot internal key for input [index]. */ +fun Psbt.inputTapInternalKey(index: Int): ByteArray? = inputs[index].get(Psbt.PSBT_IN_TAP_INTERNAL_KEY) + +fun Psbt.setInputTapInternalKey( + index: Int, + xOnlyPubKey: ByteArray, +) { + require(xOnlyPubKey.size == 32) { "tap internal key must be 32 bytes" } + inputs[index].put(Psbt.PSBT_IN_TAP_INTERNAL_KEY, xOnlyPubKey) +} + +/** The 64- or 65-byte schnorr key-path signature for input [index]. */ +fun Psbt.inputTapKeySig(index: Int): ByteArray? = inputs[index].get(Psbt.PSBT_IN_TAP_KEY_SIG) + +fun Psbt.setInputTapKeySig( + index: Int, + signature: ByteArray, +) { + require(signature.size == 64 || signature.size == 65) { + "taproot key-path signature must be 64 or 65 bytes, got ${signature.size}" + } + inputs[index].put(Psbt.PSBT_IN_TAP_KEY_SIG, signature) +} + +/** The optional BIP-174 sighash type for input [index] (4-byte LE), or null. */ +fun Psbt.inputSighashType(index: Int): Int? = inputs[index].get(Psbt.PSBT_IN_SIGHASH_TYPE)?.let { BitcoinReader(it).readUInt32LE().toInt() } + +fun Psbt.setInputSighashType( + index: Int, + sighashType: Int, +) { + inputs[index].put( + Psbt.PSBT_IN_SIGHASH_TYPE, + BitcoinWriter().writeUInt32LE(sighashType.toLong()).toByteArray(), + ) +} + +/** Optional taproot internal key on a change output. */ +fun Psbt.setOutputTapInternalKey( + index: Int, + xOnlyPubKey: ByteArray, +) { + require(xOnlyPubKey.size == 32) { "tap internal key must be 32 bytes" } + outputs[index].put(Psbt.PSBT_OUT_TAP_INTERNAL_KEY, xOnlyPubKey) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt new file mode 100644 index 0000000000..a64cd4afaf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt @@ -0,0 +1,62 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Turns a fully signed key-path P2TR [Psbt] into a broadcastable transaction. + * + * For a key-path taproot spend the witness is just the single Schnorr + * signature and the scriptSig stays empty, so finalization is purely + * mechanical: move each input's `PSBT_IN_TAP_KEY_SIG` into the transaction's + * witness stack. + */ +object PsbtFinalizer { + /** + * Build the final signed transaction from [psbt]. + * + * @throws PsbtSigningException if any input is still missing its key-path + * signature. + */ + fun finalize(psbt: Psbt): BitcoinTransaction { + val tx = psbt.unsignedTx + val signedInputs = + tx.inputs.mapIndexed { index, input -> + val sig = + psbt.inputTapKeySig(index) + ?: throw PsbtSigningException("input $index is not signed") + input.copy( + scriptSig = ByteArray(0), + witness = listOf(sig), + ) + } + return BitcoinTransaction(tx.version, signedInputs, tx.outputs, tx.lockTime) + } + + /** Convenience: [finalize] then serialize to a broadcast-ready lowercase hex string. */ + fun finalizeToHex(psbt: Psbt): String = finalize(psbt).serialize().toHexKey() + + /** True when every input of [psbt] carries a key-path signature. */ + fun isFullySigned(psbt: Psbt): Boolean = + psbt.unsignedTx.inputs.indices + .all { psbt.inputTapKeySig(it) != null } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt new file mode 100644 index 0000000000..6ae4cb4904 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt @@ -0,0 +1,105 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance + +/** + * Signs the key-path P2TR inputs of a [Psbt] with a single private key. + * + * This is the core of `NostrSigner.signPsbt` — pure protocol logic, no UI or + * signer-app dependency. The signer: + * 1. derives the caller's x-only public key, + * 2. for each input whose `PSBT_IN_TAP_INTERNAL_KEY` matches that key and + * which is not already signed, + * 3. computes the BIP-341 sighash over all inputs (so every input must carry + * a `PSBT_IN_WITNESS_UTXO`), + * 4. applies the BIP-341 key-path tweak to the private key, and + * 5. produces a BIP-340 Schnorr signature, stored as `PSBT_IN_TAP_KEY_SIG`. + * + * Inputs the key does not control are left untouched. Finalization (moving the + * signature into the transaction witness) is [PsbtFinalizer]'s job. + */ +object PsbtSigner { + /** + * Sign every key-path input of [psbt] that [privKey] controls, in place. + * + * @return the number of inputs signed by this call. + * @throws PsbtSigningException if a controllable input is missing the + * witness-UTXO data needed to compute its sighash. + */ + fun signKeyPathInputs( + psbt: Psbt, + privKey: ByteArray, + ): Int { + require(privKey.size == 32) { "private key must be 32 bytes" } + + val ourXOnlyPubKey = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33) + val tx = psbt.unsignedTx + + // Lazily materialized: every input's spent output, needed for the + // all-inputs commitment in the BIP-341 sighash. + var spentOutputs: List? = null + + var signed = 0 + for (index in tx.inputs.indices) { + val internalKey = psbt.inputTapInternalKey(index) ?: continue + if (!internalKey.contentEquals(ourXOnlyPubKey)) continue + if (psbt.inputTapKeySig(index) != null) continue // already signed + + if (spentOutputs == null) { + spentOutputs = + tx.inputs.indices.map { i -> + psbt.inputWitnessUtxo(i) + ?: throw PsbtSigningException( + "input $i has no witness UTXO; cannot compute sighash", + ) + } + } + + val sighashType = psbt.inputSighashType(index) ?: TaprootSigHash.SIGHASH_DEFAULT + val sigHash = TaprootSigHash.compute(tx, index, spentOutputs, sighashType) + + val tweakedSecKey = TaprootAddress.tweakSecretKey(privKey) + val signature64 = Secp256k1Instance.signSchnorr(sigHash, tweakedSecKey) + + // SIGHASH_DEFAULT → bare 64-byte signature. Any other type → append + // the sighash byte for a 65-byte signature, per BIP-341. + val signature = + if (sighashType == TaprootSigHash.SIGHASH_DEFAULT) { + signature64 + } else { + signature64 + byteArrayOf(sighashType.toByte()) + } + + psbt.setInputTapKeySig(index, signature) + signed++ + } + return signed + } +} + +/** Thrown when a PSBT cannot be signed (e.g. missing witness-UTXO data). */ +class PsbtSigningException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt new file mode 100644 index 0000000000..2bc1b26792 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt @@ -0,0 +1,167 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * BIP-341 taproot signature hash (`SigMsg` + `TapSighash` tagged hash). + * + * Supports key-path spends (`ext_flag = 0`) with all six base sighash types, + * with and without an annex. Script-path spends (`ext_flag = 1`) are out of + * scope for NIP-BC, which only ever spends key-path P2TR outputs. + * + * Reference: + */ +object TaprootSigHash { + const val SIGHASH_DEFAULT = 0x00 + const val SIGHASH_ALL = 0x01 + const val SIGHASH_NONE = 0x02 + const val SIGHASH_SINGLE = 0x03 + const val SIGHASH_ANYONECANPAY = 0x80 + + private val tapSighashTag: ByteArray by lazy { sha256("TapSighash".encodeToByteArray()) } + + /** + * Compute the BIP-341 signature hash for a key-path P2TR spend. + * + * @param tx The transaction being signed. + * @param inputIndex The index of the input whose signature is being produced. + * @param spentOutputs The previous outputs being spent, one per input of [tx], + * in the same order as `tx.inputs`. + * @param hashType A BIP-341 sighash type byte (default `SIGHASH_DEFAULT`). + * @param annex Optional annex bytes (including the `0x50` prefix), or null. + */ + fun compute( + tx: BitcoinTransaction, + inputIndex: Int, + spentOutputs: List, + hashType: Int = SIGHASH_DEFAULT, + annex: ByteArray? = null, + ): ByteArray { + require(spentOutputs.size == tx.inputs.size) { + "spentOutputs (${spentOutputs.size}) must match inputs (${tx.inputs.size})" + } + require(inputIndex in tx.inputs.indices) { "inputIndex $inputIndex out of range" } + + val anyoneCanPay = (hashType and SIGHASH_ANYONECANPAY) != 0 + val outputType = hashType and 0x03 + require( + hashType == SIGHASH_DEFAULT || + outputType == SIGHASH_ALL || + outputType == SIGHASH_NONE || + outputType == SIGHASH_SINGLE, + ) { "invalid sighash type $hashType" } + + val ss = BitcoinWriter() + + // Sighash epoch. + ss.writeByte(0x00) + + // Common signature message fields. + ss.writeByte(hashType) + ss.writeUInt32LE(tx.version) + ss.writeUInt32LE(tx.lockTime) + + if (!anyoneCanPay) { + ss.writeBytes(shaPrevouts(tx)) + ss.writeBytes(shaAmounts(spentOutputs)) + ss.writeBytes(shaScriptPubKeys(spentOutputs)) + ss.writeBytes(shaSequences(tx)) + } + + if (outputType != SIGHASH_NONE && outputType != SIGHASH_SINGLE) { + ss.writeBytes(shaOutputs(tx)) + } + + // spend_type = ext_flag * 2 + annex_present. ext_flag = 0 for key-path. + val annexPresent = if (annex != null) 1 else 0 + ss.writeByte(annexPresent) + + if (anyoneCanPay) { + tx.inputs[inputIndex].outPoint.write(ss) + ss.writeUInt64LE(spentOutputs[inputIndex].valueSats) + ss.writeVarBytes(spentOutputs[inputIndex].scriptPubKey) + ss.writeUInt32LE(tx.inputs[inputIndex].sequence) + } else { + ss.writeUInt32LE(inputIndex.toLong()) + } + + if (annex != null) { + val a = BitcoinWriter() + a.writeVarBytes(annex) + ss.writeBytes(sha256(a.toByteArray())) + } + + if (outputType == SIGHASH_SINGLE) { + require(inputIndex < tx.outputs.size) { + "SIGHASH_SINGLE with no matching output at index $inputIndex" + } + val o = BitcoinWriter() + tx.outputs[inputIndex].write(o) + ss.writeBytes(sha256(o.toByteArray())) + } + + return taggedHash(tapSighashTag, ss.toByteArray()) + } + + /** BIP-340 tagged hash: `SHA256(SHA256(tag) || SHA256(tag) || msg)`. */ + private fun taggedHash( + tagHash: ByteArray, + msg: ByteArray, + ): ByteArray { + val buf = ByteArray(tagHash.size * 2 + msg.size) + tagHash.copyInto(buf, 0) + tagHash.copyInto(buf, tagHash.size) + msg.copyInto(buf, tagHash.size * 2) + return sha256(buf) + } + + private fun shaPrevouts(tx: BitcoinTransaction): ByteArray { + val w = BitcoinWriter() + tx.inputs.forEach { it.outPoint.write(w) } + return sha256(w.toByteArray()) + } + + private fun shaAmounts(spentOutputs: List): ByteArray { + val w = BitcoinWriter() + spentOutputs.forEach { w.writeUInt64LE(it.valueSats) } + return sha256(w.toByteArray()) + } + + private fun shaScriptPubKeys(spentOutputs: List): ByteArray { + val w = BitcoinWriter() + spentOutputs.forEach { w.writeVarBytes(it.scriptPubKey) } + return sha256(w.toByteArray()) + } + + private fun shaSequences(tx: BitcoinTransaction): ByteArray { + val w = BitcoinWriter() + tx.inputs.forEach { w.writeUInt32LE(it.sequence) } + return sha256(w.toByteArray()) + } + + private fun shaOutputs(tx: BitcoinTransaction): ByteArray { + val w = BitcoinWriter() + tx.outputs.forEach { it.write(w) } + return sha256(w.toByteArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt index 1f0cbffe12..ac5f04618a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt @@ -78,6 +78,36 @@ object TaprootAddress { return tweaked.copyOfRange(1, 33) } + /** + * BIP-341 `taproot_tweak_seckey` for a key-path-only spend (no script tree). + * + * Produces the private key that controls the P2TR output derived from + * [internalSecKey]'s public key. The internal key is first normalized to + * the even-y representation BIP-341 hashes over, then the TapTweak scalar + * is added. + * + * @param internalSecKey 32-byte internal private key. + * @return 32-byte tweaked private key suitable for a BIP-340 Schnorr + * signature over a [TaprootSigHash]-style key-path sighash. + */ + fun tweakSecretKey(internalSecKey: ByteArray): ByteArray { + require(internalSecKey.size == 32) { + "internal secret key must be 32 bytes (got ${internalSecKey.size})" + } + // P = internalSecKey · G, as a 33-byte compressed point. + val compressedPubKey = Secp256k1Instance.compressedPubKeyFor(internalSecKey) + val xOnly = compressedPubKey.copyOfRange(1, 33) + + // If P has odd y, BIP-341 negates the secret key so it corresponds to + // the even-y lift used when computing the TapTweak hash. + val evenYParity = compressedPubKey[0].toInt() == 0x02 + val normalizedSecKey = + if (evenYParity) internalSecKey else Secp256k1Instance.privKeyNegate(internalSecKey) + + val tweak = tapTweakHash(xOnly) + return Secp256k1Instance.privateKeyAdd(normalizedSecKey, tweak) + } + /** * Derive the Bitcoin mainnet taproot address (`bc1p...`) for a Nostr * public key. The pubkey is used directly as the BIP-341 internal key. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt index d3d5378047..e993a83eed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt @@ -73,4 +73,12 @@ expect object Secp256k1Instance { pubKey: ByteArray, tweak: ByteArray, ): ByteArray + + /** + * Negate a private key: returns `(n - d) mod n` as 32 bytes. + * + * Used by the BIP-341 `taproot_tweak_seckey` algorithm when the internal + * key's public point has odd y. + */ + fun privKeyNegate(privKey: ByteArray): ByteArray } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt new file mode 100644 index 0000000000..0a1aa8a161 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt @@ -0,0 +1,120 @@ +/* + * 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.quartz.nip01Core.signers + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TaprootSigHash +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxOut +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.inputTapKeySig +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** Tests that `NostrSigner.signPsbt` is wired correctly across the hierarchy. */ +class NostrSignerPsbtTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray() + private val recipientPriv = "000000000000000000000000000000000000000000000000000000000000000d".hexToByteArray() + + private fun xOnly(priv: ByteArray) = Secp256k1Instance.compressedPubKeyFor(priv).copyOfRange(1, 33).toHexKey() + + @Test + fun internalSignerSignsAndFinalizes() = + runTest { + val signer = NostrSignerInternal(KeyPair(senderPriv)) + val senderPubKey = xOnly(senderPriv) + val recipientPubKey = xOnly(recipientPriv) + + val built = + OnchainZapBuilder.build( + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 4.0, + availableUtxos = listOf(Utxo("1".repeat(64), 0, 250_000L, 6)), + ) + + val signedHex = signer.signPsbt(built.psbt.toHex()) + val signedPsbt = Psbt.parse(signedHex) + + assertTrue(PsbtFinalizer.isFullySigned(signedPsbt)) + + val finalTx = PsbtFinalizer.finalize(signedPsbt) + val senderScript = TaprootAddress.scriptPubKeyForRecipient(senderPubKey) + val senderOutputKey = TaprootAddress.tweakOutputKey(senderPubKey.hexToByteArray()) + val spent = built.selectedUtxos.map { TxOut(it.valueSats, senderScript) } + + finalTx.inputs.forEachIndexed { i, input -> + val sigHash = TaprootSigHash.compute(finalTx, i, spent, TaprootSigHash.SIGHASH_DEFAULT) + assertTrue( + Secp256k1Instance.verifySchnorr(input.witness[0], sigHash, senderOutputKey), + "input $i must verify after NostrSigner.signPsbt", + ) + } + } + + @Test + fun signPsbtIsIdempotentlySafeOnAlreadySignedInputs() = + runTest { + val signer = NostrSignerInternal(KeyPair(senderPriv)) + val built = + OnchainZapBuilder.build( + xOnly(senderPriv), + xOnly(recipientPriv), + 20_000L, + 3.0, + listOf(Utxo("2".repeat(64), 1, 100_000L, 3)), + ) + val once = signer.signPsbt(built.psbt.toHex()) + val twice = signer.signPsbt(once) + // Re-signing must not clobber or duplicate the existing signature. + assertEquals(once, twice) + assertEquals(64, Psbt.parse(twice).inputTapKeySig(0)!!.size) + } + + @Test + fun readOnlySignerCannotSignPsbt() = + runTest { + // A key-less (watch-only) keypair. + val pubOnly = Secp256k1Instance.compressedPubKeyFor(senderPriv).copyOfRange(1, 33) + val signer = NostrSignerInternal(KeyPair(pubKey = pubOnly)) + val built = + OnchainZapBuilder.build( + xOnly(senderPriv), + xOnly(recipientPriv), + 20_000L, + 3.0, + listOf(Utxo("3".repeat(64), 0, 100_000L, 3)), + ) + assertFailsWith { + signer.signPsbt(built.psbt.toHex()) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt new file mode 100644 index 0000000000..7cc9252075 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt @@ -0,0 +1,106 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BitcoinTransactionTest { + // The Bitcoin genesis block coinbase transaction — a well-known legacy tx. + private val genesisCoinbaseHex = + "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff" + + "4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72" + + "206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff" + + "0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f" + + "61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000" + private val genesisCoinbaseTxid = + "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" + + // BIP-341 wallet-test-vectors keyPathSpending: the raw unsigned transaction. + private val bip341UnsignedTxHex = + "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c0100000000" + + "00000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd9900000000" + + "00fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000" + + "0000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100" + + "000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c00" + + "00000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050" + + "000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c" + + "94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4" + + "eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1" + + "b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f24" + + "1244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefc" + + "c9a663f78bab962b0065cd1d" + + @Test + fun computesGenesisCoinbaseTxid() { + val tx = BitcoinTransaction.parse(genesisCoinbaseHex) + assertEquals(genesisCoinbaseTxid, tx.txid()) + } + + @Test + fun genesisCoinbaseRoundTrips() { + val tx = BitcoinTransaction.parse(genesisCoinbaseHex) + assertEquals(genesisCoinbaseHex, tx.serialize().toHexKey()) + assertEquals(1, tx.inputs.size) + assertEquals(1, tx.outputs.size) + assertEquals(5_000_000_000L, tx.outputs[0].valueSats) + } + + @Test + fun parsesBip341UnsignedTransaction() { + val tx = BitcoinTransaction.parse(bip341UnsignedTxHex) + assertEquals(2L, tx.version) + assertEquals(9, tx.inputs.size) + assertEquals(2, tx.outputs.size) + assertEquals(1_000_000_000L, tx.outputs[0].valueSats) + assertEquals(3_410_000_000L, tx.outputs[1].valueSats) + // No witness on the unsigned tx → legacy serialization round-trips exactly. + assertEquals(bip341UnsignedTxHex, tx.serialize().toHexKey()) + } + + @Test + fun segwitSerializationAddsMarkerFlagAndWitness() { + val base = BitcoinTransaction.parse(bip341UnsignedTxHex) + val withWitness = + base.copy( + inputs = + base.inputs.mapIndexed { i, input -> + if (i == 0) input.copy(witness = listOf(ByteArray(64) { 0x11 })) else input + }, + ) + val serialized = withWitness.serialize().toHexKey() + // version(4 bytes = 8 hex) then segwit marker+flag 0001 + assertTrue(serialized.substring(8, 12) == "0001", "expected segwit marker+flag") + // txid is witness-stripped, so it is unchanged by adding a witness. + assertEquals(base.txid(), withWitness.txid()) + } + + @Test + fun varIntRoundTrips() { + val values = listOf(0L, 1L, 0xFCL, 0xFDL, 0xFFFFL, 0x10000L, 0xFFFFFFFFL, 0x100000000L) + for (v in values) { + val bytes = BitcoinWriter().writeVarInt(v).toByteArray() + assertEquals(v, BitcoinReader(bytes).readVarInt(), "varint round-trip failed for $v") + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt new file mode 100644 index 0000000000..45c26ca889 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt @@ -0,0 +1,182 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * End-to-end signing tests for the PSBT pipeline. Anchored on the BIP-341 + * `keyPathSpending` test vector (input 0) so the BIP-341 tweak, the BIP-341 + * sighash, and the BIP-340 signature are all validated against an authoritative + * source. + */ +class PsbtSignerTest { + private val unsignedTxHex = + "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c0100000000" + + "00000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd9900000000" + + "00fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000" + + "0000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100" + + "000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c00" + + "00000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050" + + "000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c" + + "94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4" + + "eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1" + + "b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f24" + + "1244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefc" + + "c9a663f78bab962b0065cd1d" + + private val spentOutputs = + listOf( + TxOut(420_000_000L, "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray()), + TxOut(462_000_000L, "5120147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3".hexToByteArray()), + TxOut(294_000_000L, "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac".hexToByteArray()), + TxOut(504_000_000L, "5120e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e".hexToByteArray()), + TxOut(630_000_000L, "512091b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605".hexToByteArray()), + TxOut(378_000_000L, "00147dd65592d0ab2fe0d0257d571abf032cd9db93dc".hexToByteArray()), + TxOut(672_000_000L, "512075169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831".hexToByteArray()), + TxOut(546_000_000L, "5120712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5".hexToByteArray()), + TxOut(588_000_000L, "512077e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220".hexToByteArray()), + ) + + // BIP-341 keyPathSpending input 0. + private val internalPrivKey0 = "6b973d88838f27366ed61c9ad6367663045cb456e28335c109e30717ae0c6baa" + private val internalPubKey0 = "d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d" + private val tweakedPrivKey0 = "2405b971772ad26915c8dcdf10f238753a9b837e5f8e6a86fd7c0cce5b7296d9" + private val outputKey0 = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" + private val sigHashSingle0 = "2514a6272f85cfa0f45eb907fcb0d121b808ed37c6ea160a5a9046ed5526d555" + + private fun psbtForVectorTx(): Psbt { + val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) + spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) } + psbt.setInputTapInternalKey(0, internalPubKey0.hexToByteArray()) + return psbt + } + + @Test + fun tweakSecretKeyMatchesBip341Vector() { + val tweaked = TaprootAddress.tweakSecretKey(internalPrivKey0.hexToByteArray()) + assertEquals(tweakedPrivKey0, tweaked.toHexKey()) + } + + @Test + fun signsAndProducesVerifiableSignature_sighashSingle() { + val psbt = psbtForVectorTx() + psbt.setInputSighashType(0, TaprootSigHash.SIGHASH_SINGLE) + + val count = PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + assertEquals(1, count, "exactly input 0 should be signed") + + val sig = psbt.inputTapKeySig(0)!! + // Non-default sighash → 65-byte signature with the type byte appended. + assertEquals(65, sig.size) + assertEquals(TaprootSigHash.SIGHASH_SINGLE, sig[64].toInt()) + + // The 64-byte BIP-340 signature must verify against the vector's exact + // SIGHASH_SINGLE sighash and the input's taproot output key. + val ok = + Secp256k1Instance.verifySchnorr( + sig.copyOfRange(0, 64), + sigHashSingle0.hexToByteArray(), + outputKey0.hexToByteArray(), + ) + assertTrue(ok, "signature must verify against the BIP-341 vector sighash + output key") + } + + @Test + fun signsDefaultSighashHappyPath() { + // The path NIP-BC actually uses: SIGHASH_DEFAULT, bare 64-byte signature. + val psbt = psbtForVectorTx() + PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + + val sig = psbt.inputTapKeySig(0)!! + assertEquals(64, sig.size, "SIGHASH_DEFAULT → bare 64-byte signature") + + val expectedSigHash = + TaprootSigHash.compute(psbt.unsignedTx, 0, spentOutputs, TaprootSigHash.SIGHASH_DEFAULT) + val ok = Secp256k1Instance.verifySchnorr(sig, expectedSigHash, outputKey0.hexToByteArray()) + assertTrue(ok, "default-sighash signature must verify against the output key") + } + + @Test + fun skipsInputsTheKeyDoesNotControl() { + // A wrong internal key on input 0 → nothing to sign. + val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) + spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) } + psbt.setInputTapInternalKey(0, "ab".repeat(32).hexToByteArray()) + + val count = PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + assertEquals(0, count) + } + + @Test + fun failsWhenWitnessUtxoMissing() { + val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) + psbt.setInputTapInternalKey(0, internalPubKey0.hexToByteArray()) + // No witness UTXOs set → sighash cannot be computed. + assertFailsWith { + PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + } + } + + @Test + fun finalizeMovesSignatureIntoWitness() { + // A minimal self-contained 1-in / 1-out taproot spend. + val privKey = "0000000000000000000000000000000000000000000000000000000000000003".hexToByteArray() + val xOnly = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33) + val outputKey = TaprootAddress.tweakOutputKey(xOnly) + val prevScript = TaprootAddress.outputKeyToScriptPubKey(outputKey) + + val tx = + BitcoinTransaction( + version = 2L, + inputs = listOf(TxIn(OutPoint("a".repeat(64), 0L), sequence = 0xFFFFFFFFL)), + outputs = listOf(TxOut(90_000L, prevScript)), + lockTime = 0L, + ) + val psbt = Psbt.fromUnsignedTx(tx) + psbt.setInputWitnessUtxo(0, TxOut(100_000L, prevScript)) + psbt.setInputTapInternalKey(0, xOnly) + + assertTrue(!PsbtFinalizer.isFullySigned(psbt)) + PsbtSigner.signKeyPathInputs(psbt, privKey) + assertTrue(PsbtFinalizer.isFullySigned(psbt)) + + val finalTx = PsbtFinalizer.finalize(psbt) + assertEquals(1, finalTx.inputs[0].witness.size) + assertEquals(64, finalTx.inputs[0].witness[0].size) + assertTrue(finalTx.hasWitness) + // txid is witness-stripped — unchanged by finalization. + assertEquals(tx.txid(), finalTx.txid()) + + // The signature in the witness must verify. + val sigHash = TaprootSigHash.compute(tx, 0, listOf(TxOut(100_000L, prevScript)), TaprootSigHash.SIGHASH_DEFAULT) + assertTrue( + Secp256k1Instance.verifySchnorr(finalTx.inputs[0].witness[0], sigHash, outputKey), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt new file mode 100644 index 0000000000..7fcb8026a2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt @@ -0,0 +1,112 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PsbtTest { + private fun sampleTx() = + BitcoinTransaction( + version = 2L, + inputs = + listOf( + TxIn(OutPoint("a".repeat(64), 0L), sequence = 0xFFFFFFFFL), + TxIn(OutPoint("b".repeat(64), 3L), sequence = 0xFFFFFFFFL), + ), + outputs = + listOf( + TxOut(25_000L, "5120${"c".repeat(64)}".hexToByteArray()), + TxOut(99_000L, "5120${"d".repeat(64)}".hexToByteArray()), + ), + lockTime = 0L, + ) + + @Test + fun emptyPsbtSerializesToExpectedBytes() { + // version 2, 0 inputs, 0 outputs, locktime 0 → 10-byte tx. + val tx = BitcoinTransaction(2L, emptyList(), emptyList(), 0L) + val psbt = Psbt.fromUnsignedTx(tx) + // magic | keylen 01 | keytype 00 | valuelen 0a | <10-byte tx> | global separator 00 + assertEquals("70736274ff01000a0200000000000000000000", psbt.toHex()) + } + + @Test + fun rejectsBadMagic() { + assertFailsWith { + Psbt.parse("00112233445566778899") + } + } + + @Test + fun roundTripsWithTypedFields() { + val tx = sampleTx() + val psbt = Psbt.fromUnsignedTx(tx) + + // Input 0: witness utxo + tap internal key + sighash type. + psbt.setInputWitnessUtxo(0, TxOut(120_000L, "5120${"e".repeat(64)}".hexToByteArray())) + psbt.setInputTapInternalKey(0, "f".repeat(64).hexToByteArray()) + psbt.setInputSighashType(0, TaprootSigHash.SIGHASH_DEFAULT) + + // Input 1: a different witness utxo. + psbt.setInputWitnessUtxo(1, TxOut(80_000L, "0014${"1".repeat(40)}".hexToByteArray())) + + // Output 0: change-style tap internal key. + psbt.setOutputTapInternalKey(0, "2".repeat(64).hexToByteArray()) + + val reparsed = Psbt.parse(psbt.serialize()) + + assertEquals(tx.txid(), reparsed.unsignedTx.txid()) + assertEquals(120_000L, reparsed.inputWitnessUtxo(0)!!.valueSats) + assertEquals("f".repeat(64), reparsed.inputTapInternalKey(0)!!.toHexKey()) + assertEquals(TaprootSigHash.SIGHASH_DEFAULT, reparsed.inputSighashType(0)) + assertEquals(80_000L, reparsed.inputWitnessUtxo(1)!!.valueSats) + assertNull(reparsed.inputTapInternalKey(1)) + assertNull(reparsed.inputTapKeySig(0)) + // Exact byte round-trip. + assertEquals(psbt.toHex(), reparsed.toHex()) + } + + @Test + fun keySigAccessorEnforcesLength() { + val psbt = Psbt.fromUnsignedTx(sampleTx()) + assertFailsWith { + psbt.setInputTapKeySig(0, ByteArray(32)) + } + psbt.setInputTapKeySig(0, ByteArray(64) { 0x07 }) + assertTrue(psbt.inputTapKeySig(0)!!.size == 64) + } + + @Test + fun preservesUnknownRecords() { + val psbt = Psbt.fromUnsignedTx(sampleTx()) + // An unrecognized global keytype must survive a round-trip untouched. + psbt.global.records.add(PsbtRecord(0x7E, ByteArray(0), byteArrayOf(0x01, 0x02, 0x03))) + val reparsed = Psbt.parse(psbt.serialize()) + val unknown = reparsed.global.records.firstOrNull { it.keyType == 0x7E } + assertTrue(unknown != null && unknown.value.contentEquals(byteArrayOf(0x01, 0x02, 0x03))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt new file mode 100644 index 0000000000..5e55edc11d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt @@ -0,0 +1,93 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Validates [TaprootSigHash] against the BIP-341 `wallet-test-vectors.json` + * `keyPathSpending` cases — all seven spendable inputs, covering every base + * sighash type and both ANYONECANPAY variants. + */ +class TaprootSigHashTest { + private val unsignedTxHex = + "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c0100000000" + + "00000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd9900000000" + + "00fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000" + + "0000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100" + + "000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c00" + + "00000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050" + + "000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c" + + "94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4" + + "eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1" + + "b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f24" + + "1244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefc" + + "c9a663f78bab962b0065cd1d" + + // utxosSpent from the vector, in input order. + private val spentOutputs = + listOf( + TxOut(420_000_000L, "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray()), + TxOut(462_000_000L, "5120147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3".hexToByteArray()), + TxOut(294_000_000L, "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac".hexToByteArray()), + TxOut(504_000_000L, "5120e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e".hexToByteArray()), + TxOut(630_000_000L, "512091b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605".hexToByteArray()), + TxOut(378_000_000L, "00147dd65592d0ab2fe0d0257d571abf032cd9db93dc".hexToByteArray()), + TxOut(672_000_000L, "512075169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831".hexToByteArray()), + TxOut(546_000_000L, "5120712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5".hexToByteArray()), + TxOut(588_000_000L, "512077e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220".hexToByteArray()), + ) + + private val tx = BitcoinTransaction.parse(unsignedTxHex) + + private fun check( + inputIndex: Int, + hashType: Int, + expected: String, + ) { + val sigHash = TaprootSigHash.compute(tx, inputIndex, spentOutputs, hashType) + assertEquals(expected, sigHash.toHexKey(), "sighash mismatch for input $inputIndex hashType $hashType") + } + + @Test + fun input4_sighashDefault() = check(4, 0x00, "4f900a0bae3f1446fd48490c2958b5a023228f01661cda3496a11da502a7f7ef") + + @Test + fun input3_sighashAll() = check(3, 0x01, "bf013ea93474aa67815b1b6cc441d23b64fa310911d991e713cd34c7f5d46669") + + @Test + fun input6_sighashNone() = check(6, 0x02, "15f25c298eb5cdc7eb1d638dd2d45c97c4c59dcaec6679cfc16ad84f30876b85") + + @Test + fun input0_sighashSingle() = check(0, 0x03, "2514a6272f85cfa0f45eb907fcb0d121b808ed37c6ea160a5a9046ed5526d555") + + @Test + fun input8_sighashAllAnyoneCanPay() = check(8, 0x81, "cccb739eca6c13a8a89e6e5cd317ffe55669bbda23f2fd37b0f18755e008edd2") + + @Test + fun input7_sighashNoneAnyoneCanPay() = check(7, 0x82, "cd292de50313804dabe4685e83f923d2969577191a3e1d2882220dca88cbeb10") + + @Test + fun input1_sighashSingleAnyoneCanPay() = check(1, 0x83, "325a644af47e8a5a2591cda0ab0723978537318f10e6a63d4eed783b96a71a4d") +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt index f142e506bd..efc5276dad 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt @@ -72,4 +72,6 @@ actual object Secp256k1Instance { val full = if (pubKey.size == 32) h02 + pubKey else pubKey return secp256k1.pubKeyCompress(secp256k1.pubKeyTweakAdd(full, tweak)) } + + actual fun privKeyNegate(privKey: ByteArray): ByteArray = secp256k1.privKeyNegate(privKey) } diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt index bf61c5877f..7a3473a476 100644 --- a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt @@ -71,4 +71,6 @@ actual object Secp256k1Instance { val full = if (pubKey.size == 32) h02 + pubKey else pubKey return secp256k1Ref.pubKeyCompress(secp256k1Ref.pubKeyTweakAdd(full, tweak)) } + + actual fun privKeyNegate(privKey: ByteArray): ByteArray = secp256k1Ref.privKeyNegate(privKey) } From f5e72404254d22cae5c90e5336725a6b9d371547 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 11:32:15 +0000 Subject: [PATCH 07/19] docs: mark onchain zaps Phase A.2 (send-side foundation) shipped --- amethyst/plans/2026-05-14-onchain-zaps.md | 39 +++++++++++++---------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/amethyst/plans/2026-05-14-onchain-zaps.md b/amethyst/plans/2026-05-14-onchain-zaps.md index 551f1eff97..9baae3f67f 100644 --- a/amethyst/plans/2026-05-14-onchain-zaps.md +++ b/amethyst/plans/2026-05-14-onchain-zaps.md @@ -148,27 +148,32 @@ Lightning fast path. Two minimal hooks: |---|---|---| | **A.1** | Quartz foundation (receive side): taproot address, bech32m segwit, Esplora client, verifier + tests | **Shipped** | | **C** | Receive + display: `OnchainSection` in `WalletScreen` (address + live balance), Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 7 in-scope filter files | **Shipped** | -| **A.2** | Quartz foundation (send side): minimal BIP-174 PSBT codec, `OnchainZapBuilder`, `signPsbt` on `NostrSigner` hierarchy | Pending | +| **A.2** | Quartz foundation (send side): BIP-174 PSBT codec, BIP-341 sighash, `OnchainZapBuilder`, `signPsbt` on the `NostrSigner` hierarchy. All crypto validated against BIP-341 wallet test vectors (31 tests). | **Shipped** | | **B** | NIP-55 `sign_psbt` intent contract + `NostrSignerExternal.signPsbt`. Broken until Amber ships matching support — surface "update your signer" in the UI. | Pending | | **D** | Send: dialog in zap menu, UTXO selection, build → sign → broadcast → publish kind 8333 | Pending | -### What's still required before send can ship (Phase A.2) +### Phase A.2 — shipped -1. **PSBT codec.** Hand-rolled BIP-174 reader/writer for the single shape we need - (1+ inputs key-path-only P2TR, 1-2 outputs, no script tree). Needs explicit - test vectors from the BIP-174 / BIP-341 test suites — any bug here can lose - user funds. -2. **TapTweak in signer.** `NostrSignerInternal.signPsbt` applies the BIP-341 - key-path-only tweak to its private key before producing the Schnorr sig - over the BIP-341 sighash. Today `signSchnorr*` always uses the raw - keypair — we need an internal `signWithTweakedKey` variant. Tests must - compare against the libsecp256k1 reference output. -3. **Sighash computation.** BIP-341 default sighash (SIGHASH_DEFAULT) is its - own serialization; not reusable from existing Nostr signing. -4. **Coin selection.** Simple smallest-set-covering-amount is enough for v1 - but needs a fee-vs-dust guard. -5. **Broadcast & publish.** Already plumbed through `EsploraBackend.broadcast`; - just needs the orchestrator in commons. +- `nipBCOnchainZaps/psbt/`: `BitcoinIO` (LE byte codec + varint), `BitcoinTransaction` + (legacy + segwit serialization, witness-stripped txid), `TaprootSigHash` + (BIP-341 SigMsg + TapSighash, all sighash types), `Psbt` (BIP-174 subset, + unknown-record-preserving), `PsbtSigner` (key-path signing), `PsbtFinalizer`. +- `nipBCOnchainZaps/build/OnchainZapBuilder`: largest-first coin selection + + unsigned-PSBT assembly with dust-aware change. +- `TaprootAddress.tweakSecretKey`: BIP-341 `taproot_tweak_seckey`. +- `Secp256k1Instance.privKeyNegate`: new primitive (commonMain + 3 actuals). +- `NostrSigner.signPsbt`: real impl on internal signer; delegated by the + client-tag wrapper; `UnsupportedMethodException` stubs on NIP-46 / NIP-55. + +### What's still required before send works end-to-end + +1. **Phase B — NIP-55 `sign_psbt` Intent.** The Android external-signer Intent + contract for PSBT signing, plus `NostrSignerExternal.signPsbt`. Blocked on + Amber shipping support; until then external-signer accounts must fall back + or see "update your signer". +2. **Phase D — Send flow.** Send dialog in the zap menu, fee-rate picker, the + build → `signPsbt` → `EsploraBackend.broadcast` → publish-kind-8333 + orchestrator (in commons), and wallet-screen wiring. ## Risks / open questions From 40ccffee35dc52dfae9108b7642e812bf94d75ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:29:31 +0000 Subject: [PATCH 08/19] feat: NIP-BC onchain zap send flow (Phase D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the Phase A.2 quartz send-side foundation into a working end-to-end send: build → sign → broadcast → publish kind:8333. commons/onchain/OnchainZapSender — stateless orchestrator. Loads the sender's UTXOs, builds the unsigned PSBT via OnchainZapBuilder, signs it through NostrSigner.signPsbt, finalizes + broadcasts via OnchainBackend, then publishes the kind:8333 receipt through an injected publish callback. Per-stage failures surface as OnchainZapSendResult.Failure (with the broadcast txid preserved when only the receipt publish failed) instead of throwing; CancellationException always propagates. Account.sendOnchainZap — thin wrapper binding the account's signer, the LocalCache.onchainBackend, and signAndComputeBroadcast into the orchestrator. amethyst wallet UI: - OnchainZapSendDialog — collects recipient npub (or a fixed recipient when launched from a note's zap menu), amount, fee tier (slow/normal/fast from the backend's fee estimates), and an optional comment; runs the send and shows progress + success/failure. - OnchainSection — the Bitcoin card on the wallet screen gains a "Send" button next to "Copy address" that opens the dialog for a profile zap. Tests: OnchainZapSenderTest covers the happy path (receipt references the broadcast txid, recipient, and amount), insufficient-funds failure at the build stage, broadcast failure (no txid leaked), and publish failure (txid preserved for retry). Pending: Phase B (NIP-55 sign_psbt Intent) — external/remote signers still throw UnsupportedMethodException; note-zap-menu entry point can reuse OnchainZapSendDialog's recipientPubKey/zappedEvent parameters once wired. --- .../vitorpamplona/amethyst/model/Account.kt | 34 ++ .../screen/loggedIn/wallet/OnchainSection.kt | 21 +- .../loggedIn/wallet/OnchainZapSendDialog.kt | 313 ++++++++++++++++++ .../commons/onchain/OnchainZapSender.kt | 208 ++++++++++++ .../commons/onchain/OnchainZapSenderTest.kt | 178 ++++++++++ 5 files changed, 751 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 396eee5ce0..2d1721287d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -36,6 +36,9 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListS import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator @@ -754,6 +757,37 @@ class Account( return zapRequest } + /** + * Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's + * derived Taproot address, sign it, broadcast it, and publish the kind:8333 + * zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or + * leave it null for a profile zap. + */ + suspend fun sendOnchainZap( + recipientPubKey: HexKey, + amountSats: Long, + feeRateSatPerVByte: Double, + comment: String = "", + zappedEvent: EventHintBundle? = null, + ): OnchainZapSendResult { + val backend = + cache.onchainBackend + ?: return OnchainZapSendResult.Failure( + OnchainZapSendStage.LOADING_UTXOS, + "Bitcoin chain backend is not configured", + ) + return OnchainZapSender.send( + backend = backend, + signer = signer, + senderPubKey = signer.pubKey, + recipientPubKey = recipientPubKey, + amountSats = amountSats, + feeRateSatPerVByte = feeRateSatPerVByte, + comment = comment, + zappedEvent = zappedEvent, + ) { template -> signAndComputeBroadcast(template) } + } + suspend fun report( note: Note, type: ReportType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt index 087c62556a..49c804e269 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme @@ -133,7 +134,7 @@ fun OnchainSection( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - CopyAddressRow(address) + ActionRow(address = address, accountViewModel = accountViewModel) } } } @@ -173,13 +174,17 @@ private fun BalanceRow( } @Composable -private fun CopyAddressRow(address: String) { +private fun ActionRow( + address: String, + accountViewModel: AccountViewModel, +) { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() + var showSendDialog by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { OutlinedButton(onClick = { @@ -187,5 +192,15 @@ private fun CopyAddressRow(address: String) { }) { Text("Copy address") } + Button(onClick = { showSendDialog = true }) { + Text("Send") + } + } + + if (showSendDialog) { + OnchainZapSendDialog( + accountViewModel = accountViewModel, + onDismiss = { showSendDialog = false }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt new file mode 100644 index 0000000000..744c654981 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -0,0 +1,313 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +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.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.compose.material3.ExperimentalMaterial3Api as ExpM3 + +private enum class FeeTier( + val label: String, +) { + SLOW("Slow"), + NORMAL("Normal"), + FAST("Fast"), +} + +private fun FeeEstimates.rateFor(tier: FeeTier): Double = + when (tier) { + FeeTier.SLOW -> slowSatPerVbyte + FeeTier.NORMAL -> normalSatPerVbyte + FeeTier.FAST -> fastSatPerVbyte + } + +/** + * Dialog that drives a NIP-BC onchain zap: collect recipient / amount / fee + * tier / comment, then run [com.vitorpamplona.amethyst.model.Account.sendOnchainZap] + * and show progress + the result. + * + * When [recipientPubKey] is null the user enters a recipient npub and the zap + * targets that profile. When provided (e.g. from a note's zap menu) the + * recipient is fixed and [zappedEvent] attributes the zap to that event. + */ +@OptIn(ExpM3::class) +@Composable +fun OnchainZapSendDialog( + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + recipientPubKey: HexKey? = null, + zappedEvent: EventHintBundle? = null, +) { + val scope = rememberCoroutineScope() + + var npubInput by remember { mutableStateOf("") } + var amountInput by remember { mutableStateOf("") } + var comment by remember { mutableStateOf("") } + var feeTier by remember { mutableStateOf(FeeTier.NORMAL) } + var fees by remember { mutableStateOf(null) } + + var sending by remember { mutableStateOf(false) } + var result by remember { mutableStateOf(null) } + + // Fetch recommended fee rates once when the dialog opens. + LaunchedEffect(Unit) { + val backend = LocalCache.onchainBackend ?: return@LaunchedEffect + fees = + runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull() + } + + val resolvedRecipient: HexKey? = + recipientPubKey ?: npubInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) } + val amountSats = amountInput.trim().toLongOrNull() + val canSend = + !sending && + result == null && + resolvedRecipient != null && + amountSats != null && + amountSats > 0 && + fees != null + + AlertDialog( + onDismissRequest = { if (!sending) onDismiss() }, + title = { Text("Onchain zap") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + when (val r = result) { + is OnchainZapSendResult.Success -> { + SuccessBody(r) + } + + is OnchainZapSendResult.Failure -> { + FailureBody(r) + } + + null -> { + if (sending) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + Text(" Sending onchain zap…") + } + } else { + SendForm( + recipientPubKey = recipientPubKey, + npubInput = npubInput, + onNpubChange = { npubInput = it }, + amountInput = amountInput, + onAmountChange = { amountInput = it }, + comment = comment, + onCommentChange = { comment = it }, + feeTier = feeTier, + onFeeTierChange = { feeTier = it }, + fees = fees, + ) + } + } + } + } + }, + confirmButton = { + if (result != null) { + TextButton(onClick = onDismiss) { Text("Close") } + } else { + TextButton( + enabled = canSend, + onClick = { + val recipient = resolvedRecipient ?: return@TextButton + val amount = amountSats ?: return@TextButton + val feeRate = fees?.rateFor(feeTier) ?: return@TextButton + sending = true + scope.launch { + val r = + accountViewModel.account.sendOnchainZap( + recipientPubKey = recipient, + amountSats = amount, + feeRateSatPerVByte = feeRate, + comment = comment.trim(), + zappedEvent = zappedEvent, + ) + sending = false + result = r + } + }, + ) { + Text("Send") + } + } + }, + dismissButton = { + if (result == null) { + TextButton(onClick = onDismiss, enabled = !sending) { Text("Cancel") } + } + }, + ) +} + +@OptIn(ExpM3::class) +@Composable +private fun SendForm( + recipientPubKey: HexKey?, + npubInput: String, + onNpubChange: (String) -> Unit, + amountInput: String, + onAmountChange: (String) -> Unit, + comment: String, + onCommentChange: (String) -> Unit, + feeTier: FeeTier, + onFeeTierChange: (FeeTier) -> Unit, + fees: FeeEstimates?, +) { + if (recipientPubKey == null) { + OutlinedTextField( + value = npubInput, + onValueChange = onNpubChange, + label = { Text("Recipient npub") }, + singleLine = true, + isError = npubInput.isNotBlank() && decodePublicKeyAsHexOrNull(npubInput.trim()) == null, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text( + text = "Zapping the post author", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedTextField( + value = amountInput, + onValueChange = { onAmountChange(it.filter(Char::isDigit)) }, + label = { Text("Amount (sats)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = comment, + onValueChange = onCommentChange, + label = { Text("Comment (optional)") }, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = "Fee priority", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FeeTier.entries.forEach { tier -> + val rate = fees?.rateFor(tier) + FilterChip( + selected = feeTier == tier, + onClick = { onFeeTierChange(tier) }, + label = { + Text( + if (rate != null) { + "${tier.label} · ${formatRate(rate)} sat/vB" + } else { + tier.label + }, + ) + }, + ) + } + } + if (fees == null) { + Text( + text = "Loading fee estimates…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SuccessBody(result: OnchainZapSendResult.Success) { + Text("Onchain zap sent.", style = MaterialTheme.typography.bodyLarge) + Text( + text = "Transaction: ${result.txid}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = + "Fee: ${result.feeSats} sats" + + if (result.changeSats > 0) " · change: ${result.changeSats} sats" else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun FailureBody(result: OnchainZapSendResult.Failure) { + Text( + text = result.message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + result.broadcastTxid?.let { + Text( + text = "The payment was broadcast (tx $it) but the receipt was not published.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "Failed at: ${result.stage.name.lowercase().replace('_', ' ')}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +private fun formatRate(rate: Double): String = if (rate == rate.toLong().toDouble()) rate.toLong().toString() else ((rate * 10).toLong() / 10.0).toString() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt new file mode 100644 index 0000000000..7bba078c74 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt @@ -0,0 +1,208 @@ +/* + * 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.onchain + +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.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import kotlin.coroutines.cancellation.CancellationException + +/** The stage a NIP-BC onchain zap send reached before it finished or failed. */ +enum class OnchainZapSendStage { + /** Querying the chain backend for the sender's spendable UTXOs. */ + LOADING_UTXOS, + + /** Selecting coins and assembling the unsigned PSBT. */ + BUILDING, + + /** Signing the PSBT inputs and finalizing the transaction. */ + SIGNING, + + /** Broadcasting the signed transaction to the network. */ + BROADCASTING, + + /** Publishing the kind:8333 zap receipt to relays. */ + PUBLISHING, +} + +/** Outcome of an [OnchainZapSender.send] attempt. */ +sealed interface OnchainZapSendResult { + /** + * The transaction was broadcast and the zap receipt was published. + * + * @property txid The broadcast Bitcoin transaction id. + * @property receiptEventId The id of the published kind:8333 event. + * @property feeSats Miner fee paid. + * @property changeSats Change returned to the sender (0 if none). + */ + data class Success( + val txid: String, + val receiptEventId: HexKey, + val feeSats: Long, + val changeSats: Long, + ) : OnchainZapSendResult + + /** + * The send failed at [stage]. The transaction was NOT broadcast unless + * [stage] is [OnchainZapSendStage.PUBLISHING], in which case the payment + * went through but the receipt could not be published. + */ + data class Failure( + val stage: OnchainZapSendStage, + val message: String, + val cause: Throwable? = null, + /** Non-null when the payment was broadcast but a later stage failed. */ + val broadcastTxid: String? = null, + ) : OnchainZapSendResult +} + +/** + * Orchestrates a NIP-BC onchain zap end to end: + * load UTXOs → build PSBT → sign → finalize → broadcast → publish kind:8333. + * + * Stateless and platform-agnostic — it takes the chain backend, the signer, + * and a publish callback, so the same pipeline works from the Android app, the + * CLI, or tests. Per-stage failures are reported as + * [OnchainZapSendResult.Failure] rather than thrown, except [CancellationException] + * which always propagates. + */ +object OnchainZapSender { + /** + * @param backend Chain data source (UTXOs + broadcast). + * @param signer The sender's signer; must support `signPsbt`. + * @param senderPubKey The sender's x-only Nostr pubkey (hex). + * @param recipientPubKey The recipient's x-only Nostr pubkey (hex). + * @param amountSats Amount to pay the recipient. + * @param feeRateSatPerVByte Target fee rate. + * @param comment Optional human-readable comment for the receipt's content. + * @param zappedEvent The event being zapped, or null for a profile zap. + * @param publish Publishes the signed kind:8333 receipt and returns the event. + */ + suspend fun send( + backend: OnchainBackend, + signer: NostrSigner, + senderPubKey: HexKey, + recipientPubKey: HexKey, + amountSats: Long, + feeRateSatPerVByte: Double, + comment: String, + zappedEvent: EventHintBundle?, + publish: suspend (EventTemplate) -> Event, + ): OnchainZapSendResult { + // 1. Load the sender's UTXOs. + val utxos = + try { + val address = TaprootAddress.fromPubKey(senderPubKey) + backend.getUtxosForAddress(address) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.LOADING_UTXOS, "Could not load your Bitcoin balance", e) + } + + // 2. Coin-select and assemble the unsigned PSBT. + val built = + try { + OnchainZapBuilder.build( + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = amountSats, + feeRateSatPerVByte = feeRateSatPerVByte, + availableUtxos = utxos, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.BUILDING, e.message ?: "Could not build the transaction", e) + } + + // 3. Sign and finalize. + val rawTxHex = + try { + val signedHex = signer.signPsbt(built.psbt.toHex()) + val signedPsbt = Psbt.parse(signedHex) + if (!PsbtFinalizer.isFullySigned(signedPsbt)) { + return fail( + OnchainZapSendStage.SIGNING, + "The signer did not sign every input", + ) + } + PsbtFinalizer.finalizeToHex(signedPsbt) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.SIGNING, e.message ?: "Could not sign the transaction", e) + } + + // 4. Broadcast. + val txid = + try { + backend.broadcast(rawTxHex) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.BROADCASTING, "Could not broadcast the transaction", e) + } + + // 5. Publish the kind:8333 receipt. The payment is already on-chain at + // this point, so a failure here keeps the txid for retry/diagnostics. + val receiptId = + try { + val template = + if (zappedEvent != null) { + OnchainZapEvent.build(txid, recipientPubKey, amountSats, zappedEvent, comment) + } else { + OnchainZapEvent.buildProfileZap(txid, recipientPubKey, amountSats, comment) + } + publish(template).id + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return OnchainZapSendResult.Failure( + stage = OnchainZapSendStage.PUBLISHING, + message = "Payment sent, but the zap receipt could not be published", + cause = e, + broadcastTxid = txid, + ) + } + + return OnchainZapSendResult.Success( + txid = txid, + receiptEventId = receiptId, + feeSats = built.feeSats, + changeSats = built.changeSats, + ) + } + + private fun fail( + stage: OnchainZapSendStage, + message: String, + cause: Throwable? = null, + ) = OnchainZapSendResult.Failure(stage, message, cause) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt new file mode 100644 index 0000000000..ac9cf016c1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt @@ -0,0 +1,178 @@ +/* + * 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.onchain + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class OnchainZapSenderTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val recipientPriv = "000000000000000000000000000000000000000000000000000000000000000d" + + private fun xOnly(privHex: String) = + Secp256k1Instance + .compressedPubKeyFor(privHex.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private val senderSigner = NostrSignerInternal(KeyPair(senderPriv.hexToByteArray())) + private val senderPubKey = xOnly(senderPriv) + private val recipientPubKey = xOnly(recipientPriv) + + /** Records the broadcast tx and hands back its real txid. */ + private class FakeBackend( + private val utxos: List, + private val broadcastFails: Boolean = false, + ) : OnchainBackend { + var broadcastedHex: String? = null + + override suspend fun getTx(txid: String): BitcoinTx? = null + + override suspend fun getUtxosForAddress(address: String): List = utxos + + override suspend fun broadcast(rawTxHex: String): String { + if (broadcastFails) throw RuntimeException("relay rejected tx") + broadcastedHex = rawTxHex + return BitcoinTransaction.parse(rawTxHex).txid() + } + + override suspend fun tipHeight(): Long = 800_000L + + override suspend fun feeEstimates(): FeeEstimates = FeeEstimates(20.0, 10.0, 5.0) + } + + @Test + fun profileZapSuccess() = + runTest { + val backend = FakeBackend(listOf(Utxo("1".repeat(64), 0, 250_000L, 6))) + var publishedTemplate: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate? = null + + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "thanks!", + zappedEvent = null, + ) { template -> + publishedTemplate = template + senderSigner.sign(template) + } + + assertIs(result) + assertTrue(result.feeSats > 0) + + // The broadcast transaction's txid must match what the receipt references. + val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid() + assertEquals(broadcastTxid, result.txid) + + // The published kind:8333 receipt must reference the same txid, recipient, amount. + val receipt = senderSigner.sign(publishedTemplate!!) + assertEquals(OnchainZapEvent.KIND, receipt.kind) + assertEquals(broadcastTxid, receipt.txid()) + assertEquals(recipientPubKey, receipt.recipient()) + assertEquals(50_000L, receipt.claimedAmountInSats()) + assertTrue(receipt.isProfileZap()) + } + + @Test + fun insufficientFundsFailsAtBuilding() = + runTest { + val backend = FakeBackend(listOf(Utxo("2".repeat(64), 0, 10_000L, 6))) + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 1_000_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { senderSigner.sign(it) } + + assertIs(result) + assertEquals(OnchainZapSendStage.BUILDING, result.stage) + assertEquals(null, result.broadcastTxid) + } + + @Test + fun broadcastFailureKeepsNoTxid() = + runTest { + val backend = + FakeBackend(listOf(Utxo("3".repeat(64), 0, 250_000L, 6)), broadcastFails = true) + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { senderSigner.sign(it) } + + assertIs(result) + assertEquals(OnchainZapSendStage.BROADCASTING, result.stage) + assertEquals(null, result.broadcastTxid) + } + + @Test + fun publishFailureReportsBroadcastTxid() = + runTest { + val backend = FakeBackend(listOf(Utxo("4".repeat(64), 0, 250_000L, 6))) + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { throw RuntimeException("relay down") } + + assertIs(result) + assertEquals(OnchainZapSendStage.PUBLISHING, result.stage) + // The payment went through — the txid is preserved for retry/diagnostics. + val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid() + assertEquals(broadcastTxid, result.broadcastTxid) + } +} From bb0f41d3209a7285ae6e5c9e93d6f2134a2c2c34 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:31:11 +0000 Subject: [PATCH 09/19] docs: mark onchain zaps Phase D (send flow) shipped --- amethyst/plans/2026-05-14-onchain-zaps.md | 28 +++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/amethyst/plans/2026-05-14-onchain-zaps.md b/amethyst/plans/2026-05-14-onchain-zaps.md index 9baae3f67f..d145b261be 100644 --- a/amethyst/plans/2026-05-14-onchain-zaps.md +++ b/amethyst/plans/2026-05-14-onchain-zaps.md @@ -149,8 +149,8 @@ Lightning fast path. Two minimal hooks: | **A.1** | Quartz foundation (receive side): taproot address, bech32m segwit, Esplora client, verifier + tests | **Shipped** | | **C** | Receive + display: `OnchainSection` in `WalletScreen` (address + live balance), Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 7 in-scope filter files | **Shipped** | | **A.2** | Quartz foundation (send side): BIP-174 PSBT codec, BIP-341 sighash, `OnchainZapBuilder`, `signPsbt` on the `NostrSigner` hierarchy. All crypto validated against BIP-341 wallet test vectors (31 tests). | **Shipped** | +| **D** | Send flow: `OnchainZapSender` orchestrator, `Account.sendOnchainZap`, `OnchainZapSendDialog`, "Send" button on the wallet `OnchainSection`. | **Shipped** | | **B** | NIP-55 `sign_psbt` intent contract + `NostrSignerExternal.signPsbt`. Broken until Amber ships matching support — surface "update your signer" in the UI. | Pending | -| **D** | Send: dialog in zap menu, UTXO selection, build → sign → broadcast → publish kind 8333 | Pending | ### Phase A.2 — shipped @@ -165,15 +165,29 @@ Lightning fast path. Two minimal hooks: - `NostrSigner.signPsbt`: real impl on internal signer; delegated by the client-tag wrapper; `UnsupportedMethodException` stubs on NIP-46 / NIP-55. -### What's still required before send works end-to-end +### Phase D — shipped + +- `commons/onchain/OnchainZapSender`: stateless orchestrator — + load UTXOs → `OnchainZapBuilder.build` → `signer.signPsbt` → + `PsbtFinalizer` → `OnchainBackend.broadcast` → publish kind:8333 via an + injected callback. Per-stage `OnchainZapSendResult.Failure`; broadcast txid + preserved when only the receipt-publish stage fails. +- `Account.sendOnchainZap`: binds the account signer, `LocalCache.onchainBackend`, + and `signAndComputeBroadcast` into the orchestrator. +- `OnchainZapSendDialog`: recipient npub (or fixed recipient + `zappedEvent` + for a future note-zap-menu entry), amount, fee tier from the backend's + estimates, comment; runs the send and shows progress + result. +- `OnchainSection`: "Send" button on the wallet-screen Bitcoin card. + +### What's still pending 1. **Phase B — NIP-55 `sign_psbt` Intent.** The Android external-signer Intent contract for PSBT signing, plus `NostrSignerExternal.signPsbt`. Blocked on - Amber shipping support; until then external-signer accounts must fall back - or see "update your signer". -2. **Phase D — Send flow.** Send dialog in the zap menu, fee-rate picker, the - build → `signPsbt` → `EsploraBackend.broadcast` → publish-kind-8333 - orchestrator (in commons), and wallet-screen wiring. + Amber shipping support; until then external-signer accounts hit + `UnsupportedMethodException` (the send dialog surfaces it as a failure). +2. **Note-zap-menu entry point.** `OnchainZapSendDialog` already accepts + `recipientPubKey` + `zappedEvent`; wiring an "Onchain" option into the + existing `ZapAmountChoicePopup` is the remaining UI hook for event zaps. ## Risks / open questions From 5f16ebc0603edb1b8dfb581e13137e08fd4d7495 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:50:47 +0000 Subject: [PATCH 10/19] feat(quartz): NIP-BC sign_psbt over NIP-55 external signer (Phase B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the NIP-55 Android external-signer contract for sign_psbt, so NostrSignerExternal.signPsbt is real instead of a stub. - CommandType.SIGN_PSBT ("sign_psbt") — also available in NIP-55 `perms` lists, since Permission wraps CommandType directly. - SignPsbtResult result type. - SignPsbtQuery (background ContentResolver) + SignPsbtRequest / SignPsbtResponse (foreground Intent), modeled on the derive_key string-in/string-out shape: the PSBT hex rides the `nostrsigner:` URI, the signed (not finalized) PSBT comes back in the `result` field. - BackgroundRequestHandler.signPsbt / ForegroundRequestHandler.signPsbt. - NostrSignerExternal.signPsbt now runs the background-then-foreground query. Signer apps that predate sign_psbt reply with no `result`, which surfaces as CouldNotPerformException — the send dialog shows it as a failure ("update your signer"). NIP-46 (NostrSignerRemote) still throws UnsupportedMethodException — the bunker-side command isn't standardized yet. --- amethyst/plans/2026-05-14-onchain-zaps.md | 24 ++++++-- .../nip55AndroidSigner/api/CommandType.kt | 2 + .../nip55AndroidSigner/api/SignerResult.kt | 4 ++ .../api/background/queries/SignPsbtQuery.kt | 59 +++++++++++++++++++ .../intents/requests/SignPsbtRequest.kt | 48 +++++++++++++++ .../intents/responses/SignPsbtResponse.kt | 50 ++++++++++++++++ .../client/NostrSignerExternal.kt | 26 +++++--- .../handlers/BackgroundRequestHandler.kt | 5 ++ .../handlers/ForegroundRequestHandler.kt | 8 +++ 9 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignPsbtQuery.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignPsbtRequest.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignPsbtResponse.kt diff --git a/amethyst/plans/2026-05-14-onchain-zaps.md b/amethyst/plans/2026-05-14-onchain-zaps.md index d145b261be..0507bda2ab 100644 --- a/amethyst/plans/2026-05-14-onchain-zaps.md +++ b/amethyst/plans/2026-05-14-onchain-zaps.md @@ -150,7 +150,7 @@ Lightning fast path. Two minimal hooks: | **C** | Receive + display: `OnchainSection` in `WalletScreen` (address + live balance), Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 7 in-scope filter files | **Shipped** | | **A.2** | Quartz foundation (send side): BIP-174 PSBT codec, BIP-341 sighash, `OnchainZapBuilder`, `signPsbt` on the `NostrSigner` hierarchy. All crypto validated against BIP-341 wallet test vectors (31 tests). | **Shipped** | | **D** | Send flow: `OnchainZapSender` orchestrator, `Account.sendOnchainZap`, `OnchainZapSendDialog`, "Send" button on the wallet `OnchainSection`. | **Shipped** | -| **B** | NIP-55 `sign_psbt` intent contract + `NostrSignerExternal.signPsbt`. Broken until Amber ships matching support — surface "update your signer" in the UI. | Pending | +| **B** | NIP-55 `sign_psbt` Intent + ContentResolver contract, wired through `NostrSignerExternal.signPsbt`. Works once the external signer app (Amber etc.) ships `sign_psbt` support — older signers reply with no `result`, surfaced as a send failure. | **Shipped** | ### Phase A.2 — shipped @@ -179,15 +179,27 @@ Lightning fast path. Two minimal hooks: estimates, comment; runs the send and shows progress + result. - `OnchainSection`: "Send" button on the wallet-screen Bitcoin card. +### Phase B — shipped + +- `CommandType.SIGN_PSBT` (`sign_psbt`) — also usable in NIP-55 `perms` lists + via `Permission`, which wraps `CommandType` directly. +- `SignPsbtResult` result type; `SignPsbtQuery` (background ContentResolver), + `SignPsbtRequest` / `SignPsbtResponse` (foreground Intent), mirroring the + `derive_key` string-in/string-out shape — the PSBT hex rides the + `nostrsigner:` URI, the signed PSBT comes back in `result`. +- `BackgroundRequestHandler.signPsbt` / `ForegroundRequestHandler.signPsbt`; + `NostrSignerExternal.signPsbt` now does the real background-then-foreground + query instead of throwing. External signers that predate `sign_psbt` reply + with no `result` → `CouldNotPerformException`, surfaced by the send dialog. + ### What's still pending -1. **Phase B — NIP-55 `sign_psbt` Intent.** The Android external-signer Intent - contract for PSBT signing, plus `NostrSignerExternal.signPsbt`. Blocked on - Amber shipping support; until then external-signer accounts hit - `UnsupportedMethodException` (the send dialog surfaces it as a failure). -2. **Note-zap-menu entry point.** `OnchainZapSendDialog` already accepts +1. **Note-zap-menu entry point.** `OnchainZapSendDialog` already accepts `recipientPubKey` + `zappedEvent`; wiring an "Onchain" option into the existing `ZapAmountChoicePopup` is the remaining UI hook for event zaps. +2. **NIP-46 `sign_psbt`.** `NostrSignerRemote.signPsbt` still throws + `UnsupportedMethodException` — the bunker-side command is not standardized + yet. ## Risks / open questions diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt index ebe0c9f1fa..78f63195e6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt @@ -31,6 +31,7 @@ enum class CommandType( GET_PUBLIC_KEY("get_public_key"), DECRYPT_ZAP_EVENT("decrypt_zap_event"), DERIVE_KEY("derive_key"), + SIGN_PSBT("sign_psbt"), ; companion object { @@ -44,6 +45,7 @@ enum class CommandType( GET_PUBLIC_KEY.code -> GET_PUBLIC_KEY DECRYPT_ZAP_EVENT.code -> DECRYPT_ZAP_EVENT DERIVE_KEY.code -> DERIVE_KEY + SIGN_PSBT.code -> SIGN_PSBT else -> null } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt index 89ed53e860..2191d6a903 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt @@ -89,3 +89,7 @@ data class ZapEventDecryptionResult( data class DerivationResult( val newPrivKey: HexKey, ) : IResult + +data class SignPsbtResult( + val signedPsbtHex: String, +) : IResult diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignPsbtQuery.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignPsbtQuery.kt new file mode 100644 index 0000000000..b40d2f8ae8 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignPsbtQuery.kt @@ -0,0 +1,59 @@ +/* + * 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.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignPsbtResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +/** + * NIP-BC `sign_psbt` background (ContentResolver) query. + * + * Passes the unsigned/partially-signed PSBT (lowercase hex) to the external + * signer app and expects the updated PSBT back in the `result` column. The + * signer signs each input whose `tapInternalKey` matches the user's pubkey; + * it does NOT finalize the PSBT. + */ +class SignPsbtQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.SIGN_PSBT}".toUri() + + fun query(psbtHex: String): SignerResult = + contentResolver.query( + uri, + arrayOf(psbtHex, loggedInUser), + ) { cursor -> + val signedPsbtHex = cursor.getStringByName("result") + if (!signedPsbtHex.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(SignPsbtResult(signedPsbtHex)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignPsbtRequest.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignPsbtRequest.kt new file mode 100644 index 0000000000..dc48835349 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignPsbtRequest.kt @@ -0,0 +1,48 @@ +/* + * 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.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +/** + * NIP-BC `sign_psbt` foreground Intent request. + * + * Carries the PSBT (lowercase hex) as the `nostrsigner:` URI data so the + * signer app can display the inputs/outputs to the user for confirmation. + */ +class SignPsbtRequest { + companion object { + fun assemble( + psbtHex: String, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$psbtHex".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.SIGN_PSBT.code) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignPsbtResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignPsbtResponse.kt new file mode 100644 index 0000000000..60b820e787 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignPsbtResponse.kt @@ -0,0 +1,50 @@ +/* + * 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.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignPsbtResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +/** + * Parses the external signer's `sign_psbt` Intent reply. The `result` field + * carries the updated (signed, not finalized) PSBT as lowercase hex. + */ +class SignPsbtResponse { + companion object { + fun assemble(signedPsbtHex: String): IntentResult = + IntentResult( + result = signedPsbtHex, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + if (intent.rejected == true) { + return SignerResult.RequestAddressed.ManuallyRejected() + } + val signedPsbtHex = intent.result + return if (!signedPsbtHex.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(SignPsbtResult(signedPsbtHex)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt index 681e521ee3..2da61eba2a 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignPsbtResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult @@ -175,16 +176,23 @@ class NostrSignerExternal( } /** - * NIP-BC `sign_psbt` over NIP-55. The Android external-signer Intent - * contract for PSBT signing is not implemented yet (Phase B); Amber and - * other signer apps must also ship support before this can work. Until - * then, callers should fall back to other signer kinds or surface an - * "update your signer" message. + * NIP-BC `sign_psbt` over NIP-55. Sends the PSBT (lowercase hex) to the + * external signer app, which signs each input whose `tapInternalKey` + * matches the user's pubkey and returns the updated (not finalized) PSBT. + * + * Signer apps that predate `sign_psbt` support reply with no `result`, + * which surfaces here as [SignerExceptions.CouldNotPerformException] — + * callers should treat that as "update your signer". */ - override suspend fun signPsbt(psbtHex: String): String = - throw SignerExceptions.UnsupportedMethodException( - "This external signer does not support sign_psbt yet", - ) + override suspend fun signPsbt(psbtHex: String): String { + val result = backgroundQuery.signPsbt(psbtHex) ?: foregroundQuery.signPsbt(psbtHex) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.signedPsbtHex + } + + throw convertExceptions("Could not sign PSBT", result) + } // always ready override fun hasForegroundSupport() = hasForegroundActivity() diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt index 7016cfdbe0..d6d7cec689 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignPsbtResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult @@ -37,6 +38,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04D import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04EncryptQuery import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44DecryptQuery import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44EncryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.SignPsbtQuery import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.SignQuery import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -53,6 +55,7 @@ class BackgroundRequestHandler( val nip44Decrypt = Nip44DecryptQuery(loggedInUser, packageName, contentResolver) val decryptZap = DecryptZapQuery(loggedInUser, packageName, contentResolver) val deriveKey = DeriveKeyQuery(loggedInUser, packageName, contentResolver) + val signPsbt = SignPsbtQuery(loggedInUser, packageName, contentResolver) fun login() = login.query() as? SignerResult.RequestAddressed @@ -81,4 +84,6 @@ class BackgroundRequestHandler( fun decryptZapEvent(event: LnZapRequestEvent) = decryptZap.query(event) as? SignerResult.RequestAddressed fun deriveKey(nonce: HexKey) = deriveKey.query(nonce) as? SignerResult.RequestAddressed + + fun signPsbt(psbtHex: String) = signPsbt.query(psbtHex) as? SignerResult.RequestAddressed } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt index b07da3122e..78841f3172 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.reques import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip04EncryptRequest import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44DecryptRequest import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44EncryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.SignPsbtRequest import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.SignRequest import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DecryptZapResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DeriveKeyResponse @@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.respon import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04EncryptResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44DecryptResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44EncryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignPsbtResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignResponse import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -95,4 +97,10 @@ class ForegroundRequestHandler( requestIntentBuilder = { DeriveKeyRequest.assemble(nonce, loggedInUser, packageName) }, parser = DeriveKeyResponse::parse, ) + + suspend fun signPsbt(psbtHex: String) = + launcher.launchWaitAndParse( + requestIntentBuilder = { SignPsbtRequest.assemble(psbtHex, loggedInUser, packageName) }, + parser = SignPsbtResponse::parse, + ) } From 1d7be1d44400cd101a7ac81da16be160a2ea79f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:29:48 +0000 Subject: [PATCH 11/19] =?UTF-8?q?fix(onchain-zaps):=20address=20audit=20fi?= =?UTF-8?q?ndings=20=E2=80=94=20fund-safety,=20interop,=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL - PsbtSignatureVerifier: independently verifies every key-path signature in a PSBT (BIP-340 sig over the BIP-341 sighash, against the tweaked output key). - OnchainZapSender now rejects a signer that returns a different transaction than it was asked to sign (byte-compares the unsigned tx) and verifies all signatures before broadcasting — closes a substitution attack where a malicious/buggy external signer could redirect funds. HIGH — break test circularity - Pin the full BIP-341 keyPathSpending input-0 witness: signing the vector sighash with the vector tweaked key reproduces the vector signature byte-for-byte (also pins BIP-340 nonce determinism). - Pin TaprootAddress.fromPubKey + SegwitAddress.encodeP2TR against all seven BIP-341 wallet-test-vector P2TR mainnet addresses. - EsploraBackendTest: JSON-parsing coverage for /tx, /address/{}/utxo, the mempool.space and blockstream fee formats, and schema-fallback. MEDIUM - OnchainZapBuilder filters to confirmed UTXOs by default (allowUnconfirmed opt-in) and signals BIP-125 RBF (nSequence 0xFFFFFFFD). - CachingOnchainBackend: TTL-caching decorator (confirmed tx forever, unconfirmed/tip/fees short TTL) so a feed of onchain zaps doesn't fan out into one HTTP request per event. Wired in AppModules. - OnchainZapVerifier computes real confirmation depth from the chain tip and asserts the backend echoed the requested txid. - EsploraBackend falls back to the standard Esplora /fee-estimates endpoint (blockstream.info) when /v1/fees/recommended 404s. LOW - BitcoinTransaction.parse caps input/output/witness-item counts to stop a hostile varint from triggering a giant pre-allocation. - OnchainZapEventTest: asserts kind:8333 on-the-wire tag structure against the NIP-BC spec. - alt tag now includes the amount ("Onchain zap: N sats"), matching the spec example. All quartz / commons / androidHostTest suites pass; amethyst compiles. --- .../com/vitorpamplona/amethyst/AppModules.kt | 17 ++- .../commons/onchain/OnchainZapSender.kt | 24 ++- .../commons/onchain/OnchainZapSenderTest.kt | 91 +++++++++++ .../chain/CachingOnchainBackend.kt | 103 +++++++++++++ .../psbt/BitcoinTransaction.kt | 20 +++ .../psbt/PsbtSignatureVerifier.kt | 92 +++++++++++ .../verify/OnchainZapVerifier.kt | 19 ++- .../nipBCOnchainZaps/zap/OnchainZapEvent.kt | 8 +- .../psbt/PsbtSignatureVerifierTest.kt | 101 +++++++++++++ .../nipBCOnchainZaps/psbt/PsbtSignerTest.kt | 22 +++ .../taproot/SegwitAddressTest.kt | 32 +++- .../taproot/TaprootAddressTest.kt | 16 +- .../verify/OnchainZapVerifierTest.kt | 9 +- .../zap/OnchainZapEventTest.kt | 107 +++++++++++++ .../nipBCOnchainZaps/chain/EsploraBackend.kt | 79 ++++++++-- .../chain/EsploraBackendTest.kt | 143 ++++++++++++++++++ 16 files changed, 844 insertions(+), 39 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifier.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEventTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 6675445fe9..e2a59d29a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -100,6 +100,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineExceptionHandler @@ -334,14 +335,18 @@ class AppModules( // NIP-BC onchain zap verification backend. Wired up once at app init so // LocalCache.consume(OnchainZapEvent) can sum the on-chain output values - // that pay the recipient's derived Taproot address. Endpoint is currently - // a fixed default; per-account override (AccountSettings.onchainEsploraEndpoint) - // is plumbed for a future Settings UI. + // that pay the recipient's derived Taproot address. Wrapped in a caching + // decorator so a feed full of onchain zaps doesn't fan out into one HTTP + // request per event. Endpoint is currently a fixed default; per-account + // override (AccountSettings.onchainEsploraEndpoint) is plumbed for a future + // Settings UI. init { cache.onchainBackend = - EsploraBackend( - baseUrl = { DEFAULT_ESPLORA_ENDPOINT }, - client = roleBasedHttpClientBuilder.okHttpClientForMoney(DEFAULT_ESPLORA_ENDPOINT), + CachingOnchainBackend( + EsploraBackend( + baseUrl = { DEFAULT_ESPLORA_ENDPOINT }, + client = roleBasedHttpClientBuilder.okHttpClientForMoney(DEFAULT_ESPLORA_ENDPOINT), + ), ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt index 7bba078c74..55cde865e1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSignatureVerifier import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import kotlin.coroutines.cancellation.CancellationException @@ -142,17 +143,38 @@ object OnchainZapSender { return fail(OnchainZapSendStage.BUILDING, e.message ?: "Could not build the transaction", e) } - // 3. Sign and finalize. + // 3. Sign, verify the signer didn't tamper, and finalize. val rawTxHex = try { val signedHex = signer.signPsbt(built.psbt.toHex()) val signedPsbt = Psbt.parse(signedHex) + + // Fund-safety: the signer must ONLY add signatures. If it returns a + // different transaction (with valid signatures over IT), finalizing + // and broadcasting would send funds wherever that tx says. Reject + // anything whose unsigned transaction isn't byte-identical to ours. + val expectedTx = built.psbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) + val returnedTx = signedPsbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) + if (expectedTx == null || returnedTx == null || !expectedTx.contentEquals(returnedTx)) { + return fail( + OnchainZapSendStage.SIGNING, + "The signer returned a different transaction than the one it was asked to sign", + ) + } if (!PsbtFinalizer.isFullySigned(signedPsbt)) { return fail( OnchainZapSendStage.SIGNING, "The signer did not sign every input", ) } + // Verify every signature is actually valid before money moves — + // catches a broken signer up front instead of a doomed broadcast. + if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(signedPsbt)) { + return fail( + OnchainZapSendStage.SIGNING, + "The signed transaction has invalid signatures", + ) + } PsbtFinalizer.finalizeToHex(signedPsbt) } catch (e: CancellationException) { throw e diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt index ac9cf016c1..397ccfac30 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt @@ -20,15 +20,22 @@ */ package com.vitorpamplona.amethyst.commons.onchain +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlinx.coroutines.test.runTest @@ -175,4 +182,88 @@ class OnchainZapSenderTest { val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid() assertEquals(broadcastTxid, result.broadcastTxid) } + + /** + * A signer that ignores the PSBT it was handed and instead signs a + * completely different transaction of its own — the substitution attack. + */ + private class TamperingSigner( + private val inner: NostrSignerInternal, + private val attackerControlledPubKey: HexKey, + ) : NostrSigner(inner.pubKey) { + override fun isWriteable() = inner.isWriteable() + + override fun hasForegroundSupport() = inner.hasForegroundSupport() + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = inner.sign(createdAt, kind, tags, content) + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = inner.nip04Encrypt(plaintext, toPublicKey) + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = inner.nip04Decrypt(ciphertext, fromPublicKey) + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = inner.nip44Encrypt(plaintext, toPublicKey) + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = inner.nip44Decrypt(ciphertext, fromPublicKey) + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = inner.decryptZapEvent(event) + + override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) + + override suspend fun signPsbt(psbtHex: String): String { + // Discard the requested PSBT entirely; build and sign one that pays + // the attacker instead, then hand it back as if it were the answer. + val malicious = + OnchainZapBuilder.build( + senderPubKey = inner.pubKey, + recipientPubKey = attackerControlledPubKey, + amountSats = 200_000L, + feeRateSatPerVByte = 1.0, + availableUtxos = listOf(Utxo("9".repeat(64), 0, 250_000L, 6)), + ) + PsbtSigner.signKeyPathInputs(malicious.psbt, inner.keyPair.privKey!!) + return malicious.psbt.toHex() + } + } + + @Test + fun rejectsASignerThatReturnsADifferentTransaction() = + runTest { + val backend = FakeBackend(listOf(Utxo("5".repeat(64), 0, 250_000L, 6))) + val tamperingSigner = TamperingSigner(senderSigner, attackerControlledPubKey = recipientPubKey) + + val result = + OnchainZapSender.send( + backend = backend, + signer = tamperingSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { senderSigner.sign(it) } + + // The substituted transaction must be rejected at the signing stage, + // and nothing must have been broadcast. + assertIs(result) + assertEquals(OnchainZapSendStage.SIGNING, result.stage) + assertEquals(null, backend.broadcastedHex) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt new file mode 100644 index 0000000000..077bb3bdba --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt @@ -0,0 +1,103 @@ +/* + * 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.quartz.nipBCOnchainZaps.chain + +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * An [OnchainBackend] decorator that caches read-only lookups so a feed full + * of NIP-BC zaps doesn't fan out into one HTTP request per event. + * + * Caching policy: + * - `getTx`: a **confirmed** transaction is immutable, so it's cached + * indefinitely; an unconfirmed one is cached only briefly (its confirmation + * status will change). `null` (not-found) is never cached — the tx may + * appear later. + * - `tipHeight` / `feeEstimates`: cached with a short TTL. + * - `getUtxosForAddress`: never cached — wallet balance must be fresh. + * - `broadcast`: never cached. + * + * The delegate call is made **outside** the lock, so concurrent lookups still + * run in parallel; a brief window where two callers fetch the same txid is + * accepted (it only wastes a request, never returns wrong data). + */ +class CachingOnchainBackend( + private val delegate: OnchainBackend, + private val unconfirmedTxTtlSeconds: Long = 60, + private val tipHeightTtlSeconds: Long = 60, + private val feeEstimatesTtlSeconds: Long = 60, + private val nowSeconds: () -> Long = { TimeUtils.now() }, +) : OnchainBackend { + private class Stamped( + val value: T, + val fetchedAt: Long, + ) + + private val mutex = Mutex() + private val txCache = mutableMapOf>() + private var tipCache: Stamped? = null + private var feeCache: Stamped? = null + + override suspend fun getTx(txid: String): BitcoinTx? { + mutex.withLock { + val cached = txCache[txid] + if (cached != null) { + val stillFresh = + cached.value.confirmations > 0 || + nowSeconds() - cached.fetchedAt < unconfirmedTxTtlSeconds + if (stillFresh) return cached.value + } + } + + val fetched = delegate.getTx(txid) ?: return null + + mutex.withLock { txCache[txid] = Stamped(fetched, nowSeconds()) } + return fetched + } + + override suspend fun getUtxosForAddress(address: String): List = delegate.getUtxosForAddress(address) + + override suspend fun broadcast(rawTxHex: String): String = delegate.broadcast(rawTxHex) + + override suspend fun tipHeight(): Long { + mutex.withLock { + tipCache?.let { + if (nowSeconds() - it.fetchedAt < tipHeightTtlSeconds) return it.value + } + } + val fetched = delegate.tipHeight() + mutex.withLock { tipCache = Stamped(fetched, nowSeconds()) } + return fetched + } + + override suspend fun feeEstimates(): FeeEstimates { + mutex.withLock { + feeCache?.let { + if (nowSeconds() - it.fetchedAt < feeEstimatesTtlSeconds) return it.value + } + } + val fetched = delegate.feeEstimates() + mutex.withLock { feeCache = Stamped(fetched, nowSeconds()) } + return fetched + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt index 3e54203cf1..c35541f4b4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt @@ -185,6 +185,14 @@ data class BitcoinTransaction( fun txid(): String = hash256(serializeForId()).reversedArray().toHexKey() companion object { + /** + * Sanity cap on input/output/witness-item counts while parsing. A real + * Bitcoin transaction is bounded by the 4 MWU block weight (~100k + * minimal inputs); this generous limit just stops an attacker-supplied + * varint from triggering a giant pre-allocation or a long spin. + */ + const val MAX_PARSE_ITEMS = 1_000_000L + fun parse(rawHex: String): BitcoinTransaction = parse(rawHex.hexToByteArray()) fun parse(bytes: ByteArray): BitcoinTransaction { @@ -210,6 +218,7 @@ data class BitcoinTransaction( else -> firstByte.toLong() } } + requireCount(inputCount, "input") val inputs = ArrayList(inputCount.toInt()) for (i in 0 until inputCount) { @@ -217,6 +226,7 @@ data class BitcoinTransaction( } val outputCount = reader.readVarInt() + requireCount(outputCount, "output") val outputs = ArrayList(outputCount.toInt()) for (i in 0 until outputCount) { outputs.add(TxOut.read(reader)) @@ -225,6 +235,7 @@ data class BitcoinTransaction( if (isSegwit) { for (i in inputs.indices) { val itemCount = reader.readVarInt() + requireCount(itemCount, "witness item") val items = ArrayList(itemCount.toInt()) for (j in 0 until itemCount) { items.add(reader.readVarBytes()) @@ -236,5 +247,14 @@ data class BitcoinTransaction( val lockTime = reader.readUInt32LE() return BitcoinTransaction(version, inputs, outputs, lockTime) } + + private fun requireCount( + count: Long, + label: String, + ) { + if (count < 0 || count > MAX_PARSE_ITEMS) { + throw PsbtParseException("$label count out of range: $count") + } + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifier.kt new file mode 100644 index 0000000000..78e4b19d8f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifier.kt @@ -0,0 +1,92 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance + +/** + * Independently verifies the key-path P2TR signatures inside a [Psbt]. + * + * [PsbtFinalizer.isFullySigned] only checks that a `PSBT_IN_TAP_KEY_SIG` + * record is *present*; it does not check the signature is *valid* nor that it + * commits to *this* transaction. Before broadcasting a transaction that came + * back from an external signer, the client MUST also confirm every signature + * actually verifies — otherwise a buggy or malicious signer could substitute a + * different transaction (with valid signatures over it) and redirect funds. + * + * Each signature is checked as a BIP-340 Schnorr signature over the BIP-341 + * sighash, against the output key derived from the input's + * `PSBT_IN_TAP_INTERNAL_KEY`. Both anchors — the sighash and the tweak — are + * validated against the BIP-341 wallet test vectors, so this check is not + * circular with the signer. + */ +object PsbtSignatureVerifier { + /** + * True iff every input of [psbt] carries a key-path tap signature, every + * input has the witness-UTXO data needed to compute its sighash, and every + * signature is a valid BIP-340 signature over the BIP-341 sighash. + */ + fun verifyAllKeyPathInputs(psbt: Psbt): Boolean { + val tx = psbt.unsignedTx + if (tx.inputs.isEmpty()) return false + + val spentOutputs = + tx.inputs.indices.map { psbt.inputWitnessUtxo(it) ?: return false } + + for (index in tx.inputs.indices) { + val internalKey = psbt.inputTapInternalKey(index) ?: return false + val sig = psbt.inputTapKeySig(index) ?: return false + + // BIP-341: a 64-byte signature implies SIGHASH_DEFAULT; a 65-byte + // signature carries the (non-default) sighash type as its last byte. + val sighashType: Int + val sig64: ByteArray + when (sig.size) { + 64 -> { + sighashType = TaprootSigHash.SIGHASH_DEFAULT + sig64 = sig + } + + 65 -> { + sighashType = sig[64].toInt() and 0xFF + // A 65-byte signature must not encode SIGHASH_DEFAULT. + if (sighashType == TaprootSigHash.SIGHASH_DEFAULT) return false + sig64 = sig.copyOfRange(0, 64) + } + + else -> { + return false + } + } + + val sigHash = + runCatching { TaprootSigHash.compute(tx, index, spentOutputs, sighashType) } + .getOrElse { return false } + val outputKey = + runCatching { TaprootAddress.tweakOutputKey(internalKey) } + .getOrElse { return false } + + if (!Secp256k1Instance.verifySchnorr(sig64, sigHash, outputKey)) return false + } + return true + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt index 2e8185fac7..43dd9406ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt @@ -64,6 +64,11 @@ class OnchainZapVerifier( backend.getTx(txid) ?: return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND) + // Defensive: the backend must have returned the transaction we asked for. + if (!tx.txid.equals(txid, ignoreCase = true)) { + return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND) + } + val recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientPubKey).lowercase() val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(event.pubKey).lowercase() @@ -81,7 +86,7 @@ class OnchainZapVerifier( txid = txid, recipientPubKey = recipientPubKey, verifiedSats = verifiedSats, - confirmations = tx.confirmations, + confirmations = realConfirmations(tx), blockHeight = tx.blockHeight, blockHashHex = tx.blockHashHex, ) @@ -94,6 +99,18 @@ class OnchainZapVerifier( } } + /** + * Resolve the real confirmation depth. Backends often report only a binary + * confirmed/unconfirmed flag (as `confirmations` 1 or 0), so when a block + * height is available we compute `tip - height + 1` against the chain tip. + * Falls back to the backend-reported value if the tip can't be fetched. + */ + private suspend fun realConfirmations(tx: BitcoinTx): Int { + val height = tx.blockHeight ?: return tx.confirmations + val tip = runCatching { backend.tipHeight() }.getOrNull() ?: return tx.confirmations + return (tip - height + 1).coerceAtLeast(1).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } + /** * Sum the value of outputs paying [recipientScriptHex]. Outputs paying * back to [senderScriptHex] are change and MUST NOT be counted. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt index 63dc0cf279..a4ac489043 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt @@ -97,7 +97,9 @@ class OnchainZapEvent( companion object { const val KIND = 8333 - const val ALT_DESCRIPTION = "Onchain Zap" + + /** NIP-31 human-readable fallback. Includes the amount, as in the NIP-BC example. */ + fun altDescription(amountInSats: Long) = "Onchain zap: $amountInSats sats" /** * Build an onchain zap that targets a specific event. @@ -111,7 +113,7 @@ class OnchainZapEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, content, createdAt) { - alt(ALT_DESCRIPTION) + alt(altDescription(amountInSats)) txid(txid) recipient(recipientPubKey) amountInSats(amountInSats) @@ -134,7 +136,7 @@ class OnchainZapEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, content, createdAt) { - alt(ALT_DESCRIPTION) + alt(altDescription(amountInSats)) txid(txid) recipient(recipientPubKey) amountInSats(amountInSats) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt new file mode 100644 index 0000000000..e53f2a040b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt @@ -0,0 +1,101 @@ +/* + * 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.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PsbtSignatureVerifierTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray() + private val senderPubKey = Secp256k1Instance.compressedPubKeyFor(senderPriv).copyOfRange(1, 33).toHexKey() + private val recipientPubKey = + Secp256k1Instance + .compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000b".hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private fun signedPsbt(): Psbt { + val built = + OnchainZapBuilder.build( + senderPubKey, + recipientPubKey, + 40_000L, + 5.0, + listOf(Utxo("1".repeat(64), 0, 250_000L, 6)), + ) + PsbtSigner.signKeyPathInputs(built.psbt, senderPriv) + return built.psbt + } + + @Test + fun acceptsAProperlySignedPsbt() { + assertTrue(PsbtSignatureVerifier.verifyAllKeyPathInputs(signedPsbt())) + } + + @Test + fun rejectsWhenAnInputIsUnsigned() { + val psbt = signedPsbt() + psbt.inputs[0].remove(Psbt.PSBT_IN_TAP_KEY_SIG) + assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(psbt)) + } + + @Test + fun rejectsAGarbageSignature() { + val psbt = signedPsbt() + psbt.setInputTapKeySig(0, ByteArray(64) { 0x11 }) + assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(psbt)) + } + + @Test + fun rejectsSignaturesOverATamperedTransaction() { + // Sign tx A, then graft A's signed input maps onto a PSBT whose + // transaction has a mutated output. The signatures no longer commit to + // the transaction being verified — exactly the substitution attack the + // verifier exists to catch. + val signed = signedPsbt() + val original = signed.unsignedTx + val tamperedTx = + original.copy( + outputs = + original.outputs.mapIndexed { i, o -> + if (i == 0) o.copy(valueSats = o.valueSats + 10_000L) else o + }, + ) + val tamperedGlobal = PsbtMap() + tamperedGlobal.put(Psbt.PSBT_GLOBAL_UNSIGNED_TX, tamperedTx.serializeForId()) + val tamperedPsbt = Psbt(tamperedGlobal, signed.inputs, signed.outputs) + + assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(tamperedPsbt)) + } + + @Test + fun rejectsWhenWitnessUtxoMissing() { + val psbt = signedPsbt() + psbt.inputs[0].remove(Psbt.PSBT_IN_WITNESS_UTXO) + assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(psbt)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt index 45c26ca889..ae35029efc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt @@ -70,6 +70,28 @@ class PsbtSignerTest { private val outputKey0 = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" private val sigHashSingle0 = "2514a6272f85cfa0f45eb907fcb0d121b808ed37c6ea160a5a9046ed5526d555" + // BIP-341 keyPathSpending input 0 — the full expected witness (64-byte + // signature + the SIGHASH_SINGLE 0x03 byte). + private val expectedWitness0 = + "ed7c1647cb97379e76892be0cacff57ec4a7102aa24296ca39af7541246d8ff1" + + "4d38958d4cc1e2e478e4d4a764bbfd835b16d4e314b72937b29833060b87276c03" + + @Test + fun producesExactBip341VectorSignature() { + // Pins the full BIP-340 signing pipeline (and its nonce determinism) to + // the authoritative BIP-341 wallet test vector: signing the vector's + // sighash with the vector's tweaked key must reproduce the vector's + // witness signature byte-for-byte. + val sig = + Secp256k1Instance.signSchnorr( + sigHashSingle0.hexToByteArray(), + tweakedPrivKey0.hexToByteArray(), + ) + // Witness is the 64-byte signature; the trailing 0x03 is the sighash type + // appended by the PSBT signer, not part of the BIP-340 signature itself. + assertEquals(expectedWitness0.substring(0, 128), sig.toHexKey()) + } + private fun psbtForVectorTx(): Psbt { val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt index 54ef5de44c..3b07618eca 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt @@ -53,13 +53,31 @@ class SegwitAddressTest { } @Test - fun encodedTaprootIsLowercaseAndCorrectLength() { - // P2TR encoded address must be 62 chars, lowercase, with 'bc1p' prefix. - val outputKey = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray() - val address = SegwitAddress.encodeP2TR(outputKey) - assertEquals(62, address.length) - assertEquals(address, address.lowercase()) - assertEquals("bc1p", address.substring(0, 4)) + fun encodesP2trAgainstBip341WalletTestVectors() { + // All seven `scriptPubKey` entries from the BIP-341 wallet-test-vectors: + // (tweaked output key, expected bip350Address). + val vectors = + listOf( + "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" to + "bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5", + "147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3" to + "bc1pz37fc4cn9ah8anwm4xqqhvxygjf9rjf2resrw8h8w4tmvcs0863sa2e586", + "e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e" to + "bc1punvppl2stp38f7kwv2u2spltjuvuaayuqsthe34hd2dyy5w4g58qqfuag5", + "712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5" to + "bc1pwyjywgrd0ffr3tx8laflh6228dj98xkjj8rum0zfpd6h0e930h6saqxrrm", + "77e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220" to + "bc1pwl3s54fzmk0cjnpl3w9af39je7pv5ldg504x5guk2hpecpg2kgsqaqstjq", + "91b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605" to + "bc1pjxmy65eywgafs5tsunw95ruycpqcqnev6ynxp7jaasylcgtcxczs6n332e", + "75169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831" to + "bc1pw5tf7sqp4f50zka7629jrr036znzew70zxyvvej3zrpf8jg8hqcssyuewe", + ) + for ((outputKey, address) in vectors) { + assertEquals(address, SegwitAddress.encodeP2TR(outputKey.hexToByteArray())) + // And it must decode back to the same output key. + assertEquals(outputKey, SegwitAddress.decode(address).program.toHexKey()) + } } // ============================================================ diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt index 4999795eed..9dc199f4c6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt @@ -61,13 +61,19 @@ class TaprootAddressTest { assertEquals(expectedScriptPubKey, script.toHexKey()) } + // BIP-341 wallet-test-vectors `scriptPubKey` entry 1 (scriptTree = null): + // internalPubkey d6889cb0… → bip350Address. + private val expectedAddress = "bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5" + + @Test + fun derivesExactBip350Address() { + // Full key-path-only derivation pinned to the BIP-341 wallet test vector. + assertEquals(expectedAddress, TaprootAddress.fromPubKey(internalKey)) + assertEquals(expectedAddress, TaprootAddress.fromPubKey(internalKey.hexToByteArray())) + } + @Test fun derivedAddressRoundTripsToOutputKey() { - // The address itself is the bech32m encoding of (v1, output_key). We - // don't hard-code a specific bech32m string here because some - // historical BIP-341 wallet vectors used post-Taproot-Schnorr key - // adjustments — instead, verify the address decodes back to the - // expected output key. val address = TaprootAddress.fromPubKey(internalKey) val decoded = SegwitAddress.decode(address) assertEquals(1, decoded.witnessVersion) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt index 8537a97591..d6fab46b71 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt @@ -77,6 +77,7 @@ class OnchainZapVerifierTest { private class FakeBackend( private val tx: BitcoinTx?, + private val tip: Long = 800_006L, ) : OnchainBackend { override suspend fun getTx(txid: String): BitcoinTx? = if (tx?.txid == txid) tx else null @@ -84,7 +85,7 @@ class OnchainZapVerifierTest { override suspend fun broadcast(rawTxHex: String): String = throw UnsupportedOperationException() - override suspend fun tipHeight(): Long = 0L + override suspend fun tipHeight(): Long = tip override suspend fun feeEstimates(): FeeEstimates = FeeEstimates(20.0, 10.0, 5.0) } @@ -100,17 +101,19 @@ class OnchainZapVerifierTest { BitcoinTxOutput(0, 25000L, recipientScriptHex), BitcoinTxOutput(1, 99000L, senderScriptHex), // change ), - confirmations = 3, + confirmations = 1, blockHashHex = "f".repeat(64), blockHeight = 800_000L, ) - val verifier = OnchainZapVerifier(FakeBackend(tx)) + val verifier = OnchainZapVerifier(FakeBackend(tx, tip = 800_006L)) val result = verifier.verify(mkEvent()) assertIs(result) assertEquals(25000L, result.verifiedSats) assertEquals(recipientHex, result.recipientPubKey) assertEquals(800_000L, result.blockHeight) + // Real depth computed from the chain tip: 800006 - 800000 + 1 = 7. + assertEquals(7, result.confirmations) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEventTest.kt new file mode 100644 index 0000000000..6919e20b53 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEventTest.kt @@ -0,0 +1,107 @@ +/* + * 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.quartz.nipBCOnchainZaps.zap + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Asserts the on-the-wire tag structure of kind:8333 events against the + * NIP-BC spec, so cross-app interoperability doesn't drift. + */ +class OnchainZapEventTest { + private val txid = "a".repeat(64) + private val recipient = "8b34731183a85a4fc1a3ea9e8caa14e72ff31716bf6dd0d2f8c93f5b14e44f5d" + + private fun Array>.tag(name: String) = firstOrNull { it.isNotEmpty() && it[0] == name } + + @Test + fun profileZapHasExactlyTheSpecTags() { + val template = OnchainZapEvent.buildProfileZap(txid, recipient, 25_000L) + + assertEquals(OnchainZapEvent.KIND, template.kind) + val tags = template.tags + + // Required NIP-BC tags, exact values. + assertEquals(listOf("alt", "Onchain zap: 25000 sats"), tags.tag("alt")?.toList()) + assertEquals(listOf("i", "bitcoin:tx:$txid"), tags.tag("i")?.toList()) + assertEquals(listOf("p", recipient), tags.tag("p")?.toList()) + assertEquals(listOf("amount", "25000"), tags.tag("amount")?.toList()) + + // A profile zap targets no event — no e / a / k tags. + assertNull(tags.tag("e"), "profile zap must not carry an e tag") + assertNull(tags.tag("a"), "profile zap must not carry an a tag") + assertNull(tags.tag("k"), "profile zap must not carry a k tag") + } + + @Test + fun eventZapCarriesEventAndKindTags() { + val zapped = + Event( + id = "b".repeat(64), + pubKey = "c".repeat(64), + createdAt = 1_700_000_000L, + kind = 1, + tags = emptyArray(), + content = "hello", + sig = "d".repeat(128), + ) + val template = + OnchainZapEvent.build(txid, recipient, 21_000L, EventHintBundle(zapped)) + val tags = template.tags + + assertEquals(listOf("i", "bitcoin:tx:$txid"), tags.tag("i")?.toList()) + assertEquals(listOf("p", recipient), tags.tag("p")?.toList()) + assertEquals(listOf("amount", "21000"), tags.tag("amount")?.toList()) + assertEquals("Onchain zap: 21000 sats", tags.tag("alt")?.get(1)) + + // The zapped event is referenced by an `e` tag and its kind by a `k` tag. + val eTag = tags.tag("e") + assertTrue(eTag != null && eTag[1] == "b".repeat(64), "e tag must reference the zapped event id") + assertEquals(listOf("k", "1"), tags.tag("k")?.toList()) + // Kind 1 is not addressable — no a tag. + assertNull(tags.tag("a")) + } + + @Test + fun parsedBackEventExposesTheSameFields() { + // The receipt must round-trip through the event accessors used by the + // verifier and feed code. + val template = OnchainZapEvent.buildProfileZap(txid, recipient, 25_000L) + val event = + OnchainZapEvent( + id = "e".repeat(64), + pubKey = "f".repeat(64), + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "0".repeat(128), + ) + assertEquals(txid, event.txid()) + assertEquals(recipient, event.recipient()) + assertEquals(25_000L, event.claimedAmountInSats()) + assertTrue(event.isProfileZap()) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt index fc7534d5bc..d2058cb737 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt @@ -37,7 +37,8 @@ import okhttp3.coroutines.executeAsync * - `GET /address/{addr}/utxo` — UTXO list for a derived address * - `POST /tx` — broadcast (body = raw tx hex) * - `GET /blocks/tip/height` — chain tip - * - `GET /v1/fees/recommended` — fee tier suggestions (mempool.space variant) + * - `GET /v1/fees/recommended` — fee tiers (mempool.space); falls back to + * `GET /fee-estimates` (the standard Esplora target→rate map) on 404 * * The `baseUrl` is supplied as a function so callers can hot-swap endpoints * from `AccountSettings` without rebuilding the backend. @@ -134,26 +135,54 @@ class EsploraBackend( } override suspend fun feeEstimates(): FeeEstimates { - // mempool.space-style recommended fees endpoint. - val url = "${baseUrl()}/v1/fees/recommended" - val request = + // mempool.space-style recommended-fees endpoint. + val recommendedUrl = "${baseUrl()}/v1/fees/recommended" + val recommended = Request .Builder() .header("Accept", "application/json") - .url(url) + .url(recommendedUrl) .get() .build() - return client.newCall(request).executeAsync().use { response -> + client.newCall(recommended).executeAsync().use { response -> + when { + response.isSuccessful -> { + return parseRecommendedFees(response.body.string()) + } + + // Standard Esplora servers (blockstream.info, self-hosted) don't + // expose /v1/fees/recommended — fall back to /fee-estimates. + response.code == 404 -> { + Unit + } + + else -> { + throw OnchainBackendException( + "GET $recommendedUrl failed: ${response.code} ${response.message}", + ) + } + } + } + + val estimatesUrl = "${baseUrl()}/fee-estimates" + val estimates = + Request + .Builder() + .header("Accept", "application/json") + .url(estimatesUrl) + .get() + .build() + return client.newCall(estimates).executeAsync().use { response -> if (!response.isSuccessful) { throw OnchainBackendException( - "GET $url failed: ${response.code} ${response.message}", + "GET $estimatesUrl failed: ${response.code} ${response.message}", ) } - parseFees(response.body.string()) + parseFeeEstimates(response.body.string()) } } - private fun parseTx(json: String): BitcoinTx { + internal fun parseTx(json: String): BitcoinTx { val node = JacksonMapper.mapper.readTree(json) val txid = node["txid"].asText() val status = node["status"] @@ -189,7 +218,7 @@ class EsploraBackend( ) } - private fun parseUtxoList(json: String): List { + internal fun parseUtxoList(json: String): List { val node = JacksonMapper.mapper.readTree(json) return node.map { utxo -> val confirmed = utxo["status"]?.get("confirmed")?.asBoolean() == true @@ -202,16 +231,15 @@ class EsploraBackend( } } - private fun parseFees(json: String): FeeEstimates { + /** mempool.space `/v1/fees/recommended`: `{ fastestFee, halfHourFee, hourFee, minimumFee }`. */ + internal fun parseRecommendedFees(json: String): FeeEstimates { val node = JacksonMapper.mapper.readTree(json) - // mempool.space exposes fastestFee / halfHourFee / hourFee / minimumFee. val fast = node["fastestFee"]?.asDouble() val normal = node["halfHourFee"]?.asDouble() ?: fast val slow = node["hourFee"]?.asDouble() ?: node["minimumFee"]?.asDouble() ?: normal if (fast == null) { Log.w(logTag) { "fee response missing 'fastestFee': $json" } - // Sensible default if the endpoint disagrees with our schema. return FeeEstimates(20.0, 10.0, 5.0) } return FeeEstimates( @@ -221,6 +249,31 @@ class EsploraBackend( ) } + /** + * Standard Esplora `/fee-estimates`: a JSON object mapping a confirmation + * target (in blocks, as a string key) to the estimated sat/vB. We map the + * 2-block / 6-block / 144-block targets to the fast / normal / slow tiers. + */ + internal fun parseFeeEstimates(json: String): FeeEstimates { + val node = JacksonMapper.mapper.readTree(json) + + fun rate(vararg targets: String): Double? = targets.firstNotNullOfOrNull { node[it]?.asDouble() } + + val fast = rate("1", "2") + val normal = rate("4", "6", "3") + val slow = rate("144", "1008", "10") + + if (fast == null) { + Log.w(logTag) { "fee-estimates response missing block targets: $json" } + return FeeEstimates(20.0, 10.0, 5.0) + } + return FeeEstimates( + fastSatPerVbyte = fast, + normalSatPerVbyte = normal ?: fast, + slowSatPerVbyte = slow ?: normal ?: fast, + ) + } + companion object { const val MEMPOOL_API_URL = "https://mempool.space/api" const val BLOCKSTREAM_API_URL = "https://blockstream.info/api" diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt new file mode 100644 index 0000000000..77b41dabb3 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt @@ -0,0 +1,143 @@ +/* + * 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.quartz.nipBCOnchainZaps.chain + +import okhttp3.OkHttpClient +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Tests the JSON-parsing layer of [EsploraBackend] against representative + * mempool.space / blockstream.info response bodies. The parse functions are + * pure (string in, model out) so no HTTP client is exercised. + */ +class EsploraBackendTest { + private val backend = EsploraBackend({ "https://example.test/api" }, OkHttpClient()) + + @Test + fun parsesConfirmedTransaction() { + val json = + """ + { + "txid": "7e3ab0f...not validated here", + "version": 2, + "locktime": 0, + "vin": [], + "vout": [ + { "scriptpubkey": "512053A1F6E454DF1AA2776A2814A721372D6258050DE330B3C6D10EE8F4E0DDA343", + "scriptpubkey_type": "v1_p2tr", "value": 25000 }, + { "scriptpubkey": "0014abababababababababababababababababababab", "value": 99000 } + ], + "status": { + "confirmed": true, + "block_height": 800000, + "block_hash": "00000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_time": 1690000000 + } + } + """.trimIndent() + + val tx = backend.parseTx(json) + assertEquals(2, tx.outputs.size) + assertEquals(25000L, tx.outputs[0].valueSats) + // scriptPubKey is normalized to lowercase for byte-comparison with our own. + assertEquals( + "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343", + tx.outputs[0].scriptPubKeyHex, + ) + assertEquals(0, tx.outputs[0].index) + assertEquals(1, tx.outputs[1].index) + assertEquals(99000L, tx.outputs[1].valueSats) + assertEquals(800000L, tx.blockHeight) + assertEquals(1, tx.confirmations, "a confirmed tx must report ≥1 confirmation") + } + + @Test + fun parsesUnconfirmedTransaction() { + val json = + """ + { "txid": "abc", "version": 2, "locktime": 0, "vin": [], + "vout": [ { "scriptpubkey": "5120ff", "value": 1000 } ], + "status": { "confirmed": false } } + """.trimIndent() + val tx = backend.parseTx(json) + assertEquals(0, tx.confirmations, "an unconfirmed tx must report 0 confirmations") + assertNull(tx.blockHeight) + } + + @Test + fun parsesUtxoList() { + val json = + """ + [ + { "txid": "1111111111111111111111111111111111111111111111111111111111111111", + "vout": 0, "value": 100000, + "status": { "confirmed": true, "block_height": 799000 } }, + { "txid": "2222222222222222222222222222222222222222222222222222222222222222", + "vout": 3, "value": 50000, + "status": { "confirmed": false } } + ] + """.trimIndent() + + val utxos = backend.parseUtxoList(json) + assertEquals(2, utxos.size) + assertEquals(100000L, utxos[0].valueSats) + assertEquals(0, utxos[0].vout) + assertEquals(1, utxos[0].confirmations) + assertEquals(50000L, utxos[1].valueSats) + assertEquals(3, utxos[1].vout) + assertEquals(0, utxos[1].confirmations, "unconfirmed UTXO must report 0 confirmations") + } + + @Test + fun parsesMempoolSpaceRecommendedFees() { + // mempool.space /v1/fees/recommended + val json = + """{ "fastestFee": 25, "halfHourFee": 15, "hourFee": 10, "economyFee": 5, "minimumFee": 1 }""" + val fees = backend.parseRecommendedFees(json) + assertEquals(25.0, fees.fastSatPerVbyte) + assertEquals(15.0, fees.normalSatPerVbyte) + assertEquals(10.0, fees.slowSatPerVbyte) + } + + @Test + fun parsesBlockstreamFeeEstimates() { + // blockstream.info / standard Esplora /fee-estimates: block target → sat/vB. + val json = + """ + { "1": 87.0, "2": 87.0, "3": 81.0, "4": 76.0, "6": 68.0, + "10": 50.0, "144": 1.027, "504": 1.0, "1008": 1.0 } + """.trimIndent() + val fees = backend.parseFeeEstimates(json) + assertEquals(87.0, fees.fastSatPerVbyte, "fast = 1-block target") + assertEquals(76.0, fees.normalSatPerVbyte, "normal = 4-block target") + assertEquals(1.027, fees.slowSatPerVbyte, "slow = 144-block target") + } + + @Test + fun feeParsersFallBackWhenSchemaUnexpected() { + val recommended = backend.parseRecommendedFees("""{ "unexpected": true }""") + assertEquals(20.0, recommended.fastSatPerVbyte) + val estimates = backend.parseFeeEstimates("""{ "unexpected": true }""") + assertEquals(20.0, estimates.fastSatPerVbyte) + } +} From db6254872e3b3850cb56c234f2d8b4cbc5fc8f5c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:55:18 +0000 Subject: [PATCH 12/19] fix(onchain-zaps): recover the OnchainZapBuilder package + re-audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The big one: the source package was named `nipBCOnchainZaps/build/`, which the repo .gitignore (`build/`) silently matched — so OnchainZapBuilder.kt (the main source, not just its test) was NEVER committed. The pushed branch did not compile; the pre-commit hook only checks the working tree, so this went unnoticed. Renamed the package `build` -> `builder` (a source package must never be named `build`) and updated all references; the recovered files are now actually tracked. Re-audit findings on the previous fix commit: - CachingOnchainBackend.txCache was unbounded -> memory leak in a long session. Added a bounded cache (maxCachedTxs, oldest-entry eviction). - OnchainZapSender still trusted the signer's returned PSBT for witness UTXOs and tap internal keys (used to compute the sighash + the verified output keys). Now it copies ONLY the PSBT_IN_TAP_KEY_SIG records back onto the PSBT we built, so a signer can contribute signatures and nothing else; verification and finalization run entirely on our own PSBT. Test coverage added: - OnchainZapBuilderTest: confirmed-UTXO filter — unconfirmed UTXOs excluded by default, spendable only with allowUnconfirmed, confirmed preferred. - CachingOnchainBackendTest: confirmed-tx cached forever, unconfirmed re-fetched after TTL, not-found never cached, tip/fee TTL, bounded eviction. All quartz / commons jvmTest suites pass; quartz android + amethyst compile. --- .../commons/onchain/OnchainZapSender.kt | 34 ++- .../commons/onchain/OnchainZapSenderTest.kt | 2 +- .../builder/OnchainZapBuilder.kt | 220 ++++++++++++++++++ .../chain/CachingOnchainBackend.kt | 12 +- .../nip01Core/signers/NostrSignerPsbtTest.kt | 2 +- .../builder/OnchainZapBuilderTest.kt | 199 ++++++++++++++++ .../chain/CachingOnchainBackendTest.kt | 150 ++++++++++++ .../psbt/PsbtSignatureVerifierTest.kt | 2 +- 8 files changed, 604 insertions(+), 17 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt index 55cde865e1..2b57828fcb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt @@ -25,11 +25,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSignatureVerifier +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.inputTapKeySig +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setInputTapKeySig import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import kotlin.coroutines.cancellation.CancellationException @@ -149,10 +151,9 @@ object OnchainZapSender { val signedHex = signer.signPsbt(built.psbt.toHex()) val signedPsbt = Psbt.parse(signedHex) - // Fund-safety: the signer must ONLY add signatures. If it returns a - // different transaction (with valid signatures over IT), finalizing - // and broadcasting would send funds wherever that tx says. Reject - // anything whose unsigned transaction isn't byte-identical to ours. + // Fund-safety: the signer must ONLY contribute signatures. First + // reject anything whose unsigned transaction isn't byte-identical + // to ours — that gives a clear error for the substitution attack. val expectedTx = built.psbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) val returnedTx = signedPsbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) if (expectedTx == null || returnedTx == null || !expectedTx.contentEquals(returnedTx)) { @@ -161,21 +162,30 @@ object OnchainZapSender { "The signer returned a different transaction than the one it was asked to sign", ) } - if (!PsbtFinalizer.isFullySigned(signedPsbt)) { - return fail( - OnchainZapSendStage.SIGNING, - "The signer did not sign every input", - ) + + // Copy ONLY the signatures back onto the PSBT we built. Everything + // else used downstream (witness UTXOs, tap internal keys) stays the + // values WE chose, so a signer can never influence the sighash, the + // verified output keys, or where funds go. + built.psbt.unsignedTx.inputs.indices.forEach { i -> + val sig = + signedPsbt.inputTapKeySig(i) + ?: return fail( + OnchainZapSendStage.SIGNING, + "The signer did not sign every input", + ) + built.psbt.setInputTapKeySig(i, sig) } + // Verify every signature is actually valid before money moves — // catches a broken signer up front instead of a doomed broadcast. - if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(signedPsbt)) { + if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(built.psbt)) { return fail( OnchainZapSendStage.SIGNING, "The signed transaction has invalid signatures", ) } - PsbtFinalizer.finalizeToHex(signedPsbt) + PsbtFinalizer.finalizeToHex(built.psbt) } catch (e: CancellationException) { throw e } catch (e: Throwable) { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt index 397ccfac30..3f00933cd1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt new file mode 100644 index 0000000000..64d969cdd0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt @@ -0,0 +1,220 @@ +/* + * 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.quartz.nipBCOnchainZaps.builder + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.OutPoint +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxIn +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxOut +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setInputTapInternalKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setInputWitnessUtxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setOutputTapInternalKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import kotlin.math.ceil + +/** + * Assembles the unsigned [Psbt] for a NIP-BC onchain zap. + * + * The sender's whole "wallet" is the single Taproot address derived from their + * Nostr pubkey, so every input spends from — and any change returns to — that + * one address. The recipient output pays the recipient's derived Taproot + * address. + * + * Coin selection is a simple largest-first greedy fill: correct and + * predictable, not privacy- or fee-optimal. The result is an unsigned PSBT + * with `PSBT_IN_WITNESS_UTXO` and `PSBT_IN_TAP_INTERNAL_KEY` populated on + * every input, ready for `NostrSigner.signPsbt`. + */ +object OnchainZapBuilder { + /** P2TR outputs below this are unspendable dust and must not be created. */ + const val DUST_THRESHOLD_SATS = 330L + + /** + * nSequence that opts the transaction into BIP-125 replace-by-fee, so a + * zap stuck at a low fee rate can be bumped instead of being stuck forever. + */ + const val RBF_SEQUENCE = 0xFFFFFFFDL + + // Virtual-size estimates for an all-P2TR-key-path transaction. + private const val OVERHEAD_VBYTES = 10.5 + private const val P2TR_INPUT_VBYTES = 57.5 + private const val P2TR_OUTPUT_VBYTES = 43.0 + + /** + * @property psbt The unsigned PSBT, ready to sign. + * @property selectedUtxos The UTXOs chosen as inputs. + * @property recipientSats Amount paid to the recipient. + * @property changeSats Amount returned to the sender (0 if no change output). + * @property feeSats The miner fee. + */ + data class Result( + val psbt: Psbt, + val selectedUtxos: List, + val recipientSats: Long, + val changeSats: Long, + val feeSats: Long, + ) + + fun estimateVsize( + inputCount: Int, + outputCount: Int, + ): Double = OVERHEAD_VBYTES + inputCount * P2TR_INPUT_VBYTES + outputCount * P2TR_OUTPUT_VBYTES + + fun estimateFee( + inputCount: Int, + outputCount: Int, + feeRateSatPerVByte: Double, + ): Long = ceil(estimateVsize(inputCount, outputCount) * feeRateSatPerVByte).toLong() + + /** + * Build the unsigned onchain-zap PSBT. + * + * @param senderPubKey The sender's 32-byte x-only Nostr pubkey (hex). + * @param recipientPubKey The recipient's 32-byte x-only Nostr pubkey (hex). + * @param amountSats Amount to pay the recipient. + * @param feeRateSatPerVByte Target fee rate. + * @param availableUtxos UTXOs spendable from the sender's Taproot address. + * @param allowUnconfirmed When false (the default), 0-confirmation UTXOs are + * excluded — chaining off an unconfirmed parent risks the whole zap + * being invalidated if that parent is dropped or replaced. + * @throws InsufficientFundsException when the UTXOs can't cover amount + fee. + */ + fun build( + senderPubKey: HexKey, + recipientPubKey: HexKey, + amountSats: Long, + feeRateSatPerVByte: Double, + availableUtxos: List, + allowUnconfirmed: Boolean = false, + ): Result { + require(amountSats > 0) { "amount must be positive" } + require(amountSats >= DUST_THRESHOLD_SATS) { "amount is below the dust threshold" } + require(feeRateSatPerVByte > 0) { "fee rate must be positive" } + require(senderPubKey != recipientPubKey) { "cannot zap yourself" } + + val senderXOnly = senderPubKey.hexToByteArray() + require(senderXOnly.size == 32) { "sender pubkey must be 32 bytes" } + val senderScript = TaprootAddress.scriptPubKeyForRecipient(senderPubKey) + val recipientScript = TaprootAddress.scriptPubKeyForRecipient(recipientPubKey) + + // Only spend confirmed UTXOs unless the caller explicitly opts in. + val spendableUtxos = + if (allowUnconfirmed) availableUtxos else availableUtxos.filter { it.confirmations > 0 } + + // Largest-first greedy selection. + val sorted = spendableUtxos.sortedByDescending { it.valueSats } + val selected = ArrayList() + var selectedSum = 0L + var cursor = 0 + + while (true) { + val feeWithChange = estimateFee(selected.size, 2, feeRateSatPerVByte) + if (selected.isNotEmpty() && selectedSum >= amountSats + feeWithChange) break + + if (cursor >= sorted.size) { + // Last chance: maybe it fits without a change output. + val feeNoChange = estimateFee(selected.size, 1, feeRateSatPerVByte) + if (selected.isNotEmpty() && selectedSum >= amountSats + feeNoChange) break + throw InsufficientFundsException( + needed = amountSats + estimateFee(selected.size.coerceAtLeast(1), 2, feeRateSatPerVByte), + available = spendableUtxos.sumOf { it.valueSats }, + ) + } + selected.add(sorted[cursor]) + selectedSum += sorted[cursor].valueSats + cursor++ + } + + // Decide whether a change output is worth creating. + val feeWithChange = estimateFee(selected.size, 2, feeRateSatPerVByte) + val candidateChange = selectedSum - amountSats - feeWithChange + + val feeSats: Long + val changeSats: Long + if (candidateChange >= DUST_THRESHOLD_SATS) { + feeSats = feeWithChange + changeSats = candidateChange + } else { + // Drop the change output; the leftover (dust + would-be change) is + // absorbed into the fee. + val feeNoChange = estimateFee(selected.size, 1, feeRateSatPerVByte) + val leftover = selectedSum - amountSats + if (leftover < feeNoChange) { + throw InsufficientFundsException( + needed = amountSats + feeNoChange, + available = spendableUtxos.sumOf { it.valueSats }, + ) + } + feeSats = leftover + changeSats = 0L + } + + // Assemble the unsigned transaction. + val inputs = + selected.map { utxo -> + TxIn( + outPoint = OutPoint(utxo.txid, utxo.vout.toLong()), + scriptSig = ByteArray(0), + sequence = RBF_SEQUENCE, + ) + } + val outputs = ArrayList(2) + outputs.add(TxOut(amountSats, recipientScript)) + if (changeSats > 0) { + outputs.add(TxOut(changeSats, senderScript)) + } + + val tx = BitcoinTransaction(version = 2L, inputs = inputs, outputs = outputs, lockTime = 0L) + + // Wrap into a PSBT and populate the signing metadata. + val psbt = Psbt.fromUnsignedTx(tx) + selected.forEachIndexed { i, utxo -> + psbt.setInputWitnessUtxo(i, TxOut(utxo.valueSats, senderScript)) + psbt.setInputTapInternalKey(i, senderXOnly) + } + if (changeSats > 0) { + psbt.setOutputTapInternalKey(1, senderXOnly) + } + + return Result( + psbt = psbt, + selectedUtxos = selected, + recipientSats = amountSats, + changeSats = changeSats, + feeSats = feeSats, + ) + } +} + +/** + * Thrown when the available UTXOs cannot cover the requested amount plus fee. + * + * @property needed Total satoshis required (amount + estimated fee). + * @property available Total satoshis available across all UTXOs. + */ +class InsufficientFundsException( + val needed: Long, + val available: Long, +) : RuntimeException("Insufficient funds: need $needed sats, have $available sats") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt index 077bb3bdba..3ca48b80e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt @@ -32,7 +32,8 @@ import kotlinx.coroutines.sync.withLock * - `getTx`: a **confirmed** transaction is immutable, so it's cached * indefinitely; an unconfirmed one is cached only briefly (its confirmation * status will change). `null` (not-found) is never cached — the tx may - * appear later. + * appear later. The tx cache is bounded ([maxCachedTxs]); when full, the + * oldest entry is evicted so a long session can't leak memory. * - `tipHeight` / `feeEstimates`: cached with a short TTL. * - `getUtxosForAddress`: never cached — wallet balance must be fresh. * - `broadcast`: never cached. @@ -46,6 +47,7 @@ class CachingOnchainBackend( private val unconfirmedTxTtlSeconds: Long = 60, private val tipHeightTtlSeconds: Long = 60, private val feeEstimatesTtlSeconds: Long = 60, + private val maxCachedTxs: Int = 512, private val nowSeconds: () -> Long = { TimeUtils.now() }, ) : OnchainBackend { private class Stamped( @@ -71,7 +73,13 @@ class CachingOnchainBackend( val fetched = delegate.getTx(txid) ?: return null - mutex.withLock { txCache[txid] = Stamped(fetched, nowSeconds()) } + mutex.withLock { + if (txCache.size >= maxCachedTxs && !txCache.containsKey(txid)) { + // Evict the oldest entry to keep the cache bounded. + txCache.minByOrNull { it.value.fetchedAt }?.key?.let { txCache.remove(it) } + } + txCache[txid] = Stamped(fetched, nowSeconds()) + } return fetched } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt index 0a1aa8a161..776b99b100 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.signers import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt new file mode 100644 index 0000000000..3de8ede56a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt @@ -0,0 +1,199 @@ +/* + * 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.quartz.nipBCOnchainZaps.builder + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TaprootSigHash +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxOut +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class OnchainZapBuilderTest { + private val senderPrivKey = "0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray() + private val senderPubKey = Secp256k1Instance.compressedPubKeyFor(senderPrivKey).copyOfRange(1, 33).toHexKey() + private val recipientPubKey = + Secp256k1Instance + .compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000b".hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(senderPubKey).lowercase() + private val recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientPubKey).lowercase() + + private fun utxo( + valueSats: Long, + index: Int, + confirmations: Int = 6, + ) = Utxo(txid = index.toString().padStart(64, '0'), vout = 0, valueSats = valueSats, confirmations = confirmations) + + @Test + fun buildsZapWithChangeAndBalances() { + val utxos = listOf(utxo(100_000L, 1), utxo(50_000L, 2)) + val result = + OnchainZapBuilder.build( + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 25_000L, + feeRateSatPerVByte = 5.0, + availableUtxos = utxos, + ) + + // One UTXO of 100k covers 25k + change + fee — greedy picks the largest first. + assertEquals(1, result.selectedUtxos.size) + assertEquals(25_000L, result.recipientSats) + assertTrue(result.changeSats > 0, "should have a change output") + assertTrue(result.feeSats > 0) + + // inputs == outputs + fee + val inputSum = result.selectedUtxos.sumOf { it.valueSats } + assertEquals(inputSum, result.recipientSats + result.changeSats + result.feeSats) + + val tx = result.psbt.unsignedTx + assertEquals(2, tx.outputs.size) + assertEquals(25_000L, tx.outputs[0].valueSats) + assertEquals(recipientScriptHex, tx.outputs[0].scriptPubKey.toHexKey()) + assertEquals(result.changeSats, tx.outputs[1].valueSats) + assertEquals(senderScriptHex, tx.outputs[1].scriptPubKey.toHexKey()) + } + + @Test + fun signedTransactionVerifies() { + val utxos = listOf(utxo(200_000L, 1)) + val result = + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 40_000L, 8.0, utxos) + + val signed = PsbtSigner.signKeyPathInputs(result.psbt, senderPrivKey) + assertEquals(result.selectedUtxos.size, signed) + assertTrue(PsbtFinalizer.isFullySigned(result.psbt)) + + val finalTx = PsbtFinalizer.finalize(result.psbt) + + // Every input's witness signature must verify against the sender's + // taproot output key over the BIP-341 sighash. + val senderOutputKey = TaprootAddress.tweakOutputKey(senderPubKey.hexToByteArray()) + val spentOutputs = result.selectedUtxos.map { TxOut(it.valueSats, senderScriptHex.hexToByteArray()) } + finalTx.inputs.forEachIndexed { i, input -> + assertEquals(1, input.witness.size) + val sigHash = TaprootSigHash.compute(finalTx, i, spentOutputs, TaprootSigHash.SIGHASH_DEFAULT) + assertTrue( + Secp256k1Instance.verifySchnorr(input.witness[0], sigHash, senderOutputKey), + "input $i signature must verify", + ) + } + + // Broadcast hex parses back to the same txid. + val rebroadcast = + com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction + .parse(PsbtFinalizer.finalizeToHex(result.psbt)) + assertEquals(finalTx.txid(), rebroadcast.txid()) + } + + @Test + fun dropsChangeWhenItWouldBeDust() { + // 30_000 utxo, pay 29_800: after the ~154-sat with-change fee the + // leftover change (~46 sats) is below the 330-sat dust threshold, so + // the builder must fold it into the fee instead of creating dust. + val utxos = listOf(utxo(30_000L, 1)) + val result = + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 29_800L, 1.0, utxos) + assertEquals(0L, result.changeSats, "tiny leftover must be absorbed into the fee") + assertEquals(1, result.psbt.unsignedTx.outputs.size, "no change output") + assertEquals(30_000L, result.recipientSats + result.feeSats) + } + + @Test + fun combinesMultipleUtxosWhenNeeded() { + val utxos = listOf(utxo(20_000L, 1), utxo(20_000L, 2), utxo(20_000L, 3)) + val result = + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 45_000L, 2.0, utxos) + assertTrue(result.selectedUtxos.size >= 3, "needs all three 20k UTXOs to cover 45k + fee") + } + + @Test + fun throwsOnInsufficientFunds() { + val utxos = listOf(utxo(10_000L, 1)) + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 1_000_000L, 5.0, utxos) + } + } + + @Test + fun rejectsSelfZap() { + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, senderPubKey, 25_000L, 5.0, listOf(utxo(100_000L, 1))) + } + } + + @Test + fun rejectsDustAmount() { + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 100L, 5.0, listOf(utxo(100_000L, 1))) + } + } + + @Test + fun excludesUnconfirmedUtxosByDefault() { + // Only a 0-conf UTXO is available — by default it must not be spent, + // so there are effectively no funds. + val unconfirmed = listOf(utxo(250_000L, 1, confirmations = 0)) + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 25_000L, 5.0, unconfirmed) + } + } + + @Test + fun spendsUnconfirmedUtxosOnlyWhenOptedIn() { + val unconfirmed = listOf(utxo(250_000L, 1, confirmations = 0)) + val result = + OnchainZapBuilder.build( + senderPubKey, + recipientPubKey, + 25_000L, + 5.0, + unconfirmed, + allowUnconfirmed = true, + ) + assertEquals(1, result.selectedUtxos.size) + assertEquals(0, result.selectedUtxos[0].confirmations) + } + + @Test + fun prefersConfirmedUtxosAndSkipsUnconfirmedOnes() { + // A large unconfirmed UTXO is ignored; the build falls back to the + // smaller confirmed ones. + val utxos = + listOf( + utxo(1_000_000L, 1, confirmations = 0), + utxo(30_000L, 2, confirmations = 3), + utxo(30_000L, 3, confirmations = 3), + ) + val result = OnchainZapBuilder.build(senderPubKey, recipientPubKey, 25_000L, 2.0, utxos) + assertTrue(result.selectedUtxos.all { it.confirmations > 0 }, "must not select the 0-conf UTXO") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt new file mode 100644 index 0000000000..e7ca67e7c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt @@ -0,0 +1,150 @@ +/* + * 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.quartz.nipBCOnchainZaps.chain + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CachingOnchainBackendTest { + /** Counts every delegate call so the cache's hit/miss behaviour is observable. */ + private class CountingBackend( + var txByTxid: (String) -> BitcoinTx? = { null }, + ) : OnchainBackend { + var getTxCalls = 0 + var tipCalls = 0 + var feeCalls = 0 + + override suspend fun getTx(txid: String): BitcoinTx? { + getTxCalls++ + return txByTxid(txid) + } + + override suspend fun getUtxosForAddress(address: String): List = emptyList() + + override suspend fun broadcast(rawTxHex: String): String = "broadcast" + + override suspend fun tipHeight(): Long { + tipCalls++ + return 800_000L + } + + override suspend fun feeEstimates(): FeeEstimates { + feeCalls++ + return FeeEstimates(20.0, 10.0, 5.0) + } + } + + private fun confirmedTx(txid: String) = BitcoinTx(txid = txid, outputs = emptyList(), confirmations = 1, blockHeight = 799_000L) + + private fun mempoolTx(txid: String) = BitcoinTx(txid = txid, outputs = emptyList(), confirmations = 0) + + @Test + fun confirmedTransactionIsCachedIndefinitely() = + runTest { + val delegate = CountingBackend { confirmedTx(it) } + var clock = 1_000L + val cache = CachingOnchainBackend(delegate, nowSeconds = { clock }) + + cache.getTx("aa") + clock += 100_000 // way past any TTL + cache.getTx("aa") + + assertEquals(1, delegate.getTxCalls, "a confirmed tx must be served from cache forever") + } + + @Test + fun unconfirmedTransactionIsRefetchedAfterTtl() = + runTest { + val delegate = CountingBackend { mempoolTx(it) } + var clock = 1_000L + val cache = CachingOnchainBackend(delegate, unconfirmedTxTtlSeconds = 60, nowSeconds = { clock }) + + cache.getTx("bb") + clock += 30 // within TTL + cache.getTx("bb") + assertEquals(1, delegate.getTxCalls, "still fresh — served from cache") + + clock += 60 // now past TTL + cache.getTx("bb") + assertEquals(2, delegate.getTxCalls, "stale unconfirmed tx must be re-fetched") + } + + @Test + fun notFoundIsNeverCached() = + runTest { + val delegate = CountingBackend { null } + val cache = CachingOnchainBackend(delegate, nowSeconds = { 1_000L }) + + assertNull(cache.getTx("cc")) + assertNull(cache.getTx("cc")) + assertEquals(2, delegate.getTxCalls, "a not-found result must not be cached") + } + + @Test + fun tipHeightAndFeesAreCachedWithinTtl() = + runTest { + val delegate = CountingBackend() + var clock = 1_000L + val cache = + CachingOnchainBackend( + delegate, + tipHeightTtlSeconds = 60, + feeEstimatesTtlSeconds = 60, + nowSeconds = { clock }, + ) + + cache.tipHeight() + cache.feeEstimates() + cache.tipHeight() + cache.feeEstimates() + assertEquals(1, delegate.tipCalls) + assertEquals(1, delegate.feeCalls) + + clock += 61 + cache.tipHeight() + cache.feeEstimates() + assertEquals(2, delegate.tipCalls, "tip height must be re-fetched after its TTL") + assertEquals(2, delegate.feeCalls, "fee estimates must be re-fetched after its TTL") + } + + @Test + fun txCacheIsBoundedAndEvictsTheOldest() = + runTest { + val delegate = CountingBackend { confirmedTx(it) } + var clock = 1_000L + val cache = CachingOnchainBackend(delegate, maxCachedTxs = 2, nowSeconds = { clock }) + + cache.getTx("t1") // fetched at 1000 + clock += 1 + cache.getTx("t2") // fetched at 1001 + clock += 1 + cache.getTx("t3") // fetched at 1002 → cache full (2), evicts oldest (t1) + + // t1 was evicted → re-fetch. + cache.getTx("t1") + // t3 is still cached → no re-fetch. + cache.getTx("t3") + + assertEquals(4, delegate.getTxCalls, "t1/t2/t3 + a re-fetch of evicted t1") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt index e53f2a040b..767e54bc61 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlin.test.Test From 07724e410bd9a55a5c8627704972ef56db3da2f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 17:26:44 +0000 Subject: [PATCH 13/19] fix(onchain-zaps): share the OTS Tor-aware explorer endpoint + server setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP-BC EsploraBackend was wired with a hardcoded mempool.space URL, silently bypassing the user's Tor preference and ignoring the explorer server they may have configured for OpenTimestamps. - New BitcoinExplorerEndpoint: the single source of truth for the Bitcoin explorer base URL, shared by OTS verification and onchain zaps. A user-configured custom URL always wins; otherwise mempool.space when Tor is active, blockstream.info when not. - TorAwareOkHttpOtsResolverBuilder.getAPI now delegates to it (behaviour unchanged for OTS). - AppModules wires EsploraBackend through BitcoinExplorerEndpoint using the same OTS server setting (otsPrefs) and the same money-flavoured Tor-aware OkHttp client OTS uses — so onchain zaps honour Tor and the user's explorer choice. - Removed the dead AccountSettings.onchainEsploraEndpoint field and DEFAULT_ESPLORA_ENDPOINT const: the OTS explorer setting is now the single configuration point. The field was never persisted or read. Adds BitcoinExplorerEndpointTest. amethyst compiles; the new test passes. --- .../com/vitorpamplona/amethyst/AppModules.kt | 24 +++++-- .../amethyst/model/AccountSettings.kt | 5 -- .../nip03Timestamp/BitcoinExplorerEndpoint.kt | 64 +++++++++++++++++ .../TorAwareOkHttpOtsResolverBuilder.kt | 8 +-- .../BitcoinExplorerEndpointTest.kt | 72 +++++++++++++++++++ 5 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index e2a59d29a0..cf65c19049 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -28,10 +28,10 @@ import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.DEFAULT_ESPLORA_ENDPOINT import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState +import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever @@ -92,6 +92,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache +import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher @@ -337,15 +338,26 @@ class AppModules( // LocalCache.consume(OnchainZapEvent) can sum the on-chain output values // that pay the recipient's derived Taproot address. Wrapped in a caching // decorator so a feed full of onchain zaps doesn't fan out into one HTTP - // request per event. Endpoint is currently a fixed default; per-account - // override (AccountSettings.onchainEsploraEndpoint) is plumbed for a future - // Settings UI. + // request per event. + // + // The explorer endpoint is shared with OpenTimestamps: it honours the same + // user-configured server (OTS settings) and the same Tor-aware default + // selection, via BitcoinExplorerEndpoint — onchain zaps must not silently + // bypass the user's Tor preference. init { cache.onchainBackend = CachingOnchainBackend( EsploraBackend( - baseUrl = { DEFAULT_ESPLORA_ENDPOINT }, - client = roleBasedHttpClientBuilder.okHttpClientForMoney(DEFAULT_ESPLORA_ENDPOINT), + baseUrl = { + BitcoinExplorerEndpoint.resolveNormalized( + customExplorerUrl = otsPrefs.current.normalizedUrl(), + usingTor = + roleBasedHttpClientBuilder.shouldUseTorForMoneyOperations( + OkHttpBitcoinExplorer.MEMPOOL_API_URL, + ), + ) + }, + client = roleBasedHttpClientBuilder.okHttpClientForMoney(OkHttpBitcoinExplorer.MEMPOOL_API_URL), ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 1e6e0135a9..4a5511df5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -141,9 +141,6 @@ sealed class TopFilter( ) : TopFilter("InterestSet/${address.toValue()}") } -/** Default Esplora-compatible Bitcoin chain backend for NIP-BC onchain zaps. */ -const val DEFAULT_ESPLORA_ENDPOINT = "https://mempool.space/api" - @Stable class AccountSettings( val keyPair: KeyPair, @@ -179,8 +176,6 @@ class AccountSettings( val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), - // NIP-BC onchain zap configuration. - val onchainEsploraEndpoint: MutableStateFlow = MutableStateFlow(DEFAULT_ESPLORA_ENDPOINT), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt new file mode 100644 index 0000000000..a9a118647c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt @@ -0,0 +1,64 @@ +/* + * 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.model.nip03Timestamp + +import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer + +/** + * Picks the Bitcoin block-explorer (Esplora) base URL. + * + * This is the single source of truth shared by two features that both talk to + * the same Esplora API: + * - OpenTimestamps verification ([TorAwareOkHttpOtsResolverBuilder]). + * - NIP-BC onchain zaps (the `EsploraBackend` wired in `AppModules`). + * + * So a user who configures a custom explorer in the OTS settings gets it for + * onchain zaps too, and both honour the same Tor preference: a configured + * [OtsSettings] custom URL always wins; otherwise mempool.space is used when + * Tor is active (it is reachable over Tor) and blockstream.info when it is not. + */ +object BitcoinExplorerEndpoint { + /** + * @param customExplorerUrl A user-configured explorer base URL, or null/blank + * for the automatic Tor-aware default. Typically + * `OtsSettings.normalizedUrl()`. + * @param usingTor Whether Bitcoin/"money" traffic is currently routed over Tor. + */ + fun resolve( + customExplorerUrl: String?, + usingTor: Boolean, + ): String = + customExplorerUrl?.takeIf { it.isNotBlank() } + ?: if (usingTor) { + OkHttpBitcoinExplorer.MEMPOOL_API_URL + } else { + OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL + } + + /** + * Same as [resolve] but with any trailing slash stripped, for callers that + * join request paths with a leading `/` (e.g. `"$base/tx/$txid"`). + */ + fun resolveNormalized( + customExplorerUrl: String?, + usingTor: Boolean, + ): String = resolve(customExplorerUrl, usingTor).trimEnd('/') +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt index dcf34bd96a..a2b78880ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt @@ -33,13 +33,7 @@ class TorAwareOkHttpOtsResolverBuilder( val cache: OtsBlockHeightCache, val customExplorerUrl: () -> String? = { null }, ) : OtsResolverBuilder { - fun getAPI(usingTor: Boolean): String = - customExplorerUrl() - ?: if (usingTor) { - OkHttpBitcoinExplorer.MEMPOOL_API_URL - } else { - OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL - } + fun getAPI(usingTor: Boolean): String = BitcoinExplorerEndpoint.resolve(customExplorerUrl(), usingTor) override fun build(): OtsResolver = OtsResolver( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt new file mode 100644 index 0000000000..31b26e07da --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt @@ -0,0 +1,72 @@ +/* + * 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.model.nip03Timestamp + +import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer +import org.junit.Assert.assertEquals +import org.junit.Test + +class BitcoinExplorerEndpointTest { + @Test + fun defaultsToMempoolWhenTorIsActive() { + assertEquals( + OkHttpBitcoinExplorer.MEMPOOL_API_URL, + BitcoinExplorerEndpoint.resolve(customExplorerUrl = null, usingTor = true), + ) + } + + @Test + fun defaultsToBlockstreamWhenTorIsInactive() { + assertEquals( + OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL, + BitcoinExplorerEndpoint.resolve(customExplorerUrl = null, usingTor = false), + ) + } + + @Test + fun aConfiguredCustomUrlAlwaysWins() { + val custom = "https://my.esplora.example/api/" + assertEquals(custom, BitcoinExplorerEndpoint.resolve(custom, usingTor = true)) + assertEquals(custom, BitcoinExplorerEndpoint.resolve(custom, usingTor = false)) + } + + @Test + fun blankCustomUrlFallsBackToTheDefault() { + assertEquals( + OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL, + BitcoinExplorerEndpoint.resolve(customExplorerUrl = " ", usingTor = false), + ) + } + + @Test + fun normalizedFormStripsTrailingSlashForPathJoining() { + assertEquals( + "https://my.esplora.example/api", + BitcoinExplorerEndpoint.resolveNormalized("https://my.esplora.example/api/", usingTor = true), + ) + // The mempool default carries a trailing slash; normalized form drops it + // so callers can safely do "$base/tx/$txid". + assertEquals( + OkHttpBitcoinExplorer.MEMPOOL_API_URL.trimEnd('/'), + BitcoinExplorerEndpoint.resolveNormalized(customExplorerUrl = null, usingTor = true), + ) + } +} From d77f2f733308a6f5e2bbacca0ac44fa5d1f5500c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 18:16:30 +0000 Subject: [PATCH 14/19] docs(onchain-zaps): record the hand-rolled Bitcoin consensus code decision Decision: keep the hand-rolled psbt/ + taproot/ consensus layer rather than adopting fr.acinq.bitcoin-kmp. Rationale: a deliberately small single-key-path P2TR subset, pinned to authoritative BIP-341/350 test vectors at every layer (sighash, tweak, witness signature bytes, addresses, tx serialization), and consistent with the project's minimal-dependency stance. Recorded in amethyst/plans/2026-05-14-onchain-zaps.md with the consequence spelled out (we own correctness; revisit if scope expands past single-key-path P2TR). The psbt/ and taproot/ packages now carry a pointer back to that decision so a future reader doesn't reflexively swap in a library. Doc-comment + plan-doc only; no logic change. --- amethyst/plans/2026-05-14-onchain-zaps.md | 34 ++++++++++++++++++- .../quartz/nipBCOnchainZaps/psbt/Psbt.kt | 6 ++++ .../taproot/TaprootAddress.kt | 5 +++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/amethyst/plans/2026-05-14-onchain-zaps.md b/amethyst/plans/2026-05-14-onchain-zaps.md index 0507bda2ab..f61494e015 100644 --- a/amethyst/plans/2026-05-14-onchain-zaps.md +++ b/amethyst/plans/2026-05-14-onchain-zaps.md @@ -20,10 +20,42 @@ rest of the system. - **Chain backend.** User-configured Esplora-compatible API (mempool.space, blockstream.info, self-hosted). The configured server sees the user's UTXO queries — accepted tradeoff for v1. Header-only SPV mode is - a future-phase add. + a future-phase add. The explorer endpoint is shared with OpenTimestamps via + `BitcoinExplorerEndpoint`: same user-configured server, same Tor-aware + default selection. - **Scope.** Full send + receive + display loop on Android. Desktop is out of scope for v1. +### Architecture decision: hand-rolled Bitcoin consensus code (2026-05-14) + +**Decision:** keep the hand-rolled Bitcoin consensus layer in +`quartz/.../nipBCOnchainZaps/{psbt,taproot}/` — the transaction codec, +serialization/txid, BIP-341 sighash, BIP-174 PSBT codec/signer/finalizer, and +BIP-341/350 address derivation. Do **not** pull in `fr.acinq.bitcoin-kmp`. + +**Considered alternative:** replace the `psbt/` + transaction + sighash layer +with `fr.acinq.bitcoin-kmp` (mature, same vendor as the `secp256k1` binding +already in the build). + +**Rationale for keeping it hand-rolled:** +- It is a deliberately small, constrained subset — single-key-path P2TR only, + no script trees, one transaction shape. +- It is pinned to authoritative external test vectors at *every* layer: + BIP-341 sighash (all 7 vectors + ANYONECANPAY), the BIP-341 tweak, the full + BIP-341 witness *signature bytes*, the 7 BIP-341/350 P2TR mainnet addresses, + and tx serialization against the genesis coinbase. It is "matches the + authoritative vectors," not "trust our code." +- Consistent with the project's stance on minimal dependencies (cf. the + from-scratch `quic` module). +- No new transitive dependencies or version-conflict surface. + +**Consequence / what this commits us to:** we own the correctness of this code +forever. If the scope ever expands beyond single-key-path P2TR (script-path +spends, multisig, PSBT fields we don't model), revisit this decision — at that +point a vetted library is the better trade. The `nipBCOnchainZaps/{psbt,taproot}/` +packages carry a pointer back to this section. + + ## Architecture | Layer | Concerns | Location | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt index 5875b2c5dd..9ac7c76cef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt @@ -97,6 +97,12 @@ class PsbtMap( * finalize the single-key-path P2TR spends NIP-BC needs. Unknown records are * preserved verbatim so the container round-trips even when fields aren't * modeled. + * + * This `psbt/` package is intentionally hand-rolled rather than delegated to a + * Bitcoin library. That is a recorded architecture decision — see + * `amethyst/plans/2026-05-14-onchain-zaps.md` ("Architecture decision: + * hand-rolled Bitcoin consensus code"). It holds only while the scope stays at + * single-key-path P2TR; expanding past that should revisit the decision. */ class Psbt( val global: PsbtMap, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt index ac5f04618a..638f2bdbc5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt @@ -37,6 +37,11 @@ import com.vitorpamplona.quartz.utils.sha256.sha256 * * where `bytes(P)` is the 32-byte x-only Nostr pubkey and `hashTapTweak` is * the BIP-340 tagged hash with tag `"TapTweak"`. + * + * This `taproot/` package is intentionally hand-rolled rather than delegated to + * a Bitcoin library — a recorded architecture decision, see + * `amethyst/plans/2026-05-14-onchain-zaps.md` ("Architecture decision: + * hand-rolled Bitcoin consensus code"). */ object TaprootAddress { private const val TAP_TWEAK_TAG = "TapTweak" From 6361fb1c9d05fc6583c1fe2212a56b56dcea2c4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 19:42:01 +0000 Subject: [PATCH 15/19] feat(onchain-zaps): rich NoteCompose render for kind 8333 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RenderOnchainZap, dispatched from RenderNoteRow alongside RenderLnZap. The card shows a pulsing Bitcoin badge, sender→recipient avatars, big sats amount in Bitcoin orange, an animated 'ON-CHAIN' pill, a live confirmation pill (Verifying → In mempool → Confirmed at block N) sourced from LocalCache.onchainBackend.getTx, and a tap-to-copy mempool.space tx link. --- .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/OnchainZapEvent.kt | 425 ++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index e33451c4c4..117ec3fd7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -149,6 +149,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryRespo import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent +import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -294,6 +295,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.coroutines.Dispatchers @@ -945,6 +947,10 @@ private fun RenderNoteRow( RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) } + is OnchainZapEvent -> { + RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav) + } + is LiveActivitiesClipEvent -> { RenderChatClip(baseNote, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt new file mode 100644 index 0000000000..a4b6075330 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt @@ -0,0 +1,425 @@ +/* + * 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.ui.note.types + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.NumberFormat + +private const val MEMPOOL_TX_URL = "https://mempool.space/tx/" + +@Composable +fun RenderOnchainZap( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? OnchainZapEvent ?: return + val sender = note.author?.pubkeyHex ?: event.pubKey + val recipient = event.recipient() ?: return + val sats = event.claimedAmountInSats() ?: 0L + val txid = event.txid() + val message = event.content.takeIf { it.isNotBlank() } + + val orange = MaterialTheme.colorScheme.bitcoinColor + + // Async chain lookup so we can show a real "Confirmed at block N" or + // "In mempool…" pill rather than a sender-claimed status. Null = backend + // not configured, or fetch failed — we just show the sender-claimed sats + // and skip the pill. + var tx by remember(txid) { mutableStateOf(null) } + if (txid != null) { + LaunchedEffect(txid) { + val backend = LocalCache.onchainBackend ?: return@LaunchedEffect + runCatching { + withContext(Dispatchers.IO) { backend.getTx(txid) } + }.onSuccess { tx = it } + } + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background( + Brush.linearGradient( + colors = + listOf( + orange.copy(alpha = 0.16f), + orange.copy(alpha = 0.04f), + ), + ), + ).padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HeaderRow( + sender = sender, + recipient = recipient, + orange = orange, + accountViewModel = accountViewModel, + nav = nav, + ) + + AmountRow(sats = sats, orange = orange) + + ConfirmationPill(tx = tx, hasTxid = txid != null, orange = orange) + + if (txid != null) { + TxidRow(txid = txid, orange = orange) + } + + message?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +@Composable +private fun HeaderRow( + sender: String, + recipient: String, + orange: Color, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PulsingBitcoinBadge(orange) + UserPicture(sender, Size25dp, Modifier, accountViewModel, nav) + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowForwardIos, + contentDescription = null, + tint = orange, + modifier = Size16Modifier, + ) + UserPicture(recipient, Size25dp, Modifier, accountViewModel, nav) + + Spacer(Modifier.weight(1f)) + + OnchainPill(orange) + } +} + +@Composable +private fun PulsingBitcoinBadge(orange: Color) { + val pulse = rememberInfiniteTransition(label = "btcPulse") + val scale by pulse.animateFloat( + initialValue = 0.92f, + targetValue = 1.08f, + animationSpec = + infiniteRepeatable( + animation = tween(1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "btcScale", + ) + val glow by pulse.animateFloat( + initialValue = 0.25f, + targetValue = 0.55f, + animationSpec = + infiniteRepeatable( + animation = tween(1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "btcGlow", + ) + + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(32.dp) + .drawBehind { + drawCircle( + brush = + Brush.radialGradient( + colors = listOf(orange.copy(alpha = glow), Color.Transparent), + ), + radius = size.minDimension / 2f, + ) + }, + ) { + Box( + modifier = + Modifier + .size(24.dp) + .scale(scale) + .clip(CircleShape) + .background(orange), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = "Bitcoin", + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } + } +} + +@Composable +private fun OnchainPill(orange: Color) { + val infinite = rememberInfiniteTransition(label = "pillDash") + val phase by infinite.animateFloat( + initialValue = 0f, + targetValue = 40f, + animationSpec = + infiniteRepeatable( + animation = tween(2500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "pillPhase", + ) + + Box( + modifier = + Modifier + .drawBehind { + val brush = + Brush.sweepGradient( + colors = + listOf( + orange, + orange.copy(alpha = 0.4f), + orange, + ), + ) + drawRoundRect( + brush = brush, + style = + Stroke( + width = 1.4.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f), phase), + ), + cornerRadius = + androidx.compose.ui.geometry + .CornerRadius(10.dp.toPx()), + ) + }.padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = "ON-CHAIN", + style = MaterialTheme.typography.labelSmall, + color = orange, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun AmountRow( + sats: Long, + orange: Color, +) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = NumberFormat.getNumberInstance().format(sats), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold, + color = orange, + ) + Text( + text = "sats", + style = MaterialTheme.typography.titleSmall, + color = orange, + modifier = Modifier.padding(bottom = 6.dp), + ) + } +} + +@Composable +private fun ConfirmationPill( + tx: BitcoinTx?, + hasTxid: Boolean, + orange: Color, +) { + AnimatedVisibility(visible = hasTxid) { + val confirmations = tx?.confirmations ?: -1 + val (label, color) = + when { + tx == null -> "Verifying on chain…" to orange.copy(alpha = 0.75f) + confirmations <= 0 -> "In mempool — pending confirmation" to orange + confirmations < 6 -> "Confirmed · $confirmations conf" to orange + else -> + "Confirmed · ${tx.blockHeight?.let { "block $it" } ?: "$confirmations conf"}" to + MaterialTheme.colorScheme.primary + } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (tx == null || confirmations <= 0) { + PulsingDot(color) + } else { + Icon( + symbol = MaterialSymbols.Block, + contentDescription = null, + tint = color, + modifier = Size16Modifier, + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = color, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun PulsingDot(color: Color) { + val infinite = rememberInfiniteTransition(label = "dotPulse") + val alpha by infinite.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "dotAlpha", + ) + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(color.copy(alpha = alpha)), + ) +} + +@Composable +private fun TxidRow( + txid: String, + orange: Color, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val short = remember(txid) { "${txid.take(10)}…${txid.takeLast(8)}" } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = + Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable { + scope.launch { clipboard.setText("$MEMPOOL_TX_URL$txid") } + }.padding(vertical = 2.dp), + ) { + Text( + text = "tx", + style = MaterialTheme.typography.labelSmall, + color = orange, + fontWeight = FontWeight.Bold, + ) + Text( + text = short, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(2.dp)) + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = "Copy tx link", + tint = orange, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.height(0.dp)) + } +} From 1d5ce994a6ebff8cd83b13912a18d3485553c852 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 19:56:22 +0000 Subject: [PATCH 16/19] fix(onchain-zaps): UserProfileZapsViewModel crashed casting kind 8333 as LnZapEvent observeEvents(filterAcceptingBothKinds) is generic-erased at runtime, so an OnchainZapEvent landing in the cache (e.g. right after sending an onchain zap from the wallet) flowed through and crashed in sumAmountsByUser's forEach checkcast. Observe as and dispatch on type: LnZapEvent keeps its private-zap decryption path; OnchainZapEvent attributes the claimedAmountInSats to event.pubKey (no zap-request envelope). --- .../zaps/dal/UserProfileZapsViewModel.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 703f3d7a90..6ec8803b4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -96,11 +97,24 @@ class UserProfileZapsViewModel( } } - suspend fun List.sumAmountsByUser(): List { + private fun mapOnchainZap(event: OnchainZapEvent): ZapAmount { + val amountSats = event.claimedAmountInSats() ?: 0L + return ZapAmount( + LocalCache.getOrCreateUser(event.pubKey), + BigDecimal(amountSats), + ) + } + + suspend fun List.sumAmountsByUser(): List { val results = mutableMapOf() this.forEach { zapEvent -> - val zapAmount = mapRequest(zapEvent) + val zapAmount = + when (zapEvent) { + is LnZapEvent -> mapRequest(zapEvent) + is OnchainZapEvent -> mapOnchainZap(zapEvent) + else -> null + } if (zapAmount != null) { val existingAmount = results[zapAmount.user] ?: BigDecimal.ZERO results[zapAmount.user] = existingAmount + zapAmount.amount @@ -113,7 +127,7 @@ class UserProfileZapsViewModel( @OptIn(kotlinx.coroutines.FlowPreview::class) val receivedZapAmountsByUser: StateFlow> = account.cache - .observeEvents(zapsToUser) + .observeEvents(zapsToUser) .sample(500) .map { zapEvents -> zapEvents.sumAmountsByUser() From 5aedcd0a1979aa353debb167cfea8d8a557d6aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 20:05:09 +0000 Subject: [PATCH 17/19] feat(onchain-zaps): user search + chip wrapping in send dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipient field now uses ShowUserSuggestionList + UserSuggestionState (same plumbing as the post composer @-mention and AwardBadgeScreen). The user can type a display name, NIP-05, or paste an npub/hex; picked recipients render as a chip with avatar + name + clear button. Raw npub paste still works as a fallback when no result is picked. Fee tier chips moved from Row to FlowRow so 'Slow · 1 sat/vB', 'Normal · 12 sat/vB', 'Fast · 50 sat/vB' wrap to a second line on narrow dialog widths instead of clipping the rightmost chip. --- .../loggedIn/wallet/OnchainZapSendDialog.kt | 229 ++++++++++++++---- 1 file changed, 188 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index 744c654981..f98832772b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -22,18 +22,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -42,10 +48,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -56,6 +70,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.compose.material3.ExperimentalMaterial3Api as ExpM3 +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon private enum class FeeTier( val label: String, @@ -77,9 +92,10 @@ private fun FeeEstimates.rateFor(tier: FeeTier): Double = * tier / comment, then run [com.vitorpamplona.amethyst.model.Account.sendOnchainZap] * and show progress + the result. * - * When [recipientPubKey] is null the user enters a recipient npub and the zap - * targets that profile. When provided (e.g. from a note's zap menu) the - * recipient is fixed and [zappedEvent] attributes the zap to that event. + * When [recipientPubKey] is null the user searches for a recipient by display + * name, NIP-05, or pastes an npub directly; the zap targets that profile. When + * provided (e.g. from a note's zap menu) the recipient is fixed and + * [zappedEvent] attributes the zap to that event. */ @OptIn(ExpM3::class) @Composable @@ -91,7 +107,16 @@ fun OnchainZapSendDialog( ) { val scope = rememberCoroutineScope() - var npubInput by remember { mutableStateOf("") } + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + DisposableEffect(Unit) { + onDispose { userSuggestions.reset() } + } + + var searchInput by remember { mutableStateOf("") } + var selectedUser by remember { mutableStateOf(null) } var amountInput by remember { mutableStateOf("") } var comment by remember { mutableStateOf("") } var feeTier by remember { mutableStateOf(FeeTier.NORMAL) } @@ -100,15 +125,21 @@ fun OnchainZapSendDialog( var sending by remember { mutableStateOf(false) } var result by remember { mutableStateOf(null) } - // Fetch recommended fee rates once when the dialog opens. LaunchedEffect(Unit) { val backend = LocalCache.onchainBackend ?: return@LaunchedEffect fees = runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull() } + // Recipient resolution priority: + // 1. preset recipientPubKey (note zap menu) + // 2. user picked from the suggestion dropdown + // 3. raw npub / hex pasted into the search field that parses cleanly val resolvedRecipient: HexKey? = - recipientPubKey ?: npubInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) } + recipientPubKey + ?: selectedUser?.pubkeyHex + ?: searchInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) } + val amountSats = amountInput.trim().toLongOrNull() val canSend = !sending && @@ -124,14 +155,8 @@ fun OnchainZapSendDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { when (val r = result) { - is OnchainZapSendResult.Success -> { - SuccessBody(r) - } - - is OnchainZapSendResult.Failure -> { - FailureBody(r) - } - + is OnchainZapSendResult.Success -> SuccessBody(r) + is OnchainZapSendResult.Failure -> FailureBody(r) null -> { if (sending) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -140,9 +165,29 @@ fun OnchainZapSendDialog( } } else { SendForm( + accountViewModel = accountViewModel, recipientPubKey = recipientPubKey, - npubInput = npubInput, - onNpubChange = { npubInput = it }, + userSuggestions = userSuggestions, + selectedUser = selectedUser, + onSelectUser = { + selectedUser = it + searchInput = "" + userSuggestions.reset() + }, + onClearUser = { + selectedUser = null + searchInput = "" + userSuggestions.reset() + }, + searchInput = searchInput, + onSearchChange = { newValue -> + searchInput = newValue + if (newValue.length > 2) { + userSuggestions.processCurrentWord(newValue) + } else { + userSuggestions.reset() + } + }, amountInput = amountInput, onAmountChange = { amountInput = it }, comment = comment, @@ -196,9 +241,14 @@ fun OnchainZapSendDialog( @OptIn(ExpM3::class) @Composable private fun SendForm( + accountViewModel: AccountViewModel, recipientPubKey: HexKey?, - npubInput: String, - onNpubChange: (String) -> Unit, + userSuggestions: UserSuggestionState, + selectedUser: User?, + onSelectUser: (User) -> Unit, + onClearUser: () -> Unit, + searchInput: String, + onSearchChange: (String) -> Unit, amountInput: String, onAmountChange: (String) -> Unit, comment: String, @@ -207,22 +257,16 @@ private fun SendForm( onFeeTierChange: (FeeTier) -> Unit, fees: FeeEstimates?, ) { - if (recipientPubKey == null) { - OutlinedTextField( - value = npubInput, - onValueChange = onNpubChange, - label = { Text("Recipient npub") }, - singleLine = true, - isError = npubInput.isNotBlank() && decodePublicKeyAsHexOrNull(npubInput.trim()) == null, - modifier = Modifier.fillMaxWidth(), - ) - } else { - Text( - text = "Zapping the post author", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + RecipientPicker( + accountViewModel = accountViewModel, + recipientPubKey = recipientPubKey, + userSuggestions = userSuggestions, + selectedUser = selectedUser, + onSelectUser = onSelectUser, + onClearUser = onClearUser, + searchInput = searchInput, + onSearchChange = onSearchChange, + ) OutlinedTextField( value = amountInput, @@ -245,20 +289,27 @@ private fun SendForm( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(), + ) { FeeTier.entries.forEach { tier -> val rate = fees?.rateFor(tier) FilterChip( selected = feeTier == tier, onClick = { onFeeTierChange(tier) }, label = { - Text( + Column { + Text(tier.label) if (rate != null) { - "${tier.label} · ${formatRate(rate)} sat/vB" - } else { - tier.label - }, - ) + Text( + text = "${formatRate(rate)} sat/vB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } }, ) } @@ -272,6 +323,102 @@ private fun SendForm( } } +@Composable +private fun RecipientPicker( + accountViewModel: AccountViewModel, + recipientPubKey: HexKey?, + userSuggestions: UserSuggestionState, + selectedUser: User?, + onSelectUser: (User) -> Unit, + onClearUser: () -> Unit, + searchInput: String, + onSearchChange: (String) -> Unit, +) { + if (recipientPubKey != null) { + Text( + text = "Zapping the post author", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + if (selectedUser != null) { + SelectedRecipientChip(selectedUser, accountViewModel, onClearUser) + return + } + + OutlinedTextField( + value = searchInput, + onValueChange = onSearchChange, + label = { Text("Recipient") }, + placeholder = { Text("name, NIP-05 or npub") }, + singleLine = true, + isError = + searchInput.isNotBlank() && + searchInput.length > 50 && + decodePublicKeyAsHexOrNull(searchInput.trim()) == null, + modifier = Modifier.fillMaxWidth(), + ) + + if (searchInput.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = onSelectUser, + accountViewModel = accountViewModel, + modifier = Modifier.heightIn(0.dp, 200.dp), + ) + } +} + +@Composable +private fun SelectedRecipientChip( + user: User, + accountViewModel: AccountViewModel, + onClear: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = user.pubkeyHex, + size = 32.dp, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = user.pubkeyDisplayHex(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onClear) { + SymbolIcon( + symbol = MaterialSymbols.Close, + contentDescription = "Change recipient", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun SuccessBody(result: OnchainZapSendResult.Success) { Text("Onchain zap sent.", style = MaterialTheme.typography.bodyLarge) From e4b8e7ccc8785fd37e3485a6eb5643f2780601f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 20:16:21 +0000 Subject: [PATCH 18/19] feat(onchain-zaps): redesign send sheet + render in thread NoteMaster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnchainZapSendDialog moves from AlertDialog to ModalBottomSheet — matches the design language already used by CreateNestSheet, EditNestSheet and the participant-action sheets. The form gets imePadding + navigationBarsPadding, a scrollable content area, and a sticky full-width Send button at the bottom. Layout changes: - Header with Bitcoin ₿ glyph + title + close. - Recipient picker: the suggestion dropdown now sits flush under its text field (was: separated by the outer Column's spacedBy gap). - Amount section: big number field with 'sats' suffix + FlowRow of SuggestionChips bound to AccountViewModel.zapAmountChoices (same quick picks the LN zap dialog uses, formatted with showAmount). - Fee priority: FlowRow of FilterChips with explicit vertical padding around the row and inside each chip; each chip shows 'Slow / Normal / Fast', the sat/vB rate, and a rough ETA. Selected chip uses bitcoinColor. - State machine: idle → sending (orange spinner) → success (bitcoinColor checkmark) / failure (error tint), each with a 'Done' / 'Close' bottom button. Also wire RenderOnchainZap into ThreadFeedView.NoteMaster's event-type dispatch right after RenderLnZap, so kind 8333 receipts get the full rich render on the thread screen instead of the default text body. --- .../loggedIn/threadview/ThreadFeedView.kt | 4 + .../loggedIn/wallet/OnchainZapSendDialog.kt | 614 ++++++++++++------ 2 files changed, 435 insertions(+), 183 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 6ff45b1f50..d0e69096ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -168,6 +168,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -291,6 +292,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.toImmutableList @@ -679,6 +681,8 @@ private fun FullBleedNoteCompose( DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is LnZapEvent) { RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is OnchainZapEvent) { + RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is BlockedRelayListEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index f98832772b..2dc59bc595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -21,23 +21,34 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -52,6 +63,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.model.LocalCache @@ -60,24 +72,27 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor 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.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import androidx.compose.material3.ExperimentalMaterial3Api as ExpM3 -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon +import java.text.NumberFormat private enum class FeeTier( val label: String, + val etaLabel: String, ) { - SLOW("Slow"), - NORMAL("Normal"), - FAST("Fast"), + SLOW("Slow", "~1 hr"), + NORMAL("Normal", "~30 min"), + FAST("Fast", "~10 min"), } private fun FeeEstimates.rateFor(tier: FeeTier): Double = @@ -88,16 +103,22 @@ private fun FeeEstimates.rateFor(tier: FeeTier): Double = } /** - * Dialog that drives a NIP-BC onchain zap: collect recipient / amount / fee - * tier / comment, then run [com.vitorpamplona.amethyst.model.Account.sendOnchainZap] - * and show progress + the result. + * Modal bottom sheet that drives a NIP-BC onchain zap. * - * When [recipientPubKey] is null the user searches for a recipient by display - * name, NIP-05, or pastes an npub directly; the zap targets that profile. When - * provided (e.g. from a note's zap menu) the recipient is fixed and - * [zappedEvent] attributes the zap to that event. + * Layout (top to bottom): + * - Title row with close button + * - Recipient picker — search field with inline dropdown, or selected-user chip + * - Amount section — quick-pick chips (reuses [AccountViewModel.zapAmountChoices]) + * and a big sats text field + * - Optional comment + * - Fee priority — three chips with rate + ETA, in a FlowRow that wraps + * - Sticky bottom send button + * + * When [recipientPubKey] is null the user picks a recipient. When provided + * (e.g. from a note's zap menu) the recipient is fixed and [zappedEvent] + * attributes the zap to that event. */ -@OptIn(ExpM3::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun OnchainZapSendDialog( accountViewModel: AccountViewModel, @@ -105,6 +126,7 @@ fun OnchainZapSendDialog( recipientPubKey: HexKey? = null, zappedEvent: EventHintBundle? = null, ) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() val userSuggestions = @@ -131,15 +153,15 @@ fun OnchainZapSendDialog( runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull() } - // Recipient resolution priority: - // 1. preset recipientPubKey (note zap menu) - // 2. user picked from the suggestion dropdown - // 3. raw npub / hex pasted into the search field that parses cleanly + val presetAmounts = + remember(accountViewModel) { + accountViewModel.zapAmountChoices() + } + val resolvedRecipient: HexKey? = recipientPubKey ?: selectedUser?.pubkeyHex ?: searchInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) } - val amountSats = amountInput.trim().toLongOrNull() val canSend = !sending && @@ -149,22 +171,55 @@ fun OnchainZapSendDialog( amountSats > 0 && fees != null - AlertDialog( + ModalBottomSheet( onDismissRequest = { if (!sending) onDismiss() }, - title = { Text("Onchain zap") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - when (val r = result) { - is OnchainZapSendResult.Success -> SuccessBody(r) - is OnchainZapSendResult.Failure -> FailureBody(r) - null -> { - if (sending) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) - Text(" Sending onchain zap…") - } - } else { - SendForm( + sheetState = sheetState, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .imePadding() + .navigationBarsPadding(), + ) { + Header(onClose = { if (!sending) onDismiss() }) + + when (val r = result) { + is OnchainZapSendResult.Success -> { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + SuccessBody(r) + } + DoneButton(label = "Done", onClick = onDismiss) + } + + is OnchainZapSendResult.Failure -> { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + FailureBody(r) + } + DoneButton(label = "Close", onClick = onDismiss) + } + + null -> { + if (sending) { + SendingState() + } else { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + RecipientSection( accountViewModel = accountViewModel, recipientPubKey = recipientPubKey, userSuggestions = userSuggestions, @@ -188,143 +243,111 @@ fun OnchainZapSendDialog( userSuggestions.reset() } }, + ) + + SectionSpacer() + + AmountSection( amountInput = amountInput, onAmountChange = { amountInput = it }, - comment = comment, - onCommentChange = { comment = it }, + presetAmounts = presetAmounts, + ) + + SectionSpacer() + + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text("Comment (optional)") }, + modifier = Modifier.fillMaxWidth(), + ) + + SectionSpacer() + + FeeSection( feeTier = feeTier, onFeeTierChange = { feeTier = it }, fees = fees, ) + + Spacer(Modifier.height(20.dp)) } + + SendButton( + enabled = canSend, + amountSats = amountSats, + onClick = { + val recipient = resolvedRecipient ?: return@SendButton + val amount = amountSats ?: return@SendButton + val feeRate = fees?.rateFor(feeTier) ?: return@SendButton + sending = true + scope.launch { + val r = + accountViewModel.account.sendOnchainZap( + recipientPubKey = recipient, + amountSats = amount, + feeRateSatPerVByte = feeRate, + comment = comment.trim(), + zappedEvent = zappedEvent, + ) + sending = false + result = r + } + }, + ) } } } - }, - confirmButton = { - if (result != null) { - TextButton(onClick = onDismiss) { Text("Close") } - } else { - TextButton( - enabled = canSend, - onClick = { - val recipient = resolvedRecipient ?: return@TextButton - val amount = amountSats ?: return@TextButton - val feeRate = fees?.rateFor(feeTier) ?: return@TextButton - sending = true - scope.launch { - val r = - accountViewModel.account.sendOnchainZap( - recipientPubKey = recipient, - amountSats = amount, - feeRateSatPerVByte = feeRate, - comment = comment.trim(), - zappedEvent = zappedEvent, - ) - sending = false - result = r - } - }, - ) { - Text("Send") - } - } - }, - dismissButton = { - if (result == null) { - TextButton(onClick = onDismiss, enabled = !sending) { Text("Cancel") } - } - }, - ) + } + } } -@OptIn(ExpM3::class) @Composable -private fun SendForm( - accountViewModel: AccountViewModel, - recipientPubKey: HexKey?, - userSuggestions: UserSuggestionState, - selectedUser: User?, - onSelectUser: (User) -> Unit, - onClearUser: () -> Unit, - searchInput: String, - onSearchChange: (String) -> Unit, - amountInput: String, - onAmountChange: (String) -> Unit, - comment: String, - onCommentChange: (String) -> Unit, - feeTier: FeeTier, - onFeeTierChange: (FeeTier) -> Unit, - fees: FeeEstimates?, -) { - RecipientPicker( - accountViewModel = accountViewModel, - recipientPubKey = recipientPubKey, - userSuggestions = userSuggestions, - selectedUser = selectedUser, - onSelectUser = onSelectUser, - onClearUser = onClearUser, - searchInput = searchInput, - onSearchChange = onSearchChange, - ) +private fun SectionSpacer() { + Spacer(Modifier.height(16.dp)) +} - OutlinedTextField( - value = amountInput, - onValueChange = { onAmountChange(it.filter(Char::isDigit)) }, - label = { Text("Amount (sats)") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = comment, - onValueChange = onCommentChange, - label = { Text("Comment (optional)") }, - modifier = Modifier.fillMaxWidth(), - ) - - Text( - text = "Fee priority", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(), +@Composable +private fun Header(onClose: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - FeeTier.entries.forEach { tier -> - val rate = fees?.rateFor(tier) - FilterChip( - selected = feeTier == tier, - onClick = { onFeeTierChange(tier) }, - label = { - Column { - Text(tier.label) - if (rate != null) { - Text( - text = "${formatRate(rate)} sat/vB", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, + Box( + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + tint = MaterialTheme.colorScheme.bitcoinColor, + modifier = Modifier.size(22.dp), + ) + } + Text( + text = "Send onchain zap", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = + Modifier + .weight(1f) + .padding(start = 8.dp), + ) + IconButton(onClick = onClose) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - if (fees == null) { - Text( - text = "Loading fee estimates…", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } @Composable -private fun RecipientPicker( +private fun RecipientSection( accountViewModel: AccountViewModel, recipientPubKey: HexKey?, userSuggestions: UserSuggestionState, @@ -334,12 +357,32 @@ private fun RecipientPicker( searchInput: String, onSearchChange: (String) -> Unit, ) { + SectionLabel("To") + if (recipientPubKey != null) { - Text( - text = "Zapping the post author", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = recipientPubKey, + size = 32.dp, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + Text( + text = "Post author", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 12.dp), + ) + } + } return } @@ -348,6 +391,8 @@ private fun RecipientPicker( return } + // Tight column: no extra parent spacing between the field and its + // suggestion dropdown — the dropdown sits flush under the field. OutlinedTextField( value = searchInput, onValueChange = onSearchChange, @@ -366,7 +411,7 @@ private fun RecipientPicker( userSuggestions = userSuggestions, onSelect = onSelectUser, accountViewModel = accountViewModel, - modifier = Modifier.heightIn(0.dp, 200.dp), + modifier = Modifier.heightIn(0.dp, 220.dp), ) } } @@ -388,11 +433,11 @@ private fun SelectedRecipientChip( ) { UserPicture( userHex = user.pubkeyHex, - size = 32.dp, + size = 36.dp, accountViewModel = accountViewModel, nav = EmptyNav(), ) - Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) { + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { Text( text = user.toBestDisplayName(), style = MaterialTheme.typography.bodyMedium, @@ -409,7 +454,7 @@ private fun SelectedRecipientChip( ) } IconButton(onClick = onClear) { - SymbolIcon( + Icon( symbol = MaterialSymbols.Close, contentDescription = "Change recipient", tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -419,42 +464,245 @@ private fun SelectedRecipientChip( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SuccessBody(result: OnchainZapSendResult.Success) { - Text("Onchain zap sent.", style = MaterialTheme.typography.bodyLarge) - Text( - text = "Transaction: ${result.txid}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = - "Fee: ${result.feeSats} sats" + - if (result.changeSats > 0) " · change: ${result.changeSats} sats" else "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, +private fun AmountSection( + amountInput: String, + onAmountChange: (String) -> Unit, + presetAmounts: List, +) { + SectionLabel("Amount") + + OutlinedTextField( + value = amountInput, + onValueChange = { onAmountChange(it.filter(Char::isDigit)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + placeholder = { Text("0") }, + suffix = { Text("sats", color = MaterialTheme.colorScheme.onSurfaceVariant) }, + modifier = Modifier.fillMaxWidth(), ) + + if (presetAmounts.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + presetAmounts.forEach { amount -> + SuggestionChip( + onClick = { onAmountChange(amount.toString()) }, + label = { Text("⚡ ${showAmount(BigDecimal(amount))}") }, + ) + } + } + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun FailureBody(result: OnchainZapSendResult.Failure) { - Text( - text = result.message, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - ) - result.broadcastTxid?.let { +private fun FeeSection( + feeTier: FeeTier, + onFeeTierChange: (FeeTier) -> Unit, + fees: FeeEstimates?, +) { + SectionLabel("Priority") + + FlowRow( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FeeTier.entries.forEach { tier -> + val rate = fees?.rateFor(tier) + FilterChip( + selected = feeTier == tier, + onClick = { onFeeTierChange(tier) }, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.bitcoinColor, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary, + ), + label = { + Column( + modifier = Modifier.padding(vertical = 4.dp), + ) { + Text( + text = tier.label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (rate != null) "${formatRate(rate)} sat/vB · ${tier.etaLabel}" else tier.etaLabel, + style = MaterialTheme.typography.labelSmall, + ) + } + }, + ) + } + } + + if (fees == null) { + Spacer(Modifier.height(4.dp)) Text( - text = "The payment was broadcast (tx $it) but the receipt was not published.", + text = "Loading fee estimates…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } +} + +@Composable +private fun SectionLabel(text: String) { Text( - text = "Failed at: ${result.stage.name.lowercase().replace('_', ' ')}", - style = MaterialTheme.typography.bodySmall, + text = text, + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp), ) } +@Composable +private fun SendButton( + enabled: Boolean, + amountSats: Long?, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + Text( + text = + if (amountSats != null && amountSats > 0) { + "Send ${NumberFormat.getNumberInstance().format(amountSats)} sats" + } else { + "Send" + }, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun DoneButton( + label: String, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text(label, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun SendingState() { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + color = MaterialTheme.colorScheme.bitcoinColor, + ) + Text( + text = "Building, signing and broadcasting…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SuccessBody(result: OnchainZapSendResult.Success) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.bitcoinColor, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.size(8.dp)) + Text( + text = "Onchain zap sent", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + ResultRow("Transaction", result.txid) + ResultRow("Fee", "${NumberFormat.getNumberInstance().format(result.feeSats)} sats") + if (result.changeSats > 0) { + ResultRow("Change", "${NumberFormat.getNumberInstance().format(result.changeSats)} sats") + } + } +} + +@Composable +private fun FailureBody(result: OnchainZapSendResult.Failure) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = result.message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + result.broadcastTxid?.let { + Text( + text = "Payment was broadcast (tx $it) but the receipt was not published.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "Failed at: ${result.stage.name.lowercase().replace('_', ' ')}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ResultRow( + label: String, + value: String, +) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + private fun formatRate(rate: Double): String = if (rate == rate.toLong().toDouble()) rate.toLong().toString() else ((rate * 10).toLong() / 10.0).toString() From 627b75681f437b6cb9546b2ee6261ac9bd8bc09c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 20:21:25 +0000 Subject: [PATCH 19/19] feat(wallet): redesign onchain card to match NWC WalletCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnchainSection now mirrors WalletCard's layout structurally so both payment rails read as the same kind of object on the wallet screen, with bitcoin-orange (BitcoinDark #F7931A / BitcoinLight #B66605) replacing NWC's primary purple to keep them distinct at a glance: - RoundedCornerShape(16.dp), 2.dp bitcoinColor border, bitcoinColor.copy(alpha = 0.12f) container — same idiom NWC uses for the 'default wallet' card. - Header row: small orange ₿ chip + 'Bitcoin' title + 'Onchain · Taproot' subtitle on the left, big 24sp bold balance + 'sats' label on the right (same typography as NWC). - Loading shows an orange CircularProgressIndicator in the balance slot. Error / no-backend states render an em-dash placeholder with a small status caption. - Address block: 'Your Taproot address' caption + monospace truncated address. - Action row: outlined 'Copy' with copy icon on the left, filled bitcoin-orange 'Send' with send icon on the right — same 36dp height + RoundedCornerShape(8.dp) as the NWC card actions. --- .../screen/loggedIn/wallet/OnchainSection.kt | 249 ++++++++++++++---- 1 file changed, 196 insertions(+), 53 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt index 49c804e269..490672ed95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt @@ -20,14 +20,25 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -40,13 +51,20 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -54,13 +72,13 @@ import kotlinx.coroutines.withContext import java.text.NumberFormat /** - * "Bitcoin" section card on the wallet screen, shown above the lightning NWC - * wallet list. Every account has exactly one Taproot address (derived from - * its Nostr pubkey via NIP-BC / BIP-341), so this is always a single card — - * not a list. + * "Bitcoin" card on the wallet screen, shown above the lightning NWC wallet + * list. Visually mirrors [WalletCard] so the two payment rails read as the + * same kind of object; bitcoin-orange accent in place of NWC's primary + * (purple) keeps the rails distinct at a glance. * - * Phase C scope: derive + display + copy address. Balance, recent zaps, send, - * and tap-to-detail UI come in later phases. + * Every account has exactly one Taproot address (derived from its Nostr + * pubkey via NIP-BC / BIP-341), so this is always a single card — not a list. */ @Composable fun OnchainSection( @@ -69,16 +87,11 @@ fun OnchainSection( ) { val pubKey = accountViewModel.account.signer.pubKey - // Cache the derived address — bech32m + tap-tweak is cheap but pubkey doesn't - // change for the lifetime of the screen. val address = remember(pubKey) { runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull() } - // Balance fetched from the configured OnchainBackend. null = unknown / loading - // / unconfigured. We intentionally do not retry on failure here — the user can - // refresh by leaving and re-entering the screen. var balanceSats by remember(pubKey) { mutableStateOf(null) } var balanceState by remember(pubKey) { mutableStateOf(BalanceState.LOADING) } @@ -102,39 +115,37 @@ fun OnchainSection( } } + val orange = MaterialTheme.colorScheme.bitcoinColor + Card( modifier = modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(2.dp, orange), + colors = + CardDefaults.cardColors( + containerColor = orange.copy(alpha = 0.12f), + ), ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Bitcoin", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + Column(modifier = Modifier.padding(16.dp)) { + HeaderRow( + orange = orange, + balanceState = balanceState, + balanceSats = balanceSats, ) + if (address == null) { + Spacer(modifier = Modifier.height(12.dp)) Text( text = "Address derivation unavailable for this account.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - BalanceRow(state = balanceState, sats = balanceSats) - Text( - text = "Your Taproot address", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = address, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - ActionRow(address = address, accountViewModel = accountViewModel) + Spacer(modifier = Modifier.height(12.dp)) + AddressBlock(address = address) + + Spacer(modifier = Modifier.height(12.dp)) + ActionRow(address = address, accountViewModel = accountViewModel, orange = orange) } } } @@ -143,33 +154,138 @@ fun OnchainSection( private enum class BalanceState { LOADING, READY, ERROR, UNAVAILABLE } @Composable -private fun BalanceRow( +private fun HeaderRow( + orange: Color, + balanceState: BalanceState, + balanceSats: Long?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + BitcoinChip(orange) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "Bitcoin", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Onchain · Taproot", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + BalanceBlock(state = balanceState, sats = balanceSats, orange = orange) + } +} + +@Composable +private fun BitcoinChip(orange: Color) { + Box( + modifier = + Modifier + .size(28.dp) + .clip(CircleShape) + .background(color = orange), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun BalanceBlock( state: BalanceState, sats: Long?, + orange: Color, ) { - val text = - when (state) { - BalanceState.LOADING -> { - "Loading balance…" - } + if (state == BalanceState.LOADING && sats == null) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = orange, + ) + return + } + Column(horizontalAlignment = Alignment.End) { + when (state) { BalanceState.READY -> { - val formatted = NumberFormat.getNumberInstance().format(sats ?: 0L) - "$formatted sats" + val formatted = + remember(sats) { + NumberFormat.getIntegerInstance().format(sats ?: 0L) + } + Text( + text = formatted, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = orange, + ) + Text( + text = "sats", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } BalanceState.ERROR -> { - "Balance unavailable" + Text( + text = "—", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "unavailable", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) } BalanceState.UNAVAILABLE -> { - "Chain backend not configured" + Text( + text = "—", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "no backend", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + + BalanceState.LOADING -> Unit } + } +} + +@Composable +private fun AddressBlock(address: String) { Text( - text = text, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, + text = "Your Taproot address", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = address, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } @@ -177,6 +293,7 @@ private fun BalanceRow( private fun ActionRow( address: String, accountViewModel: AccountViewModel, + orange: Color, ) { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() @@ -184,16 +301,42 @@ private fun ActionRow( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - OutlinedButton(onClick = { - scope.launch { clipboard.setText(address) } - }) { - Text("Copy address") + OutlinedButton( + onClick = { scope.launch { clipboard.setText(address) } }, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(8.dp), + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Copy", style = MaterialTheme.typography.bodySmall) } - Button(onClick = { showSendDialog = true }) { - Text("Send") + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { showSendDialog = true }, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = orange, + contentColor = Color.White, + ), + ) { + Icon( + symbol = MaterialSymbols.AutoMirrored.Send, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Send", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold) } }