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 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 let identity_keys = derive_keys_for_index(&payload.mnemonic, IDENTITY_KEY_INDEX)
186 .map_err(identity_error_to_response)?;
187
188 let trade_keys = derive_keys_for_index(&payload.mnemonic, payload.trade_index)
190 .map_err(identity_error_to_response)?;
191
192 tracing::info!("Payload message: {}", payload.message_json);
194 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 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 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 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
317async 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 let message_str = message.as_json().map_err(|e| {
347 format!("Failed to serialize message: {}", e)
348 })?;
349
350 let signature = MostroMessage::sign(message_str.clone(), trade_keys);
352
353 let content = serde_json::to_string(&(message, signature))
363 .map_err(|e| format!("Failed to serialize message and signature: {}", e))?;
364
365 let rumor = EventBuilder::text_note(content).build(trade_keys.public_key());
372
373 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 — 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}