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:
+2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -89,3 +89,7 @@ data class ZapEventDecryptionResult(
|
||||
data class DerivationResult(
|
||||
val newPrivKey: HexKey,
|
||||
) : IResult
|
||||
|
||||
data class SignPsbtResult(
|
||||
val signedPsbtHex: String,
|
||||
) : IResult
|
||||
|
||||
+59
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-9
@@ -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()
|
||||
|
||||
+5
@@ -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>
|
||||
}
|
||||
|
||||
+8
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user