Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .codex
Empty file.
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ reqwest = { version = "0.12.23", default-features = false, features = [
"json",
"rustls-tls",
] }
mostro-core = "0.9.1"
mostro-core = "0.10.0"
lnurl-rs = { version = "0.9.0", default-features = false, features = ["ureq"] }
pretty_env_logger = "0.5.0"
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio-rustls"] }
Expand Down
1 change: 1 addition & 0 deletions src/cli/add_invoice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ pub async fn execute_add_invoice(order_id: &Uuid, invoice: &str, ctx: &Context)
// Send the DM
let sent_message = send_dm(
&ctx.client,
&ctx.identity_keys,
&order_trade_keys,
&ctx.mostro_pubkey,
message_json,
Expand Down
2 changes: 1 addition & 1 deletion src/cli/adm_send_dm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub async fn execute_adm_send_dm(receiver: PublicKey, ctx: &Context, message: &s
println!("{table}");
println!("💡 Sending admin gift wrap message...\n");

send_plain_text_dm(&ctx.client, admin_keys, &receiver, message).await?;
send_plain_text_dm(&ctx.client, admin_keys, admin_keys, &receiver, message).await?;

println!(
"✅ Admin gift wrap message sent successfully to {}",
Expand Down
7 changes: 4 additions & 3 deletions src/cli/last_trade_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ pub async fn execute_last_trade_index(
.map_err(|_| anyhow::anyhow!("Failed to serialize message"))?;

// LastTradeIndex is account-scoped: the answer depends on which user
// is asking, and Mostro looks that up by the sender pubkey. Sign with
// `identity_keys` so the request resolves to the account, not to a
// (possibly unregistered) trade key.
// is asking, and Mostro looks that up by the sender pubkey. Sign both
// seal and rumor with `identity_keys` so the request resolves to the
// account, not to a (possibly unregistered) trade key.
let sent_message = send_dm(
&ctx.client,
identity_keys,
identity_keys,
&mostro_key,
message_json,
None,
Expand Down
1 change: 1 addition & 0 deletions src/cli/new_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ pub async fn execute_new_order(
// Send the DM
let sent_message = send_dm(
&ctx.client,
&ctx.identity_keys,
&ctx.trade_keys,
&ctx.mostro_pubkey,
message_json,
Expand Down
19 changes: 8 additions & 11 deletions src/cli/orders_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,10 @@ pub async fn execute_orders_info(order_ids: &[Uuid], ctx: &Context) -> Result<()
// Create payload with the order IDs
let payload = Payload::Ids(order_ids.to_vec());

// Create message using the proper Message structure
let message = Message::new_order(
None,
Some(request_id),
Some(ctx.trade_index),
Action::Orders,
Some(payload),
);
// Orders info is account-scoped — Mostro indexes users by their identity
// pubkey, so the message carries no trade_index and the whole exchange
// (send, wait, decrypt) runs on `identity_keys`.
let message = Message::new_order(None, Some(request_id), None, Action::Orders, Some(payload));

// Serialize the message
let message_json = message
Expand All @@ -44,18 +40,19 @@ pub async fn execute_orders_info(order_ids: &[Uuid], ctx: &Context) -> Result<()
// Send the DM
let sent_message = send_dm(
&ctx.client,
&ctx.trade_keys,
&ctx.identity_keys,
&ctx.identity_keys,
&ctx.mostro_pubkey,
message_json,
None,
false,
);

// Wait for the DM response from mostro
let recv_event = wait_for_dm(ctx, None, sent_message).await?;
let recv_event = wait_for_dm(ctx, Some(&ctx.identity_keys), sent_message).await?;

// Parse the incoming DM and handle the response
let messages = crate::parser::dms::parse_dm_events(recv_event, &ctx.trade_keys, None).await;
let messages = crate::parser::dms::parse_dm_events(recv_event, &ctx.identity_keys, None).await;
if let Some((message, _, _)) = messages.first() {
let message_kind = message.get_inner_message_kind();

Expand Down
1 change: 1 addition & 0 deletions src/cli/rate_user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub async fn execute_rate_user(order_id: &Uuid, rating: &u8, ctx: &Context) -> R

let sent_message = send_dm(
&ctx.client,
&ctx.identity_keys,
&trade_keys,
&ctx.mostro_pubkey,
rate_message,
Expand Down
5 changes: 4 additions & 1 deletion src/cli/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ pub async fn execute_restore(
// Restore is account-scoped: Mostro indexes users by their identity
// pubkey, so the whole exchange (send, wait, decrypt) runs on
// `identity_keys` — an unregistered trade key would look like an
// unknown user and recovery would silently return nothing.
// unknown user and recovery would silently return nothing. With the
// mostro-core 0.10 dual-key split we pass `identity_keys` as both
// the seal signer and the rumor author.
let sent_message = send_dm(
&ctx.client,
identity_keys,
identity_keys,
&mostro_key,
message_json,
None,
Expand Down
11 changes: 10 additions & 1 deletion src/cli/send_dm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,16 @@ pub async fn execute_send_dm(
return Err(anyhow::anyhow!("order {} not found", order_id));
};

send_dm(&ctx.client, &trade_keys, &receiver, message, None, false).await?;
send_dm(
&ctx.client,
&ctx.identity_keys,
&trade_keys,
&receiver,
message,
None,
false,
)
.await?;

println!("✅ Direct message sent successfully!");

Expand Down
1 change: 1 addition & 0 deletions src/cli/send_msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ pub async fn execute_send_msg(
// Send DM
let sent_message = send_dm(
&ctx.client,
&ctx.identity_keys,
&trade_keys,
&ctx.mostro_pubkey,
message_json,
Expand Down
3 changes: 3 additions & 0 deletions src/cli/take_dispute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,12 @@ pub async fn execute_take_dispute(dispute_id: &Uuid, ctx: &Context) -> Result<()

// Send the dispute message and wait for response. Admin identity
// binds via the rumor/seal/inner-signature produced from `admin_keys`.
// The admin role doesn't rotate trade keys, so the same key signs both
// the seal and the rumor (full-privacy-style wrap).
let sent_message = send_dm(
&ctx.client,
admin_keys,
admin_keys,
&ctx.mostro_pubkey,
take_dispute_message,
None,
Expand Down
1 change: 1 addition & 0 deletions src/cli/take_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ pub async fn execute_take_order(
// This is so we can wait for the gift wrap event in the main thread
let sent_message = send_dm(
&ctx.client,
&ctx.identity_keys,
&ctx.trade_keys,
&ctx.mostro_pubkey,
message_json,
Expand Down
102 changes: 83 additions & 19 deletions src/util/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,27 +201,35 @@ pub async fn fetch_gift_wraps_for_shared_key(
}

/// Internal: wrap a Mostro `Message` via [`wrap_message`] and publish it.
///
/// Follows the mostro-core 0.10 dual-key split: `identity_keys` sign the
/// seal (long-lived reputation binding), `trade_keys` author the rumor and
/// produce the inner tuple signature. Pass the same `Keys` for both to
/// opt into full-privacy mode.
async fn publish_gift_wrap(
client: &Client,
signer_keys: &Keys,
identity_keys: &Keys,
trade_keys: &Keys,
receiver_pubkey: &PublicKey,
message: &Message,
opts: WrapOptions,
) -> Result<()> {
let event = wrap_message(message, signer_keys, *receiver_pubkey, opts)
let event = wrap_message(message, identity_keys, trade_keys, *receiver_pubkey, opts)
.await
.map_err(|e| anyhow::anyhow!("Failed to wrap message: {e}"))?;
client.send_event(&event).await?;
Ok(())
}

/// Send a plain-text DM wrapped as a NIP-59 Gift Wrap using `signer_keys`.
/// Send a plain-text DM wrapped as a NIP-59 Gift Wrap.
///
/// The wrap uses `signed = false` so the inner rumor carries `(Message, None)`,
/// matching the behavior of the deleted `send_gift_wrap_dm_internal` helper.
/// The wrap uses `signed = false` so the inner rumor carries `(Message, None)`.
/// `identity_keys` sign the seal and `trade_keys` author the rumor; admin
/// flows that do not rotate trade keys should pass the admin keys for both.
pub async fn send_plain_text_dm(
client: &Client,
signer_keys: &Keys,
identity_keys: &Keys,
trade_keys: &Keys,
receiver_pubkey: &PublicKey,
text: &str,
) -> Result<()> {
Expand All @@ -237,7 +245,15 @@ pub async fn send_plain_text_dm(
expiration: None,
signed: false,
};
publish_gift_wrap(client, signer_keys, receiver_pubkey, &dm_message, opts).await
publish_gift_wrap(
client,
identity_keys,
trade_keys,
receiver_pubkey,
&dm_message,
opts,
)
.await
}

pub async fn wait_for_dm<F>(
Expand Down Expand Up @@ -318,18 +334,28 @@ async fn create_private_dm_event(

/// Send a Mostro protocol message to `receiver_pubkey`.
///
/// * `signer_keys` drives the whole NIP-59 pipeline: it authors the inner
/// rumor, signs the seal, and (when `signed` is true) produces the inner
/// tuple signature. Pass admin keys for admin flows and per-order trade
/// keys for user flows.
/// * `to_user` routes the message as a NIP-17 `PrivateDirectMessage`
/// (kind 14) instead of a gift wrap.
/// * Respects `POW` (mined on the outer wrap / DM) and `SECRET` (when true
/// the inner tuple is unsigned). Gift wraps go through
/// [`mostro_core::prelude::wrap_message`].
/// mostro-core 0.10 splits the NIP-59 pipeline across two keys:
///
/// * `identity_keys` sign the seal (kind 13). Long-lived per user — the
/// key the Mostro node uses to attach reputation. Admin flows pass the
/// admin keys here; identity-scoped requests (restore, last trade index)
/// pass the account's identity keys.
/// * `trade_keys` author the rumor (kind 1) and produce the inner tuple
/// signature when `signed = true`. Rotated per order for user flows,
/// equal to `identity_keys` for full-privacy mode and for flows that
/// don't bind to a specific trade (admin, restore, last trade index).
///
/// For NIP-17 `PrivateDirectMessage` traffic (`to_user = true`), kind 14
/// is signed directly by `trade_keys` — identity is irrelevant because
/// there is no seal.
///
/// Respects the `POW` and `SECRET` env vars: PoW is mined on the outer
/// wrap (or kind-14 event), and `SECRET=true` flips the inner tuple to
/// unsigned. Gift wraps go through [`mostro_core::prelude::wrap_message`].
pub async fn send_dm(
client: &Client,
signer_keys: &Keys,
identity_keys: &Keys,
trade_keys: &Keys,
receiver_pubkey: &PublicKey,
payload: String,
expiration: Option<Timestamp>,
Expand All @@ -338,7 +364,7 @@ pub async fn send_dm(
let pow = parse_pow_env()?;

if to_user {
let event = create_private_dm_event(signer_keys, receiver_pubkey, payload, pow).await?;
let event = create_private_dm_event(trade_keys, receiver_pubkey, payload, pow).await?;
client.send_event(&event).await?;
return Ok(());
}
Expand All @@ -352,7 +378,15 @@ pub async fn send_dm(
signed: !private,
};

publish_gift_wrap(client, signer_keys, receiver_pubkey, &message, opts).await
publish_gift_wrap(
client,
identity_keys,
trade_keys,
receiver_pubkey,
&message,
opts,
)
.await
}

pub async fn print_dm_events(
Expand Down Expand Up @@ -422,12 +456,14 @@ mod tests {

#[tokio::test]
async fn send_dm_gift_wrap_roundtrips_via_unwrap_message() {
let identity_keys = Keys::generate();
let trade_keys = Keys::generate();
let mostro_keys = Keys::generate();
let message = sample_protocol_message(Some(42));

let event = wrap_message(
&message,
&identity_keys,
&trade_keys,
mostro_keys.public_key(),
WrapOptions::default(),
Expand All @@ -443,6 +479,7 @@ mod tests {
.expect("addressed to mostro_keys");

assert_eq!(unwrapped.sender, trade_keys.public_key());
assert_eq!(unwrapped.identity, identity_keys.public_key());
assert_eq!(
unwrapped.message.as_json().unwrap(),
message.as_json().unwrap()
Expand All @@ -453,13 +490,36 @@ mod tests {
);
}

#[tokio::test]
async fn full_privacy_mode_identity_equals_trade() {
let trade_keys = Keys::generate();
let mostro_keys = Keys::generate();
let message = sample_protocol_message(Some(7));

let event = wrap_message(
&message,
&trade_keys,
&trade_keys,
mostro_keys.public_key(),
WrapOptions::default(),
)
.await
.expect("wrap");

let unwrapped = unwrap_message(&event, &mostro_keys).await.unwrap().unwrap();
assert_eq!(unwrapped.sender, trade_keys.public_key());
assert_eq!(unwrapped.identity, unwrapped.sender);
}

#[tokio::test]
async fn secret_env_semantics_drop_inner_signature() {
let identity_keys = Keys::generate();
let trade_keys = Keys::generate();
let mostro_keys = Keys::generate();

let event = wrap_message(
&sample_protocol_message(Some(1)),
&identity_keys,
&trade_keys,
mostro_keys.public_key(),
WrapOptions {
Expand All @@ -476,12 +536,14 @@ mod tests {

#[tokio::test]
async fn wrap_message_respects_pow_option() {
let identity_keys = Keys::generate();
let trade_keys = Keys::generate();
let mostro_keys = Keys::generate();
let pow = 4;

let event = wrap_message(
&sample_protocol_message(None),
&identity_keys,
&trade_keys,
mostro_keys.public_key(),
WrapOptions {
Expand All @@ -497,12 +559,14 @@ mod tests {

#[tokio::test]
async fn wrong_keys_yield_none_on_unwrap() {
let identity_keys = Keys::generate();
let trade_keys = Keys::generate();
let mostro_keys = Keys::generate();
let stranger = Keys::generate();

let event = wrap_message(
&sample_protocol_message(Some(1)),
&identity_keys,
&trade_keys,
mostro_keys.public_key(),
WrapOptions::default(),
Expand Down
Loading
Loading