Skip to main content

mostro_webtool/
lib.rs

1use std::fmt;
2
3use axum::{
4    Json, Router,
5    http::StatusCode,
6    response::{Html, IntoResponse, Response},
7    routing::{get, post},
8};
9use bip39::{Language, Mnemonic};
10use mostro_core::message::Message as MostroMessage;
11use nostr_sdk::prelude::{
12    Event, EventBuilder, FromMnemonic, JsonUtil, Keys, PublicKey, Tags, nip06,
13};
14use serde::{Deserialize, Serialize};
15use serde_json::Value as JsonValue;
16use tower_http::services::ServeDir;
17use tracing_subscriber::{EnvFilter, fmt::SubscriberBuilder};
18
19const MAIN_COLOR: &str = "#8dc63f";
20const MAIN_COLOR_DARK: &str = "#4a7f1f";
21const MOSTRO_BASE_PATH: &str = "m/44'/1237'/38383'/0";
22const IDENTITY_PATH: &str = "m/44'/1237'/38383'/0/0";
23const MOSTRO_ACCOUNT_INDEX: u32 = 38_383;
24const BRANCH_INDEX: u32 = 0;
25const IDENTITY_KEY_INDEX: u32 = 0;
26const TRADE_MIN_INDEX: u32 = 1;
27const DEFAULT_TRADE_INDEX: u32 = TRADE_MIN_INDEX;
28
29const ACTIONS: &[&str] = &[
30    "new-order",
31    "take-sell",
32    "take-buy",
33    "pay-invoice",
34    "fiat-sent",
35    "fiat-sent-ok",
36    "release",
37    "released",
38    "cancel",
39    "canceled",
40    "cooperative-cancel-initiated-by-you",
41    "cooperative-cancel-initiated-by-peer",
42    "dispute-initiated-by-you",
43    "dispute-initiated-by-peer",
44    "cooperative-cancel-accepted",
45    "buyer-invoice-accepted",
46    "purchase-completed",
47    "hold-invoice-payment-accepted",
48    "hold-invoice-payment-settled",
49    "hold-invoice-payment-canceled",
50    "waiting-seller-to-pay",
51    "waiting-buyer-invoice",
52    "add-invoice",
53    "buyer-took-order",
54    "rate",
55    "rate-user",
56    "rate-received",
57    "cant-do",
58    "dispute",
59    "admin-cancel",
60    "admin-canceled",
61    "admin-settle",
62    "admin-settled",
63    "admin-add-solver",
64    "admin-take-dispute",
65    "admin-took-dispute",
66    "payment-failed",
67    "invoice-updated",
68    "send-dm",
69    "trade-pubkey",
70    "restore-session",
71    "orders",
72];
73
74const MESSAGE_TYPES: &[&str] = &["order", "dispute", "cant-do", "rate", "dm", "restore"];
75const ORDER_KINDS: &[&str] = &["buy", "sell"];
76const ORDER_STATUSES: &[&str] = &[
77    "active",
78    "canceled",
79    "canceled-by-admin",
80    "settled-by-admin",
81    "completed-by-admin",
82    "dispute",
83    "expired",
84    "fiat-sent",
85    "settled-hold-invoice",
86    "pending",
87    "success",
88    "waiting-buyer-invoice",
89    "waiting-payment",
90    "cooperatively-canceled",
91];
92
93pub const DEFAULT_PORT: u16 = 3000;
94
95pub fn init_tracing() {
96    let env_filter = EnvFilter::try_from_default_env()
97        .unwrap_or_else(|_| "mostro_webtool=info,axum::rejection=trace".into());
98    SubscriberBuilder::default()
99        .with_env_filter(env_filter)
100        .with_target(false)
101        .compact()
102        .init();
103}
104
105pub fn app() -> Router {
106    Router::new()
107        .route("/", get(index))
108        .route("/api/trade-key", post(derive_trade_key))
109        .route("/api/build-gift-wrap", post(build_gift_wrap))
110        .nest_service("/static", ServeDir::new("static"))
111}
112
113async fn index() -> Result<Html<String>, AppError> {
114    let ctx = IdentityContext::new()?;
115    Ok(Html(render_identity_page(&ctx)))
116}
117
118#[derive(Deserialize)]
119struct TradeKeyRequest {
120    mnemonic: String,
121    index: u32,
122}
123
124#[derive(Serialize)]
125struct TradeKeyResponse {
126    index: u32,
127    derivation_path: String,
128    public_key: String,
129    private_key: String,
130}
131
132#[derive(Serialize)]
133struct ErrorResponse {
134    error: String,
135}
136
137async fn derive_trade_key(
138    Json(payload): Json<TradeKeyRequest>,
139) -> Result<Json<TradeKeyResponse>, (StatusCode, Json<ErrorResponse>)> {
140    if payload.index < TRADE_MIN_INDEX {
141        return Err(json_error(
142            StatusCode::BAD_REQUEST,
143            format!("Trade key index must be at least {TRADE_MIN_INDEX}"),
144        ));
145    }
146
147    let keys = derive_keys_for_index(payload.mnemonic.as_str(), payload.index)
148        .map_err(identity_error_to_response)?;
149
150    let response = TradeKeyResponse {
151        index: payload.index,
152        derivation_path: trade_derivation_path(payload.index),
153        public_key: keys.public_key().to_hex(),
154        private_key: keys.secret_key().to_secret_hex(),
155    };
156
157    Ok(Json(response))
158}
159
160#[derive(Deserialize, Debug)]
161struct GiftWrapRequest {
162    mnemonic: String,
163    trade_index: u32,
164    mostro_pubkey: String,
165    message_json: String,
166}
167
168#[derive(Serialize)]
169struct GiftWrapResponse {
170    gift_wrap_event: JsonValue,
171}
172
173async fn build_gift_wrap(
174    Json(payload): Json<GiftWrapRequest>,
175) -> Result<Json<GiftWrapResponse>, (StatusCode, Json<ErrorResponse>)> {
176    // Parse mostro pubkey
177    let mostro_pubkey = PublicKey::from_hex(&payload.mostro_pubkey).map_err(|e| {
178        json_error(
179            StatusCode::BAD_REQUEST,
180            format!("Invalid Mostro pubkey: {}", e),
181        )
182    })?;
183
184    // Derive identity key (index 0) for seal signing
185    let identity_keys = derive_keys_for_index(&payload.mnemonic, IDENTITY_KEY_INDEX)
186        .map_err(identity_error_to_response)?;
187
188    // Derive trade key for content signature
189    let trade_keys = derive_keys_for_index(&payload.mnemonic, payload.trade_index)
190        .map_err(identity_error_to_response)?;
191
192    // Log the incoming message JSON for debugging
193    tracing::info!("Payload message: {}", payload.message_json);
194    // Parse message JSON into MostroMessage
195    // Use serde directly to get detailed error messages
196    let mut message: MostroMessage = serde_json::from_str(&payload.message_json).map_err(|e| {
197        tracing::error!("Failed to parse Mostro message: {} | JSON: {}", e, payload.message_json);
198        json_error(
199            StatusCode::BAD_REQUEST,
200            format!("Invalid Mostro message: {}", e),
201        )
202    })?;
203
204    // Ensure trade_index is set correctly from API payload
205    // The trade_index tells mostrod which trade key to expect for signature verification
206    match &mut message {
207        MostroMessage::Order(ref mut kind)
208        | MostroMessage::Dispute(ref mut kind)
209        | MostroMessage::CantDo(ref mut kind)
210        | MostroMessage::Rate(ref mut kind)
211        | MostroMessage::Dm(ref mut kind)
212        | MostroMessage::Restore(ref mut kind) => {
213            kind.trade_index = Some(payload.trade_index as i64);
214        }
215    }
216
217    // Build gift wrap using helper function
218    let gift_wrap = create_gift_wrap(
219        &identity_keys,
220        &trade_keys,
221        &mostro_pubkey,
222        &message,
223    )
224    .await
225    .map_err(|e| {
226        json_error(
227            StatusCode::INTERNAL_SERVER_ERROR,
228            format!("Failed to create gift wrap: {}", e),
229        )
230    })?;
231
232    // Serialize to JSON
233    let event_json: JsonValue = serde_json::from_str(&gift_wrap.as_json()).map_err(|e| {
234        json_error(
235            StatusCode::INTERNAL_SERVER_ERROR,
236            format!("Failed to serialize gift wrap: {}", e),
237        )
238    })?;
239
240    Ok(Json(GiftWrapResponse {
241        gift_wrap_event: event_json,
242    }))
243}
244
245#[derive(Debug)]
246struct IdentityContext {
247    mnemonic_phrase: String,
248    identity_key_hex: String,
249    identity_secret_hex: String,
250    trade_index: u32,
251    trade_key_hex: String,
252    trade_secret_hex: String,
253}
254
255impl IdentityContext {
256    fn new() -> Result<Self, IdentityError> {
257        let mnemonic =
258            Mnemonic::generate_in(Language::English, 12).map_err(IdentityError::Mnemonic)?;
259        let phrase = mnemonic.to_string();
260
261        let identity_keys = derive_keys_for_index(phrase.as_str(), IDENTITY_KEY_INDEX)?;
262        let trade_keys = derive_keys_for_index(phrase.as_str(), DEFAULT_TRADE_INDEX)?;
263
264        let identity_key_hex = identity_keys.public_key().to_hex();
265        let identity_secret_hex = identity_keys.secret_key().to_secret_hex();
266        let trade_key_hex = trade_keys.public_key().to_hex();
267        let trade_secret_hex = trade_keys.secret_key().to_secret_hex();
268
269        Ok(Self {
270            mnemonic_phrase: phrase,
271            identity_key_hex,
272            identity_secret_hex,
273            trade_index: DEFAULT_TRADE_INDEX,
274            trade_key_hex,
275            trade_secret_hex,
276        })
277    }
278
279    fn trade_derivation_path(&self) -> String {
280        trade_derivation_path(self.trade_index)
281    }
282}
283
284#[derive(Debug)]
285enum IdentityError {
286    Mnemonic(bip39::Error),
287    Derivation(nip06::Error),
288}
289
290impl fmt::Display for IdentityError {
291    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292        match self {
293            Self::Mnemonic(err) => write!(f, "failed to generate mnemonic seed: {err}"),
294            Self::Derivation(err) => write!(f, "failed to derive identity key: {err}"),
295        }
296    }
297}
298
299impl std::error::Error for IdentityError {}
300
301fn derive_keys_for_index(mnemonic: &str, index: u32) -> Result<Keys, IdentityError> {
302    let trimmed = mnemonic.trim();
303    Keys::from_mnemonic_advanced(
304        trimmed,
305        None::<&str>,
306        Some(MOSTRO_ACCOUNT_INDEX),
307        Some(BRANCH_INDEX),
308        Some(index),
309    )
310    .map_err(IdentityError::Derivation)
311}
312
313fn trade_derivation_path(index: u32) -> String {
314    format!("{}/{index}", MOSTRO_BASE_PATH)
315}
316
317/// Creates a NIP-59 gift wrap event for Mostro protocol
318///
319/// This implements the Mostro-specific NIP-59 flow as documented in docs/protocol/key_management.md
320///
321/// The structure is:
322/// 1. Rumor: Custom JSON with content = [message, signature]
323/// 2. Seal: Encrypts rumor, signed with IDENTITY KEY (index 0)
324/// 3. Gift Wrap: Encrypts seal, signed with EPHEMERAL KEY
325///
326/// Key Usage:
327/// - Identity Key (index 0): Signs the seal - links to user reputation
328/// - Trade Key (index N): Signs the message - proves ownership of trade key, rotates per trade
329/// - Ephemeral Key (random): Signs gift wrap - provides metadata privacy
330async fn create_gift_wrap(
331    identity_keys: &Keys,
332    trade_keys: &Keys,
333    recipient_pubkey: &PublicKey,
334    message: &MostroMessage,
335) -> Result<Event, Box<dyn std::error::Error>> {
336    // ========================================================================
337    // STEP 1: Create the signature for the message
338    // ========================================================================
339    // Per docs/protocol/key_management.md line 46:
340    // "index N signature of the sha256 hash of the serialized first element of content"
341    //
342    // We sign ONLY the message object with the trade key
343    // This proves we control the trade key without revealing it in plaintext
344
345    // Serialize the message to JSON string (compact, no whitespace)
346    let message_str = message.as_json().map_err(|e| {
347        format!("Failed to serialize message: {}", e)
348    })?;
349
350    // Sign the message using MostroMessage::sign() which handles the SHA256 hashing internally
351    let signature = MostroMessage::sign(message_str.clone(), trade_keys);
352
353    // ========================================================================
354    // STEP 2: Create the rumor content as serialized (message, signature) tuple
355    // ========================================================================
356    // The rumor content must be a STRING containing the serialized tuple.
357    // This allows standard NIP-59 tools to work with our custom format.
358    //
359    // The content will be: "[{message_object}, \"signature_hex\"]"
360    // When deserialized, it becomes: (MostroMessage, Signature)
361
362    let content = serde_json::to_string(&(message, signature))
363        .map_err(|e| format!("Failed to serialize message and signature: {}", e))?;
364
365    // ========================================================================
366    // STEP 3: Build the rumor using EventBuilder
367    // ========================================================================
368    // Use EventBuilder::text_note() to create a proper UnsignedEvent
369    // with kind=1 and content as a string
370
371    let rumor = EventBuilder::text_note(content).build(trade_keys.public_key());
372
373    // ========================================================================
374    // STEP 4: Create the gift wrap using EventBuilder::gift_wrap()
375    // ========================================================================
376    // This handles:
377    // - Creating the seal (kind 13) signed with identity_keys
378    // - Encrypting the rumor into the seal
379    // - Creating the gift wrap (kind 1059) with ephemeral keys
380    // - Encrypting the seal into the gift wrap
381
382    let gift_wrap = EventBuilder::gift_wrap(identity_keys, recipient_pubkey, rumor, Tags::default())
383        .await
384        .map_err(|e| format!("Failed to create gift wrap: {}", e))?;
385
386    Ok(gift_wrap)
387}
388
389fn json_error(status: StatusCode, message: impl Into<String>) -> (StatusCode, Json<ErrorResponse>) {
390    (
391        status,
392        Json(ErrorResponse {
393            error: message.into(),
394        }),
395    )
396}
397
398fn identity_error_to_response(err: IdentityError) -> (StatusCode, Json<ErrorResponse>) {
399    let status = match &err {
400        IdentityError::Mnemonic(_) => StatusCode::INTERNAL_SERVER_ERROR,
401        IdentityError::Derivation(_) => StatusCode::BAD_REQUEST,
402    };
403    json_error(status, err.to_string())
404}
405
406#[derive(Debug)]
407struct AppError {
408    message: String,
409}
410
411impl From<IdentityError> for AppError {
412    fn from(value: IdentityError) -> Self {
413        Self {
414            message: value.to_string(),
415        }
416    }
417}
418
419impl IntoResponse for AppError {
420    fn into_response(self) -> Response {
421        let html = render_error_page(&self.message);
422        (StatusCode::INTERNAL_SERVER_ERROR, Html(html)).into_response()
423    }
424}
425
426fn render_identity_page(ctx: &IdentityContext) -> String {
427    let actions_json = serde_json::to_string(&ACTIONS).unwrap();
428    let message_types_json = serde_json::to_string(&MESSAGE_TYPES).unwrap();
429    let order_kinds_json = serde_json::to_string(&ORDER_KINDS).unwrap();
430    let order_statuses_json = serde_json::to_string(&ORDER_STATUSES).unwrap();
431    format!(
432        r#"<!DOCTYPE html>
433<html lang="en">
434<head>
435<meta charset="utf-8">
436<meta name="viewport" content="width=device-width, initial-scale=1.0">
437<title>Mostro Message Builder</title>
438<style>
439:root {{
440  --main-color: {main_color};
441  --main-color-dark: {main_color_dark};
442}}
443* {{
444  box-sizing: border-box;
445}}
446body {{
447  margin: 0;
448  min-height: 100vh;
449  font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
450  background: linear-gradient(135deg, var(--main-color) 0%, var(--main-color-dark) 100%);
451  display: flex;
452  align-items: center;
453  justify-content: center;
454  padding: 2rem;
455  color: #fff;
456}}
457.container {{
458  width: min(760px, 100%);
459}}
460.brand-logo {{
461  display: flex;
462  justify-content: center;
463  align-items: center;
464  margin-bottom: 1.5rem;
465}}
466.brand-logo img {{
467  width: 40%;
468  height: auto;
469  display: block;
470}}
471form {{
472  background: rgba(0, 0, 0, 0.9);
473  border: 1px solid #7f7f7f;
474  border-radius: 20px;
475  padding: 2.5rem 2rem;
476  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
477  backdrop-filter: blur(10px);
478}}
479fieldset {{
480  border: 1px solid #7f7f7f;
481  border-radius: 16px;
482  padding: 1.5rem;
483  margin: 0;
484}}
485legend {{
486  margin-left: 1rem;
487  padding: 0 0.75rem;
488  font-size: 1.1rem;
489  font-weight: 700;
490  color: #fff;
491  letter-spacing: 0.05em;
492}}
493.label-input {{
494  display: flex;
495  flex-direction: column;
496  gap: 0.45rem;
497  margin-bottom: 1.5rem;
498}}
499.label-row {{
500  display: flex;
501  align-items: center;
502  justify-content: space-between;
503  gap: 0.75rem;
504}}
505label {{
506  text-transform: uppercase;
507  letter-spacing: 0.08em;
508  font-size: 0.8rem;
509  font-weight: 600;
510  color: #f5f5f5;
511}}
512.key-state {{
513  font-size: 0.75rem;
514  letter-spacing: 0.08em;
515  text-transform: uppercase;
516  background: rgba(141, 198, 63, 0.2);
517  color: var(--main-color);
518  border: 1px solid rgba(141, 198, 63, 0.45);
519  border-radius: 999px;
520  padding: 0.35rem 0.75rem;
521}}
522.key-row {{
523  display: flex;
524  align-items: stretch;
525  gap: 0.75rem;
526  flex-wrap: wrap;
527}}
528.key-input {{
529  flex: 1 1 0;
530  min-width: 240px;
531  width: 100%;
532}}
533input[type="text"],
534input[type="number"],
535select {{
536  background: rgba(33, 51, 13, 0.9);
537  border: 1px solid rgba(255, 255, 255, 0.25);
538  border-radius: 14px;
539  padding: 0.95rem 1.1rem;
540  font-size: 1rem;
541  color: #fff;
542  transition: border-color 0.2s ease, box-shadow 0.2s ease;
543  outline: none;
544}}
545select {{
546  cursor: pointer;
547}}
548.toggle-key, .copy-key, .trade-step {{
549  background: rgba(255, 255, 255, 0.08);
550  color: #fff;
551  border: 1px solid rgba(255, 255, 255, 0.25);
552  border-radius: 12px;
553  padding: 0.75rem 1.2rem;
554  font-size: 0.9rem;
555  font-weight: 600;
556  letter-spacing: 0.04em;
557  cursor: pointer;
558  transition: background 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
559  flex: 0 0 auto;
560}}
561.toggle-key:hover, .copy-key:hover, .trade-step:hover {{
562  background: rgba(141, 198, 63, 0.25);
563  border-color: rgba(141, 198, 63, 0.6);
564  transform: translateY(-1px);
565}}
566.toggle-key:active, .copy-key:active, .trade-step:active {{
567  transform: translateY(0);
568}}
569.trade-controls {{
570  display: flex;
571  align-items: center;
572  gap: 0.75rem;
573  margin-bottom: 0.75rem;
574  flex-wrap: wrap;
575}}
576.trade-step {{
577  min-width: 3rem;
578  text-align: center;
579}}
580.trade-step[disabled] {{
581  opacity: 0.45;
582  cursor: not-allowed;
583  transform: none;
584  border-color: rgba(255, 255, 255, 0.2);
585}}
586.trade-index {{
587  font-size: 0.85rem;
588  letter-spacing: 0.08em;
589  text-transform: uppercase;
590  color: rgba(255, 255, 255, 0.85);
591}}
592.message-builder {{
593  margin-top: 0.5rem;
594  display: flex;
595  flex-direction: column;
596  gap: 1.6rem;
597}}
598.message-columns {{
599  display: grid;
600  gap: 1.5rem;
601}}
602.message-columns.two {{
603  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
604}}
605.message-column {{
606  display: flex;
607  flex-direction: column;
608}}
609.payload-fields {{
610  display: flex;
611  flex-direction: column;
612  gap: 1rem;
613}}
614.payload-note {{
615  font-size: 0.85rem;
616  color: rgba(255, 255, 255, 0.7);
617}}
618.payload-group {{
619  display: grid;
620  gap: 1rem;
621}}
622.payload-row {{
623  display: grid;
624  gap: 0.75rem;
625  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
626}}
627.message-preview {{
628  display: flex;
629  flex-direction: column;
630  gap: 0.75rem;
631}}
632.preview-header {{
633  display: flex;
634  align-items: center;
635  justify-content: space-between;
636  gap: 1rem;
637}}
638.message-actions {{
639  display: flex;
640  gap: 0.75rem;
641  flex-wrap: wrap;
642}}
643.json-preview {{
644  background: rgba(0, 0, 0, 0.45);
645  border: 1px solid rgba(255, 255, 255, 0.12);
646  border-radius: 14px;
647  padding: 1.5rem;
648  max-height: 360px;
649  overflow: auto;
650  font-size: 0.9rem;
651  color: #d7f9b6;
652  line-height: 1.5;
653}}
654.json-preview code {{
655  white-space: pre;
656  font-family: 'Fira Code', 'JetBrains Mono', 'SFMono-Regular', Menlo, monospace;
657}}
658.textarea-input {{
659  width: 100%;
660  min-height: 140px;
661  resize: vertical;
662  background: rgba(33, 51, 13, 0.9);
663  border: 1px solid rgba(255, 255, 255, 0.25);
664  border-radius: 14px;
665  padding: 0.95rem 1.1rem;
666  font-size: 0.95rem;
667  color: #fff;
668}}
669input[type="text"]:focus,
670input[type="number"]:focus,
671select:focus,
672.textarea-input:focus {{
673  border-color: var(--main-color);
674  box-shadow: 0 0 0 3px rgba(141, 198, 63, 0.35);
675}}
676.helper {{
677  font-size: 0.9rem;
678  color: rgba(255, 255, 255, 0.75);
679  margin-top: 0.25rem;
680}}
681.helper.warning {{
682  color: #ffb4b4;
683}}
684.path-display {{
685  display: inline-flex;
686  align-items: center;
687  gap: 0.4rem;
688  margin: 0.85rem 0 1.4rem;
689  padding: 0.5rem 1rem;
690  border-radius: 999px;
691  background: rgba(141, 198, 63, 0.2);
692  color: #fff;
693  font-size: 0.85rem;
694  letter-spacing: 0.04em;
695}}
696code {{
697  background: rgba(0, 0, 0, 0.4);
698  padding: 0.2rem 0.45rem;
699  border-radius: 6px;
700  color: var(--main-color);
701  font-size: 0.85rem;
702}}
703.send-section {{
704  margin-top: 1.5rem;
705  display: flex;
706  flex-direction: column;
707  gap: 0.75rem;
708  align-items: center;
709}}
710.send-button {{
711  background: var(--main-color);
712  color: #000;
713  border: 2px solid var(--main-color);
714  border-radius: 14px;
715  padding: 1rem 2.5rem;
716  font-size: 1.05rem;
717  font-weight: 700;
718  letter-spacing: 0.05em;
719  cursor: pointer;
720  transition: all 0.2s ease;
721  text-transform: uppercase;
722}}
723.send-button:hover {{
724  background: var(--main-color-dark);
725  border-color: var(--main-color-dark);
726  transform: translateY(-2px);
727  box-shadow: 0 4px 12px rgba(141, 198, 63, 0.4);
728}}
729.send-button:active {{
730  transform: translateY(0);
731}}
732.send-button:disabled {{
733  opacity: 0.5;
734  cursor: not-allowed;
735  transform: none;
736}}
737.send-status {{
738  padding: 0.75rem 1.5rem;
739  border-radius: 12px;
740  font-size: 0.9rem;
741  letter-spacing: 0.04em;
742  text-align: center;
743  min-width: 300px;
744}}
745.send-status.info {{
746  background: rgba(59, 130, 246, 0.2);
747  border: 1px solid rgba(59, 130, 246, 0.5);
748  color: #93c5fd;
749}}
750.send-status.success {{
751  background: rgba(141, 198, 63, 0.2);
752  border: 1px solid rgba(141, 198, 63, 0.5);
753  color: var(--main-color);
754}}
755.send-status.error {{
756  background: rgba(239, 68, 68, 0.2);
757  border: 1px solid rgba(239, 68, 68, 0.5);
758  color: #fca5a5;
759}}
760@media (max-width: 640px) {{
761  body {{
762    padding: 1.5rem;
763  }}
764  form {{
765    padding: 1.75rem 1.5rem;
766  }}
767  legend {{
768    font-size: 1rem;
769  }}
770  .key-row {{
771    gap: 0.5rem;
772  }}
773  .trade-controls {{
774    gap: 0.5rem;
775  }}
776}}
777</style>
778</head>
779<body>
780  <div class="container">
781    <div class="brand-logo">
782      <img src="/static/mostro-web-tool-logo.png" alt="Mostro Web Tool logo">
783    </div>
784    <form>
785      <fieldset>
786        <legend>Keys</legend>
787        <div class="helper">Mostro derivation path in use:</div>
788        <div class="path-display">{base_path}</div>
789        <div class="label-input">
790          <label for="mnemonic">Mnemonic Seed</label>
791          <input id="mnemonic" type="text" value="{mnemonic}" readonly spellcheck="false">
792          <p class="helper">Random 12-word BIP39 seed generated when the page loads. Securely back it up.</p>
793        </div>
794        <div class="label-input">
795          <div class="label-row">
796            <label for="identity">Identity Key</label>
797            <span class="key-state" id="identity-state">Public</span>
798          </div>
799          <div class="key-row">
800            <input class="key-input" id="identity" type="text" value="{identity}" readonly spellcheck="false" data-private="{identity_private}">
801            <button class="toggle-key" id="toggle-identity" type="button">Show Private Key</button>
802            <button class="copy-key" id="copy-identity" type="button">Copy</button>
803          </div>
804          <p class="helper">Derived from <code>{identity_path}</code>. Share only the public key, keep the seed private.</p>
805        </div>
806        <div class="label-input">
807          <div class="label-row">
808            <label for="trade-key">Trade Key</label>
809            <span class="key-state" id="trade-state">Public</span>
810          </div>
811          <div class="helper">Trade derivation path:</div>
812          <div class="path-display" id="trade-path">{trade_path}</div>
813          <div class="trade-controls">
814            <button class="trade-step" id="trade-decrement" type="button" aria-label="Previous trade key">-</button>
815            <span class="trade-index" id="trade-index">Trade #{trade_index}</span>
816            <button class="trade-step" id="trade-increment" type="button" aria-label="Next trade key">+</button>
817          </div>
818          <div class="key-row">
819            <input class="key-input" id="trade-key" type="text" value="{trade_public}" readonly spellcheck="false" data-private="{trade_private}" data-index="{trade_index}" data-min-index="{trade_min_index}">
820            <button class="toggle-key" id="toggle-trade" type="button">Show Private Key</button>
821            <button class="copy-key" id="copy-trade" type="button">Copy</button>
822          </div>
823          <p class="helper">Adjust the index to explore trade keys without affecting identity key derivations.</p>
824          <p class="helper warning" id="trade-error" hidden></p>
825        </div>
826        <div class="label-input">
827          <label for="mostro-pubkey">Mostro Pubkey</label>
828          <input id="mostro-pubkey" name="mostro-pubkey" type="text" placeholder="Enter Mostro pubkey" required spellcheck="false">
829          <p class="helper">Provide the destination Mostro daemon public key to build messages correctly.</p>
830        </div>
831      </fieldset>
832      <fieldset class="message-builder">
833        <legend>Message</legend>
834        <div class="helper">Compose a Mostro message wrapper and payload using the selections below.</div>
835        <div class="message-columns two">
836          <div class="message-column">
837            <div class="label-input">
838              <label for="message-type">Message Kind</label>
839              <select id="message-type" data-wrapper-field="message-type"></select>
840              <p class="helper">Choose the top-level wrapper (order, dispute, etc.).</p>
841            </div>
842            <div class="label-input">
843              <label for="message-action">Action</label>
844              <select id="message-action" data-wrapper-field="action"></select>
845              <p class="helper" id="action-hint">Select an action to tailor payload fields.</p>
846            </div>
847            <div class="label-input">
848              <label for="message-version">Version</label>
849              <input id="message-version" data-wrapper-field="version" type="number" min="1" step="1" value="1">
850            </div>
851            <div class="label-input">
852              <label for="message-id">Message ID</label>
853              <input id="message-id" data-wrapper-field="id" type="text" placeholder="Optional UUID">
854            </div>
855            <div class="label-input">
856              <label for="message-request-id">Request ID</label>
857              <input id="message-request-id" data-wrapper-field="request_id" type="number" min="0" step="1" placeholder="Optional">
858            </div>
859            <div class="label-input">
860              <label for="message-trade-index">Trade Index</label>
861              <input id="message-trade-index" data-wrapper-field="trade_index" type="number" min="0" step="1" placeholder="Optional">
862            </div>
863            <div class="label-input">
864              <label for="payload-mode">Payload Builder</label>
865              <select id="payload-mode">
866                <option value="none">No payload</option>
867                <option value="order">Order payload</option>
868                <option value="custom">Custom JSON</option>
869              </select>
870              <p class="helper" id="payload-hint">Choose how to build the payload for the selected action.</p>
871            </div>
872          </div>
873          <div class="message-column">
874            <div id="payload-fields" class="payload-fields">
875              <p class="payload-note" id="payload-empty-note">Select an action to load payload fields or switch to custom JSON.</p>
876              <div class="payload-group" id="payload-order" data-payload-section="order" hidden>
877                <div class="payload-row">
878                  <div class="label-input">
879                    <label for="order-id">Order ID</label>
880                    <input id="order-id" data-order-field="id" type="text" placeholder="Optional UUID">
881                  </div>
882                  <div class="label-input">
883                    <label for="order-kind">Kind</label>
884                    <select id="order-kind" data-order-field="kind"></select>
885                  </div>
886                  <div class="label-input">
887                    <label for="order-status">Status</label>
888                    <select id="order-status" data-order-field="status"></select>
889                  </div>
890                </div>
891                <div class="payload-row">
892                  <div class="label-input">
893                    <label for="order-amount">Sats Amount</label>
894                    <input id="order-amount" data-order-field="amount" type="number" step="1" min="0" value="0">
895                  </div>
896                  <div class="label-input">
897                    <label for="order-fiat-code">Fiat Code</label>
898                    <input id="order-fiat-code" data-order-field="fiat_code" type="text" value="USD">
899                  </div>
900                  <div class="label-input">
901                    <label for="order-fiat-amount">Fiat Amount</label>
902                    <input id="order-fiat-amount" data-order-field="fiat_amount" type="number" step="1" min="0" value="0">
903                  </div>
904                </div>
905                <div class="payload-row">
906                  <div class="label-input">
907                    <label for="order-payment-method">Payment Method</label>
908                    <input id="order-payment-method" data-order-field="payment_method" type="text" placeholder="e.g. bank transfer" required>
909                  </div>
910                  <div class="label-input">
911                    <label for="order-premium">Premium</label>
912                    <input id="order-premium" data-order-field="premium" type="number" step="1" value="0">
913                  </div>
914                  <div class="label-input">
915                    <label for="order-created-at">Created At (timestamp)</label>
916                    <input id="order-created-at" data-order-field="created_at" type="number" step="1" placeholder="Optional">
917                  </div>
918                </div>
919                <div class="payload-row">
920                  <div class="label-input">
921                    <label for="order-min-amount">Min Amount</label>
922                    <input id="order-min-amount" data-order-field="min_amount" type="number" step="1" placeholder="Optional">
923                  </div>
924                  <div class="label-input">
925                    <label for="order-max-amount">Max Amount</label>
926                    <input id="order-max-amount" data-order-field="max_amount" type="number" step="1" placeholder="Optional">
927                  </div>
928                  <div class="label-input">
929                    <label for="order-expires-at">Expires At (timestamp)</label>
930                    <input id="order-expires-at" data-order-field="expires_at" type="number" step="1" placeholder="Optional">
931                  </div>
932                </div>
933                <div class="payload-row">
934                  <div class="label-input">
935                    <label for="order-buyer-trade">Buyer Trade Pubkey</label>
936                    <input id="order-buyer-trade" data-order-field="buyer_trade_pubkey" type="text" placeholder="Optional">
937                  </div>
938                  <div class="label-input">
939                    <label for="order-seller-trade">Seller Trade Pubkey</label>
940                    <input id="order-seller-trade" data-order-field="seller_trade_pubkey" type="text" placeholder="Optional">
941                  </div>
942                  <div class="label-input">
943                    <label for="order-buyer-invoice">Buyer Invoice</label>
944                    <input id="order-buyer-invoice" data-order-field="buyer_invoice" type="text" placeholder="Optional">
945                  </div>
946                </div>
947              </div>
948              <div class="payload-group" id="payload-custom" data-payload-section="custom" hidden>
949                <label for="payload-json" class="payload-note">Provide a JSON object representing the payload variant.</label>
950                <textarea id="payload-json" class="textarea-input" placeholder='{{"order": {{ ... }}}}'></textarea>
951              </div>
952            </div>
953          </div>
954        </div>
955        <div class="message-preview">
956          <div class="preview-header">
957            <span class="helper">Generated Message Preview</span>
958            <div class="message-actions">
959              <button class="copy-key" id="copy-message" type="button">Copy JSON</button>
960            </div>
961          </div>
962          <pre class="json-preview"><code id="message-preview">{{}}</code></pre>
963        </div>
964        <div class="send-section">
965          <button class="send-button" id="send-to-mostro" type="button">Send to Mostro</button>
966          <div class="send-status" id="send-status" hidden>
967            <span id="status-text">Ready</span>
968          </div>
969        </div>
970      </fieldset>
971    </form>
972  </div>
973  <script>
974    async function copyText(value) {{
975      if (navigator.clipboard && navigator.clipboard.writeText) {{
976        try {{
977          await navigator.clipboard.writeText(value);
978          return true;
979        }} catch (_) {{
980          // Continue to fallback
981        }}
982      }}
983
984      try {{
985        const temp = document.createElement('textarea');
986        temp.value = value;
987        temp.setAttribute('readonly', '');
988        temp.style.position = 'absolute';
989        temp.style.left = '-9999px';
990        document.body.appendChild(temp);
991        temp.select();
992        document.execCommand('copy');
993        document.body.removeChild(temp);
994        return true;
995      }} catch (_) {{
996        return false;
997      }}
998    }}
999
1000    (function() {{
1001      const identityInput = document.getElementById('identity');
1002      const identityState = document.getElementById('identity-state');
1003      const identityToggle = document.getElementById('toggle-identity');
1004      const identityCopy = document.getElementById('copy-identity');
1005      const mnemonicInput = document.getElementById('mnemonic');
1006      const mostroPubkey = document.getElementById('mostro-pubkey');
1007      if (!identityInput || !identityState || !identityToggle || !identityCopy || !mnemonicInput || !mostroPubkey) return;
1008
1009      const identityPublic = identityInput.value;
1010      const identityPrivate = identityInput.dataset.private || '';
1011      let identityShowingPrivate = false;
1012
1013      const updateIdentityDisplay = () => {{
1014        identityInput.value = identityShowingPrivate ? identityPrivate : identityPublic;
1015        identityState.textContent = identityShowingPrivate ? 'Private' : 'Public';
1016        identityToggle.textContent = identityShowingPrivate ? 'Show Public Key' : 'Show Private Key';
1017      }};
1018
1019      identityInput.addEventListener('input', () => {{
1020        identityShowingPrivate = false;
1021        identityState.textContent = 'Custom';
1022        identityToggle.textContent = 'Show Private Key';
1023      }});
1024
1025      identityToggle.addEventListener('click', () => {{
1026        identityShowingPrivate = !identityShowingPrivate;
1027        updateIdentityDisplay();
1028      }});
1029
1030      identityCopy.addEventListener('click', async () => {{
1031        const originalLabel = identityCopy.textContent;
1032        const ok = await copyText(identityInput.value);
1033        identityCopy.textContent = ok ? 'Copied!' : 'Copy Failed';
1034        setTimeout(() => {{
1035          identityCopy.textContent = originalLabel;
1036        }}, 1500);
1037      }});
1038
1039      updateIdentityDisplay();
1040
1041      const tradeInput = document.getElementById('trade-key');
1042      const tradeState = document.getElementById('trade-state');
1043      const tradeToggle = document.getElementById('toggle-trade');
1044      const tradeCopy = document.getElementById('copy-trade');
1045      const tradeIncrement = document.getElementById('trade-increment');
1046      const tradeDecrement = document.getElementById('trade-decrement');
1047      const tradeIndexDisplay = document.getElementById('trade-index');
1048      const tradePath = document.getElementById('trade-path');
1049      const tradeError = document.getElementById('trade-error');
1050      if (!tradeInput || !tradeState || !tradeToggle || !tradeCopy || !tradeIncrement || !tradeDecrement || !tradeIndexDisplay || !tradePath || !tradeError) {{
1051        return;
1052      }}
1053
1054      let tradePublic = tradeInput.value;
1055      let tradePrivate = tradeInput.dataset.private || '';
1056      const tradeMinIndex = Number(tradeInput.dataset.minIndex || '1');
1057      let tradeIndex = Number(tradeInput.dataset.index || tradeMinIndex);
1058      let tradeShowingPrivate = false;
1059      let tradeLoading = false;
1060
1061      const updateTradeDisplay = () => {{
1062        tradeInput.value = tradeShowingPrivate ? tradePrivate : tradePublic;
1063        tradeState.textContent = tradeShowingPrivate ? 'Private' : 'Public';
1064        tradeToggle.textContent = tradeShowingPrivate ? 'Show Public Key' : 'Show Private Key';
1065      }};
1066
1067      const updateTradeControls = () => {{
1068        tradeIndexDisplay.textContent = `Trade #${{tradeIndex}}`;
1069        tradeIncrement.disabled = tradeLoading;
1070        tradeDecrement.disabled = tradeLoading || tradeIndex <= tradeMinIndex;
1071      }};
1072
1073      const resetTradeCopyLabel = () => {{
1074        tradeCopy.textContent = 'Copy';
1075      }};
1076
1077      tradeToggle.addEventListener('click', () => {{
1078        tradeShowingPrivate = !tradeShowingPrivate;
1079        updateTradeDisplay();
1080      }});
1081
1082      tradeCopy.addEventListener('click', async () => {{
1083        const originalLabel = tradeCopy.textContent;
1084        const ok = await copyText(tradeInput.value);
1085        tradeCopy.textContent = ok ? 'Copied!' : 'Copy Failed';
1086        setTimeout(() => {{
1087          tradeCopy.textContent = originalLabel;
1088        }}, 1500);
1089      }});
1090
1091      tradeIncrement.addEventListener('click', () => {{
1092        void requestTradeKey(tradeIndex + 1);
1093      }});
1094
1095      tradeDecrement.addEventListener('click', () => {{
1096        if (tradeIndex > tradeMinIndex) {{
1097          void requestTradeKey(tradeIndex - 1);
1098        }}
1099      }});
1100
1101      async function requestTradeKey(nextIndex) {{
1102        if (tradeLoading || nextIndex < tradeMinIndex) {{
1103          return;
1104        }}
1105
1106        tradeLoading = true;
1107        resetTradeCopyLabel();
1108        tradeError.hidden = true;
1109        tradeError.textContent = '';
1110        updateTradeControls();
1111
1112        try {{
1113          const response = await fetch('/api/trade-key', {{
1114            method: 'POST',
1115            headers: {{ 'Content-Type': 'application/json' }},
1116            body: JSON.stringify({{ mnemonic: mnemonicInput.value, index: nextIndex }})
1117          }});
1118
1119          if (!response.ok) {{
1120            const data = await response.json().catch(() => null);
1121            const message = data && data.error ? data.error : 'Failed to derive trade key';
1122            throw new Error(message);
1123          }}
1124
1125          const data = await response.json();
1126          tradeIndex = Number(data.index);
1127          tradePublic = data.public_key;
1128          tradePrivate = data.private_key;
1129          tradeInput.dataset.index = String(tradeIndex);
1130          tradeInput.dataset.private = tradePrivate;
1131          tradePath.textContent = data.derivation_path;
1132
1133          updateTradeDisplay();
1134          updateTradeControls();
1135        }} catch (error) {{
1136          tradeError.textContent = error instanceof Error ? error.message : 'Failed to derive trade key';
1137          tradeError.hidden = false;
1138        }} finally {{
1139          tradeLoading = false;
1140          updateTradeControls();
1141        }}
1142      }}
1143
1144      updateTradeDisplay();
1145      updateTradeControls();
1146    }})();
1147    (function() {{
1148      const ACTIONS = JSON.parse('{actions}');
1149      const MESSAGE_TYPES = JSON.parse('{message_types}');
1150      const ORDER_KINDS = JSON.parse('{order_kinds}');
1151      const ORDER_STATUSES = JSON.parse('{order_statuses}');
1152
1153      const ID_REQUIRED_ACTIONS = new Set([
1154        'take-sell','take-buy','fiat-sent','fiat-sent-ok','release','released','dispute','admin-cancel','admin-canceled','admin-settle','admin-settled','rate','rate-received','admin-take-dispute','admin-took-dispute','dispute-initiated-by-you','dispute-initiated-by-peer','waiting-buyer-invoice','purchase-completed','hold-invoice-payment-accepted','hold-invoice-payment-settled','hold-invoice-payment-canceled','waiting-seller-to-pay','buyer-took-order','buyer-invoice-accepted','cooperative-cancel-initiated-by-you','cooperative-cancel-initiated-by-peer','cooperative-cancel-accepted','cancel','invoice-updated','admin-add-solver','send-dm','trade-pubkey','canceled','payment-failed','pay-invoice','add-invoice'
1155      ]);
1156
1157      const messageTypeSelect = document.getElementById('message-type');
1158      const actionSelect = document.getElementById('message-action');
1159      const versionInput = document.getElementById('message-version');
1160      const idInput = document.getElementById('message-id');
1161      const requestIdInput = document.getElementById('message-request-id');
1162      const tradeIndexInput = document.getElementById('message-trade-index');
1163      const payloadModeSelect = document.getElementById('payload-mode');
1164      const payloadFields = document.getElementById('payload-fields');
1165      const payloadHint = document.getElementById('payload-hint');
1166      const actionHint = document.getElementById('action-hint');
1167      const previewCode = document.getElementById('message-preview');
1168      const copyMessageBtn = document.getElementById('copy-message');
1169      const orderKindSelect = document.getElementById('order-kind');
1170      const orderStatusSelect = document.getElementById('order-status');
1171      const orderFiatCodeInput = document.getElementById('order-fiat-code');
1172      const orderIdInput = document.getElementById('order-id');
1173      const payloadEmptyNote = document.getElementById('payload-empty-note');
1174      const payloadOrderSection = document.getElementById('payload-order');
1175      const payloadCustomSection = document.getElementById('payload-custom');
1176
1177      if (!messageTypeSelect || !actionSelect || !payloadModeSelect || !payloadFields || !payloadHint || !previewCode) {{
1178        return;
1179      }}
1180
1181      function titleCase(value) {{
1182        return value
1183          .split('-')
1184          .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1185          .join(' ');
1186      }}
1187
1188      function populateSelect(select, values) {{
1189        if (!select) return;
1190        select.innerHTML = '';
1191        values.forEach((value) => {{
1192          const option = document.createElement('option');
1193          option.value = value;
1194          option.textContent = titleCase(value);
1195          select.appendChild(option);
1196        }});
1197      }}
1198
1199      populateSelect(messageTypeSelect, MESSAGE_TYPES);
1200      populateSelect(actionSelect, ACTIONS);
1201      populateSelect(orderKindSelect, ORDER_KINDS);
1202      populateSelect(orderStatusSelect, ORDER_STATUSES);
1203      if (orderStatusSelect) {{
1204        orderStatusSelect.value = 'pending';
1205      }}
1206
1207      messageTypeSelect.value = MESSAGE_TYPES[0] ?? '';
1208      actionSelect.value = ACTIONS[0] ?? '';
1209
1210      orderKindSelect?.addEventListener('change', updatePreview);
1211      orderStatusSelect?.addEventListener('change', updatePreview);
1212      orderFiatCodeInput?.addEventListener('input', () => {{
1213        orderFiatCodeInput.value = orderFiatCodeInput.value.toUpperCase();
1214        updatePreview();
1215      }});
1216      orderIdInput?.addEventListener('input', updatePreview);
1217      orderIdInput?.addEventListener('change', updatePreview);
1218
1219      let latestPreview = '{{}}';
1220
1221      function setPayloadSection(mode) {{
1222        if (payloadEmptyNote) {{
1223          payloadEmptyNote.hidden = mode !== 'none';
1224        }}
1225        if (payloadOrderSection) {{
1226          payloadOrderSection.hidden = mode !== 'order';
1227        }}
1228        if (payloadCustomSection) {{
1229          payloadCustomSection.hidden = mode !== 'custom';
1230        }}
1231        payloadModeSelect.value = mode;
1232        if (payloadHint) {{
1233          if (mode === 'order') {{
1234            payloadHint.textContent = 'Populate the order payload fields to describe the new order.';
1235          }} else if (mode === 'custom') {{
1236            payloadHint.textContent = 'Paste or type a JSON object to use as payload content.';
1237          }} else {{
1238            payloadHint.textContent = 'Most actions do not require a payload.';
1239          }}
1240        }}
1241      }}
1242
1243      function parseInteger(value) {{
1244        if (value === undefined || value === null || value === '') {{
1245          return undefined;
1246        }}
1247        const parsed = Number(value);
1248        return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
1249      }}
1250
1251      function collectOrderPayload() {{
1252        if (payloadOrderSection?.hidden) {{
1253          return null;
1254        }}
1255        const data = {{}};
1256        payloadOrderSection.querySelectorAll('[data-order-field]').forEach((input) => {{
1257          const key = input.getAttribute('data-order-field');
1258          if (!key) return;
1259          const value = input.value.trim();
1260          if (!value) {{
1261            if (['amount', 'fiat_amount', 'premium'].includes(key)) {{
1262              data[key] = 0;
1263            }}
1264            return;
1265          }}
1266          switch (key) {{
1267            case 'amount':
1268            case 'fiat_amount':
1269            case 'premium':
1270            case 'min_amount':
1271            case 'max_amount':
1272            case 'created_at':
1273            case 'expires_at':
1274              const numeric = parseInteger(value);
1275              if (numeric !== undefined) {{
1276                data[key] = numeric;
1277              }}
1278              break;
1279            default:
1280              data[key] = value;
1281          }}
1282        }});
1283
1284        // Ensure all required fields are present (SmallOrder struct requirements)
1285        if (!('amount' in data)) {{
1286          data.amount = 0;
1287        }}
1288        if (!('fiat_amount' in data)) {{
1289          data.fiat_amount = 0;
1290        }}
1291        if (!('premium' in data)) {{
1292          data.premium = 0;
1293        }}
1294        if (!('fiat_code' in data)) {{
1295          data.fiat_code = 'USD';
1296        }} else if (typeof data.fiat_code === 'string') {{
1297          data.fiat_code = data.fiat_code.toUpperCase();
1298        }}
1299        if (!('payment_method' in data)) {{
1300          data.payment_method = '';
1301        }}
1302
1303        return Object.keys(data).length > 0 ? {{ order: data }} : null;
1304      }}
1305
1306      function collectCustomPayload() {{
1307        if (payloadCustomSection?.hidden) {{
1308          return null;
1309        }}
1310        const textarea = document.getElementById('payload-json');
1311        if (!textarea) return null;
1312        const value = textarea.value.trim();
1313        if (!value) return null;
1314        try {{
1315          const parsed = JSON.parse(value);
1316          payloadHint.textContent = 'Custom payload parsed successfully.';
1317          return parsed;
1318        }} catch (error) {{
1319          payloadHint.textContent = 'Invalid JSON payload: ' + error.message;
1320          return null;
1321        }}
1322      }}
1323
1324      function buildPayload() {{
1325        const mode = payloadModeSelect.value;
1326        if (mode === 'order') return collectOrderPayload();
1327        if (mode === 'custom') return collectCustomPayload();
1328        return null;
1329      }}
1330
1331      function buildWrapper() {{
1332        const action = actionSelect.value;
1333        const wrapper = {{
1334          version: parseInteger(versionInput.value) ?? 1,
1335          action,
1336        }};
1337
1338        const idValue = idInput.value.trim();
1339        if (idValue) {{
1340          wrapper.id = idValue;
1341        }}
1342
1343        const requestId = parseInteger(requestIdInput.value);
1344        if (requestId !== undefined) {{
1345          wrapper.request_id = requestId;
1346        }}
1347
1348        const tradeIndex = parseInteger(tradeIndexInput.value);
1349        if (tradeIndex !== undefined) {{
1350          wrapper.trade_index = tradeIndex;
1351        }}
1352
1353        const payload = buildPayload();
1354        if (payload && Object.keys(payload).length > 0) {{
1355          wrapper.payload = payload;
1356        }}
1357
1358        return wrapper;
1359      }}
1360
1361      function updatePreview() {{
1362        const messageKind = messageTypeSelect.value || 'order';
1363        const wrapper = buildWrapper();
1364        const output = {{ [messageKind]: wrapper }};
1365        latestPreview = JSON.stringify(output, null, 2);
1366        previewCode.textContent = latestPreview;
1367      }}
1368
1369      function onActionChanged() {{
1370        const action = actionSelect.value;
1371        const requiresId = ID_REQUIRED_ACTIONS.has(action);
1372        idInput.required = requiresId;
1373        actionHint.textContent = requiresId
1374          ? 'This action requires a message id (UUID).'
1375          : 'Select an action to tailor payload fields.';
1376
1377        const defaultMode = action === 'new-order' ? 'order' : 'none';
1378        setPayloadSection(defaultMode);
1379        updatePreview();
1380      }}
1381
1382      payloadModeSelect.addEventListener('change', () => {{
1383        setPayloadSection(payloadModeSelect.value);
1384        updatePreview();
1385      }});
1386
1387      actionSelect.addEventListener('change', onActionChanged);
1388      messageTypeSelect.addEventListener('change', updatePreview);
1389
1390      [versionInput, idInput, requestIdInput, tradeIndexInput].forEach((input) => {{
1391        input?.addEventListener('input', updatePreview);
1392      }});
1393
1394      payloadFields.addEventListener('input', updatePreview, true);
1395      payloadFields.addEventListener('change', updatePreview, true);
1396
1397      copyMessageBtn?.addEventListener('click', async () => {{
1398        const original = copyMessageBtn.textContent;
1399        const ok = await copyText(latestPreview);
1400        copyMessageBtn.textContent = ok ? 'Copied!' : 'Copy Failed';
1401        setTimeout(() => {{ copyMessageBtn.textContent = original; }}, 1500);
1402      }});
1403
1404      onActionChanged();
1405      updatePreview();
1406    }})();
1407
1408    // Gift Wrap and Relay Publishing
1409    (function() {{
1410      const sendButton = document.getElementById('send-to-mostro');
1411      const sendStatus = document.getElementById('send-status');
1412      const statusText = document.getElementById('status-text');
1413      const mostroPubkeyInput = document.getElementById('mostro-pubkey');
1414      const mnemonicInput = document.getElementById('mnemonic');
1415      const tradeKeyInput = document.getElementById('trade-key');
1416
1417      if (!sendButton || !sendStatus || !statusText) return;
1418
1419      function showStatus(message, type) {{
1420        sendStatus.hidden = false;
1421        sendStatus.className = 'send-status ' + type;
1422        statusText.textContent = message;
1423      }}
1424
1425      function hideStatus() {{
1426        sendStatus.hidden = true;
1427      }}
1428
1429      async function buildGiftWrapEvent() {{
1430        const mostroPubkey = mostroPubkeyInput.value.trim();
1431        if (!mostroPubkey) {{
1432          throw new Error('Mostro pubkey is required');
1433        }}
1434
1435        const mnemonic = mnemonicInput.value;
1436        const tradeIndex = parseInt(tradeKeyInput.dataset.index);
1437
1438        // Build message from current form state
1439        const messageKind = document.getElementById('message-type').value || 'order';
1440        const action = document.getElementById('message-action').value;
1441        const versionInput = document.getElementById('message-version');
1442        const idInput = document.getElementById('message-id');
1443        const requestIdInput = document.getElementById('message-request-id');
1444        const tradeIndexInput = document.getElementById('message-trade-index');
1445
1446        const wrapper = {{
1447          version: parseInt(versionInput.value) || 1,
1448          action,
1449        }};
1450
1451        const idValue = idInput.value.trim();
1452        if (idValue) wrapper.id = idValue;
1453
1454        const requestId = parseInt(requestIdInput.value);
1455        if (!isNaN(requestId)) wrapper.request_id = requestId;
1456
1457        const msgTradeIndex = parseInt(tradeIndexInput.value);
1458        if (!isNaN(msgTradeIndex)) wrapper.trade_index = msgTradeIndex;
1459
1460        // Get payload if any
1461        const payloadMode = document.getElementById('payload-mode').value;
1462        if (payloadMode === 'order') {{
1463          const payload = collectOrderPayload();
1464          if (payload) wrapper.payload = payload;
1465        }} else if (payloadMode === 'custom') {{
1466          const payload = collectCustomPayload();
1467          if (payload) wrapper.payload = payload;
1468        }}
1469
1470        const message = {{ [messageKind]: wrapper }};
1471
1472        // Call API to build gift wrap
1473        const response = await fetch('/api/build-gift-wrap', {{
1474          method: 'POST',
1475          headers: {{ 'Content-Type': 'application/json' }},
1476          body: JSON.stringify({{
1477            mnemonic,
1478            trade_index: tradeIndex,
1479            mostro_pubkey: mostroPubkey,
1480            message_json: JSON.stringify(message)
1481          }})
1482        }});
1483
1484        if (!response.ok) {{
1485          const error = await response.json();
1486          throw new Error(error.error || 'Failed to build gift wrap');
1487        }}
1488
1489        const data = await response.json();
1490        return data.gift_wrap_event;
1491      }}
1492
1493      function collectOrderPayload() {{
1494        const orderSection = document.getElementById('payload-order');
1495        if (!orderSection || orderSection.hidden) return null;
1496
1497        const data = {{}};
1498        orderSection.querySelectorAll('[data-order-field]').forEach((input) => {{
1499          const key = input.getAttribute('data-order-field');
1500          if (!key) return;
1501          const value = input.value.trim();
1502          if (!value) {{
1503            if (['amount', 'fiat_amount', 'premium'].includes(key)) data[key] = 0;
1504            return;
1505          }}
1506          switch (key) {{
1507            case 'amount':
1508            case 'fiat_amount':
1509            case 'premium':
1510            case 'min_amount':
1511            case 'max_amount':
1512            case 'created_at':
1513            case 'expires_at':
1514              const numeric = parseInt(value);
1515              if (!isNaN(numeric)) data[key] = numeric;
1516              break;
1517            default:
1518              data[key] = value;
1519          }}
1520        }});
1521
1522        // Ensure all required fields are present (SmallOrder struct requirements)
1523        if (!('amount' in data)) data.amount = 0;
1524        if (!('fiat_amount' in data)) data.fiat_amount = 0;
1525        if (!('premium' in data)) data.premium = 0;
1526        if (!('fiat_code' in data)) data.fiat_code = 'USD';
1527        else if (typeof data.fiat_code === 'string') data.fiat_code = data.fiat_code.toUpperCase();
1528        if (!('payment_method' in data)) data.payment_method = '';
1529
1530        return Object.keys(data).length > 0 ? {{ order: data }} : null;
1531      }}
1532
1533      function collectCustomPayload() {{
1534        const customSection = document.getElementById('payload-custom');
1535        if (!customSection || customSection.hidden) return null;
1536        const textarea = document.getElementById('payload-json');
1537        if (!textarea) return null;
1538        const value = textarea.value.trim();
1539        if (!value) return null;
1540        try {{
1541          return JSON.parse(value);
1542        }} catch (error) {{
1543          throw new Error('Invalid JSON payload: ' + error.message);
1544        }}
1545      }}
1546
1547      async function publishToRelay(giftWrapEvent) {{
1548        const relay = 'wss://relay.mostro.network';
1549
1550        return new Promise((resolve, reject) => {{
1551          const ws = new WebSocket(relay);
1552          let timeout;
1553
1554          ws.onopen = () => {{
1555            // Send EVENT command
1556            ws.send(JSON.stringify(['EVENT', giftWrapEvent]));
1557
1558            // Set timeout for response
1559            timeout = setTimeout(() => {{
1560              ws.close();
1561              reject(new Error('Relay response timeout'));
1562            }}, 30000);
1563          }};
1564
1565          ws.onmessage = (event) => {{
1566            try {{
1567              const data = JSON.parse(event.data);
1568              if (data[0] === 'OK') {{
1569                clearTimeout(timeout);
1570                const eventId = data[1];
1571                const accepted = data[2];
1572                const message = data[3] || '';
1573
1574                ws.close();
1575
1576                if (accepted) {{
1577                  resolve(eventId);
1578                }} else {{
1579                  reject(new Error('Relay rejected event: ' + message));
1580                }}
1581              }} else if (data[0] === 'NOTICE') {{
1582                clearTimeout(timeout);
1583                ws.close();
1584                reject(new Error('Relay notice: ' + data[1]));
1585              }}
1586            }} catch (err) {{
1587              clearTimeout(timeout);
1588              ws.close();
1589              reject(new Error('Failed to parse relay response'));
1590            }}
1591          }};
1592
1593          ws.onerror = (error) => {{
1594            clearTimeout(timeout);
1595            reject(new Error('WebSocket connection error'));
1596          }};
1597
1598          ws.onclose = (event) => {{
1599            clearTimeout(timeout);
1600            if (!event.wasClean) {{
1601              reject(new Error('Connection closed unexpectedly'));
1602            }}
1603          }};
1604        }});
1605      }}
1606
1607      sendButton.addEventListener('click', async () => {{
1608        try {{
1609          sendButton.disabled = true;
1610          hideStatus();
1611
1612          // Validate Mostro pubkey
1613          if (!mostroPubkeyInput.value.trim()) {{
1614            throw new Error('Please enter Mostro pubkey');
1615          }}
1616
1617          // Step 1: Build gift wrap
1618          showStatus('Building gift wrap event...', 'info');
1619          const giftWrapEvent = await buildGiftWrapEvent();
1620
1621          // Step 2: Connect to relay and publish
1622          showStatus('Connecting to relay...', 'info');
1623          const eventId = await publishToRelay(giftWrapEvent);
1624
1625          // Success
1626          showStatus('Event published successfully! ID: ' + eventId, 'success');
1627
1628        }} catch (error) {{
1629          showStatus('Error: ' + error.message, 'error');
1630          console.error('Send error:', error);
1631        }} finally {{
1632          sendButton.disabled = false;
1633        }}
1634      }});
1635    }})();
1636  </script>
1637</body>
1638</html>"#,
1639        main_color = MAIN_COLOR,
1640        main_color_dark = MAIN_COLOR_DARK,
1641        base_path = MOSTRO_BASE_PATH,
1642        mnemonic = ctx.mnemonic_phrase,
1643        identity = ctx.identity_key_hex,
1644        identity_private = ctx.identity_secret_hex,
1645        identity_path = IDENTITY_PATH,
1646        trade_path = ctx.trade_derivation_path(),
1647        trade_public = ctx.trade_key_hex,
1648        trade_private = ctx.trade_secret_hex,
1649        trade_index = ctx.trade_index,
1650        trade_min_index = TRADE_MIN_INDEX,
1651        actions = actions_json,
1652        message_types = message_types_json,
1653        order_kinds = order_kinds_json,
1654        order_statuses = order_statuses_json,
1655    )
1656}
1657
1658fn render_error_page(message: &str) -> String {
1659    format!(
1660        r#"<!DOCTYPE html>
1661<html lang="en">
1662<head>
1663<meta charset="utf-8">
1664<meta name="viewport" content="width=device-width, initial-scale=1.0">
1665<title>Mostro Message Builder &mdash; Error</title>
1666<style>
1667body {{
1668  margin: 0;
1669  min-height: 100vh;
1670  display: flex;
1671  align-items: center;
1672  justify-content: center;
1673  background: linear-gradient(135deg, {main_color} 0%, {main_color_dark} 100%);
1674  font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1675  color: #fff;
1676}}
1677section {{
1678  background: rgba(0, 0, 0, 0.85);
1679  border: 1px solid rgba(255, 255, 255, 0.2);
1680  border-radius: 18px;
1681  padding: 2rem 2.5rem;
1682  max-width: 520px;
1683  text-align: center;
1684  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
1685}}
1686h1 {{
1687  font-size: 1.6rem;
1688  margin-bottom: 1rem;
1689}}
1690p {{
1691  color: rgba(255, 255, 255, 0.78);
1692  line-height: 1.5;
1693}}
1694</style>
1695</head>
1696<body>
1697  <section>
1698    <h1>Something went wrong</h1>
1699    <p>{message}</p>
1700  </section>
1701</body>
1702</html>"#,
1703        main_color = MAIN_COLOR,
1704        main_color_dark = MAIN_COLOR_DARK,
1705        message = message,
1706    )
1707}