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, + ) }