Compare commits

..

1 commit

Author SHA1 Message Date
9f116ff1f8 feat(signer): stop reading account.prvkey in merchant provision/rotate paths (#5)
Pre-cascade prerequisite for aiolabs/lnbits#17 (signer abstraction
phase 1), which lands an m002 startup job that fail-closed NULLs the
legacy `accounts.prvkey` column. This commit migrates the two sites
in `views_api.py` that read `account.prvkey` so they no longer
silently undo m002, and fail-closed cleanly when prvkey is missing.

Scope intentionally narrow — this is the prvkey-elimination subset
of aiolabs/nostrmarket#5. The full phase A (envelope-encrypt
`merchants.private_key` → `signer_blob`) and phase B (route
`Merchant.sign_hash` through core's `NostrSigner`) work remains
tracked under that issue.

## What changed

### views_api.py — `_auto_create_merchant`

Was: lazy fallback that, if `account.prvkey` was missing, generated
a fresh keypair and wrote it back into the account (lines 112-118).
After m002 NULLs `accounts.prvkey`, this regenerate-and-write-back
path would silently undo the migration AND change the user's
Nostr pubkey out from under them.

Now: no longer touches the account. Asserts `account.prvkey` is
present (matching the existing pubkey assertion) with a clear
fail-closed message pointing at aiolabs/nostrmarket#5 for the
phase A/B fix. For accounts that still carry a plaintext prvkey
(pre-m002, FakeWallet local dev, etc.) the auto-provision path
continues to work unchanged. For migrated accounts, the assertion
fires fast with an actionable error.

Removed the regenerate block entirely. Dropped now-unused imports:
`update_account`, `generate_keypair`.

### views_api.py — `api_migrate_merchant_keys`

Was: same `account and account.pubkey and account.prvkey` assertion
with the generic message "Account has no Nostr keypair".

Now: assertion updated with the same bridge-state framing — points
at aiolabs/nostrmarket#5 for the phase A/B fix.

## Acceptance

- [x] regenerate-and-write-back block removed (would undo m002)
- [x] `account.prvkey` references in views_api.py are assertions only
      (fail-closed guards, not data reads)
- [x] unused imports dropped (`update_account`, `generate_keypair`)
- [x] error messages reference aiolabs/nostrmarket#5 for the
      phase A/B fix path

Manual smoke / version bump / tag / catalog entry deferred until
the lnbits cascade lands AND phase A's schema migration ships;
this commit alone doesn't change the on-disk merchants table.

## Out of scope (per aiolabs/nostrmarket#5)

- Phase A: envelope-encrypting `merchants.private_key` column.
- Phase B (full): refactoring `Merchant.sign_hash` /
  `helpers.sign_message_hash` through core's `NostrSigner`.
- Phase C: NIP-46 bunker + NIP-26 delegation variants.
- Re-enabling `_create_default_merchant` on the lnbits core side.

## Cross-references

- aiolabs/nostrmarket#5 — issue this is a partial step toward
- aiolabs/lnbits#17 — the cascading signer-abstraction PR whose
  m002 fail-closed NULLs `accounts.prvkey`
- aiolabs/lnbits#21 — umbrella audit (5 affected extensions)
- aiolabs/events#23 / aiolabs/tasks#3 / aiolabs/restaurant#11 —
  sister migrations already on signer-abstraction branches

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:58:31 +02:00
8 changed files with 147 additions and 432 deletions

25
crud.py
View file

@ -23,19 +23,16 @@ from .models import (
async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
merchant_id = urlsafe_short_hash() merchant_id = urlsafe_short_hash()
# Post-aiolabs/nostrmarket#5: no `private_key` column written. The
# legacy column is dropped by `migrations_fork.m001_aio_drop_private_key`
# for fresh installs and NULL-tolerated for the brief window between
# this code change deploying and the fork-migration running.
await db.execute( await db.execute(
""" """
INSERT INTO nostrmarket.merchants INSERT INTO nostrmarket.merchants
(user_id, id, public_key, meta) (user_id, id, private_key, public_key, meta)
VALUES (:user_id, :id, :public_key, :meta) VALUES (:user_id, :id, :private_key, :public_key, :meta)
""", """,
{ {
"user_id": user_id, "user_id": user_id,
"id": merchant_id, "id": merchant_id,
"private_key": m.private_key,
"public_key": m.public_key, "public_key": m.public_key,
"meta": json.dumps(dict(m.config)), "meta": json.dumps(dict(m.config)),
}, },
@ -58,24 +55,18 @@ async def update_merchant(
return await get_merchant(user_id, merchant_id) return await get_merchant(user_id, merchant_id)
async def update_merchant_pubkey( async def update_merchant_keys(
user_id: str, merchant_id: str, public_key: str user_id: str, merchant_id: str, private_key: str, public_key: str
) -> Optional[Merchant]: ) -> Optional[Merchant]:
"""Re-point a merchant's identity to a new pubkey (e.g. after the
account migrated to a fresh RemoteBunkerSigner keypair).
Post-aiolabs/nostrmarket#5: there is no `private_key` column to
update the merchant pubkey is the only stored identity material,
and the signing nsec lives entirely in the bunker against
`account.id` (== `merchant.user_id`) on the lnbits side.
"""
await db.execute( await db.execute(
f""" f"""
UPDATE nostrmarket.merchants UPDATE nostrmarket.merchants
SET public_key = :public_key, time = {db.timestamp_now} SET private_key = :private_key, public_key = :public_key,
time = {db.timestamp_now}
WHERE id = :id AND user_id = :user_id WHERE id = :id AND user_id = :user_id
""", """,
{ {
"private_key": private_key,
"public_key": public_key, "public_key": public_key,
"id": merchant_id, "id": merchant_id,
"user_id": user_id, "user_id": user_id,

View file

@ -1,9 +1,11 @@
import coincurve
from bech32 import bech32_decode, convertbits from bech32 import bech32_decode, convertbits
# NOTE (aiolabs/nostrmarket#5): `sign_message_hash` is gone. All merchant
# signing routes through the lnbits `NostrSigner` ABC via def sign_message_hash(private_key: str, hash_: bytes) -> str:
# `services._resolve_merchant_signer(merchant)`. The nsec lives in the privkey = coincurve.PrivateKey(bytes.fromhex(private_key))
# bunker, never in this process. sig = privkey.sign_schnorr(hash_)
return sig.hex()
def normalize_public_key(pubkey: str) -> str: def normalize_public_key(pubkey: str) -> str:

View file

@ -1,77 +0,0 @@
"""
aiolabs fork-migrations for nostrmarket (companion to upstream
`migrations.py`).
Per the aiolabs/lnbits#8 fork-migrations pattern: every aio-only
schema delta lives in this single squashed function so we never
introduce conflicts in `migrations.py` (which stays byte-identical to
upstream and rebases cleanly).
The function is loaded by lnbits's patched `migrate_extension_database()`
under the `nostrmarket_fork` namespace in core `dbversions`, with the
following invariants:
- Every ALTER must be idempotent (use `_alter_drop_column_safe`-style
wrappers or `IF NOT EXISTS` / `IF EXISTS` predicates) so re-runs
are no-ops on already-migrated installs.
- Schema changes here MUST NOT depend on the version of upstream's
`migrations.py` they're running against — upstream rebases must
not require this file to be edited.
See `aiolabs/lnbits#8` for the pattern + `aiolabs/lnbits/core/services/
signer_migration.py` for the prior art on `_alter_*_safe` helpers.
"""
from loguru import logger
async def _drop_column_safe(db, table: str, column: str) -> None:
"""SQLite-safe drop-column. Newer SQLite (3.35+) supports
`ALTER TABLE DROP COLUMN`; older versions need the classic
create-new-table + copy + swap dance. Postgres handles
`ALTER TABLE DROP COLUMN IF EXISTS` natively.
Idempotent: catches "no such column" + "column does not exist"
so re-runs are no-ops.
"""
try:
# Postgres path (supports IF EXISTS natively); also works on
# SQLite ≥ 3.35.
await db.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column};")
return
except Exception as exc:
# SQLite < 3.35 doesn't support IF EXISTS; fall through to the
# bare DROP COLUMN attempt + swallow the not-found case.
msg = str(exc).lower()
if "syntax" not in msg and "if exists" not in msg:
# Something other than the IF-EXISTS unsupported case; surface.
raise
try:
await db.execute(f"ALTER TABLE {table} DROP COLUMN {column};")
except Exception as exc:
msg = str(exc).lower()
if "no such column" in msg or "does not exist" in msg:
# Already dropped; idempotent skip.
return
raise
async def m001_aio_drop_merchant_private_key(db):
"""Drop the legacy `nostrmarket.merchants.private_key` column.
Per aiolabs/nostrmarket#5, the merchant's signing identity is owned
by the lnbits-side account: signing routes through
`resolve_signer(account).sign_event(...)` (which dispatches to
`RemoteBunkerSigner` post-aiolabs/lnbits#18 phase 2.x). The nsec
never lives in this extension's storage. Dropping the column makes
that contract enforced at the schema level rather than relying on
"nobody writes to it anymore."
Idempotent: re-runs no-op via `_drop_column_safe`.
"""
logger.info(
"[NOSTRMARKET fork] m001: dropping merchants.private_key "
"(aiolabs/nostrmarket#5 — signing routes through lnbits NostrSigner)"
)
await _drop_column_safe(db, "nostrmarket.merchants", "private_key")
logger.info("[NOSTRMARKET fork] m001: done")

View file

@ -7,6 +7,7 @@ from typing import Any, List, Optional, Tuple
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
from pydantic import BaseModel from pydantic import BaseModel
from .helpers import sign_message_hash
from .nostr.event import NostrEvent from .nostr.event import NostrEvent
######################################## NOSTR ######################################## ######################################## NOSTR ########################################
@ -51,22 +52,17 @@ class CreateMerchantRequest(BaseModel):
class PartialMerchant(BaseModel): class PartialMerchant(BaseModel):
private_key: str
public_key: str public_key: str
config: MerchantConfig = MerchantConfig() config: MerchantConfig = MerchantConfig()
class Merchant(PartialMerchant, Nostrable): class Merchant(PartialMerchant, Nostrable):
id: str id: str
user_id: str
time: Optional[int] = 0 time: Optional[int] = 0
# NOTE (aiolabs/nostrmarket#5): no `sign_hash` / `encrypt_message` / def sign_hash(self, hash_: bytes) -> str:
# `decrypt_message` methods on Merchant anymore. Signing + NIP-44 crypto return sign_message_hash(self.private_key, hash_)
# for a merchant goes through the lnbits `NostrSigner` abstraction
# (`resolve_signer(account)`); merchant is now pure metadata pointing
# at its owning account via `user_id`. The bunker (`RemoteBunkerSigner`)
# holds the merchant's nsec — lnbits never has it server-side.
# See `services._resolve_merchant_signer()` for the resolution helper.
@classmethod @classmethod
def from_row(cls, row: dict) -> "Merchant": def from_row(cls, row: dict) -> "Merchant":

View file

@ -7,26 +7,6 @@ Three-layer protocol for metadata-protected messaging:
3. Gift Wrap (kind 1059) encrypts seal with ephemeral key, has recipient p-tag 3. Gift Wrap (kind 1059) encrypts seal with ephemeral key, has recipient p-tag
Reference: https://github.com/nostr-protocol/nips/blob/master/59.md Reference: https://github.com/nostr-protocol/nips/blob/master/59.md
## Bunker integration (aiolabs/nostrmarket#5)
Merchant-identity layers (rumor's sender-pubkey + seal's encryption +
seal's signature) route through the lnbits `NostrSigner` abstraction
so the merchant's nsec stays in the bunker — never reaches this
process. Specifically:
- `create_seal` is async; takes a `sender_signer` instead of a
plaintext nsec. The seal's NIP-44 encrypt + Schnorr sign happen
via `await sender_signer.nip44_encrypt(...)` +
`await sender_signer.sign_event(...)` over the NIP-46 channel.
- `unwrap_gift_wrap` + `unseal` are async; take a `recipient_signer`
and call `await recipient_signer.nip44_decrypt(...)` for each layer.
The **ephemeral keypair layer** (`create_gift_wrap`) stays synchronous
+ local: the ephemeral nsec exists for the lifetime of one wrap and
provides no merchant-identity capability, so there's no reason to
involve the bunker. Generating it locally avoids one round-trip per
DM.
""" """
import json import json
@ -49,10 +29,8 @@ def _random_past_timestamp() -> int:
return int(time.time()) - secrets.randbelow(TWO_DAYS) return int(time.time()) - secrets.randbelow(TWO_DAYS)
def _sign_event_local(event: NostrEvent, private_key_hex: str) -> NostrEvent: def _sign_event(event: NostrEvent, private_key_hex: str) -> NostrEvent:
"""Compute event id and sign it locally with a privkey held in this """Compute event id and sign it."""
process. Used only for the ephemeral-keypair layer (gift wrap outer);
merchant-identity sign goes through the signer ABC instead."""
event.id = event.event_id event.id = event.event_id
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex() event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex()
@ -88,43 +66,26 @@ def create_rumor(
return event return event
async def create_seal( def create_seal(
rumor: NostrEvent, rumor: NostrEvent,
sender_signer, sender_privkey: str,
recipient_pubkey: str, recipient_pubkey: str,
) -> NostrEvent: ) -> NostrEvent:
""" """
Create a kind 13 seal: encrypts the rumor for the recipient. Create a kind 13 seal: encrypts the rumor for the recipient.
Signed by the sender. Tags are always empty. Signed by the sender. Tags are always empty.
Both crypto operations (NIP-44 encrypt + Schnorr sign) route
through the sender's `NostrSigner` (`sender_signer`) — the
plaintext nsec is never observable in this process.
""" """
encrypted_rumor = await sender_signer.nip44_encrypt( conv_key = get_conversation_key(sender_privkey, recipient_pubkey)
rumor.stringify(), recipient_pubkey encrypted_rumor = nip44_encrypt(rumor.stringify(), conv_key)
)
seal = NostrEvent( seal = NostrEvent(
pubkey=sender_signer.pubkey, pubkey=_pubkey_from_privkey(sender_privkey),
created_at=_random_past_timestamp(), created_at=_random_past_timestamp(),
kind=13, kind=13,
tags=[], tags=[],
content=encrypted_rumor, content=encrypted_rumor,
) )
# The signer fills id + sig (computed bunker-side). return _sign_event(seal, sender_privkey)
signed = await sender_signer.sign_event(
{
"pubkey": seal.pubkey,
"created_at": seal.created_at,
"kind": seal.kind,
"tags": seal.tags,
"content": seal.content,
}
)
seal.id = signed["id"]
seal.sig = signed["sig"]
return seal
def create_gift_wrap( def create_gift_wrap(
@ -134,11 +95,6 @@ def create_gift_wrap(
""" """
Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key. Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key.
The only public metadata is the recipient's p-tag. The only public metadata is the recipient's p-tag.
Stays synchronous + local: the ephemeral nsec exists only for the
lifetime of one wrap and provides no merchant-identity capability,
so there's no point routing through the bunker (would add one NIP-46
round-trip per DM with zero security benefit).
""" """
ephemeral_privkey = secrets.token_bytes(32).hex() ephemeral_privkey = secrets.token_bytes(32).hex()
ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey) ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey)
@ -153,35 +109,33 @@ def create_gift_wrap(
tags=[["p", recipient_pubkey]], tags=[["p", recipient_pubkey]],
content=encrypted_seal, content=encrypted_seal,
) )
return _sign_event_local(wrap, ephemeral_privkey) return _sign_event(wrap, ephemeral_privkey)
async def unwrap_gift_wrap( def unwrap_gift_wrap(
gift_wrap: NostrEvent, gift_wrap: NostrEvent,
recipient_signer, recipient_privkey: str,
) -> NostrEvent: ) -> NostrEvent:
""" """
Decrypt a kind 1059 gift wrap to reveal the inner seal. Decrypt a kind 1059 gift wrap to reveal the inner seal.
Routes NIP-44 decrypt through the recipient's signer abstraction Uses the recipient's private key and the gift wrap's ephemeral pubkey.
so the recipient's nsec stays in the bunker.
""" """
seal_json = await recipient_signer.nip44_decrypt( conv_key = get_conversation_key(recipient_privkey, gift_wrap.pubkey)
gift_wrap.content, gift_wrap.pubkey seal_json = nip44_decrypt(gift_wrap.content, conv_key)
)
return NostrEvent(**json.loads(seal_json)) return NostrEvent(**json.loads(seal_json))
async def unseal( def unseal(
seal: NostrEvent, seal: NostrEvent,
recipient_signer, recipient_privkey: str,
) -> NostrEvent: ) -> NostrEvent:
""" """
Decrypt a kind 13 seal to reveal the inner rumor. Decrypt a kind 13 seal to reveal the inner rumor.
Uses the recipient signer (their nsec stays in the bunker) and the Uses the recipient's private key and the seal's pubkey (the sender).
seal's pubkey (the sender). Validates that the rumor's pubkey Validates that the rumor's pubkey matches the seal's pubkey.
matches the seal's pubkey.
""" """
rumor_json = await recipient_signer.nip44_decrypt(seal.content, seal.pubkey) conv_key = get_conversation_key(recipient_privkey, seal.pubkey)
rumor_json = nip44_decrypt(seal.content, conv_key)
rumor = NostrEvent(**json.loads(rumor_json)) rumor = NostrEvent(**json.loads(rumor_json))
if rumor.pubkey != seal.pubkey: if rumor.pubkey != seal.pubkey:
@ -195,37 +149,30 @@ async def unseal(
# --- Convenience functions --- # --- Convenience functions ---
async def wrap_message( def wrap_message(
content: str, content: str,
sender_signer, sender_privkey: str,
sender_pubkey: str,
recipient_pubkey: str, recipient_pubkey: str,
kind: int = 14, kind: int = 14,
tags: Optional[list[list[str]]] = None, tags: Optional[list[list[str]]] = None,
) -> NostrEvent: ) -> NostrEvent:
""" """
Full wrap pipeline: create rumor seal gift wrap. Full wrap pipeline: create rumor -> seal -> gift wrap.
Returns the gift wrap event ready to publish. Returns the gift wrap event ready to publish.
`sender_signer` is the sender merchant's `NostrSigner` (post-#5:
always a `RemoteBunkerSigner`). The merchant's nsec never leaves
the bunker.
""" """
rumor = create_rumor(sender_signer.pubkey, content, kind=kind, tags=tags) rumor = create_rumor(sender_pubkey, content, kind=kind, tags=tags)
seal = await create_seal(rumor, sender_signer, recipient_pubkey) seal = create_seal(rumor, sender_privkey, recipient_pubkey)
return create_gift_wrap(seal, recipient_pubkey) return create_gift_wrap(seal, recipient_pubkey)
async def unwrap_message( def unwrap_message(
gift_wrap: NostrEvent, gift_wrap: NostrEvent,
recipient_signer, recipient_privkey: str,
) -> NostrEvent: ) -> NostrEvent:
""" """
Full unwrap pipeline: gift wrap seal rumor. Full unwrap pipeline: gift wrap -> seal -> rumor.
Returns the rumor with sender pubkey and plaintext content. Returns the rumor with sender pubkey and plaintext content.
`recipient_signer` is the recipient merchant's `NostrSigner`. Both
NIP-44 decrypt layers (gift wrap seal, seal rumor) route
through the signer abstraction.
""" """
seal = await unwrap_gift_wrap(gift_wrap, recipient_signer) seal = unwrap_gift_wrap(gift_wrap, recipient_privkey)
return await unseal(seal, recipient_signer) return unseal(seal, recipient_privkey)

View file

@ -3,10 +3,8 @@ import json
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from lnbits.bolt11 import decode from lnbits.bolt11 import decode
from lnbits.core.crud import get_account, get_wallet from lnbits.core.crud import get_wallet
from lnbits.core.services import create_invoice, websocket_updater from lnbits.core.services import create_invoice, websocket_updater
from lnbits.core.signers import resolve_signer
from lnbits.core.signers.base import NostrSigner, SignerError
from loguru import logger from loguru import logger
from . import nostr_client from . import nostr_client
@ -173,57 +171,15 @@ async def update_merchant_to_nostr(
return merchant return merchant
async def _resolve_merchant_signer(merchant: Merchant) -> NostrSigner:
"""Resolve the lnbits NostrSigner for a merchant's owning account.
Post-#5 (aiolabs/nostrmarket#5), the merchant's nsec lives in the
bunker via the account's `signer_config`. No fast-path or caching
today per-call lookup is fine for v1 throughput; if the events
extension or DM hot path becomes contended, revisit with a
process-local cache keyed on `merchant.user_id`.
Raises `SignerError` if the account can't be found or its signer
can't be resolved — callers should propagate, not silently skip,
so misconfigured rows surface loudly.
"""
account = await get_account(merchant.user_id)
if account is None:
raise SignerError(
f"merchant {merchant.id[:8]} references missing account "
f"{merchant.user_id[:8]} — can't resolve signer"
)
return resolve_signer(account)
async def sign_and_send_to_nostr( async def sign_and_send_to_nostr(
merchant: Merchant, n: Nostrable, delete=False merchant: Merchant, n: Nostrable, delete=False
) -> NostrEvent: ) -> NostrEvent:
"""Sign + publish a Nostrable as the merchant's identity.
Signing routes through the merchant's account `NostrSigner` (post-#5).
The signer fills `id` + `sig` server-side (bunker for the
`RemoteBunkerSigner` case) this function builds the unsigned dict
shape, hands it to the signer, and copies the result back onto the
`NostrEvent` instance for the publisher.
"""
event = ( event = (
n.to_nostr_delete_event(merchant.public_key) n.to_nostr_delete_event(merchant.public_key)
if delete if delete
else n.to_nostr_event(merchant.public_key) else n.to_nostr_event(merchant.public_key)
) )
event.sig = merchant.sign_hash(bytes.fromhex(event.id))
signer = await _resolve_merchant_signer(merchant)
signed = await signer.sign_event(
{
"pubkey": event.pubkey,
"created_at": event.created_at,
"kind": event.kind,
"tags": event.tags,
"content": event.content,
}
)
event.id = signed["id"]
event.sig = signed["sig"]
await nostr_client.publish_nostr_event(event) await nostr_client.publish_nostr_event(event)
return event return event
@ -233,6 +189,7 @@ async def provision_merchant(
user_id: str, user_id: str,
wallet_id: str, wallet_id: str,
public_key: str, public_key: str,
private_key: str,
display_name: Optional[str] = None, display_name: Optional[str] = None,
config: Optional[MerchantConfig] = None, config: Optional[MerchantConfig] = None,
) -> Merchant: ) -> Merchant:
@ -240,13 +197,6 @@ async def provision_merchant(
Provision a merchant with a default shipping zone and default stall, Provision a merchant with a default shipping zone and default stall,
and publish the stall to Nostr relays. and publish the stall to Nostr relays.
Post-aiolabs/nostrmarket#5: no `private_key` argument. The merchant
identity IS the lnbits account's identity (`public_key` parameter
must equal `account.pubkey` for the same `user_id`); signing routes
through the account's `NostrSigner` (`RemoteBunkerSigner` in the
target deployment). The merchant nsec lives in the bunker, never
server-side.
Single source of truth used by: Single source of truth used by:
- LNbits user-creation hook (eager, on signup) see - LNbits user-creation hook (eager, on signup) see
lnbits/core/services/users.py:_create_default_merchant lnbits/core/services/users.py:_create_default_merchant
@ -261,6 +211,7 @@ async def provision_merchant(
return existing return existing
partial_merchant = PartialMerchant( partial_merchant = PartialMerchant(
private_key=private_key,
public_key=public_key, public_key=public_key,
config=config or MerchantConfig(), config=config or MerchantConfig(),
) )
@ -275,11 +226,10 @@ async def provision_merchant(
) )
await create_zone(merchant.id, online_zone) await create_zone(merchant.id, online_zone)
raw_owner_name = display_name or "My" name = display_name or "My"
owner_name = raw_owner_name[:1].upper() + raw_owner_name[1:]
default_stall = Stall( default_stall = Stall(
wallet=wallet_id, wallet=wallet_id,
name=f"{owner_name}'s Store", name=f"{name}'s Store",
currency="sat", currency="sat",
shipping_zones=[online_zone], shipping_zones=[online_zone],
) )
@ -288,57 +238,18 @@ async def provision_merchant(
# Publish the kind 30017 stall event so customers' clients can resolve # Publish the kind 30017 stall event so customers' clients can resolve
# the stall name when they fetch products. Non-fatal on failure: a # the stall name when they fetch products. Non-fatal on failure: a
# later product publish (or webapp self-heal) will retry. # later product publish (or webapp self-heal) will retry.
#
# Fire-and-forget: `nostr_client.publish_nostr_event` has no per-relay
# deadline and will block indefinitely if every configured relay is
# unreachable (cf. aiolabs/nostrmarket#7). When `provision_merchant`
# is called from the eager signup hook (lnbits/core/services/users.py
# ::_create_default_merchant, aiolabs/lnbits#46), inline-awaiting that
# publish hangs the uvicorn worker on `POST /auth/register` forever.
# The DB rows we just wrote are sufficient to serve the wallet UI;
# the stall event_id gets backfilled when the publish completes (or
# stays NULL until a later resubscribe-driven republish lands it).
asyncio.create_task(
_publish_default_stall_background(merchant.id, merchant, default_stall)
)
return merchant
# Generous bound: signing through the bunker can take 12 s on a cold
# session, plus the relay publish itself. 30 s is well over both, and
# the cap matters only when the relay set is unreachable.
STALL_PUBLISH_TIMEOUT_S = 30.0
async def _publish_default_stall_background(
merchant_id: str, merchant: Merchant, default_stall: Stall
) -> None:
"""Background helper for `provision_merchant`'s default-stall publish.
Bounded by `STALL_PUBLISH_TIMEOUT_S` so even a permanently-unreachable
relay set doesn't pin an asyncio task forever. Errors and timeouts are
logged at warning never raised, since the caller scheduled-and-forgot.
"""
try: try:
stall_event = await asyncio.wait_for( stall_event = await sign_and_send_to_nostr(merchant, default_stall)
sign_and_send_to_nostr(merchant, default_stall),
timeout=STALL_PUBLISH_TIMEOUT_S,
)
default_stall.event_id = stall_event.id default_stall.event_id = stall_event.id
await update_stall(merchant_id, default_stall) await update_stall(merchant.id, default_stall)
except asyncio.TimeoutError:
logger.warning(
f"[NOSTRMARKET] Default stall publish for merchant "
f"{merchant_id} timed out after {STALL_PUBLISH_TIMEOUT_S}s; "
f"event_id stays NULL until a later republish lands it"
)
except Exception as ex: except Exception as ex:
logger.warning( logger.warning(
f"[NOSTRMARKET] Failed to publish default stall for " f"[NOSTRMARKET] Failed to publish default stall for "
f"merchant {merchant_id}: {ex}" f"merchant {merchant.id}: {ex}"
) )
return merchant
async def handle_order_paid(order_id: str, merchant_pubkey: str): async def handle_order_paid(order_id: str, merchant_pubkey: str):
try: try:
@ -432,15 +343,11 @@ async def send_dm(
type_: int, type_: int,
dm_content: str, dm_content: str,
) -> DirectMessage: ) -> DirectMessage:
# Post-#5: nsec stays in the bunker; both the to-recipient wrap and
# the to-self archival wrap route their seal-layer crypto through
# the merchant's NostrSigner.
signer = await _resolve_merchant_signer(merchant)
# Wrap message to recipient via NIP-59 gift wrap # Wrap message to recipient via NIP-59 gift wrap
gift_wrap = await wrap_message( gift_wrap = wrap_message(
dm_content, dm_content,
signer, merchant.private_key,
merchant.public_key,
other_pubkey, other_pubkey,
) )
@ -456,9 +363,10 @@ async def send_dm(
await nostr_client.publish_nostr_event(gift_wrap) await nostr_client.publish_nostr_event(gift_wrap)
# Also wrap a copy to self for archival # Also wrap a copy to self for archival
self_wrap = await wrap_message( self_wrap = wrap_message(
dm_content, dm_content,
signer, merchant.private_key,
merchant.public_key,
merchant.public_key, merchant.public_key,
) )
await nostr_client.publish_nostr_event(self_wrap) await nostr_client.publish_nostr_event(self_wrap)
@ -633,8 +541,7 @@ async def _handle_gift_wrap(event: NostrEvent):
return return
try: try:
recipient_signer = await _resolve_merchant_signer(merchant) rumor = unwrap_message(event, merchant.private_key)
rumor = await unwrap_message(event, recipient_signer)
except Exception as ex: except Exception as ex:
logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}") logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}")
return return
@ -750,10 +657,10 @@ async def _persist_dm(
async def reply_to_structured_dm( async def reply_to_structured_dm(
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
): ):
signer = await _resolve_merchant_signer(merchant) gift_wrap = wrap_message(
gift_wrap = await wrap_message(
dm_reply, dm_reply,
signer, merchant.private_key,
merchant.public_key,
customer_pubkey, customer_pubkey,
) )
dm = PartialDirectMessage( dm = PartialDirectMessage(

View file

@ -1,12 +1,4 @@
"""Tests for NIP-59 gift wrap protocol. """Tests for NIP-59 gift wrap protocol."""
Post-aiolabs/nostrmarket#5: the merchant-identity crypto operations
(`create_seal`, `unseal`, `unwrap_gift_wrap`, `wrap_message`,
`unwrap_message`) are async + take a `NostrSigner`-shaped object
instead of a raw privkey. These tests use a local-privkey-backed
fake signer so the NIP-59 plumbing can be tested in isolation
the real runtime uses `RemoteBunkerSigner` against nsecbunkerd.
"""
import json import json
import time import time
@ -14,10 +6,6 @@ import time
import coincurve import coincurve
import pytest import pytest
from nostr.event import NostrEvent
from nostr.nip44 import decrypt as _nip44_decrypt
from nostr.nip44 import encrypt as _nip44_encrypt
from nostr.nip44 import get_conversation_key
from nostr.nip59 import ( from nostr.nip59 import (
create_gift_wrap, create_gift_wrap,
create_rumor, create_rumor,
@ -37,48 +25,8 @@ def _generate_keypair() -> tuple[str, str]:
return privkey, pubkey return privkey, pubkey
class _LocalSignerStub:
"""Stand-in for the lnbits `NostrSigner` ABC backed by a held privkey.
Provides just the surface the NIP-59 functions touch:
`pubkey`, `nip44_encrypt`, `nip44_decrypt`, `sign_event`. Useful for
unit-testing the NIP-59 plumbing without involving a bunker the
crypto is identical, only the dispatch boundary differs.
"""
def __init__(self, privkey_hex: str):
self._privkey = privkey_hex
sk = coincurve.PrivateKey(bytes.fromhex(privkey_hex))
self.pubkey = sk.public_key.format(compressed=True)[1:].hex()
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
return _nip44_encrypt(
plaintext, get_conversation_key(self._privkey, peer_pubkey_hex)
)
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
return _nip44_decrypt(
ciphertext, get_conversation_key(self._privkey, peer_pubkey_hex)
)
async def sign_event(self, unsigned: dict) -> dict:
evt = NostrEvent(
pubkey=unsigned["pubkey"],
created_at=unsigned["created_at"],
kind=unsigned["kind"],
tags=unsigned["tags"],
content=unsigned["content"],
)
evt.id = evt.event_id
sk = coincurve.PrivateKey(bytes.fromhex(self._privkey))
sig = sk.sign_schnorr(bytes.fromhex(evt.id)).hex()
return {**unsigned, "id": evt.id, "sig": sig}
SENDER_PRIV, SENDER_PUB = _generate_keypair() SENDER_PRIV, SENDER_PUB = _generate_keypair()
RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair() RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair()
SENDER_SIGNER = _LocalSignerStub(SENDER_PRIV)
RECIPIENT_SIGNER = _LocalSignerStub(RECIPIENT_PRIV)
class TestCreateRumor: class TestCreateRumor:
@ -101,32 +49,28 @@ class TestCreateRumor:
class TestCreateSeal: class TestCreateSeal:
@pytest.mark.asyncio def test_kind_13_with_empty_tags(self):
async def test_kind_13_with_empty_tags(self):
rumor = create_rumor(SENDER_PUB, "hello") rumor = create_rumor(SENDER_PUB, "hello")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
assert seal.kind == 13 assert seal.kind == 13
assert seal.tags == [] assert seal.tags == []
assert seal.pubkey == SENDER_PUB assert seal.pubkey == SENDER_PUB
@pytest.mark.asyncio def test_is_signed(self):
async def test_is_signed(self):
rumor = create_rumor(SENDER_PUB, "hello") rumor = create_rumor(SENDER_PUB, "hello")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
assert seal.sig is not None assert seal.sig is not None
assert len(seal.sig) == 128 # 64 bytes hex assert len(seal.sig) == 128 # 64 bytes hex
@pytest.mark.asyncio def test_content_is_encrypted(self):
async def test_content_is_encrypted(self):
rumor = create_rumor(SENDER_PUB, "hello") rumor = create_rumor(SENDER_PUB, "hello")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
# Content should be base64 NIP-44 payload, not plaintext JSON # Content should be base64 NIP-44 payload, not plaintext JSON
assert "hello" not in seal.content assert "hello" not in seal.content
@pytest.mark.asyncio def test_timestamp_is_randomized(self):
async def test_timestamp_is_randomized(self):
rumor = create_rumor(SENDER_PUB, "hello") rumor = create_rumor(SENDER_PUB, "hello")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
now = int(time.time()) now = int(time.time())
# Seal timestamp should be in the past (up to 2 days) # Seal timestamp should be in the past (up to 2 days)
assert seal.created_at <= now assert seal.created_at <= now
@ -134,108 +78,98 @@ class TestCreateSeal:
class TestCreateGiftWrap: class TestCreateGiftWrap:
@pytest.mark.asyncio def test_kind_1059_with_p_tag(self):
async def test_kind_1059_with_p_tag(self):
rumor = create_rumor(SENDER_PUB, "hello") rumor = create_rumor(SENDER_PUB, "hello")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB) wrap = create_gift_wrap(seal, RECIPIENT_PUB)
assert wrap.kind == 1059 assert wrap.kind == 1059
assert ["p", RECIPIENT_PUB] in wrap.tags assert ["p", RECIPIENT_PUB] in wrap.tags
@pytest.mark.asyncio def test_uses_ephemeral_key(self):
async def test_uses_ephemeral_key(self):
rumor = create_rumor(SENDER_PUB, "hello") rumor = create_rumor(SENDER_PUB, "hello")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB) wrap = create_gift_wrap(seal, RECIPIENT_PUB)
# Gift wrap pubkey should be neither sender nor recipient # Gift wrap pubkey should be neither sender nor recipient
assert wrap.pubkey != SENDER_PUB assert wrap.pubkey != SENDER_PUB
assert wrap.pubkey != RECIPIENT_PUB assert wrap.pubkey != RECIPIENT_PUB
@pytest.mark.asyncio def test_different_wraps_have_different_ephemeral_keys(self):
async def test_different_wraps_have_different_ephemeral_keys(self):
rumor = create_rumor(SENDER_PUB, "hello") rumor = create_rumor(SENDER_PUB, "hello")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
wrap1 = create_gift_wrap(seal, RECIPIENT_PUB) wrap1 = create_gift_wrap(seal, RECIPIENT_PUB)
wrap2 = create_gift_wrap(seal, RECIPIENT_PUB) wrap2 = create_gift_wrap(seal, RECIPIENT_PUB)
assert wrap1.pubkey != wrap2.pubkey assert wrap1.pubkey != wrap2.pubkey
class TestUnwrap: class TestUnwrap:
@pytest.mark.asyncio def test_unwrap_gift_wrap_returns_seal(self):
async def test_unwrap_gift_wrap_returns_seal(self):
rumor = create_rumor(SENDER_PUB, "hello") rumor = create_rumor(SENDER_PUB, "hello")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB) wrap = create_gift_wrap(seal, RECIPIENT_PUB)
recovered_seal = await unwrap_gift_wrap(wrap, RECIPIENT_SIGNER) recovered_seal = unwrap_gift_wrap(wrap, RECIPIENT_PRIV)
assert recovered_seal.kind == 13 assert recovered_seal.kind == 13
assert recovered_seal.pubkey == SENDER_PUB assert recovered_seal.pubkey == SENDER_PUB
@pytest.mark.asyncio def test_unseal_returns_rumor(self):
async def test_unseal_returns_rumor(self):
rumor = create_rumor(SENDER_PUB, "hello world") rumor = create_rumor(SENDER_PUB, "hello world")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
recovered_rumor = await unseal(seal, RECIPIENT_SIGNER) recovered_rumor = unseal(seal, RECIPIENT_PRIV)
assert recovered_rumor.content == "hello world" assert recovered_rumor.content == "hello world"
assert recovered_rumor.pubkey == SENDER_PUB assert recovered_rumor.pubkey == SENDER_PUB
assert recovered_rumor.kind == 14 assert recovered_rumor.kind == 14
@pytest.mark.asyncio def test_wrong_key_fails(self):
async def test_wrong_key_fails(self):
rumor = create_rumor(SENDER_PUB, "secret") rumor = create_rumor(SENDER_PUB, "secret")
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB) wrap = create_gift_wrap(seal, RECIPIENT_PUB)
wrong_priv, _ = _generate_keypair() wrong_priv, _ = _generate_keypair()
wrong_signer = _LocalSignerStub(wrong_priv)
with pytest.raises(Exception): with pytest.raises(Exception):
await unwrap_message(wrap, wrong_signer) unwrap_message(wrap, wrong_priv)
class TestFullRoundTrip: class TestFullRoundTrip:
@pytest.mark.asyncio def test_wrap_unwrap_message(self):
async def test_wrap_unwrap_message(self):
content = "Are you going to the party tonight?" content = "Are you going to the party tonight?"
wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
assert wrap.kind == 1059 assert wrap.kind == 1059
assert ["p", RECIPIENT_PUB] in wrap.tags assert ["p", RECIPIENT_PUB] in wrap.tags
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) rumor = unwrap_message(wrap, RECIPIENT_PRIV)
assert rumor.content == content assert rumor.content == content
assert rumor.pubkey == SENDER_PUB assert rumor.pubkey == SENDER_PUB
assert rumor.kind == 14 assert rumor.kind == 14
assert rumor.sig is None assert rumor.sig is None
@pytest.mark.asyncio def test_wrap_with_custom_kind_and_tags(self):
async def test_wrap_with_custom_kind_and_tags(self):
tags = [["p", RECIPIENT_PUB], ["subject", "test"]] tags = [["p", RECIPIENT_PUB], ["subject", "test"]]
wrap = await wrap_message( wrap = wrap_message(
"order data", "order data",
SENDER_SIGNER, SENDER_PRIV,
SENDER_PUB,
RECIPIENT_PUB, RECIPIENT_PUB,
kind=14, kind=14,
tags=tags, tags=tags,
) )
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) rumor = unwrap_message(wrap, RECIPIENT_PRIV)
assert rumor.content == "order data" assert rumor.content == "order data"
assert rumor.kind == 14 assert rumor.kind == 14
assert ["subject", "test"] in rumor.tags assert ["subject", "test"] in rumor.tags
@pytest.mark.asyncio def test_self_wrap_for_archival(self):
async def test_self_wrap_for_archival(self):
"""Merchant wraps a copy to self (same sender and recipient).""" """Merchant wraps a copy to self (same sender and recipient)."""
content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}' content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}'
wrap = await wrap_message(content, SENDER_SIGNER, SENDER_PUB) wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, SENDER_PUB)
rumor = await unwrap_message(wrap, SENDER_SIGNER) rumor = unwrap_message(wrap, SENDER_PRIV)
assert rumor.content == content assert rumor.content == content
assert rumor.pubkey == SENDER_PUB assert rumor.pubkey == SENDER_PUB
@pytest.mark.asyncio def test_json_content_preserved(self):
async def test_json_content_preserved(self):
"""Order JSON payloads survive the wrap/unwrap cycle.""" """Order JSON payloads survive the wrap/unwrap cycle."""
order = { order = {
"type": 0, "type": 0,
@ -244,15 +178,14 @@ class TestFullRoundTrip:
"shipping_id": "zone-1", "shipping_id": "zone-1",
} }
content = json.dumps(order) content = json.dumps(order)
wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) rumor = unwrap_message(wrap, RECIPIENT_PRIV)
recovered_order = json.loads(rumor.content) recovered_order = json.loads(rumor.content)
assert recovered_order == order assert recovered_order == order
@pytest.mark.asyncio def test_unicode_content(self):
async def test_unicode_content(self):
content = "Payment received! \u2705 Your order is being processed \U0001f4e6" content = "Payment received! \u2705 Your order is being processed \U0001f4e6"
wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) rumor = unwrap_message(wrap, RECIPIENT_PRIV)
assert rumor.content == content assert rumor.content == content

View file

@ -38,7 +38,7 @@ from .crud import (
get_last_direct_messages_time, get_last_direct_messages_time,
get_merchant_by_pubkey, get_merchant_by_pubkey,
get_merchant_for_user, get_merchant_for_user,
update_merchant_pubkey, update_merchant_keys,
get_order, get_order,
get_order_by_event_id, get_order_by_event_id,
get_orders, get_orders,
@ -100,28 +100,38 @@ async def _auto_create_merchant(
) -> Merchant: ) -> Merchant:
""" """
Lazy fallback: provision a merchant from the user's account keypair when Lazy fallback: provision a merchant from the user's account keypair when
the LNbits-side eager provisioning didn't run (e.g., older accounts, or the LNbits-side eager provisioning didn't run.
upstream LNbits without our signup hook).
Post-aiolabs/nostrmarket#5: the merchant identity IS the lnbits Delegates to services.provision_merchant the canonical implementation.
account identity. No `private_key` is read here signing routes
through the account's `NostrSigner` (which holds a Pre-cascade bridge state (see aiolabs/nostrmarket#5):
`RemoteBunkerSigner` in our target deployment, with the nsec After aiolabs/lnbits#17 m002 lands, `accounts.prvkey` is fail-closed
living entirely in the bunker). The only precondition is that the NULL'd for migrated accounts (the cleartext nsec lives encrypted in
account already has a `pubkey` every post-#9 account does, since `signer_config`, owned by the core signer abstraction). Auto-provision
`create_account` provisions one via the bunker on signup. cannot extract that cleartext to copy into `merchants.private_key`,
so this path fails-closed when prvkey is missing. The proper fix is
phase A (envelope-encrypt `merchants.private_key` `signer_blob`)
followed by phase B (route `Merchant.sign_hash` through core's
`NostrSigner`) per aiolabs/nostrmarket#5. Until then, migrated
accounts must explicitly provision a merchant through the future
phase-A-aware flow.
The previous regenerate-and-write-back block (generated a fresh
keypair and stored it into the account) was removed because it
would silently undo m002's NULL'ing.
""" """
account = await get_account(wallet.wallet.user) account = await get_account(wallet.wallet.user)
assert account, "User account not found" assert account, "User account not found"
assert account.pubkey, ( assert account.pubkey and account.prvkey, (
"Account has no Nostr pubkey — must be migrated to RemoteBunkerSigner " "Account has no plaintext Nostr keypair available for merchant "
"before a merchant can be provisioned (see aiolabs/nostrmarket#5)" "provisioning (see aiolabs/nostrmarket#5 for the phase A/B fix)"
) )
merchant = await provision_merchant( merchant = await provision_merchant(
user_id=wallet.wallet.user, user_id=wallet.wallet.user,
wallet_id=wallet.wallet.id, wallet_id=wallet.wallet.id,
public_key=account.pubkey, public_key=account.pubkey,
private_key=account.prvkey,
display_name=account.username, display_name=account.username,
config=config, config=config,
) )
@ -245,7 +255,14 @@ async def api_migrate_merchant_keys(
assert merchant.id == merchant_id, "Wrong merchant ID" assert merchant.id == merchant_id, "Wrong merchant ID"
account = await get_account(wallet.wallet.user) account = await get_account(wallet.wallet.user)
assert account and account.pubkey, "Account has no Nostr pubkey" # account.prvkey is fail-closed NULL'd by aiolabs/lnbits#17 m002
# for migrated accounts. Rotation cannot copy a cleartext nsec
# into merchants.private_key until phase A lands — see
# aiolabs/nostrmarket#5 for the migration plan.
assert account and account.pubkey and account.prvkey, (
"Account has no plaintext Nostr keypair available for key "
"rotation (see aiolabs/nostrmarket#5 for the phase A/B fix)"
)
if account.pubkey == merchant.public_key: if account.pubkey == merchant.public_key:
return merchant # already in sync return merchant # already in sync
@ -258,11 +275,10 @@ async def api_migrate_merchant_keys(
old_pubkey = merchant.public_key old_pubkey = merchant.public_key
# Post-aiolabs/nostrmarket#5: re-pointing only the pubkey; the # Update merchant keys in DB
# signing nsec lives in the bunker and is keyed on account.id, merchant = await update_merchant_keys(
# which is unchanged. No private_key column to update. wallet.wallet.user, merchant.id,
merchant = await update_merchant_pubkey( account.prvkey, account.pubkey,
wallet.wallet.user, merchant.id, account.pubkey,
) )
assert merchant assert merchant