Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
e307829b50 feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#3)
Closes aiolabs/tasks#3. Pre-cascade prerequisite for aiolabs/lnbits#17
(signer abstraction phase 1), which lands an m002 startup job that
NULLs the legacy `accounts.prvkey` column. After this migration, the
tasks extension reads no plaintext nsec and works with any
NostrSigner backend (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner).

## What changed

### nostr_hooks.py — three publisher entry points

Was: `_account_keys(wallet_id)` helper pulled `(account.pubkey,
account.prvkey)` from the wallet's owning account, returned None when
prvkey was missing, then passed both to the publishers.

Now: each of `publish_or_delete_task_event`,
`publish_task_completion`, and `publish_completion_delete` calls
`await resolve_for_wallet(...)` (the DRY helper from
aiolabs/lnbits#23 — wallet → account → signer → can_sign-check in
one call, returns None on any soft-fail). The resolved `NostrSigner`
is passed to the publisher. Soft-skip on None (wallet missing,
account unclassified, or ClientSideOnlySigner where the server has
no signing authority).

Removed the `_account_keys` helper entirely.

### nostr_publisher.py — three publishers

Was: `publish_task_to_nostr`, `publish_completion_to_nostr`, and
`publish_completion_delete_to_nostr` each accepted
`(account_pubkey: str, account_prvkey: str)` and signed via a local
`sign_nostr_event` helper that called `coincurve.PrivateKey
.sign_schnorr` directly on the plaintext nsec.

Now: each publisher accepts `signer: NostrSigner`. Signing is
factored into a shared `_sign_and_publish` helper that builds the
unsigned event dict (`kind`/`created_at`/`tags`/`content`), hands it
to `await signer.sign_event(...)`, and writes `id`/`pubkey`/`sig`
back onto the local `NostrEvent` model before publishing. The signer
backend (LocalSigner / RemoteBunkerSigner) is transparent.

Removed the `sign_nostr_event` helper entirely — the signer
abstraction handles all signing now.

Dropped the `coincurve` import; no direct crypto in this extension.

## Acceptance

- [x] `_account_keys` helper removed (nostr_hooks no longer touches account.prvkey)
- [x] all three publishers accept NostrSigner instead of (pubkey, prvkey)
- [x] extension-local Schnorr code removed (sign_nostr_event gone)
- [x] coincurve import dropped
- [x] re-grep `tasks/`: zero `account.prvkey` references
- [x] version bumped: 0.0.1 → 0.0.2 (catalog entry deferred until cascade lands)

Manual smoke testing + tag + catalog entry follow the migration
landing; will run against the regtest stack with lnbits on
`issue-18-phase-2.3` (which validates both LocalSigner and
RemoteBunkerSigner signing paths end-to-end).

## Cross-references

- aiolabs/tasks#3 — issue this commit closes
- aiolabs/lnbits#17 — the cascading signer-abstraction PR
- aiolabs/lnbits#23 — the resolve_for_wallet helper this uses
- aiolabs/lnbits#21 — umbrella audit (5 affected extensions)
- aiolabs/events#23 — sister migration (already on signer-abstraction branch)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:18:04 +02:00
3 changed files with 50 additions and 51 deletions

View file

@ -1,6 +1,6 @@
{ {
"id": "tasks", "id": "tasks",
"version": "0.0.1", "version": "0.0.2",
"name": "Tasks", "name": "Tasks",
"repo": "https://git.atitlan.io/aiolabs/tasks", "repo": "https://git.atitlan.io/aiolabs/tasks",
"short_description": "Recurring tasks and chore-tracking, published over Nostr", "short_description": "Recurring tasks and chore-tracking, published over Nostr",

View file

@ -14,21 +14,6 @@ from .nostr_publisher import (
) )
async def _account_keys(wallet_id: str) -> tuple[str, str] | None:
"""Fetch (pubkey, prvkey) for the wallet's owning account. Returns None
when the account is missing keys, so callers can skip cleanly."""
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import get_wallet
wallet_obj = await get_wallet(wallet_id)
if not wallet_obj:
return None
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey: # type: ignore[attr-defined]
return None
return account.pubkey, account.prvkey # type: ignore[attr-defined]
async def publish_or_delete_task_event( async def publish_or_delete_task_event(
task: Task, *, delete: bool = False task: Task, *, delete: bool = False
) -> None: ) -> None:
@ -37,15 +22,16 @@ async def publish_or_delete_task_event(
Errors are logged and swallowed so a Nostr outage doesn't break the Errors are logged and swallowed so a Nostr outage doesn't break the
HTTP flow that triggered the publish.""" HTTP flow that triggered the publish."""
try: try:
from lnbits.core.signers import resolve_for_wallet
from . import nostr_client from . import nostr_client
keys = await _account_keys(task.wallet) signer = await resolve_for_wallet(task.wallet)
if not keys: if signer is None:
return return
pubkey, prvkey = keys
nostr_event = await publish_task_to_nostr( nostr_event = await publish_task_to_nostr(
nostr_client, task, pubkey, prvkey, delete=delete nostr_client, task, signer, delete=delete
) )
if nostr_event and not delete: if nostr_event and not delete:
task.nostr_event_id = nostr_event.id task.nostr_event_id = nostr_event.id
@ -62,15 +48,16 @@ async def publish_task_completion(
caller can persist it as the completion's primary key, replacing the caller can persist it as the completion's primary key, replacing the
locally-generated hash from the optimistic insert.""" locally-generated hash from the optimistic insert."""
try: try:
from lnbits.core.signers import resolve_for_wallet
from . import nostr_client from . import nostr_client
keys = await _account_keys(task.wallet) signer = await resolve_for_wallet(task.wallet)
if not keys: if signer is None:
return None return None
pubkey, prvkey = keys
nostr_event = await publish_completion_to_nostr( nostr_event = await publish_completion_to_nostr(
nostr_client, task.address, completion, pubkey, prvkey nostr_client, task.address, completion, signer
) )
return nostr_event.id if nostr_event else None return nostr_event.id if nostr_event else None
except Exception as exc: except Exception as exc:
@ -83,15 +70,16 @@ async def publish_completion_delete(
) -> None: ) -> None:
"""Publish a NIP-09 delete for a previously-published completion.""" """Publish a NIP-09 delete for a previously-published completion."""
try: try:
from lnbits.core.signers import resolve_for_wallet
from . import nostr_client from . import nostr_client
keys = await _account_keys(wallet_id) signer = await resolve_for_wallet(wallet_id)
if not keys: if signer is None:
return return
pubkey, prvkey = keys
await publish_completion_delete_to_nostr( await publish_completion_delete_to_nostr(
nostr_client, completion_id, pubkey, prvkey nostr_client, completion_id, signer
) )
except Exception as exc: except Exception as exc:
logger.warning(f"[TASKS] Nostr completion delete failed: {exc}") logger.warning(f"[TASKS] Nostr completion delete failed: {exc}")

View file

@ -9,13 +9,16 @@ Builds:
- kind 5 deletes referencing either an `a` (for tasks) or `e` tag - kind 5 deletes referencing either an `a` (for tasks) or `e` tag
(for completions). (for completions).
Signs with the wallet owner's Account keypair and publishes via the Signing routes through the core `NostrSigner` abstraction
NostrClient wrapping nostrclient's WebSocket. (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The signer
fills in `id`, `pubkey`, and `sig` per NIP-01 same serialization
rules as our local `event_id` property uses, so the returned id
matches what we'd have computed locally.
""" """
import time import time
import coincurve from lnbits.core.signers import NostrSigner
from loguru import logger from loguru import logger
from .models import Task, TaskCompletion from .models import Task, TaskCompletion
@ -128,17 +131,30 @@ def build_delete_completion_event(
return nostr_event return nostr_event
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None: async def _sign_and_publish(
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) nostr_client, nostr_event: NostrEvent, signer: NostrSigner
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id)) ) -> NostrEvent:
nostr_event.sig = sig.hex() """Hand the unsigned event to the signer — it fills in `id`, `pubkey`,
and `sig` then publish through the relay client. Mutates `nostr_event`
in place with the canonical signed values."""
unsigned = {
"kind": nostr_event.kind,
"created_at": nostr_event.created_at,
"tags": nostr_event.tags,
"content": nostr_event.content,
}
signed = await signer.sign_event(unsigned)
nostr_event.id = signed["id"]
nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"]
await nostr_client.publish_nostr_event(nostr_event)
return nostr_event
async def publish_task_to_nostr( async def publish_task_to_nostr(
nostr_client, nostr_client,
task: Task, task: Task,
account_pubkey: str, signer: NostrSigner,
account_prvkey: str,
*, *,
delete: bool = False, delete: bool = False,
) -> NostrEvent | None: ) -> NostrEvent | None:
@ -147,12 +163,11 @@ async def publish_task_to_nostr(
return None return None
try: try:
nostr_event = ( nostr_event = (
build_delete_task_event(task, account_pubkey) build_delete_task_event(task, signer.pubkey)
if delete if delete
else build_task_event(task, account_pubkey) else build_task_event(task, signer.pubkey)
) )
sign_nostr_event(nostr_event, account_prvkey) await _sign_and_publish(nostr_client, nostr_event, signer)
await nostr_client.publish_nostr_event(nostr_event)
logger.info( logger.info(
f"[TASKS] Published {'delete' if delete else '31922 task'} " f"[TASKS] Published {'delete' if delete else '31922 task'} "
f"{nostr_event.id[:16]}... (kind {nostr_event.kind})" f"{nostr_event.id[:16]}... (kind {nostr_event.kind})"
@ -167,18 +182,16 @@ async def publish_completion_to_nostr(
nostr_client, nostr_client,
task_address: str, task_address: str,
completion: TaskCompletion, completion: TaskCompletion,
account_pubkey: str, signer: NostrSigner,
account_prvkey: str,
) -> NostrEvent | None: ) -> NostrEvent | None:
if not nostr_client: if not nostr_client:
logger.debug("[TASKS] No NostrClient available, skipping publish") logger.debug("[TASKS] No NostrClient available, skipping publish")
return None return None
try: try:
nostr_event = build_completion_event( nostr_event = build_completion_event(
task_address, completion, account_pubkey task_address, completion, signer.pubkey
) )
sign_nostr_event(nostr_event, account_prvkey) await _sign_and_publish(nostr_client, nostr_event, signer)
await nostr_client.publish_nostr_event(nostr_event)
logger.info( logger.info(
f"[TASKS] Published 31925 completion {nostr_event.id[:16]}... " f"[TASKS] Published 31925 completion {nostr_event.id[:16]}... "
f"(status: {completion.task_status})" f"(status: {completion.task_status})"
@ -192,15 +205,13 @@ async def publish_completion_to_nostr(
async def publish_completion_delete_to_nostr( async def publish_completion_delete_to_nostr(
nostr_client, nostr_client,
completion_id: str, completion_id: str,
account_pubkey: str, signer: NostrSigner,
account_prvkey: str,
) -> NostrEvent | None: ) -> NostrEvent | None:
if not nostr_client: if not nostr_client:
return None return None
try: try:
nostr_event = build_delete_completion_event(completion_id, account_pubkey) nostr_event = build_delete_completion_event(completion_id, signer.pubkey)
sign_nostr_event(nostr_event, account_prvkey) await _sign_and_publish(nostr_client, nostr_event, signer)
await nostr_client.publish_nostr_event(nostr_event)
logger.info( logger.info(
f"[TASKS] Published completion delete {nostr_event.id[:16]}..." f"[TASKS] Published completion delete {nostr_event.id[:16]}..."
) )