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:18 — pub 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:62 — pub 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:258 — wrap_message() wrapper |
Serializes (Message, Option<Peer>) and hands the JSON string to gift_wrap::wrap |
| Per-trade subscription + unwrap |
rust/src/api/orders.rs:897 — subscribe_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:1720 — handle_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:
- 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.
- 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
References
Summary
Migrate the app's hand-rolled NIP-59 gift-wrap layer to the new centralized API introduced in
mostro-core0.9.0:wrap_message(),unwrap_message(), andvalidate_response(). These helpers replace the localwrap()/unwrap()glue inrust/src/nostr/gift_wrap.rsand 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 inmostro-coreremoves the duplication and adds:Ok(None)signal for "not addressed to me" on NIP-44 decryption failure — a better fit for our multi-key trial-decrypt loop.validate_response()for request/response correlation againstrequest_id.New
mostro-core0.9.0 APISource:
docs/NIP59_TRANSPORT.md.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)
rust/Cargo.toml:14mostro-core = "0.8.0"— needs bump to0.9rust/src/nostr/gift_wrap.rs:18—pub async fn wrap(sender_keys, recipient_pubkey, content: &str, kind: Kind) -> Result<String>EventBuilder::new(Kind::GiftWrap, …)+.pow(pow)rust/src/nostr/gift_wrap.rs:62—pub async fn unwrap(recipient_keys, gift_wrap_json) -> Result<String>nip59::extract_rumor, returns serialized rumor JSON; no inner-signature verificationMessage→ wire)rust/src/mostro/actions.rs:258—wrap_message()wrapper(Message, Option<Peer>)and hands the JSON string togift_wrap::wraprust/src/api/orders.rs:897—subscribe_gift_wraps()/rust/src/api/orders.rs:1001— content parse(Message, Option<Peer>)afterunwraprust/src/api/orders.rs:1720—handle_global_gift_wrap()+ trade-key maprust/src/mostro/pow.rs:get_pow()WrapOptions.powrust/src/config.rs:15authorfilter on 38383 / the receiver for wrapsrust/src/api/messages.rs:165,rust/src/api/disputes.rs:214{"text": …}payloads, notmostro_core::Message— see "Scope" belowThere are no existing unit tests for wrap/unwrap (
rust/src/nostr/gift_wrap.rshas no#[cfg(test)]block). Only BIP-32 derivation is covered, inrust/src/crypto/keys.rs.Scope
In scope — migrates to the new API:
mostro_core::Messagetraffic to/from the Mostro daemon: order actions (rust/src/mostro/actions.rs), per-trade and global gift-wrap subscriptions inrust/src/api/orders.rs.Out of scope — stays on the current nostr-sdk helpers:
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, notmostro_core::Messagevalues, sowrap_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 awrap_dm()helper frommostro-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; adjustnostr-sdkfeature flags ifmostro-core0.9 requires any additional ones).cargo update -p mostro-core, rebuild, regenerateflutter_rust_bridgebindings (flutter_rust_bridge_codegen generateor whatever the project's regen command is).Message/Action/Payloadvariants surfaced by the compiler before touching transport code.2. Rewrite
rust/src/nostr/gift_wrap.rsNarrow this file to a thin, typed shim over
mostro-coreso upstream call sites change as little as possible:Notes:
(sender_keys, recipient_pubkey, content: &str, kind: Kind) -> Stringto(trade_keys, receiver, &Message) -> Event. Callers no longer passKind, and no longer pre-serialize the(Message, Option<Peer>)tuple —wrap_messageowns the inner-tuple shape. TheOption<Peer>second element we send today is alwaysnull; the new API drops it.Event, notString. Publishing sites will callpool.client().send_event(&event)directly instead ofsend_event(&Event::from_json(&s)?).WrapOptions.powis now the only knob.anyhowwrapper so call sites don't have to importMostroErroreverywhere; if we want stricter error types later we can revisit.3. Update protocol wrap sites in
rust/src/mostro/actions.rswrap_message()helper (rust/src/mostro/actions.rs:258) — the internal name now collides withmostro_core::nip59::wrap_message, and its only job was pre-serializing the(Message, Option<Peer>)tuple that the new API no longer needs.new_order,take_buy,take_sell,add_invoice,fiat_sent,release,cancel, and the ~15 others) now constructs aMessageand callswrap_mostro_message(&trade_keys, &mostro_pubkey, &message).await.Eventsend (noEvent::from_jsonround trip).4. Update unwrap sites
Two subscription paths currently feed off
unwrapreturning a rumor JSON string and manuallyserde_json::from_str-ing it back into(Message, Option<Peer>):rust/src/api/orders.rs:897(subscribe_gift_wraps) and the content-parse atrust/src/api/orders.rs:1001.rust/src/api/orders.rs:1720(handle_global_gift_wrap) with its trade-key map.Both become:
Benefits:
(Message, Option<Peer>)deserialization dance (rust/src/api/orders.rs:1024).Ok(None)means wrong key andErr(_)means a genuine protocol violation we should log.sender,signature, andcreated_atexplicitly, which opens the door to inner-signature verification (see §6).5. Plug in
validate_response()validate_response(&message, expected_request_id)enforcesCantDohandling and request/response correlation. The app does not currently threadrequest_idthrough its async flow, so migration is two steps:validate_response(&message, None)on every unwrapped response. This still catchesCantDoactions centrally, replacing any ad-hocAction::CantDomatching we do today.request_id, stash(request_id, oneshot::Sender<_>)in a pending-requests map keyed by order id / trade pubkey. The subscription handler looks up the expectedrequest_idand passes it tovalidate_response. This is its own design exercise — not blocking for this issue.6. Inner-signature verification (opt-in, same PR)
UnwrappedMessage.signatureisSome(_)when the peer usedsigned: true(our new default and the Mostro daemon's). We should verify it where the sender is known: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.rscurrently has zero coverage. Add, at minimum:Message, wrap with trade keys A against receiver B's pubkey, unwrap with B's keys, assertmessageequality andsender == A.public_key().unwrap_messageagainst an event addressed to a different recipient returnsOk(None)(covers the global trial-decrypt branch).WrapOptions { pow: 8, .. }produces an outer event that passesevent.check_pow(8).validate_responserejects aCantDoaction and (once wired) a mismatchedrequest_id.These are unit tests in Rust; no Flutter changes needed.
8. Delete old code
wrap()/unwrap()functions atrust/src/nostr/gift_wrap.rs:18and:62.EventBuilder::new(Kind::GiftWrap, …).pow(pow)branch.(Message, Option<Peer>)(de)serialization helper inrust/src/api/orders.rs:1024.Non-goals / untouched
flutter_rust_bridge. The public Dart API (createOrder,takeOrder,sendInvoice, …) is unchanged.rust/src/crypto/keys.rs(BIP-32m/44'/1237'/38383'/0/N) andrust/src/api/identity.rsare not touched.Filter::new().kind(1059).pubkey(trade_pubkey)is still correct.Acceptance criteria
rust/Cargo.tomlpinsmostro-coreto0.9.x;cargo build --releaseandcargo clippy -- -D warningsboth clean.rust/src/nostr/gift_wrap.rscontains no rumor/seal/gift-wrap construction ornip59::extract_rumorcalls — only thin shims overmostro_core::nip59::{wrap_message, unwrap_message}.wrap_message()helper inrust/src/mostro/actions.rsis 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;CantDois handled centrally.unwrapped.sender == active_mostro_pubkey()and verifyunwrapped.signaturewhen present.cargo testcovers: round-trip wrap/unwrap,Ok(None)on wrong-recipient unwrap, PoW difficulty propagation,validate_responserejectingCantDo, and tampered-inner-tuple rejection.pow::get_pow() > 0(verify on the wire).References
rust/src/nostr/gift_wrap.rsrust/src/mostro/actions.rsrust/src/api/orders.rs