feat(quartz): NIP-BC sign_psbt over NIP-55 external signer (Phase B)

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.
This commit is contained in:
Claude
2026-05-14 12:50:47 +00:00
parent bb0f41d320
commit 5f16ebc060
9 changed files with 211 additions and 15 deletions
@@ -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
}
}
@@ -89,3 +89,7 @@ data class ZapEventDecryptionResult(
data class DerivationResult(
val newPrivKey: HexKey,
) : IResult
data class SignPsbtResult(
val signedPsbtHex: String,
) : IResult
@@ -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<SignPsbtResult> =
contentResolver.query(
uri,
arrayOf(psbtHex, loggedInUser),
) { cursor ->
val signedPsbtHex = cursor.getStringByName("result")
if (!signedPsbtHex.isNullOrBlank()) {
SignerResult.RequestAddressed.Successful(SignPsbtResult(signedPsbtHex))
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
}
}
@@ -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
}
}
}
@@ -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<SignPsbtResult> {
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()
}
}
}
}
@@ -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<SignPsbtResult>) {
return result.result.signedPsbtHex
}
throw convertExceptions("Could not sign PSBT", result)
}
// always ready
override fun hasForegroundSupport() = hasForegroundActivity()
@@ -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<PubKeyResult>
@@ -81,4 +84,6 @@ class BackgroundRequestHandler(
fun decryptZapEvent(event: LnZapRequestEvent) = decryptZap.query(event) as? SignerResult.RequestAddressed<ZapEventDecryptionResult>
fun deriveKey(nonce: HexKey) = deriveKey.query(nonce) as? SignerResult.RequestAddressed<DerivationResult>
fun signPsbt(psbtHex: String) = signPsbt.query(psbtHex) as? SignerResult.RequestAddressed<SignPsbtResult>
}
@@ -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,
)
}