Skip to content

Migrate NIP-59 gift-wrap transport to mostro-core 0.9 (wrap_message / unwrap_message / validate_response) #101

@grunch

Description

@grunch

Summary

Migrate the app's hand-rolled NIP-59 gift-wrap layer to the new centralized API introduced in mostro-core 0.9.0: wrap_message(), unwrap_message(), and validate_response(). These helpers replace the local wrap() / unwrap() glue in rust/src/nostr/gift_wrap.rs and bring the client in line with the transport contract documented at NIP59_TRANSPORT.md.

The goal is to remove crypto/transport code from this repo so that every Mostro client shares one implementation of seal construction, ephemeral key generation, timestamp tweaking, NIP-13 PoW, and inner-tuple signing/verification.

Motivation

Today the wrap/unwrap path for Mostro protocol messages is ~70 lines of bespoke code that duplicates NIP-59 plumbing (rust/src/nostr/gift_wrap.rs). It has no unit tests, does not verify inner signatures on incoming messages, and has to be kept in sync by hand with server-side changes. Centralizing it in mostro-core removes the duplication and adds:

  • Inner-tuple Schnorr signature over the exact JSON bytes (trade-identity binding).
  • Ok(None) signal for "not addressed to me" on NIP-44 decryption failure — a better fit for our multi-key trial-decrypt loop.
  • Consistent timestamp tweak / ephemeral signer / PoW behavior across clients.
  • validate_response() for request/response correlation against request_id.

New mostro-core 0.9.0 API

Source: docs/NIP59_TRANSPORT.md.

pub async fn wrap_message(
    message: &Message,
    trade_keys: &Keys,
    receiver: PublicKey,
    opts: WrapOptions,
) -> Result<Event, MostroError>;

pub async fn unwrap_message(
    event: &Event,
    trade_keys: &Keys,
) -> Result<Option<UnwrappedMessage>, MostroError>;

pub fn validate_response(
    message: &Message,
    expected_request_id: Option<u64>,
) -> Result<(), MostroError>;

pub struct WrapOptions {
    pub pow: u8,                       // NIP-13 difficulty applied to outer 1059
    pub expiration: Option<Timestamp>, // NIP-40
    pub signed: bool,                  // inner-tuple signature with trade_keys
}
// Default: pow = 0, expiration = None, signed = true

pub struct UnwrappedMessage {
    pub message: Message,
    pub signature: Option<Signature>,
    pub sender: PublicKey,
    pub created_at: Timestamp,
}

Encapsulation order (handled entirely inside the helpers): Message → (Message, Option<Signature>) → Rumor(kind 1) → Seal(kind 13) → GiftWrap(kind 1059).

Current state (what we are replacing)

Concern Current location Notes
Dep pin rust/Cargo.toml:14 mostro-core = "0.8.0" — needs bump to 0.9
Custom wrap rust/src/nostr/gift_wrap.rs:18pub async fn wrap(sender_keys, recipient_pubkey, content: &str, kind: Kind) -> Result<String> Builds rumor/seal/gift-wrap by hand; manual PoW branch using EventBuilder::new(Kind::GiftWrap, …) + .pow(pow)
Custom unwrap rust/src/nostr/gift_wrap.rs:62pub async fn unwrap(recipient_keys, gift_wrap_json) -> Result<String> Delegates to nip59::extract_rumor, returns serialized rumor JSON; no inner-signature verification
Protocol wrap call (Message → wire) rust/src/mostro/actions.rs:258wrap_message() wrapper Serializes (Message, Option<Peer>) and hands the JSON string to gift_wrap::wrap
Per-trade subscription + unwrap rust/src/api/orders.rs:897subscribe_gift_wraps() / rust/src/api/orders.rs:1001 — content parse Deserializes back into (Message, Option<Peer>) after unwrap
Global/cold-start subscription rust/src/api/orders.rs:1720handle_global_gift_wrap() + trade-key map Trial-decrypts across all known trade keys
PoW source rust/src/mostro/pow.rs:get_pow() Read at wrap time; will feed WrapOptions.pow
Active Mostro pubkey rust/src/config.rs:15 Still the authority for the author filter on 38383 / the receiver for wraps
Out-of-scope wrap sites (Kind 14 text DMs) rust/src/api/messages.rs:165, rust/src/api/disputes.rs:214 Wrap plain {"text": …} payloads, not mostro_core::Message — see "Scope" below

There are no existing unit tests for wrap/unwrap (rust/src/nostr/gift_wrap.rs has no #[cfg(test)] block). Only BIP-32 derivation is covered, in rust/src/crypto/keys.rs.

Scope

In scope — migrates to the new API:

  • All mostro_core::Message traffic to/from the Mostro daemon: order actions (rust/src/mostro/actions.rs), per-trade and global gift-wrap subscriptions in rust/src/api/orders.rs.

Out of scope — stays on the current nostr-sdk helpers:

  • P2P chat (rust/src/api/messages.rs) and dispute admin messages (rust/src/api/disputes.rs) wrap a raw {"text": "..."} JSON string inside a Kind 14 rumor. These are NIP-17-style DMs, not mostro_core::Message values, so wrap_message() / unwrap_message() are the wrong shape for them. Options (to be decided in a follow-up): (a) keep the local helper for DM text only, renamed, or (b) request a wrap_dm() helper from mostro-core. This issue will not change those call sites.

Implementation plan

1. Dependency bump

  • rust/Cargo.toml:14: mostro-core = "0.9" (pin to the 0.9 line once released; adjust nostr-sdk feature flags if mostro-core 0.9 requires any additional ones).
  • cargo update -p mostro-core, rebuild, regenerate flutter_rust_bridge bindings (flutter_rust_bridge_codegen generate or whatever the project's regen command is).
  • Resolve any breaking changes in Message / Action / Payload variants surfaced by the compiler before touching transport code.

2. Rewrite rust/src/nostr/gift_wrap.rs

Narrow this file to a thin, typed shim over mostro-core so upstream call sites change as little as possible:

use anyhow::{anyhow, Result};
use mostro_core::{
    message::Message,
    nip59::{unwrap_message, wrap_message, UnwrappedMessage, WrapOptions},
};
use nostr_sdk::prelude::*;

pub async fn wrap_mostro_message(
    trade_keys: &Keys,
    receiver: &PublicKey,
    message: &Message,
) -> Result<Event> {
    let opts = WrapOptions {
        pow: crate::mostro::pow::get_pow(),
        expiration: None,
        signed: true,
    };
    wrap_message(message, trade_keys, *receiver, opts)
        .await
        .map_err(|e| anyhow!("wrap_message failed: {e}"))
}

pub async fn unwrap_mostro_message(
    trade_keys: &Keys,
    event: &Event,
) -> Result<Option<UnwrappedMessage>> {
    unwrap_message(event, trade_keys)
        .await
        .map_err(|e| anyhow!("unwrap_message failed: {e}"))
}

Notes:

  • The signature changes from (sender_keys, recipient_pubkey, content: &str, kind: Kind) -> String to (trade_keys, receiver, &Message) -> Event. Callers no longer pass Kind, and no longer pre-serialize the (Message, Option<Peer>) tuple — wrap_message owns the inner-tuple shape. The Option<Peer> second element we send today is always null; the new API drops it.
  • Return type is Event, not String. Publishing sites will call pool.client().send_event(&event) directly instead of send_event(&Event::from_json(&s)?).
  • Drop the manual NIP-13 branch entirely — WrapOptions.pow is now the only knob.
  • Keep the anyhow wrapper so call sites don't have to import MostroError everywhere; if we want stricter error types later we can revisit.

3. Update protocol wrap sites in rust/src/mostro/actions.rs

  • Delete the local wrap_message() helper (rust/src/mostro/actions.rs:258) — the internal name now collides with mostro_core::nip59::wrap_message, and its only job was pre-serializing the (Message, Option<Peer>) tuple that the new API no longer needs.
  • Each action builder (new_order, take_buy, take_sell, add_invoice, fiat_sent, release, cancel, and the ~15 others) now constructs a Message and calls wrap_mostro_message(&trade_keys, &mostro_pubkey, &message).await.
  • The publish call becomes a direct Event send (no Event::from_json round trip).

4. Update unwrap sites

Two subscription paths currently feed off unwrap returning a rumor JSON string and manually serde_json::from_str-ing it back into (Message, Option<Peer>):

  • Per-trade subscription: rust/src/api/orders.rs:897 (subscribe_gift_wraps) and the content-parse at rust/src/api/orders.rs:1001.
  • Global / cold-start subscription: rust/src/api/orders.rs:1720 (handle_global_gift_wrap) with its trade-key map.

Both become:

match unwrap_mostro_message(&trade_keys, &event).await? {
    Some(UnwrappedMessage { message, sender, signature, created_at }) => {
        // existing dispatch path, but consuming `Message` directly
        process_mostro_message(message, sender, signature, created_at).await;
    }
    None => {
        // Outer NIP-44 decryption failed → not addressed to this key.
        // In the global loop this is the natural "try next key" signal.
    }
}

Benefits:

  • Kills the (Message, Option<Peer>) deserialization dance (rust/src/api/orders.rs:1024).
  • The global trial-decrypt loop can stop swallowing errors as "wrong key" — now Ok(None) means wrong key and Err(_) means a genuine protocol violation we should log.
  • Gives us sender, signature, and created_at explicitly, which opens the door to inner-signature verification (see §6).

5. Plug in validate_response()

validate_response(&message, expected_request_id) enforces CantDo handling and request/response correlation. The app does not currently thread request_id through its async flow, so migration is two steps:

  1. Minimum viable: call validate_response(&message, None) on every unwrapped response. This still catches CantDo actions centrally, replacing any ad-hoc Action::CantDo matching we do today.
  2. Follow-up (new issue): when an action builder sends a message with a request_id, stash (request_id, oneshot::Sender<_>) in a pending-requests map keyed by order id / trade pubkey. The subscription handler looks up the expected request_id and passes it to validate_response. This is its own design exercise — not blocking for this issue.

6. Inner-signature verification (opt-in, same PR)

UnwrappedMessage.signature is Some(_) when the peer used signed: true (our new default and the Mostro daemon's). We should verify it where the sender is known:

if let Some(sig) = unwrapped.signature {
    Message::verify_signature(&message_json, unwrapped.sender, sig)?;
}

For daemon-originated messages this means asserting unwrapped.sender == config::active_mostro_pubkey() before dispatch. Today the only author check is on Kind 38383 events (which the daemon publishes directly); inbound 1059 responses from the daemon are authenticated only by the fact that they decrypt under our trade key. After migration we can authenticate by signature — a real upgrade.

7. Tests

rust/src/nostr/gift_wrap.rs currently has zero coverage. Add, at minimum:

  • Round-trip: build a Message, wrap with trade keys A against receiver B's pubkey, unwrap with B's keys, assert message equality and sender == A.public_key().
  • unwrap_message against an event addressed to a different recipient returns Ok(None) (covers the global trial-decrypt branch).
  • WrapOptions { pow: 8, .. } produces an outer event that passes event.check_pow(8).
  • validate_response rejects a CantDo action and (once wired) a mismatched request_id.
  • A tampered inner-tuple (modified JSON after the fact) fails verification — this exercises the signature path we weren't using before.

These are unit tests in Rust; no Flutter changes needed.

8. Delete old code

  • Remove the wrap() / unwrap() functions at rust/src/nostr/gift_wrap.rs:18 and :62.
  • Remove the manual PoW EventBuilder::new(Kind::GiftWrap, …).pow(pow) branch.
  • Remove the (Message, Option<Peer>) (de)serialization helper in rust/src/api/orders.rs:1024.
  • Leave the trade-key map, duplicate-event ring buffer, and subscription plumbing untouched — those remain correct.

Non-goals / untouched

  • Flutter / Dart side. All gift-wrap crypto stays in Rust behind flutter_rust_bridge. The public Dart API (createOrder, takeOrder, sendInvoice, …) is unchanged.
  • Key derivation. rust/src/crypto/keys.rs (BIP-32 m/44'/1237'/38383'/0/N) and rust/src/api/identity.rs are not touched.
  • Relay pool / subscription filters. Filter::new().kind(1059).pubkey(trade_pubkey) is still correct.
  • Kind 14 text DMs (P2P chat, dispute admin) — see "Scope" above.

Acceptance criteria

  • rust/Cargo.toml pins mostro-core to 0.9.x; cargo build --release and cargo clippy -- -D warnings both clean.
  • rust/src/nostr/gift_wrap.rs contains no rumor/seal/gift-wrap construction or nip59::extract_rumor calls — only thin shims over mostro_core::nip59::{wrap_message, unwrap_message}.
  • The local wrap_message() helper in rust/src/mostro/actions.rs is gone; action builders call the new shim directly with a &Message.
  • (Message, Option<Peer>) tuple (de)serialization is gone from both the wrap and unwrap paths.
  • validate_response(&message, None) is invoked on every unwrapped response; CantDo is handled centrally.
  • Inbound daemon responses assert unwrapped.sender == active_mostro_pubkey() and verify unwrapped.signature when present.
  • cargo test covers: round-trip wrap/unwrap, Ok(None) on wrong-recipient unwrap, PoW difficulty propagation, validate_response rejecting CantDo, and tampered-inner-tuple rejection.
  • End-to-end smoke on a real Mostro relay: create order → take order → add invoice → fiat sent → release, with both per-trade and global subscription paths observed (no duplicate dispatch, no dropped events).
  • NIP-13 PoW still applied on the outer 1059 when pow::get_pow() > 0 (verify on the wire).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions