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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions