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:
@@ -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
|
||||
|
||||
|
||||
+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