Compare commits
43
Commits
c0d10e9664
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
743e7f8b65 | ||
|
|
6b93a8ab60 | ||
|
|
19e9b62632 | ||
|
|
076bb3ff06 | ||
|
|
f0be72ac36 | ||
|
|
6e285bd4a4 | ||
|
|
fe3d2aa935 | ||
|
|
6b9bda3ee4 | ||
|
|
84ca1a5cbe | ||
|
|
669b8b7ca5 | ||
|
|
796d639ba3 | ||
|
|
daae693d1b | ||
|
|
2f1b666c20 | ||
|
|
5aa5fa7594 | ||
|
|
f56c94bc91 | ||
|
|
7320a98505 | ||
|
|
529286777f | ||
|
|
a3b4f534d5 | ||
|
|
293b633a24 | ||
|
|
ae4012adb7 | ||
|
|
6c2a277751 | ||
|
|
a558a2a271 | ||
|
|
3ee90993ed | ||
|
|
b404bb2aff | ||
|
|
3a33e9cae9 | ||
|
|
46de4cbfee | ||
|
|
d7fee801d3 | ||
|
|
4f4c6b7a16 | ||
|
|
5563becad2 | ||
|
|
6301cd5984 | ||
|
|
9b6971095c | ||
|
|
8a6d523062 | ||
|
|
16a1ca02e8 | ||
|
|
0cb1df3bfe | ||
|
|
6f369376ec | ||
|
|
02fa1a228a | ||
|
|
099d5df3e1 | ||
|
|
df98f98669 | ||
|
|
6d58802127 | ||
|
|
165675b131 | ||
|
|
08a4427e31 | ||
|
|
f4460beddd | ||
|
|
70d5c233c0 |
+5
-2
@@ -15,7 +15,7 @@ if(APPLE)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
project(ObsidianDragon
|
project(ObsidianDragon
|
||||||
VERSION 2.0.2
|
VERSION 2.1.0
|
||||||
LANGUAGES C CXX
|
LANGUAGES C CXX
|
||||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
|
|||||||
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
||||||
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
||||||
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
||||||
set(DRAGONX_LITE_VERSION "1.1.1")
|
set(DRAGONX_LITE_VERSION "2.0.0")
|
||||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||||
|
|
||||||
# C++17 standard
|
# C++17 standard
|
||||||
@@ -499,6 +499,7 @@ set(APP_SOURCES
|
|||||||
src/chat/chat_service.cpp
|
src/chat/chat_service.cpp
|
||||||
src/chat/chat_database.cpp
|
src/chat/chat_database.cpp
|
||||||
src/chat/chat_outgoing.cpp
|
src/chat/chat_outgoing.cpp
|
||||||
|
src/chat/chat_sensitive_vault.cpp
|
||||||
src/wallet/lite_owned_string.cpp
|
src/wallet/lite_owned_string.cpp
|
||||||
src/wallet/lite_rollout_policy.cpp
|
src/wallet/lite_rollout_policy.cpp
|
||||||
src/wallet/lite_client_bridge.cpp
|
src/wallet/lite_client_bridge.cpp
|
||||||
@@ -669,6 +670,7 @@ set(APP_HEADERS
|
|||||||
src/chat/chat_service.h
|
src/chat/chat_service.h
|
||||||
src/chat/chat_database.h
|
src/chat/chat_database.h
|
||||||
src/chat/chat_outgoing.h
|
src/chat/chat_outgoing.h
|
||||||
|
src/chat/chat_sensitive_vault.h
|
||||||
src/config/version.h
|
src/config/version.h
|
||||||
src/data/wallet_state.h
|
src/data/wallet_state.h
|
||||||
src/data/transaction_history_cache.h
|
src/data/transaction_history_cache.h
|
||||||
@@ -1224,6 +1226,7 @@ if(BUILD_TESTING)
|
|||||||
src/chat/chat_service.cpp
|
src/chat/chat_service.cpp
|
||||||
src/chat/chat_database.cpp
|
src/chat/chat_database.cpp
|
||||||
src/chat/chat_outgoing.cpp
|
src/chat/chat_outgoing.cpp
|
||||||
|
src/chat/chat_sensitive_vault.cpp
|
||||||
src/wallet/lite_owned_string.cpp
|
src/wallet/lite_owned_string.cpp
|
||||||
src/wallet/lite_rollout_policy.cpp
|
src/wallet/lite_rollout_policy.cpp
|
||||||
src/wallet/lite_client_bridge.cpp
|
src/wallet/lite_client_bridge.cpp
|
||||||
|
|||||||
Binary file not shown.
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "Ihre lokale Kopie der Unterhaltung mit ",
|
"chat_delete_body_prefix": "Ihre lokale Kopie der Unterhaltung mit ",
|
||||||
"chat_delete_body_suffix": " löschen?",
|
"chat_delete_body_suffix": " löschen?",
|
||||||
"chat_delete_confirm": "Löschen",
|
"chat_delete_confirm": "Löschen",
|
||||||
|
"chat_delete_everywhere": "Überall löschen",
|
||||||
|
"chat_delete_everywhere_note": "Weist auch Ihre anderen Geräte – und eine künftige Wiederherstellung aus Ihrer Seed-Phrase – an, diese Unterhaltung auszublenden. Dabei wird ein kleines On-Chain-Signal gesendet (kostet die übliche Chat-Gebühr). Sie können sie später über „Wiederherstellen“ zurückholen.",
|
||||||
|
"chat_delete_everywhere_toast": "Wird überall gelöscht – Signal gesendet",
|
||||||
"chat_delete_failed": "Die Unterhaltung konnte nicht gelöscht werden — es wurde nichts geändert.",
|
"chat_delete_failed": "Die Unterhaltung konnte nicht gelöscht werden — es wurde nichts geändert.",
|
||||||
"chat_delete_local_note": "Dies betrifft nur dieses Gerät — die Nachrichten bleiben auf der Blockchain, und die andere Person behält ihre Kopie.",
|
"chat_delete_local_note": "Dies betrifft nur dieses Gerät — die Nachrichten bleiben auf der Blockchain, und die andere Person behält ihre Kopie.",
|
||||||
|
"chat_delete_needs_sync": "Warten Sie, bis die Wallet vollständig synchronisiert ist, um überall löschen zu können.",
|
||||||
"chat_delete_revive_note": "„Löschen“ entfernt den Verlauf auf diesem Gerät. Wenn Ihnen die Person erneut schreibt, kommt die Unterhaltung zurück.",
|
"chat_delete_revive_note": "„Löschen“ entfernt den Verlauf auf diesem Gerät. Wenn Ihnen die Person erneut schreibt, kommt die Unterhaltung zurück.",
|
||||||
"chat_delete_title": "Unterhaltung löschen?",
|
"chat_delete_title": "Unterhaltung löschen?",
|
||||||
"chat_deleted_toast": "Unterhaltung gelöscht",
|
"chat_deleted_toast": "Unterhaltung gelöscht",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Kontakt umbenennen",
|
"chat_rename": "Kontakt umbenennen",
|
||||||
"chat_rename_hint": "Kontaktname",
|
"chat_rename_hint": "Kontaktname",
|
||||||
"chat_renamed": "Kontakt umbenannt",
|
"chat_renamed": "Kontakt umbenannt",
|
||||||
|
"chat_restore_btn": "Wiederherstellen",
|
||||||
|
"chat_restore_desc": "Unterhaltungen, die Sie überall gelöscht haben, können Sie hier zurückholen – auf diesem Gerät oder nach dem Importieren Ihrer Seed-Phrase. Beim Wiederherstellen werden die Nachrichten erneut aus der Chain importiert, und Ihre anderen Geräte werden angewiesen, sie wieder anzuzeigen.",
|
||||||
|
"chat_restore_manage": "Wiederherstellen",
|
||||||
|
"chat_restore_none": "Noch keine gelöschten Unterhaltungen gefunden. Wenn Sie eine auf einem anderen Gerät gelöscht haben, versuchen Sie den Schnellscan oder fügen Sie unten ihre Chat-Adresse ein.",
|
||||||
|
"chat_restore_paste_hint": "Chat-(z-)Adresse der Unterhaltung einfügen",
|
||||||
|
"chat_restore_paste_label": "Über Chat-Adresse wiederherstellen",
|
||||||
|
"chat_restore_scan": "Schnellscan",
|
||||||
|
"chat_restore_scanning": "Die Chain wird nach gelöschten Unterhaltungen gescannt...",
|
||||||
|
"chat_restore_title": "Unterhaltungen wiederherstellen",
|
||||||
|
"chat_restore_toast": "Unterhaltung wird wiederhergestellt – sie erscheint nach dem nächsten Scan wieder",
|
||||||
"chat_retry": "Wiederholen",
|
"chat_retry": "Wiederholen",
|
||||||
"chat_search": "Unterhaltungen durchsuchen",
|
"chat_search": "Unterhaltungen durchsuchen",
|
||||||
"chat_sec_appearance": "DARSTELLUNG",
|
"chat_sec_appearance": "DARSTELLUNG",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Senden",
|
"chat_send": "Senden",
|
||||||
"chat_send_failed": "nicht gesendet",
|
"chat_send_failed": "nicht gesendet",
|
||||||
"chat_sending": "senden…",
|
"chat_sending": "senden…",
|
||||||
|
"chat_sensitive_btn_mark": "Vertraulich machen",
|
||||||
|
"chat_sensitive_btn_setup": "Einrichten & vertraulich machen",
|
||||||
|
"chat_sensitive_btn_unlock": "Entsperren",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Entsperren & vertraulich machen",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Entsperren & normal machen",
|
||||||
|
"chat_sensitive_btn_unmark": "Normal machen",
|
||||||
|
"chat_sensitive_err_create": "Der Tresor für vertrauliche Chats konnte nicht eingerichtet werden.",
|
||||||
|
"chat_sensitive_err_pass": "Die Passphrasen stimmen nicht überein oder sind leer.",
|
||||||
|
"chat_sensitive_err_unlock": "Falsche Passphrase.",
|
||||||
|
"chat_sensitive_lock_btn": "Vertrauliche sperren",
|
||||||
|
"chat_sensitive_mark_tip": "Vertraulich machen",
|
||||||
|
"chat_sensitive_mark_title": "Diese Unterhaltung vertraulich machen?",
|
||||||
|
"chat_sensitive_pass2_hint": "Passphrase bestätigen",
|
||||||
|
"chat_sensitive_pass_hint": "Passphrase",
|
||||||
|
"chat_sensitive_setup_label": "Erstellen Sie eine Passphrase für vertrauliche Unterhaltungen",
|
||||||
|
"chat_sensitive_toast_marked": "Unterhaltung ist jetzt vertraulich",
|
||||||
|
"chat_sensitive_toast_unlocked": "Vertrauliche Unterhaltungen entsperrt",
|
||||||
|
"chat_sensitive_toast_unmarked": "Unterhaltung ist nicht mehr vertraulich",
|
||||||
|
"chat_sensitive_unlock_body": "Geben Sie Ihre Passphrase für vertrauliche Chats ein, um Ihre vertraulichen Unterhaltungen wieder anzuzeigen.",
|
||||||
|
"chat_sensitive_unlock_btn": "Vertrauliche entsperren",
|
||||||
|
"chat_sensitive_unlock_label": "Geben Sie Ihre Passphrase für vertrauliche Chats ein",
|
||||||
|
"chat_sensitive_unlock_title": "Vertrauliche Unterhaltungen entsperren",
|
||||||
|
"chat_sensitive_unmark_body": "Diese Unterhaltung wird nicht mehr unter Ihrer Passphrase für vertrauliche Chats verschlüsselt. Neue Nachrichten werden wie bei jeder anderen Unterhaltung gespeichert.",
|
||||||
|
"chat_sensitive_unmark_tip": "Vertraulich – zum Ändern klicken",
|
||||||
|
"chat_sensitive_unmark_title": "Diese Unterhaltung normal machen?",
|
||||||
|
"chat_sensitive_warn_1": "Die Nachrichten darin werden auf diesem Gerät unter einer separaten Passphrase verschlüsselt – nicht unter Ihrer Seed-Phrase. Wer Ihre Wallet später allein aus Ihrem Seed wiederherstellt, sieht diese Unterhaltung nicht.",
|
||||||
|
"chat_sensitive_warn_dir": "Ihre hier gesendeten Nachrichten sind vollständig geschützt. Empfangene Nachrichten liegen weiterhin auf der Blockchain und könnten theoretisch von jemandem wiederhergestellt werden, der Ihren Seed und weitere Software besitzt – DragonX selbst zeigt sie ohne Ihre Passphrase niemals an.",
|
||||||
|
"chat_sensitive_warn_hist": "Nachrichten, die bereits in dieser Unterhaltung vorhanden sind, bleiben über die Chain wiederherstellbar; ab jetzt sind Nachrichten vollständig geschützt.",
|
||||||
|
"chat_sensitive_warn_lose": "Wenn Sie diese Passphrase verlieren, sind diese Nachrichten für immer verloren – sie können nicht aus Ihrem Seed wiederhergestellt werden.",
|
||||||
"chat_settings_done": "Fertig",
|
"chat_settings_done": "Fertig",
|
||||||
"chat_settings_section": "CHAT & KONTAKTE",
|
"chat_settings_section": "CHAT & KONTAKTE",
|
||||||
"chat_settings_tip": "Chat anpassen",
|
"chat_settings_tip": "Chat anpassen",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "¿Eliminar tu copia local de la conversación con ",
|
"chat_delete_body_prefix": "¿Eliminar tu copia local de la conversación con ",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "Eliminar",
|
"chat_delete_confirm": "Eliminar",
|
||||||
|
"chat_delete_everywhere": "Eliminar en todas partes",
|
||||||
|
"chat_delete_everywhere_note": "También indica a tus otros dispositivos —y a una futura restauración desde tu frase de recuperación— que oculten esta conversación. Envía una pequeña señal en la cadena (cuesta la comisión habitual del chat). Puedes recuperarla más tarde desde Restaurar.",
|
||||||
|
"chat_delete_everywhere_toast": "Eliminando en todas partes: señal enviada",
|
||||||
"chat_delete_failed": "No se pudo eliminar la conversación — no se cambió nada.",
|
"chat_delete_failed": "No se pudo eliminar la conversación — no se cambió nada.",
|
||||||
"chat_delete_local_note": "Esto solo afecta a este dispositivo — los mensajes permanecen en la cadena de bloques y la otra persona conserva su copia.",
|
"chat_delete_local_note": "Esto solo afecta a este dispositivo — los mensajes permanecen en la cadena de bloques y la otra persona conserva su copia.",
|
||||||
|
"chat_delete_needs_sync": "Espera a que la cartera se sincronice por completo para eliminar en todas partes.",
|
||||||
"chat_delete_revive_note": "«Eliminar» borra el historial en este dispositivo. Si esa persona vuelve a escribirte, la conversación reaparece.",
|
"chat_delete_revive_note": "«Eliminar» borra el historial en este dispositivo. Si esa persona vuelve a escribirte, la conversación reaparece.",
|
||||||
"chat_delete_title": "¿Eliminar conversación?",
|
"chat_delete_title": "¿Eliminar conversación?",
|
||||||
"chat_deleted_toast": "Conversación eliminada",
|
"chat_deleted_toast": "Conversación eliminada",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Renombrar contacto",
|
"chat_rename": "Renombrar contacto",
|
||||||
"chat_rename_hint": "Nombre del contacto",
|
"chat_rename_hint": "Nombre del contacto",
|
||||||
"chat_renamed": "Contacto renombrado",
|
"chat_renamed": "Contacto renombrado",
|
||||||
|
"chat_restore_btn": "Restaurar",
|
||||||
|
"chat_restore_desc": "Las conversaciones que eliminaste en todas partes se pueden recuperar aquí, en este dispositivo o tras importar tu frase de recuperación. Restaurar reimporta los mensajes desde la cadena e indica a tus otros dispositivos que la vuelvan a mostrar.",
|
||||||
|
"chat_restore_manage": "Restaurar",
|
||||||
|
"chat_restore_none": "Aún no se han encontrado conversaciones eliminadas. Si eliminaste una en otro dispositivo, prueba con Escaneo rápido o pega su dirección de chat más abajo.",
|
||||||
|
"chat_restore_paste_hint": "Pega la dirección de chat (z) de la conversación",
|
||||||
|
"chat_restore_paste_label": "Restaurar por dirección de chat",
|
||||||
|
"chat_restore_scan": "Escaneo rápido",
|
||||||
|
"chat_restore_scanning": "Escaneando la cadena en busca de conversaciones eliminadas…",
|
||||||
|
"chat_restore_title": "Restaurar conversaciones",
|
||||||
|
"chat_restore_toast": "Restaurando conversación: reaparece tras el próximo escaneo",
|
||||||
"chat_retry": "Reintentar",
|
"chat_retry": "Reintentar",
|
||||||
"chat_search": "Buscar conversaciones",
|
"chat_search": "Buscar conversaciones",
|
||||||
"chat_sec_appearance": "APARIENCIA",
|
"chat_sec_appearance": "APARIENCIA",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Enviar",
|
"chat_send": "Enviar",
|
||||||
"chat_send_failed": "no enviado",
|
"chat_send_failed": "no enviado",
|
||||||
"chat_sending": "enviando…",
|
"chat_sending": "enviando…",
|
||||||
|
"chat_sensitive_btn_mark": "Marcar como confidencial",
|
||||||
|
"chat_sensitive_btn_setup": "Configurar y marcar como confidencial",
|
||||||
|
"chat_sensitive_btn_unlock": "Desbloquear",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Desbloquear y marcar como confidencial",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Desbloquear y hacer normal",
|
||||||
|
"chat_sensitive_btn_unmark": "Hacer normal",
|
||||||
|
"chat_sensitive_err_create": "No se pudo crear la bóveda de chat confidencial.",
|
||||||
|
"chat_sensitive_err_pass": "Las contraseñas no coinciden o están vacías.",
|
||||||
|
"chat_sensitive_err_unlock": "Contraseña incorrecta.",
|
||||||
|
"chat_sensitive_lock_btn": "Bloquear confidenciales",
|
||||||
|
"chat_sensitive_mark_tip": "Marcar como confidencial",
|
||||||
|
"chat_sensitive_mark_title": "¿Marcar esta conversación como confidencial?",
|
||||||
|
"chat_sensitive_pass2_hint": "Confirmar contraseña",
|
||||||
|
"chat_sensitive_pass_hint": "Contraseña",
|
||||||
|
"chat_sensitive_setup_label": "Crea una contraseña para las conversaciones confidenciales",
|
||||||
|
"chat_sensitive_toast_marked": "La conversación ahora es confidencial",
|
||||||
|
"chat_sensitive_toast_unlocked": "Conversaciones confidenciales desbloqueadas",
|
||||||
|
"chat_sensitive_toast_unmarked": "La conversación ya no es confidencial",
|
||||||
|
"chat_sensitive_unlock_body": "Introduce tu contraseña de chat confidencial para volver a mostrar tus conversaciones confidenciales.",
|
||||||
|
"chat_sensitive_unlock_btn": "Desbloquear confidenciales",
|
||||||
|
"chat_sensitive_unlock_label": "Introduce tu contraseña de chat confidencial",
|
||||||
|
"chat_sensitive_unlock_title": "Desbloquear las conversaciones confidenciales",
|
||||||
|
"chat_sensitive_unmark_body": "Esta conversación dejará de estar cifrada con tu contraseña de chat confidencial. Los mensajes nuevos se guardarán como en cualquier otra conversación.",
|
||||||
|
"chat_sensitive_unmark_tip": "Confidencial: clic para cambiar",
|
||||||
|
"chat_sensitive_unmark_title": "¿Hacer normal esta conversación?",
|
||||||
|
"chat_sensitive_warn_1": "Los mensajes de esta conversación se cifrarán en este dispositivo con una contraseña aparte, no con tu frase de recuperación. Quien más adelante restaure tu cartera solo con tu frase de recuperación no verá esta conversación.",
|
||||||
|
"chat_sensitive_warn_dir": "Tus mensajes enviados aquí quedan totalmente protegidos. Los mensajes recibidos siguen en la cadena de bloques y, en teoría, alguien que tenga tu frase de recuperación y otro software podría recuperarlos; DragonX nunca los mostrará sin tu contraseña.",
|
||||||
|
"chat_sensitive_warn_hist": "Los mensajes que ya están en esta conversación siguen siendo recuperables desde la cadena; esto protege por completo los mensajes a partir de ahora.",
|
||||||
|
"chat_sensitive_warn_lose": "Si pierdes esta contraseña, estos mensajes se perderán para siempre: no se pueden recuperar con tu frase de recuperación.",
|
||||||
"chat_settings_done": "Listo",
|
"chat_settings_done": "Listo",
|
||||||
"chat_settings_section": "CHAT Y CONTACTOS",
|
"chat_settings_section": "CHAT Y CONTACTOS",
|
||||||
"chat_settings_tip": "Personalizar chat",
|
"chat_settings_tip": "Personalizar chat",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "Supprimer votre copie locale de la conversation avec ",
|
"chat_delete_body_prefix": "Supprimer votre copie locale de la conversation avec ",
|
||||||
"chat_delete_body_suffix": " ?",
|
"chat_delete_body_suffix": " ?",
|
||||||
"chat_delete_confirm": "Supprimer",
|
"chat_delete_confirm": "Supprimer",
|
||||||
|
"chat_delete_everywhere": "Supprimer partout",
|
||||||
|
"chat_delete_everywhere_note": "Indique aussi à vos autres appareils — et à une future restauration depuis votre phrase de récupération — de masquer cette conversation. Un petit signal est envoyé sur la chaîne (il coûte les frais de chat habituels). Vous pourrez la restaurer plus tard depuis Restaurer.",
|
||||||
|
"chat_delete_everywhere_toast": "Suppression partout — signal envoyé",
|
||||||
"chat_delete_failed": "Impossible de supprimer la conversation — rien n'a été modifié.",
|
"chat_delete_failed": "Impossible de supprimer la conversation — rien n'a été modifié.",
|
||||||
"chat_delete_local_note": "Cela n'affecte que cet appareil — les messages restent sur la blockchain et l'autre personne conserve sa copie.",
|
"chat_delete_local_note": "Cela n'affecte que cet appareil — les messages restent sur la blockchain et l'autre personne conserve sa copie.",
|
||||||
|
"chat_delete_needs_sync": "Attendez que le portefeuille soit entièrement synchronisé pour supprimer partout.",
|
||||||
"chat_delete_revive_note": "« Supprimer » efface l'historique sur cet appareil. Si cette personne vous écrit à nouveau, la conversation revient.",
|
"chat_delete_revive_note": "« Supprimer » efface l'historique sur cet appareil. Si cette personne vous écrit à nouveau, la conversation revient.",
|
||||||
"chat_delete_title": "Supprimer la conversation ?",
|
"chat_delete_title": "Supprimer la conversation ?",
|
||||||
"chat_deleted_toast": "Conversation supprimée",
|
"chat_deleted_toast": "Conversation supprimée",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Renommer le contact",
|
"chat_rename": "Renommer le contact",
|
||||||
"chat_rename_hint": "Nom du contact",
|
"chat_rename_hint": "Nom du contact",
|
||||||
"chat_renamed": "Contact renommé",
|
"chat_renamed": "Contact renommé",
|
||||||
|
"chat_restore_btn": "Restaurer",
|
||||||
|
"chat_restore_desc": "Les conversations que vous avez supprimées partout peuvent être rétablies ici — sur cet appareil ou après avoir importé votre phrase de récupération. La restauration réimporte les messages depuis la chaîne et indique à vos autres appareils de l'afficher à nouveau.",
|
||||||
|
"chat_restore_manage": "Restaurer",
|
||||||
|
"chat_restore_none": "Aucune conversation supprimée trouvée pour l'instant. Si vous en avez supprimé une sur un autre appareil, essayez l'Analyse rapide ou collez son adresse de chat ci-dessous.",
|
||||||
|
"chat_restore_paste_hint": "Collez l'adresse de chat (z) de la conversation",
|
||||||
|
"chat_restore_paste_label": "Restaurer par adresse de chat",
|
||||||
|
"chat_restore_scan": "Analyse rapide",
|
||||||
|
"chat_restore_scanning": "Analyse de la chaîne à la recherche de conversations supprimées...",
|
||||||
|
"chat_restore_title": "Restaurer des conversations",
|
||||||
|
"chat_restore_toast": "Restauration de la conversation — elle réapparaît après la prochaine analyse",
|
||||||
"chat_retry": "Réessayer",
|
"chat_retry": "Réessayer",
|
||||||
"chat_search": "Rechercher des conversations",
|
"chat_search": "Rechercher des conversations",
|
||||||
"chat_sec_appearance": "APPARENCE",
|
"chat_sec_appearance": "APPARENCE",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Envoyer",
|
"chat_send": "Envoyer",
|
||||||
"chat_send_failed": "non envoyé",
|
"chat_send_failed": "non envoyé",
|
||||||
"chat_sending": "envoi…",
|
"chat_sending": "envoi…",
|
||||||
|
"chat_sensitive_btn_mark": "Rendre confidentielle",
|
||||||
|
"chat_sensitive_btn_setup": "Configurer et rendre confidentielle",
|
||||||
|
"chat_sensitive_btn_unlock": "Déverrouiller",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Déverrouiller et rendre confidentielle",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Déverrouiller et rendre normale",
|
||||||
|
"chat_sensitive_btn_unmark": "Rendre normale",
|
||||||
|
"chat_sensitive_err_create": "Impossible de créer le coffre des conversations confidentielles.",
|
||||||
|
"chat_sensitive_err_pass": "Les phrases secrètes ne correspondent pas, ou sont vides.",
|
||||||
|
"chat_sensitive_err_unlock": "Phrase secrète incorrecte.",
|
||||||
|
"chat_sensitive_lock_btn": "Verrouiller les confidentielles",
|
||||||
|
"chat_sensitive_mark_tip": "Rendre confidentielle",
|
||||||
|
"chat_sensitive_mark_title": "Rendre cette conversation confidentielle ?",
|
||||||
|
"chat_sensitive_pass2_hint": "Confirmez la phrase secrète",
|
||||||
|
"chat_sensitive_pass_hint": "Phrase secrète",
|
||||||
|
"chat_sensitive_setup_label": "Créez une phrase secrète pour les conversations confidentielles",
|
||||||
|
"chat_sensitive_toast_marked": "La conversation est désormais confidentielle",
|
||||||
|
"chat_sensitive_toast_unlocked": "Conversations confidentielles déverrouillées",
|
||||||
|
"chat_sensitive_toast_unmarked": "La conversation n'est plus confidentielle",
|
||||||
|
"chat_sensitive_unlock_body": "Saisissez votre phrase secrète des conversations confidentielles pour afficher à nouveau vos conversations confidentielles.",
|
||||||
|
"chat_sensitive_unlock_btn": "Déverrouiller les confidentielles",
|
||||||
|
"chat_sensitive_unlock_label": "Saisissez votre phrase secrète des conversations confidentielles",
|
||||||
|
"chat_sensitive_unlock_title": "Déverrouiller les conversations confidentielles",
|
||||||
|
"chat_sensitive_unmark_body": "Cette conversation ne sera plus chiffrée avec votre phrase secrète des conversations confidentielles. Les nouveaux messages seront stockés comme ceux de n'importe quelle autre conversation.",
|
||||||
|
"chat_sensitive_unmark_tip": "Confidentielle — cliquez pour modifier",
|
||||||
|
"chat_sensitive_unmark_title": "Rendre cette conversation normale ?",
|
||||||
|
"chat_sensitive_warn_1": "Ses messages seront chiffrés sur cet appareil avec une phrase secrète distincte — et non avec votre phrase de récupération. Quiconque restaurera plus tard votre portefeuille à partir de votre seule phrase de récupération ne verra pas cette conversation.",
|
||||||
|
"chat_sensitive_warn_dir": "Vos messages envoyés ici sont entièrement protégés. Les messages reçus restent sur la blockchain et pourraient, en théorie, être récupérés par une personne disposant de votre phrase de récupération et d'un autre logiciel — mais DragonX lui-même ne les affichera jamais sans votre phrase secrète.",
|
||||||
|
"chat_sensitive_warn_hist": "Les messages déjà présents dans cette conversation restent récupérables depuis la chaîne ; la protection s'applique pleinement aux messages à venir.",
|
||||||
|
"chat_sensitive_warn_lose": "Si vous perdez cette phrase secrète, ces messages sont perdus à jamais — ils ne pourront pas être récupérés à partir de votre phrase de récupération.",
|
||||||
"chat_settings_done": "Terminé",
|
"chat_settings_done": "Terminé",
|
||||||
"chat_settings_section": "CHAT ET CONTACTS",
|
"chat_settings_section": "CHAT ET CONTACTS",
|
||||||
"chat_settings_tip": "Personnaliser le chat",
|
"chat_settings_tip": "Personnaliser le chat",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "次の相手との会話のローカルコピーを削除しますか:",
|
"chat_delete_body_prefix": "次の相手との会話のローカルコピーを削除しますか:",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "削除",
|
"chat_delete_confirm": "削除",
|
||||||
|
"chat_delete_everywhere": "すべての端末で削除",
|
||||||
|
"chat_delete_everywhere_note": "他の端末にも、そして将来シードフレーズから復元した際にも、この会話を非表示にするよう伝えます。チェーン上に小さな信号を送信します(通常のチャット手数料がかかります)。後で「復元」から元に戻せます。",
|
||||||
|
"chat_delete_everywhere_toast": "すべての端末で削除中 — 信号を送信しました",
|
||||||
"chat_delete_failed": "会話を削除できませんでした — 変更はありません。",
|
"chat_delete_failed": "会話を削除できませんでした — 変更はありません。",
|
||||||
"chat_delete_local_note": "これはこの端末のみに影響します — メッセージはブロックチェーン上に残り、相手も自分のコピーを保持し続けます。",
|
"chat_delete_local_note": "これはこの端末のみに影響します — メッセージはブロックチェーン上に残り、相手も自分のコピーを保持し続けます。",
|
||||||
|
"chat_delete_needs_sync": "すべての端末で削除するには、ウォレットの同期が完了するまでお待ちください。",
|
||||||
"chat_delete_revive_note": "「削除」はこの端末上の履歴を消去します。相手から再度メッセージが届くと、会話は復活します。",
|
"chat_delete_revive_note": "「削除」はこの端末上の履歴を消去します。相手から再度メッセージが届くと、会話は復活します。",
|
||||||
"chat_delete_title": "会話を削除しますか?",
|
"chat_delete_title": "会話を削除しますか?",
|
||||||
"chat_deleted_toast": "会話を削除しました",
|
"chat_deleted_toast": "会話を削除しました",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "連絡先の名前を変更",
|
"chat_rename": "連絡先の名前を変更",
|
||||||
"chat_rename_hint": "連絡先名",
|
"chat_rename_hint": "連絡先名",
|
||||||
"chat_renamed": "連絡先の名前を変更しました",
|
"chat_renamed": "連絡先の名前を変更しました",
|
||||||
|
"chat_restore_btn": "復元",
|
||||||
|
"chat_restore_desc": "すべての端末で削除した会話は、ここから元に戻せます — この端末でも、シードフレーズをインポートした後でも可能です。復元すると、チェーンからメッセージが再インポートされ、他の端末にも再表示するよう伝えます。",
|
||||||
|
"chat_restore_manage": "復元",
|
||||||
|
"chat_restore_none": "削除された会話はまだ見つかりません。別の端末で削除した場合は、「クイックスキャン」を試すか、下にチャットアドレスを貼り付けてください。",
|
||||||
|
"chat_restore_paste_hint": "会話のチャット(z)アドレスを貼り付け",
|
||||||
|
"chat_restore_paste_label": "チャットアドレスで復元",
|
||||||
|
"chat_restore_scan": "クイックスキャン",
|
||||||
|
"chat_restore_scanning": "削除された会話をチェーンでスキャン中…",
|
||||||
|
"chat_restore_title": "会話を復元",
|
||||||
|
"chat_restore_toast": "会話を復元中 — 次回のスキャン後に再表示されます",
|
||||||
"chat_retry": "再送信",
|
"chat_retry": "再送信",
|
||||||
"chat_search": "会話を検索",
|
"chat_search": "会話を検索",
|
||||||
"chat_sec_appearance": "外観",
|
"chat_sec_appearance": "外観",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "送信",
|
"chat_send": "送信",
|
||||||
"chat_send_failed": "未送信",
|
"chat_send_failed": "未送信",
|
||||||
"chat_sending": "送信中…",
|
"chat_sending": "送信中…",
|
||||||
|
"chat_sensitive_btn_mark": "機密にする",
|
||||||
|
"chat_sensitive_btn_setup": "設定して機密にする",
|
||||||
|
"chat_sensitive_btn_unlock": "ロック解除",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "ロック解除して機密にする",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "ロック解除して通常に戻す",
|
||||||
|
"chat_sensitive_btn_unmark": "通常に戻す",
|
||||||
|
"chat_sensitive_err_create": "機密チャット用ボールトの作成に失敗しました。",
|
||||||
|
"chat_sensitive_err_pass": "パスフレーズが一致しないか、空です。",
|
||||||
|
"chat_sensitive_err_unlock": "パスフレーズが正しくありません。",
|
||||||
|
"chat_sensitive_lock_btn": "機密をロック",
|
||||||
|
"chat_sensitive_mark_tip": "機密にする",
|
||||||
|
"chat_sensitive_mark_title": "この会話を機密にしますか?",
|
||||||
|
"chat_sensitive_pass2_hint": "パスフレーズを再入力",
|
||||||
|
"chat_sensitive_pass_hint": "パスフレーズ",
|
||||||
|
"chat_sensitive_setup_label": "機密会話用のパスフレーズを作成",
|
||||||
|
"chat_sensitive_toast_marked": "会話を機密にしました",
|
||||||
|
"chat_sensitive_toast_unlocked": "機密会話のロックを解除しました",
|
||||||
|
"chat_sensitive_toast_unmarked": "会話を通常に戻しました",
|
||||||
|
"chat_sensitive_unlock_body": "機密会話を再び表示するには、機密チャット用パスフレーズを入力してください。",
|
||||||
|
"chat_sensitive_unlock_btn": "機密をロック解除",
|
||||||
|
"chat_sensitive_unlock_label": "機密チャット用パスフレーズを入力",
|
||||||
|
"chat_sensitive_unlock_title": "機密会話のロックを解除",
|
||||||
|
"chat_sensitive_unmark_body": "この会話は、機密チャット用パスフレーズで暗号化されなくなります。新しいメッセージは、他の会話と同じように保存されます。",
|
||||||
|
"chat_sensitive_unmark_tip": "機密 — タップして変更",
|
||||||
|
"chat_sensitive_unmark_title": "この会話を通常に戻しますか?",
|
||||||
|
"chat_sensitive_warn_1": "メッセージはこの端末上で、シードフレーズとは別の専用パスフレーズを使って暗号化されます。後でシードフレーズだけからウォレットを復元した人には、この会話は表示されません。",
|
||||||
|
"chat_sensitive_warn_dir": "ここで送信したメッセージは完全に保護されます。受信したメッセージはブロックチェーン上に残っているため、あなたのシードフレーズと別のソフトウェアを持つ人なら、理論上は復元できる可能性があります。ただしDragonX自体が、パスフレーズなしにそれらを表示することは決してありません。",
|
||||||
|
"chat_sensitive_warn_hist": "この会話に既にあるメッセージは、チェーンから復元可能なままです。この設定は、これ以降のメッセージを完全に保護します。",
|
||||||
|
"chat_sensitive_warn_lose": "このパスフレーズを失うと、これらのメッセージは永久に失われます。シードフレーズからは復元できません。",
|
||||||
"chat_settings_done": "完了",
|
"chat_settings_done": "完了",
|
||||||
"chat_settings_section": "チャットと連絡先",
|
"chat_settings_section": "チャットと連絡先",
|
||||||
"chat_settings_tip": "チャットのカスタマイズ",
|
"chat_settings_tip": "チャットのカスタマイズ",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "다음 상대와의 대화 로컬 사본을 삭제할까요: ",
|
"chat_delete_body_prefix": "다음 상대와의 대화 로컬 사본을 삭제할까요: ",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "삭제",
|
"chat_delete_confirm": "삭제",
|
||||||
|
"chat_delete_everywhere": "모든 기기에서 삭제",
|
||||||
|
"chat_delete_everywhere_note": "다른 기기에도, 그리고 나중에 시드 문구로 복원할 때에도 이 대화를 숨기도록 알립니다. 아주 작은 온체인 신호를 전송하며(평소 채팅 수수료가 듭니다), 나중에 '복원'에서 다시 가져올 수 있습니다.",
|
||||||
|
"chat_delete_everywhere_toast": "모든 기기에서 삭제하는 중 — 신호를 보냈습니다",
|
||||||
"chat_delete_failed": "대화를 삭제하지 못했습니다 — 변경된 내용이 없습니다.",
|
"chat_delete_failed": "대화를 삭제하지 못했습니다 — 변경된 내용이 없습니다.",
|
||||||
"chat_delete_local_note": "이 기기에만 영향을 줍니다 — 메시지는 블록체인에 남아 있고 상대방도 자신의 사본을 계속 보관합니다.",
|
"chat_delete_local_note": "이 기기에만 영향을 줍니다 — 메시지는 블록체인에 남아 있고 상대방도 자신의 사본을 계속 보관합니다.",
|
||||||
|
"chat_delete_needs_sync": "모든 기기에서 삭제하려면 지갑이 완전히 동기화될 때까지 기다리세요.",
|
||||||
"chat_delete_revive_note": "\"삭제\"는 이 기기의 기록만 지웁니다. 상대방이 다시 메시지를 보내면 대화가 되살아납니다.",
|
"chat_delete_revive_note": "\"삭제\"는 이 기기의 기록만 지웁니다. 상대방이 다시 메시지를 보내면 대화가 되살아납니다.",
|
||||||
"chat_delete_title": "대화를 삭제할까요?",
|
"chat_delete_title": "대화를 삭제할까요?",
|
||||||
"chat_deleted_toast": "대화를 삭제했습니다",
|
"chat_deleted_toast": "대화를 삭제했습니다",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "연락처 이름 변경",
|
"chat_rename": "연락처 이름 변경",
|
||||||
"chat_rename_hint": "연락처 이름",
|
"chat_rename_hint": "연락처 이름",
|
||||||
"chat_renamed": "연락처 이름이 변경되었습니다",
|
"chat_renamed": "연락처 이름이 변경되었습니다",
|
||||||
|
"chat_restore_btn": "복원",
|
||||||
|
"chat_restore_desc": "모든 기기에서 삭제한 대화를 여기서 다시 가져올 수 있습니다 — 이 기기에서든, 시드 문구를 가져온 뒤에든 가능합니다. 복원하면 체인에서 메시지를 다시 가져오고, 다른 기기에도 대화를 다시 표시하도록 알립니다.",
|
||||||
|
"chat_restore_manage": "복원",
|
||||||
|
"chat_restore_none": "삭제된 대화를 아직 찾지 못했습니다. 다른 기기에서 삭제한 대화가 있다면 '빠른 스캔'을 해보거나 아래에 채팅 주소를 붙여넣으세요.",
|
||||||
|
"chat_restore_paste_hint": "대화의 채팅 (z) 주소를 붙여넣으세요",
|
||||||
|
"chat_restore_paste_label": "채팅 주소로 복원",
|
||||||
|
"chat_restore_scan": "빠른 스캔",
|
||||||
|
"chat_restore_scanning": "체인에서 삭제된 대화를 스캔하는 중...",
|
||||||
|
"chat_restore_title": "대화 복원",
|
||||||
|
"chat_restore_toast": "대화를 복원하는 중 — 다음 스캔 후에 다시 표시됩니다",
|
||||||
"chat_retry": "다시 시도",
|
"chat_retry": "다시 시도",
|
||||||
"chat_search": "대화 검색",
|
"chat_search": "대화 검색",
|
||||||
"chat_sec_appearance": "모양",
|
"chat_sec_appearance": "모양",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "전송",
|
"chat_send": "전송",
|
||||||
"chat_send_failed": "전송 안 됨",
|
"chat_send_failed": "전송 안 됨",
|
||||||
"chat_sending": "전송 중…",
|
"chat_sending": "전송 중…",
|
||||||
|
"chat_sensitive_btn_mark": "민감한 대화로 전환",
|
||||||
|
"chat_sensitive_btn_setup": "암호 설정 후 민감한 대화로 전환",
|
||||||
|
"chat_sensitive_btn_unlock": "잠금 해제",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "잠금 해제 후 민감한 대화로 전환",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "잠금 해제 후 일반 대화로 전환",
|
||||||
|
"chat_sensitive_btn_unmark": "일반 대화로 전환",
|
||||||
|
"chat_sensitive_err_create": "민감한 대화 보관소를 만들지 못했습니다.",
|
||||||
|
"chat_sensitive_err_pass": "암호가 일치하지 않거나 비어 있습니다.",
|
||||||
|
"chat_sensitive_err_unlock": "잘못된 암호입니다.",
|
||||||
|
"chat_sensitive_lock_btn": "민감한 대화 잠그기",
|
||||||
|
"chat_sensitive_mark_tip": "민감한 대화로 전환",
|
||||||
|
"chat_sensitive_mark_title": "이 대화를 민감한 대화로 전환할까요?",
|
||||||
|
"chat_sensitive_pass2_hint": "암호 확인",
|
||||||
|
"chat_sensitive_pass_hint": "암호",
|
||||||
|
"chat_sensitive_setup_label": "민감한 대화를 위한 암호를 만드세요",
|
||||||
|
"chat_sensitive_toast_marked": "이제 민감한 대화입니다",
|
||||||
|
"chat_sensitive_toast_unlocked": "민감한 대화 잠금이 해제되었습니다",
|
||||||
|
"chat_sensitive_toast_unmarked": "더 이상 민감한 대화가 아닙니다",
|
||||||
|
"chat_sensitive_unlock_body": "민감한 대화를 다시 표시하려면 민감한 대화 암호를 입력하세요.",
|
||||||
|
"chat_sensitive_unlock_btn": "민감한 대화 잠금 해제",
|
||||||
|
"chat_sensitive_unlock_label": "민감한 대화 암호를 입력하세요",
|
||||||
|
"chat_sensitive_unlock_title": "민감한 대화 잠금 해제",
|
||||||
|
"chat_sensitive_unmark_body": "이 대화는 더 이상 민감한 대화 암호로 암호화되지 않습니다. 새 메시지는 다른 대화와 똑같이 저장됩니다.",
|
||||||
|
"chat_sensitive_unmark_tip": "민감한 대화 — 눌러서 변경",
|
||||||
|
"chat_sensitive_unmark_title": "이 대화를 일반 대화로 전환할까요?",
|
||||||
|
"chat_sensitive_warn_1": "이 대화의 메시지는 시드 문구가 아닌 별도의 암호로 이 기기에서 암호화됩니다. 나중에 시드만으로 지갑을 복원하는 사람은 이 대화를 볼 수 없습니다.",
|
||||||
|
"chat_sensitive_warn_dir": "여기서 보낸 메시지는 완전히 보호됩니다. 받은 메시지는 여전히 블록체인에 남아 있어, 이론적으로는 시드와 다른 소프트웨어를 가진 사람이 복구할 수도 있습니다 — 다만 DragonX 자체는 암호 없이는 그 메시지를 절대 표시하지 않습니다.",
|
||||||
|
"chat_sensitive_warn_hist": "이 대화에 이미 있는 메시지는 체인에서 계속 복구될 수 있습니다. 이렇게 하면 지금부터의 메시지는 완전히 보호됩니다.",
|
||||||
|
"chat_sensitive_warn_lose": "이 암호를 잃어버리면 이 메시지는 영원히 사라집니다 — 시드로는 복구할 수 없습니다.",
|
||||||
"chat_settings_done": "완료",
|
"chat_settings_done": "완료",
|
||||||
"chat_settings_section": "채팅 및 연락처",
|
"chat_settings_section": "채팅 및 연락처",
|
||||||
"chat_settings_tip": "채팅 사용자 지정",
|
"chat_settings_tip": "채팅 사용자 지정",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "Excluir sua cópia local da conversa com ",
|
"chat_delete_body_prefix": "Excluir sua cópia local da conversa com ",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "Excluir",
|
"chat_delete_confirm": "Excluir",
|
||||||
|
"chat_delete_everywhere": "Excluir em todo lugar",
|
||||||
|
"chat_delete_everywhere_note": "Também avisa seus outros dispositivos — e uma futura restauração a partir da sua frase de recuperação — para ocultar esta conversa. Isso envia um pequeno sinal on-chain (custa a taxa de chat habitual). Você pode trazê-la de volta depois em Restaurar.",
|
||||||
|
"chat_delete_everywhere_toast": "Excluindo em todo lugar — sinal enviado",
|
||||||
"chat_delete_failed": "Não foi possível excluir a conversa — nada foi alterado.",
|
"chat_delete_failed": "Não foi possível excluir a conversa — nada foi alterado.",
|
||||||
"chat_delete_local_note": "Isso afeta apenas este dispositivo — as mensagens permanecem na blockchain e a outra pessoa mantém sua cópia.",
|
"chat_delete_local_note": "Isso afeta apenas este dispositivo — as mensagens permanecem na blockchain e a outra pessoa mantém sua cópia.",
|
||||||
|
"chat_delete_needs_sync": "Aguarde a carteira sincronizar totalmente para excluir em todo lugar.",
|
||||||
"chat_delete_revive_note": "\"Excluir\" limpa o histórico neste dispositivo. Se essa pessoa enviar uma mensagem novamente, a conversa volta.",
|
"chat_delete_revive_note": "\"Excluir\" limpa o histórico neste dispositivo. Se essa pessoa enviar uma mensagem novamente, a conversa volta.",
|
||||||
"chat_delete_title": "Excluir conversa?",
|
"chat_delete_title": "Excluir conversa?",
|
||||||
"chat_deleted_toast": "Conversa excluída",
|
"chat_deleted_toast": "Conversa excluída",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Renomear contato",
|
"chat_rename": "Renomear contato",
|
||||||
"chat_rename_hint": "Nome do contato",
|
"chat_rename_hint": "Nome do contato",
|
||||||
"chat_renamed": "Contato renomeado",
|
"chat_renamed": "Contato renomeado",
|
||||||
|
"chat_restore_btn": "Restaurar",
|
||||||
|
"chat_restore_desc": "Conversas que você excluiu em todo lugar podem ser trazidas de volta aqui — neste dispositivo ou após importar sua frase de recuperação. Restaurar reimporta as mensagens a partir da chain e avisa seus outros dispositivos para exibi-la novamente.",
|
||||||
|
"chat_restore_manage": "Restaurar",
|
||||||
|
"chat_restore_none": "Nenhuma conversa excluída encontrada ainda. Se você excluiu uma em outro dispositivo, tente o Escaneamento rápido ou cole o endereço de chat dela abaixo.",
|
||||||
|
"chat_restore_paste_hint": "Cole o endereço de chat (z) da conversa",
|
||||||
|
"chat_restore_paste_label": "Restaurar pelo endereço de chat",
|
||||||
|
"chat_restore_scan": "Escaneamento rápido",
|
||||||
|
"chat_restore_scanning": "Escaneando a chain em busca de conversas excluídas...",
|
||||||
|
"chat_restore_title": "Restaurar conversas",
|
||||||
|
"chat_restore_toast": "Restaurando a conversa — ela reaparece após o próximo escaneamento",
|
||||||
"chat_retry": "Tentar novamente",
|
"chat_retry": "Tentar novamente",
|
||||||
"chat_search": "Pesquisar conversas",
|
"chat_search": "Pesquisar conversas",
|
||||||
"chat_sec_appearance": "APARÊNCIA",
|
"chat_sec_appearance": "APARÊNCIA",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Enviar",
|
"chat_send": "Enviar",
|
||||||
"chat_send_failed": "não enviada",
|
"chat_send_failed": "não enviada",
|
||||||
"chat_sending": "enviando…",
|
"chat_sending": "enviando…",
|
||||||
|
"chat_sensitive_btn_mark": "Tornar confidencial",
|
||||||
|
"chat_sensitive_btn_setup": "Configurar e tornar confidencial",
|
||||||
|
"chat_sensitive_btn_unlock": "Desbloquear",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Desbloquear e tornar confidencial",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Desbloquear e tornar normal",
|
||||||
|
"chat_sensitive_btn_unmark": "Tornar normal",
|
||||||
|
"chat_sensitive_err_create": "Não foi possível criar o cofre das conversas confidenciais.",
|
||||||
|
"chat_sensitive_err_pass": "As senhas não coincidem ou estão vazias.",
|
||||||
|
"chat_sensitive_err_unlock": "Senha incorreta.",
|
||||||
|
"chat_sensitive_lock_btn": "Bloquear confidenciais",
|
||||||
|
"chat_sensitive_mark_tip": "Tornar confidencial",
|
||||||
|
"chat_sensitive_mark_title": "Tornar esta conversa confidencial?",
|
||||||
|
"chat_sensitive_pass2_hint": "Confirmar senha",
|
||||||
|
"chat_sensitive_pass_hint": "Senha",
|
||||||
|
"chat_sensitive_setup_label": "Crie uma senha para as conversas confidenciais",
|
||||||
|
"chat_sensitive_toast_marked": "A conversa agora é confidencial",
|
||||||
|
"chat_sensitive_toast_unlocked": "Conversas confidenciais desbloqueadas",
|
||||||
|
"chat_sensitive_toast_unmarked": "A conversa não é mais confidencial",
|
||||||
|
"chat_sensitive_unlock_body": "Digite a sua senha de conversas confidenciais para exibir novamente as suas conversas confidenciais.",
|
||||||
|
"chat_sensitive_unlock_btn": "Desbloquear confidenciais",
|
||||||
|
"chat_sensitive_unlock_label": "Digite a sua senha de conversas confidenciais",
|
||||||
|
"chat_sensitive_unlock_title": "Desbloquear conversas confidenciais",
|
||||||
|
"chat_sensitive_unmark_body": "Esta conversa deixará de ser criptografada com a sua senha de conversas confidenciais. As novas mensagens serão armazenadas como em qualquer outra conversa.",
|
||||||
|
"chat_sensitive_unmark_tip": "Confidencial — toque para alterar",
|
||||||
|
"chat_sensitive_unmark_title": "Tornar esta conversa normal?",
|
||||||
|
"chat_sensitive_warn_1": "As mensagens dela serão criptografadas neste dispositivo com uma senha separada — não a sua frase de recuperação. Quem mais tarde restaurar sua carteira apenas a partir da sua semente não verá esta conversa.",
|
||||||
|
"chat_sensitive_warn_dir": "As mensagens que você envia aqui ficam totalmente protegidas. As mensagens recebidas continuam na blockchain e poderiam, em teoria, ser recuperadas por alguém que tenha a sua semente e outros softwares — o próprio DragonX nunca as exibirá sem a sua senha.",
|
||||||
|
"chat_sensitive_warn_hist": "As mensagens que já estão nesta conversa continuam recuperáveis a partir da chain; isso protege totalmente as mensagens daqui em diante.",
|
||||||
|
"chat_sensitive_warn_lose": "Se você perder esta senha, estas mensagens desaparecem para sempre — elas não podem ser recuperadas a partir da sua semente.",
|
||||||
"chat_settings_done": "Concluído",
|
"chat_settings_done": "Concluído",
|
||||||
"chat_settings_section": "CHAT E CONTATOS",
|
"chat_settings_section": "CHAT E CONTATOS",
|
||||||
"chat_settings_tip": "Personalizar chat",
|
"chat_settings_tip": "Personalizar chat",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "Удалить локальную копию разговора с ",
|
"chat_delete_body_prefix": "Удалить локальную копию разговора с ",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "Удалить",
|
"chat_delete_confirm": "Удалить",
|
||||||
|
"chat_delete_everywhere": "Удалить везде",
|
||||||
|
"chat_delete_everywhere_note": "Также сообщает другим вашим устройствам — и кошельку, который вы позже восстановите из сид-фразы, — что этот разговор нужно скрыть. Отправляется крошечный сигнал в блокчейне (взимается обычная комиссия чата). Позже вы сможете вернуть его в разделе «Восстановить».",
|
||||||
|
"chat_delete_everywhere_toast": "Удаление везде — сигнал отправлен",
|
||||||
"chat_delete_failed": "Не удалось удалить разговор — ничего не изменено.",
|
"chat_delete_failed": "Не удалось удалить разговор — ничего не изменено.",
|
||||||
"chat_delete_local_note": "Это влияет только на это устройство — сообщения остаются в блокчейне, и у собеседника сохранится своя копия.",
|
"chat_delete_local_note": "Это влияет только на это устройство — сообщения остаются в блокчейне, и у собеседника сохранится своя копия.",
|
||||||
|
"chat_delete_needs_sync": "Дождитесь полной синхронизации кошелька, чтобы удалить везде.",
|
||||||
"chat_delete_revive_note": "«Удалить» очищает историю на этом устройстве. Если вам снова напишут, разговор появится снова.",
|
"chat_delete_revive_note": "«Удалить» очищает историю на этом устройстве. Если вам снова напишут, разговор появится снова.",
|
||||||
"chat_delete_title": "Удалить разговор?",
|
"chat_delete_title": "Удалить разговор?",
|
||||||
"chat_deleted_toast": "Разговор удалён",
|
"chat_deleted_toast": "Разговор удалён",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Переименовать контакт",
|
"chat_rename": "Переименовать контакт",
|
||||||
"chat_rename_hint": "Имя контакта",
|
"chat_rename_hint": "Имя контакта",
|
||||||
"chat_renamed": "Контакт переименован",
|
"chat_renamed": "Контакт переименован",
|
||||||
|
"chat_restore_btn": "Восстановить",
|
||||||
|
"chat_restore_desc": "Разговоры, которые вы удалили везде, можно вернуть здесь — на этом устройстве или после импорта сид-фразы. Восстановление заново импортирует сообщения из блокчейна и сообщает другим вашим устройствам, что разговор снова нужно показывать.",
|
||||||
|
"chat_restore_manage": "Восстановить",
|
||||||
|
"chat_restore_none": "Удалённых разговоров пока не найдено. Если вы удалили разговор на другом устройстве, попробуйте «Быстрое сканирование» или вставьте его адрес чата ниже.",
|
||||||
|
"chat_restore_paste_hint": "Вставьте защищённый (z) адрес этого разговора",
|
||||||
|
"chat_restore_paste_label": "Восстановить по адресу чата",
|
||||||
|
"chat_restore_scan": "Быстрое сканирование",
|
||||||
|
"chat_restore_scanning": "Сканирование блокчейна в поиске удалённых разговоров…",
|
||||||
|
"chat_restore_title": "Восстановление разговоров",
|
||||||
|
"chat_restore_toast": "Восстановление разговора — он появится снова после следующего сканирования",
|
||||||
"chat_retry": "Повторить",
|
"chat_retry": "Повторить",
|
||||||
"chat_search": "Поиск разговоров",
|
"chat_search": "Поиск разговоров",
|
||||||
"chat_sec_appearance": "ВИД",
|
"chat_sec_appearance": "ВИД",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Отправить",
|
"chat_send": "Отправить",
|
||||||
"chat_send_failed": "не отправлено",
|
"chat_send_failed": "не отправлено",
|
||||||
"chat_sending": "отправка…",
|
"chat_sending": "отправка…",
|
||||||
|
"chat_sensitive_btn_mark": "Сделать конфиденциальным",
|
||||||
|
"chat_sensitive_btn_setup": "Настроить и сделать конфиденциальным",
|
||||||
|
"chat_sensitive_btn_unlock": "Разблокировать",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Разблокировать и сделать конфиденциальным",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Разблокировать и сделать обычным",
|
||||||
|
"chat_sensitive_btn_unmark": "Сделать обычным",
|
||||||
|
"chat_sensitive_err_create": "Не удалось создать хранилище конфиденциальных разговоров.",
|
||||||
|
"chat_sensitive_err_pass": "Пароли не совпадают или не заполнены.",
|
||||||
|
"chat_sensitive_err_unlock": "Неверный пароль.",
|
||||||
|
"chat_sensitive_lock_btn": "Заблокировать конфиденциальные",
|
||||||
|
"chat_sensitive_mark_tip": "Сделать конфиденциальным",
|
||||||
|
"chat_sensitive_mark_title": "Сделать этот разговор конфиденциальным?",
|
||||||
|
"chat_sensitive_pass2_hint": "Подтвердите пароль",
|
||||||
|
"chat_sensitive_pass_hint": "Пароль",
|
||||||
|
"chat_sensitive_setup_label": "Создайте пароль для конфиденциальных разговоров",
|
||||||
|
"chat_sensitive_toast_marked": "Разговор теперь конфиденциальный",
|
||||||
|
"chat_sensitive_toast_unlocked": "Конфиденциальные разговоры разблокированы",
|
||||||
|
"chat_sensitive_toast_unmarked": "Разговор больше не конфиденциальный",
|
||||||
|
"chat_sensitive_unlock_body": "Введите пароль для конфиденциальных разговоров, чтобы снова показать их.",
|
||||||
|
"chat_sensitive_unlock_btn": "Разблокировать конфиденциальные",
|
||||||
|
"chat_sensitive_unlock_label": "Введите пароль для конфиденциальных разговоров",
|
||||||
|
"chat_sensitive_unlock_title": "Разблокировать конфиденциальные разговоры",
|
||||||
|
"chat_sensitive_unmark_body": "Этот разговор больше не будет зашифрован вашим паролем для конфиденциальных разговоров. Новые сообщения будут храниться как в любом другом разговоре.",
|
||||||
|
"chat_sensitive_unmark_tip": "Конфиденциальный — нажмите, чтобы изменить",
|
||||||
|
"chat_sensitive_unmark_title": "Сделать этот разговор обычным?",
|
||||||
|
"chat_sensitive_warn_1": "Сообщения в нём будут зашифрованы на этом устройстве отдельным паролем, а не вашей сид-фразой. Любой, кто позже восстановит ваш кошелёк только по сид-фразе, не увидит этот разговор.",
|
||||||
|
"chat_sensitive_warn_dir": "Отправленные вами здесь сообщения защищены полностью. Полученные сообщения по-прежнему находятся в блокчейне, и теоретически их может восстановить тот, у кого есть ваша сид-фраза и другое ПО, — но сам DragonX никогда не покажет их без вашего пароля.",
|
||||||
|
"chat_sensitive_warn_hist": "Сообщения, которые уже есть в этом разговоре, по-прежнему можно восстановить из блокчейна; полная защита действует для сообщений, начиная с этого момента.",
|
||||||
|
"chat_sensitive_warn_lose": "Если вы потеряете этот пароль, эти сообщения пропадут навсегда — их нельзя восстановить по сид-фразе.",
|
||||||
"chat_settings_done": "Готово",
|
"chat_settings_done": "Готово",
|
||||||
"chat_settings_section": "ЧАТ И КОНТАКТЫ",
|
"chat_settings_section": "ЧАТ И КОНТАКТЫ",
|
||||||
"chat_settings_tip": "Настройка чата",
|
"chat_settings_tip": "Настройка чата",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "删除与以下用户会话的本地副本:",
|
"chat_delete_body_prefix": "删除与以下用户会话的本地副本:",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "删除",
|
"chat_delete_confirm": "删除",
|
||||||
|
"chat_delete_everywhere": "在所有设备删除",
|
||||||
|
"chat_delete_everywhere_note": "这还会告知您的其他设备——以及日后通过助记词恢复的钱包——隐藏此会话。此操作会发送一个微小的链上信号(需支付常规聊天手续费)。之后您可以在“恢复”中将其重新找回。",
|
||||||
|
"chat_delete_everywhere_toast": "正在所有设备上删除——信号已发送",
|
||||||
"chat_delete_failed": "无法删除会话——未做任何更改。",
|
"chat_delete_failed": "无法删除会话——未做任何更改。",
|
||||||
"chat_delete_local_note": "此操作仅影响此设备——消息仍保留在区块链上,对方也仍保有自己的副本。",
|
"chat_delete_local_note": "此操作仅影响此设备——消息仍保留在区块链上,对方也仍保有自己的副本。",
|
||||||
|
"chat_delete_needs_sync": "请等待钱包完全同步后,才能在所有设备删除。",
|
||||||
"chat_delete_revive_note": "“删除”会清除此设备上的记录。如果对方再次给您发消息,会话会重新出现。",
|
"chat_delete_revive_note": "“删除”会清除此设备上的记录。如果对方再次给您发消息,会话会重新出现。",
|
||||||
"chat_delete_title": "删除会话?",
|
"chat_delete_title": "删除会话?",
|
||||||
"chat_deleted_toast": "会话已删除",
|
"chat_deleted_toast": "会话已删除",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "重命名联系人",
|
"chat_rename": "重命名联系人",
|
||||||
"chat_rename_hint": "联系人名称",
|
"chat_rename_hint": "联系人名称",
|
||||||
"chat_renamed": "联系人已重命名",
|
"chat_renamed": "联系人已重命名",
|
||||||
|
"chat_restore_btn": "恢复",
|
||||||
|
"chat_restore_desc": "在所有设备删除的会话可以在此处找回——无论是在本设备上,还是在导入助记词之后。恢复会从链上重新导入消息,并通知您的其他设备再次显示该会话。",
|
||||||
|
"chat_restore_manage": "恢复",
|
||||||
|
"chat_restore_none": "尚未找到已删除的会话。如果您曾在其他设备上删除过某个会话,请尝试“快速扫描”,或在下方粘贴其聊天地址。",
|
||||||
|
"chat_restore_paste_hint": "粘贴该会话的聊天(z)地址",
|
||||||
|
"chat_restore_paste_label": "通过聊天地址恢复",
|
||||||
|
"chat_restore_scan": "快速扫描",
|
||||||
|
"chat_restore_scanning": "正在扫描区块链以查找已删除的会话…",
|
||||||
|
"chat_restore_title": "恢复会话",
|
||||||
|
"chat_restore_toast": "正在恢复会话——将在下次扫描后重新显示",
|
||||||
"chat_retry": "重试",
|
"chat_retry": "重试",
|
||||||
"chat_search": "搜索会话",
|
"chat_search": "搜索会话",
|
||||||
"chat_sec_appearance": "外观",
|
"chat_sec_appearance": "外观",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "发送",
|
"chat_send": "发送",
|
||||||
"chat_send_failed": "未发送",
|
"chat_send_failed": "未发送",
|
||||||
"chat_sending": "发送中…",
|
"chat_sending": "发送中…",
|
||||||
|
"chat_sensitive_btn_mark": "设为私密",
|
||||||
|
"chat_sensitive_btn_setup": "设置并设为私密",
|
||||||
|
"chat_sensitive_btn_unlock": "解锁",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "解锁并设为私密",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "解锁并设为普通",
|
||||||
|
"chat_sensitive_btn_unmark": "设为普通",
|
||||||
|
"chat_sensitive_err_create": "无法设置私密聊天保险库。",
|
||||||
|
"chat_sensitive_err_pass": "两次输入的密码短语不一致,或为空。",
|
||||||
|
"chat_sensitive_err_unlock": "密码短语错误。",
|
||||||
|
"chat_sensitive_lock_btn": "锁定私密会话",
|
||||||
|
"chat_sensitive_mark_tip": "设为私密",
|
||||||
|
"chat_sensitive_mark_title": "将此会话设为私密?",
|
||||||
|
"chat_sensitive_pass2_hint": "确认密码短语",
|
||||||
|
"chat_sensitive_pass_hint": "密码短语",
|
||||||
|
"chat_sensitive_setup_label": "为私密会话创建一个密码短语",
|
||||||
|
"chat_sensitive_toast_marked": "会话现已设为私密",
|
||||||
|
"chat_sensitive_toast_unlocked": "私密会话已解锁",
|
||||||
|
"chat_sensitive_toast_unmarked": "会话已设为普通",
|
||||||
|
"chat_sensitive_unlock_body": "输入您的私密聊天密码短语,以重新显示您的私密会话。",
|
||||||
|
"chat_sensitive_unlock_btn": "解锁私密会话",
|
||||||
|
"chat_sensitive_unlock_label": "输入您的私密聊天密码短语",
|
||||||
|
"chat_sensitive_unlock_title": "解锁私密会话",
|
||||||
|
"chat_sensitive_unmark_body": "此会话将不再使用您的私密聊天密码短语加密。新消息将像其他任何会话一样存储。",
|
||||||
|
"chat_sensitive_unmark_tip": "私密——点击可更改",
|
||||||
|
"chat_sensitive_unmark_title": "将此会话设为普通?",
|
||||||
|
"chat_sensitive_warn_1": "其消息将在此设备上以一个单独的密码短语加密——而非您的助记词。日后任何仅凭您的助记词恢复钱包的人,都将看不到此会话。",
|
||||||
|
"chat_sensitive_warn_dir": "您在此发送的消息受到完全保护。收到的消息仍保留在区块链上,理论上,拥有您助记词及其他软件的人有可能将其恢复——但没有您的密码短语,DragonX 本身绝不会显示它们。",
|
||||||
|
"chat_sensitive_warn_hist": "此会话中已有的消息仍可从链上恢复;此功能将完全保护此后的新消息。",
|
||||||
|
"chat_sensitive_warn_lose": "如果您丢失此密码短语,这些消息将永远消失——它们无法通过您的助记词恢复。",
|
||||||
"chat_settings_done": "完成",
|
"chat_settings_done": "完成",
|
||||||
"chat_settings_section": "聊天与联系人",
|
"chat_settings_section": "聊天与联系人",
|
||||||
"chat_settings_tip": "聊天自定义",
|
"chat_settings_tip": "聊天自定义",
|
||||||
|
|||||||
+138
-13
@@ -965,6 +965,9 @@ void App::update()
|
|||||||
lite_wallet_->pumpAsyncOpen();
|
lite_wallet_->pumpAsyncOpen();
|
||||||
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
||||||
lite_wallet_->pumpLifecycleResult();
|
lite_wallet_->pumpLifecycleResult();
|
||||||
|
// A create/restore whose passphrase encryption failed left the wallet UNENCRYPTED (previously log-only): say so.
|
||||||
|
if (lite_wallet_->takeEncryptionFailureNotice())
|
||||||
|
ui::Notifications::instance().error(TR("lite_encrypt_failed_unencrypted"), 15.0f);
|
||||||
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
||||||
lite_wallet_->pumpWalletOps();
|
lite_wallet_->pumpWalletOps();
|
||||||
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
||||||
@@ -1050,7 +1053,11 @@ void App::update()
|
|||||||
case LiteOpKind::None:
|
case LiteOpKind::None:
|
||||||
default:
|
default:
|
||||||
if (lite_send_callback_) {
|
if (lite_send_callback_) {
|
||||||
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
|
// A transport-style failure may mean the server accepted the tx but the reply was
|
||||||
|
// lost: tag it so the Send tab says "check History" instead of inviting a resend.
|
||||||
|
const std::string liteErr = (!broadcast.ok && rpc::liteSendErrorMayBeInFlight(broadcast.error))
|
||||||
|
? rpc::markSendMaybeInFlight(broadcast.error) : broadcast.error;
|
||||||
|
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : liteErr);
|
||||||
lite_send_callback_ = nullptr;
|
lite_send_callback_ = nullptr;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -2575,7 +2582,11 @@ void App::render()
|
|||||||
if (show_import_key_) {
|
if (show_import_key_) {
|
||||||
renderImportKeyDialog();
|
renderImportKeyDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (show_import_wallet_file_) {
|
||||||
|
renderImportWalletFileDialog();
|
||||||
|
}
|
||||||
|
|
||||||
if (show_export_key_) {
|
if (show_export_key_) {
|
||||||
renderExportKeyDialog();
|
renderExportKeyDialog();
|
||||||
}
|
}
|
||||||
@@ -2709,6 +2720,10 @@ void App::renderNodeStatusBanner()
|
|||||||
in.node_busy = busy.busy;
|
in.node_busy = busy.busy;
|
||||||
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
|
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
|
||||||
in.auth_failed = rpc_auth_failed_;
|
in.auth_failed = rpc_auth_failed_;
|
||||||
|
// Corrected trigger: ONLY a degraded wallet (wallet_degraded_ — z_getnewaddress fails, can't shield) that
|
||||||
|
// actually holds transparent funds. A merely-non-mnemonic but functional wallet has wallet_degraded_ ==
|
||||||
|
// false and is never flagged here. Full-node only (in.lite returns early regardless).
|
||||||
|
in.legacy_funds_stuck = !isLiteBuild() && wallet_degraded_ && state_.transparentBalance > 0.0001;
|
||||||
|
|
||||||
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
||||||
if (!banner.show) return;
|
if (!banner.show) return;
|
||||||
@@ -2747,6 +2762,8 @@ void App::renderNodeStatusBanner()
|
|||||||
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
|
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
|
||||||
case ui::NodeBannerReason::AuthFailed:
|
case ui::NodeBannerReason::AuthFailed:
|
||||||
title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break;
|
title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break;
|
||||||
|
case ui::NodeBannerReason::LegacyFundsStuck:
|
||||||
|
title = TR("node_banner_legacy_title"); icon = ICON_MD_KEY; break;
|
||||||
case ui::NodeBannerReason::FullNodeOffline:
|
case ui::NodeBannerReason::FullNodeOffline:
|
||||||
default:
|
default:
|
||||||
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||||
@@ -2756,6 +2773,7 @@ void App::renderNodeStatusBanner()
|
|||||||
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
||||||
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
||||||
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config");
|
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config");
|
||||||
|
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) actionLabel = TR("node_banner_recover");
|
||||||
|
|
||||||
// The NoPeers case deliberately carries no passthrough detail (connection_status is just
|
// The NoPeers case deliberately carries no passthrough detail (connection_status is just
|
||||||
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
|
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
|
||||||
@@ -2763,6 +2781,7 @@ void App::renderNodeStatusBanner()
|
|||||||
std::string detailText;
|
std::string detailText;
|
||||||
if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail");
|
if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail");
|
||||||
else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail");
|
else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail");
|
||||||
|
else if (banner.reason == ui::NodeBannerReason::LegacyFundsStuck) detailText = TR("node_banner_legacy_detail");
|
||||||
else detailText = banner.detail;
|
else detailText = banner.detail;
|
||||||
|
|
||||||
const float padX = ui::Layout::spacingLg();
|
const float padX = ui::Layout::spacingLg();
|
||||||
@@ -2852,6 +2871,7 @@ void App::renderNodeStatusBanner()
|
|||||||
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
||||||
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
||||||
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
||||||
|
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) showSeedMigrationDialog();
|
||||||
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) {
|
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) {
|
||||||
const std::string conf = rpc::Connection::getDefaultConfPath();
|
const std::string conf = rpc::Connection::getDefaultConfPath();
|
||||||
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
|
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
|
||||||
@@ -3106,6 +3126,10 @@ void App::renderStatusBar()
|
|||||||
} else if (rpc_busy_gate_->busy()) {
|
} else if (rpc_busy_gate_->busy()) {
|
||||||
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
||||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
||||||
|
} else if (isLiteBuild() && !state_.sync.lite_error.empty()) {
|
||||||
|
// Lite scan/server failure: show the error instead of a frozen 'Syncing 99.9%'.
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Error()), TR("sb_lite_sync_error"),
|
||||||
|
state_.sync.lite_error.c_str());
|
||||||
} else if (state_.sync.syncing) {
|
} else if (state_.sync.syncing) {
|
||||||
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
||||||
int blocksLeft = chainTip - state_.sync.blocks;
|
int blocksLeft = chainTip - state_.sync.blocks;
|
||||||
@@ -3821,12 +3845,13 @@ void App::renderLiteUnlockPrompt()
|
|||||||
const float btnW = 130.0f * dp;
|
const float btnW = 130.0f * dp;
|
||||||
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
||||||
if (doUnlock) {
|
if (doUnlock) {
|
||||||
const bool ok = lite_wallet_->unlockWallet(pass);
|
std::string unlockErr;
|
||||||
|
const bool ok = unlockLiteWallet(std::string(pass), &unlockErr);
|
||||||
sodium_memzero(pass, sizeof(pass));
|
sodium_memzero(pass, sizeof(pass));
|
||||||
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
||||||
else {
|
else {
|
||||||
wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed"));
|
wallet::liteLog(std::string("Unlock failed: ") + unlockErr);
|
||||||
ui::Notifications::instance().error(TR("lite_unlock_failed"));
|
ui::Notifications::instance().error(unlockErr);
|
||||||
}
|
}
|
||||||
lite_unlock_prompt_ = false;
|
lite_unlock_prompt_ = false;
|
||||||
ImGui::CloseCurrentPopup();
|
ImGui::CloseCurrentPopup();
|
||||||
@@ -3928,6 +3953,85 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
|
|||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void App::renderImportWalletFileDialog()
|
||||||
|
{
|
||||||
|
namespace m = ui::material;
|
||||||
|
float dp = ui::Layout::dpiScale();
|
||||||
|
|
||||||
|
m::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("import_wallet_file_title");
|
||||||
|
ov.p_open = &show_import_wallet_file_;
|
||||||
|
ov.style = m::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 640.0f;
|
||||||
|
ov.idSuffix = "importwalletfile";
|
||||||
|
if (!m::BeginOverlayDialog(ov)) return;
|
||||||
|
|
||||||
|
if (ImGui::IsWindowAppearing()) {
|
||||||
|
import_wallet_file_done_ = false;
|
||||||
|
import_wallet_file_ok_ = false;
|
||||||
|
import_wallet_file_status_.clear();
|
||||||
|
}
|
||||||
|
if (ImGui::IsKeyPressed(ImGuiKey_Escape) && !import_wallet_file_busy_) show_import_wallet_file_ = false;
|
||||||
|
|
||||||
|
m::DialogWarningHeader(TR("import_wallet_file_note"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (!state_.connected) {
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_need_node"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::TextUnformatted(TR("import_wallet_file_path_label"));
|
||||||
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_);
|
||||||
|
ImGui::InputText("##importwalletpath", import_wallet_file_path_, sizeof(import_wallet_file_path_));
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
if (!import_wallet_file_busy_) {
|
||||||
|
if (m::TactileButton(TR("paste"), ImVec2(104 * dp, 0))) {
|
||||||
|
const char* clip = ImGui::GetClipboardText();
|
||||||
|
if (clip) {
|
||||||
|
std::string t = dragonx::util::trimmedClipboardText(clip);
|
||||||
|
snprintf(import_wallet_file_path_, sizeof(import_wallet_file_path_), "%s", t.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (m::TactileButton(TR("clear"), ImVec2(104 * dp, 0))) import_wallet_file_path_[0] = '\0';
|
||||||
|
}
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
if (import_wallet_file_busy_) {
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s%s",
|
||||||
|
TR("import_wallet_file_importing"), m::LoadingDots());
|
||||||
|
ImGui::Spacing();
|
||||||
|
} else if (import_wallet_file_done_) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(
|
||||||
|
import_wallet_file_ok_ ? m::Success() : m::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", import_wallet_file_status_.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_ || !state_.connected || import_wallet_file_path_[0] == '\0');
|
||||||
|
if (m::TactileButton(TR("import_wallet_file_import_btn"), ImVec2(190 * dp, 0))) {
|
||||||
|
import_wallet_file_busy_ = true;
|
||||||
|
import_wallet_file_done_ = false;
|
||||||
|
import_wallet_file_status_.clear();
|
||||||
|
importWalletFile(import_wallet_file_path_, [this](bool ok, const std::string& err) {
|
||||||
|
import_wallet_file_busy_ = false;
|
||||||
|
import_wallet_file_done_ = true;
|
||||||
|
import_wallet_file_ok_ = ok;
|
||||||
|
import_wallet_file_status_ = ok ? std::string(TR("import_wallet_file_ok")) : err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_);
|
||||||
|
if (m::TactileButton(TR("close"), ImVec2(110 * dp, 0))) show_import_wallet_file_ = false;
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
m::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
|
||||||
void App::renderImportKeyDialog()
|
void App::renderImportKeyDialog()
|
||||||
{
|
{
|
||||||
namespace m = ui::material;
|
namespace m = ui::material;
|
||||||
@@ -5035,7 +5139,7 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
||||||
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
||||||
beginAdoptSeedWallet();
|
verifyThenAdoptSeedWallet(); // fresh minconf-0, sync-aware balance check before the swap
|
||||||
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
||||||
@@ -5060,6 +5164,22 @@ void App::renderSeedMigrationDialog()
|
|||||||
{
|
{
|
||||||
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
||||||
if (!canSweep) seed_migration_confirm_sweep_ = false; // never keep a stale armed state
|
if (!canSweep) seed_migration_confirm_sweep_ = false; // never keep a stale armed state
|
||||||
|
// The sweep (z_shieldcoinbase / z_mergetoaddress) needs an unlocked wallet, so an encrypted
|
||||||
|
// wallet silently disabled the button. Offer an inline unlock instead of a dead end.
|
||||||
|
if (state_.isLocked()) {
|
||||||
|
static char s_mig_unlock_pass[128] = {0};
|
||||||
|
ImGui::TextWrapped("%s", TR("mig_unlock_prompt"));
|
||||||
|
ImGui::SetNextItemWidth(220 * dp);
|
||||||
|
const bool submit = ImGui::InputText("##mig_unlock", s_mig_unlock_pass, sizeof(s_mig_unlock_pass),
|
||||||
|
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue);
|
||||||
|
ImGui::SameLine();
|
||||||
|
if ((ui::material::TactileButton(TR("mig_unlock_btn"), ImVec2(120 * dp, 0)) || submit)
|
||||||
|
&& s_mig_unlock_pass[0]) {
|
||||||
|
unlockWallet(s_mig_unlock_pass, 600);
|
||||||
|
sodium_memzero(s_mig_unlock_pass, sizeof(s_mig_unlock_pass)); // don't retain the secret
|
||||||
|
}
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
if (!seed_migration_confirm_sweep_) {
|
if (!seed_migration_confirm_sweep_) {
|
||||||
if (!canSweep) ImGui::BeginDisabled();
|
if (!canSweep) ImGui::BeginDisabled();
|
||||||
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
||||||
@@ -5099,7 +5219,8 @@ void App::renderSeedMigrationDialog()
|
|||||||
|
|
||||||
case SeedMigrationStep::Confirming: {
|
case SeedMigrationStep::Confirming: {
|
||||||
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
|
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
|
||||||
const bool confirmed = seed_migration_sweep_confs_ >= 1;
|
const bool mined = seed_migration_sweep_confs_ >= 1; // remainder re-sweep stays offered once mined
|
||||||
|
const bool confirmed = dragonx::sweepConfsAreReorgSafe(seed_migration_sweep_confs_); // adopt needs reorg-safe depth
|
||||||
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
|
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
|
||||||
seed_migration_legacy_remaining_ <= dust;
|
seed_migration_legacy_remaining_ <= dust;
|
||||||
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
||||||
@@ -5119,8 +5240,8 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (confirmed && legacyEmpty) {
|
if (confirmed && legacyEmpty) {
|
||||||
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
||||||
beginAdoptSeedWallet();
|
verifyThenAdoptSeedWallet(); // re-reads confs + balance right before the daemon stop
|
||||||
} else if (confirmed && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
|
} else if (mined && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
|
||||||
// Only claim a remainder once the legacy balance was actually measured; while a sweep is
|
// Only claim a remainder once the legacy balance was actually measured; while a sweep is
|
||||||
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
|
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
|
||||||
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
|
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
|
||||||
@@ -5144,6 +5265,10 @@ void App::renderSeedMigrationDialog()
|
|||||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
||||||
seed_migration_confirm_sweep_ = false;
|
seed_migration_confirm_sweep_ = false;
|
||||||
}
|
}
|
||||||
|
} else if (mined && legacyEmpty) {
|
||||||
|
// Mined but not yet reorg-safe deep: say so instead of the misleading "waiting to be mined".
|
||||||
|
char dbuf[200]; snprintf(dbuf, sizeof(dbuf), TR("mig_waiting_depth"), dragonx::kSweepAdoptMinConfs);
|
||||||
|
ImGui::TextWrapped("%s", dbuf);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
||||||
}
|
}
|
||||||
@@ -6072,16 +6197,16 @@ bool App::startEmbeddedDaemon()
|
|||||||
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case daemon::EmbeddedDaemon::State::Starting:
|
case daemon::EmbeddedDaemon::State::Starting:
|
||||||
setDaemonStatus(TR("sb_starting_daemon"));
|
setDaemonStatus(util::trCopy("sb_starting_daemon"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Running:
|
case daemon::EmbeddedDaemon::State::Running:
|
||||||
setDaemonStatus(TR("sb_dragonxd_running"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_running"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopping:
|
case daemon::EmbeddedDaemon::State::Stopping:
|
||||||
setDaemonStatus(TR("sb_dragonxd_stopping"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_stopping"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopped:
|
case daemon::EmbeddedDaemon::State::Stopped:
|
||||||
setDaemonStatus(TR("sb_dragonxd_stopped"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_stopped"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Error:
|
case daemon::EmbeddedDaemon::State::Error:
|
||||||
setDaemonStatus("Error: " + msg);
|
setDaemonStatus("Error: " + msg);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
#include "wallet/wallet_capabilities.h"
|
#include "wallet/wallet_capabilities.h"
|
||||||
#include "chat/chat_service.h"
|
#include "chat/chat_service.h"
|
||||||
#include "chat/chat_database.h"
|
#include "chat/chat_database.h"
|
||||||
|
#include "chat/chat_sensitive_vault.h"
|
||||||
#include "ui/sidebar.h"
|
#include "ui/sidebar.h"
|
||||||
#include "ui/windows/console_tab.h"
|
#include "ui/windows/console_tab.h"
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
@@ -321,6 +322,25 @@ public:
|
|||||||
// Send a contact request into an existing conversation (used to retry a failed request in place).
|
// Send a contact request into an existing conversation (used to retry a failed request in place).
|
||||||
void sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
|
void sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
|
||||||
const std::string& text);
|
const std::string& text);
|
||||||
|
// Broadcast a self-addressed Control memo (op "del"/"res") so every device and a fresh seed import
|
||||||
|
// hides/restores this conversation. `label` is a plaintext hint (peer address/name) carried on-chain.
|
||||||
|
// Reuses the chat send coordinator + the 0-value kChatMinFeeDrgx z_sendmany path; no UI echo bubble.
|
||||||
|
void sendChatControl(const std::string& conversationId, const std::string& op, const std::string& label);
|
||||||
|
|
||||||
|
// --- Sensitive conversations (per-conversation local-key encryption) ---
|
||||||
|
enum class ChatVaultState { NotConfigured, Locked, Unlocked };
|
||||||
|
ChatVaultState chatSensitiveVaultState() const;
|
||||||
|
bool chatSensitiveVaultConfigured() const; // a vault file exists (regardless of lock state)
|
||||||
|
// Create the vault (first-time setup) and unlock it. Returns false if one already exists or on error.
|
||||||
|
bool createChatSensitiveVault(const std::string& passphrase);
|
||||||
|
// Provide the vault key to the chat DB so sensitive rows decrypt; also pulls them into the store.
|
||||||
|
bool unlockChatSensitiveVault(const std::string& passphrase);
|
||||||
|
void lockChatSensitiveVault(); // drop the key; sensitive threads vanish from the view
|
||||||
|
// Mark/unmark a conversation sensitive. Requires the vault UNLOCKED (migrates its rows to/from the
|
||||||
|
// vault key) and broadcasts the on-chain "sensitive"/"unsensitive" signal. `peerAddr` labels it.
|
||||||
|
bool markChatConversationSensitive(const std::string& cid, const std::string& peerAddr);
|
||||||
|
void unmarkChatConversationSensitive(const std::string& cid);
|
||||||
|
|
||||||
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
|
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
|
||||||
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
|
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
|
||||||
// the chat feature is off. Returns true when it had to fabricate the (random) demo identity
|
// the chat feature is off. Returns true when it had to fabricate the (random) demo identity
|
||||||
@@ -333,6 +353,10 @@ public:
|
|||||||
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
|
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
|
||||||
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
|
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
|
||||||
bool lockLiteWallet();
|
bool lockLiteWallet();
|
||||||
|
// Unlock the lite wallet with the same escalating attempt-lockout as the full node (persisted across restarts)
|
||||||
|
// and set state_.locked=false right away on success (the poll lags). Takes the passphrase by value and wipes
|
||||||
|
// it. On failure *err (if given) holds a user-facing reason ("Unlock failed..." or the lockout wait).
|
||||||
|
bool unlockLiteWallet(std::string passphrase, std::string* err = nullptr);
|
||||||
// (Re)build the lite controller from current settings so a changed lite-server selection
|
// (Re)build the lite controller from current settings so a changed lite-server selection
|
||||||
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
||||||
void rebuildLiteWallet(bool force = false);
|
void rebuildLiteWallet(bool force = false);
|
||||||
@@ -449,6 +473,9 @@ public:
|
|||||||
// Address operations
|
// Address operations
|
||||||
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
|
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
|
||||||
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
|
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
|
||||||
|
// The daemon's actual reason the last new-address RPC failed (friendly-mapped), or "" on success. Lets
|
||||||
|
// the UI show e.g. "HD seed not found" (legacy/degraded wallet) instead of a generic "couldn't create".
|
||||||
|
const std::string& lastAddressError() const { return last_address_error_; }
|
||||||
|
|
||||||
// Hide/unhide addresses from the address list (persisted in settings)
|
// Hide/unhide addresses from the address list (persisted in settings)
|
||||||
void hideAddress(const std::string& addr);
|
void hideAddress(const std::string& addr);
|
||||||
@@ -558,10 +585,11 @@ public:
|
|||||||
private:
|
private:
|
||||||
App& app_;
|
App& app_;
|
||||||
};
|
};
|
||||||
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance
|
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call (any call, not just a
|
||||||
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that
|
// maintenance one: disconnect() needs the curl lock the call holds, which would freeze the UI), and
|
||||||
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most
|
// refuse maintenance calls that start before the next connect (onConnected() lifts that). The abort
|
||||||
// 30 s, as before), so a user's own send is never cut off mid-call.
|
// flag is cleared by the next tryConnect(). A user send aborted mid-flight is handled as a lost reply
|
||||||
|
// ("outcome unknown — check History"), never a double-send.
|
||||||
void abortMaintenanceCalls();
|
void abortMaintenanceCalls();
|
||||||
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
|
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
|
||||||
// full node that is past warmup, not rescanning and not busy.
|
// full node that is past warmup, not rescanning and not busy.
|
||||||
@@ -642,6 +670,9 @@ public:
|
|||||||
// Dialog triggers (used by settings page to open modal dialogs)
|
// Dialog triggers (used by settings page to open modal dialogs)
|
||||||
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
|
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
|
||||||
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
|
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
|
||||||
|
void showImportWalletFileDialog() { show_import_wallet_file_ = true; } // import a whole legacy key dump
|
||||||
|
// Import every key from an old wallet's z_exportwallet/dumpwallet dump file (z_importwallet, one rescan).
|
||||||
|
void importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback);
|
||||||
void showExportKeyDialog() { show_export_key_ = true; }
|
void showExportKeyDialog() { show_export_key_ = true; }
|
||||||
void showBackupDialog() { show_backup_ = true; }
|
void showBackupDialog() { show_backup_ = true; }
|
||||||
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
||||||
@@ -1009,6 +1040,8 @@ private:
|
|||||||
// z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants.
|
// z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants.
|
||||||
chat::ChatService chat_service_;
|
chat::ChatService chat_service_;
|
||||||
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
|
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
|
||||||
|
chat::ChatSensitiveVault chat_sensitive_vault_; // local key vault for "sensitive" conversations
|
||||||
|
std::string chatSensitiveVaultPath() const; // <configDir>/chat_sensitive.vault
|
||||||
bool chat_identity_provisioned_ = false; // identity set on the service this session
|
bool chat_identity_provisioned_ = false; // identity set on the service this session
|
||||||
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
|
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
|
||||||
std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure
|
std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure
|
||||||
@@ -1188,6 +1221,7 @@ private:
|
|||||||
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
|
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
|
||||||
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
||||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||||
|
void verifyThenAdoptSeedWallet(); // re-read sweep confs + minconf-0 balance, then beginAdoptSeedWallet() iff safe
|
||||||
void provisionChatIdentityFromSecret(std::string secret);
|
void provisionChatIdentityFromSecret(std::string secret);
|
||||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||||
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
||||||
@@ -1344,6 +1378,7 @@ private:
|
|||||||
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
||||||
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
||||||
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
||||||
|
std::string last_address_error_; // daemon reason the last z_getnewaddress/getnewaddress failed ("" = ok)
|
||||||
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
||||||
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
||||||
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||||
@@ -1409,6 +1444,12 @@ private:
|
|||||||
bool show_about_ = false;
|
bool show_about_ = false;
|
||||||
bool show_faq_ = false;
|
bool show_faq_ = false;
|
||||||
bool show_import_key_ = false;
|
bool show_import_key_ = false;
|
||||||
|
bool show_import_wallet_file_ = false; // the "import keys from a backup file" modal is open
|
||||||
|
char import_wallet_file_path_[1024] = {0}; // typed path to the old wallet's key dump (not a secret)
|
||||||
|
bool import_wallet_file_busy_ = false; // a z_importwallet long call is in flight
|
||||||
|
bool import_wallet_file_done_ = false; // terminal result reached (ok/err in import_wallet_file_ok_)
|
||||||
|
bool import_wallet_file_ok_ = false;
|
||||||
|
std::string import_wallet_file_status_; // result / error text shown in the modal
|
||||||
bool show_export_key_ = false;
|
bool show_export_key_ = false;
|
||||||
bool show_backup_ = false;
|
bool show_backup_ = false;
|
||||||
bool show_seed_backup_ = false;
|
bool show_seed_backup_ = false;
|
||||||
@@ -2050,6 +2091,7 @@ private:
|
|||||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||||
void renderImportKeyDialog();
|
void renderImportKeyDialog();
|
||||||
|
void renderImportWalletFileDialog(); // typed-path import of a legacy key dump (z_importwallet)
|
||||||
void renderExportKeyDialog();
|
void renderExportKeyDialog();
|
||||||
void renderBackupDialog();
|
void renderBackupDialog();
|
||||||
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
|
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
|
||||||
|
|||||||
+430
-34
@@ -41,6 +41,10 @@
|
|||||||
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
||||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#ifndef _WIN32
|
||||||
|
#include <fcntl.h> // open/fsync: flush the staged wallet + .bak before the atomic swap
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||||
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
||||||
@@ -559,15 +563,31 @@ void App::tryConnect()
|
|||||||
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
||||||
}
|
}
|
||||||
|
|
||||||
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
|
// A previous teardown leaves rpc_'s abort flag set (open() no longer clears it, so an abort issued
|
||||||
|
// while this connect is still queued is not erased). This is the reconnect entry point: clear it here
|
||||||
|
// on the main thread, after the daemon_restarting_/shutting_down_ gates above.
|
||||||
|
if (rpc_ && !shutting_down_.load()) rpc_->clearAbort();
|
||||||
|
const std::uint64_t connectSession = rpc_session_; // main thread; a teardown/onConnected bumps it
|
||||||
|
worker_->post([this, config, daemonStarting, externalDetected, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
|
||||||
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
||||||
std::string connectErr = rpc_->getLastConnectError();
|
std::string connectErr = rpc_->getLastConnectError();
|
||||||
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
||||||
bool warmingUp = rpc_->isWarmingUp();
|
bool warmingUp = rpc_->isWarmingUp();
|
||||||
std::string warmupStatus = rpc_->getWarmupStatus();
|
std::string warmupStatus = rpc_->getWarmupStatus();
|
||||||
|
|
||||||
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
||||||
connectKind]() {
|
connectKind, connectSession]() {
|
||||||
|
// A probe that succeeded after a wallet switch / adopt / restore / shutdown already tore the
|
||||||
|
// connection down must not run onConnected() against a daemon that is about to be stopped.
|
||||||
|
if (connected && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
|
||||||
|
VERBOSE_LOGF("[connect #%d] Dropping stale connect result (teardown raced the probe)\n", attempt);
|
||||||
|
if (rpc_) {
|
||||||
|
rpc_->requestAbort(); // a worker call may hold curl_mutex_; don't block the UI on disconnect()
|
||||||
|
rpc_->disconnect();
|
||||||
|
}
|
||||||
|
connection_in_progress_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
||||||
if (connected) connect_backoff_.reset();
|
if (connected) connect_backoff_.reset();
|
||||||
if (connected) {
|
if (connected) {
|
||||||
@@ -596,12 +616,19 @@ void App::tryConnect()
|
|||||||
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
||||||
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
||||||
attempt, cookieConfig.hush_dir.c_str());
|
attempt, cookieConfig.hush_dir.c_str());
|
||||||
worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, cookieConfig, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
||||||
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
||||||
cookieConfig.use_tls);
|
cookieConfig.use_tls);
|
||||||
return [this, cookieConfig, ok, attempt]() {
|
return [this, cookieConfig, ok, attempt, connectSession]() {
|
||||||
connection_in_progress_ = false;
|
connection_in_progress_ = false;
|
||||||
|
if (ok && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
|
||||||
|
if (rpc_) {
|
||||||
|
rpc_->requestAbort();
|
||||||
|
rpc_->disconnect();
|
||||||
|
}
|
||||||
|
return; // stale: a teardown raced the probe
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
||||||
saved_config_ = cookieConfig;
|
saved_config_ = cookieConfig;
|
||||||
@@ -832,6 +859,7 @@ void App::onConnected()
|
|||||||
fast_worker_->start();
|
fast_worker_->start();
|
||||||
}
|
}
|
||||||
// Connect on the fast worker's own thread (non-blocking to main)
|
// Connect on the fast worker's own thread (non-blocking to main)
|
||||||
|
if (fast_rpc_ && !shutting_down_.load()) fast_rpc_->clearAbort(); // open() no longer clears a prior teardown's abort
|
||||||
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
||||||
saved_config_.rpcuser, saved_config_.rpcpassword,
|
saved_config_.rpcuser, saved_config_.rpcpassword,
|
||||||
@@ -891,8 +919,10 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
pending_opids_.clear();
|
pending_opids_.clear();
|
||||||
pending_send_info_.clear();
|
pending_send_info_.clear();
|
||||||
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
||||||
|
// Each callback here exists only because z_sendmany already returned an opid, so the op may still
|
||||||
|
// commit (or have committed) while we lost the connection: report "outcome unknown", not a failure.
|
||||||
for (auto& entry : pending_send_callbacks_) {
|
for (auto& entry : pending_send_callbacks_) {
|
||||||
if (entry.second) entry.second(false, reason);
|
if (entry.second) entry.second(false, rpc::markSendMaybeInFlight(reason));
|
||||||
}
|
}
|
||||||
pending_send_callbacks_.clear();
|
pending_send_callbacks_.clear();
|
||||||
consecutive_core_failures_ = 0;
|
consecutive_core_failures_ = 0;
|
||||||
@@ -950,6 +980,13 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
// the fast lane dead for the rest of the session — which silently stalls the opid poll
|
// the fast lane dead for the rest of the session — which silently stalls the opid poll
|
||||||
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
||||||
fast_worker_.reset();
|
fast_worker_.reset();
|
||||||
|
// stop() discards undrained MainCbs, and these in-progress flags are cleared only inside
|
||||||
|
// those callbacks (unlock/lock/PIN/encrypt are posted on the fast lane when it is running).
|
||||||
|
// Release them here so the lock screen cannot show "Unlocking..." forever; the user can retry
|
||||||
|
// after reconnect, and the real encryption state is re-read on connect.
|
||||||
|
lock_unlock_in_progress_ = false;
|
||||||
|
pin_in_progress_ = false;
|
||||||
|
encrypt_in_progress_ = false;
|
||||||
}
|
}
|
||||||
if (fast_rpc_) {
|
if (fast_rpc_) {
|
||||||
fast_rpc_->disconnect();
|
fast_rpc_->disconnect();
|
||||||
@@ -1111,7 +1148,10 @@ void App::abortMaintenanceCalls()
|
|||||||
{
|
{
|
||||||
// Both stick until the next connect, which is fine here: every caller disconnects next.
|
// Both stick until the next connect, which is fine here: every caller disconnects next.
|
||||||
maint_abort_.store(true); // refuses maintenance calls still queued on the worker
|
maint_abort_.store(true); // refuses maintenance calls still queued on the worker
|
||||||
if (rpc_ && maint_calls_.load() > 0) rpc_->requestAbort(); // cuts off the one in flight
|
// Unconditional: an ordinary in-flight refresh/scan also holds curl_mutex_ for its whole
|
||||||
|
// curl_easy_perform, and every caller disconnect()s next. The abort is cleared by the next
|
||||||
|
// tryConnect() (rpc_) / onConnected() (fast_rpc_) before they reconnect.
|
||||||
|
if (rpc_) rpc_->requestAbort(); // cuts off whatever call is in flight
|
||||||
}
|
}
|
||||||
|
|
||||||
bool App::walletScanReady() const
|
bool App::walletScanReady() const
|
||||||
@@ -3917,15 +3957,32 @@ void App::createNewAddress(bool shielded, std::function<void(const std::string&)
|
|||||||
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
||||||
: "Receive tab / New transparent address";
|
: "Receive tab / New transparent address";
|
||||||
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
||||||
std::string addr;
|
std::string addr, genErr;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||||
json result = rpc_->call(rpcMethod);
|
json result = rpc_->call(rpcMethod);
|
||||||
addr = result.get<std::string>();
|
addr = result.get<std::string>();
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
|
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
|
||||||
|
genErr = e.what(); // keep the real reason so the UI can show it instead of a generic toast
|
||||||
}
|
}
|
||||||
return [this, callback, addr, shielded, typeStr]() {
|
return [this, callback, addr, genErr, shielded, typeStr]() {
|
||||||
|
if (addr.empty()) {
|
||||||
|
// Surface the daemon's actual reason (friendly-mapped) rather than a generic "couldn't create".
|
||||||
|
last_address_error_ = genErr.empty() ? std::string() : util::friendlyRpcError(genErr);
|
||||||
|
// "HD seed not found" means a legacy / degraded wallet can't derive new HD keys. Flip degraded
|
||||||
|
// mode here so the UI explains it even for an EXTERNAL daemon — detectWalletDegraded() only
|
||||||
|
// reads the embedded daemon's startup log and would otherwise miss this.
|
||||||
|
if (shielded && genErr.find("HD seed not found") != std::string::npos) {
|
||||||
|
wallet_degraded_ = true;
|
||||||
|
if (!wallet_degraded_warned_) {
|
||||||
|
wallet_degraded_warned_ = true;
|
||||||
|
ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
last_address_error_.clear();
|
||||||
|
}
|
||||||
if (!addr.empty()) {
|
if (!addr.empty()) {
|
||||||
// Inject immediately so UI can select the address next frame
|
// Inject immediately so UI can select the address next frame
|
||||||
AddressInfo info;
|
AddressInfo info;
|
||||||
@@ -4160,6 +4217,13 @@ void App::provisionChatIdentityFromSecret(std::string secret)
|
|||||||
chat_service_.setBlockedPredicate([this](const std::string& cid) {
|
chat_service_.setBlockedPredicate([this](const std::string& cid) {
|
||||||
return settings_ && settings_->isChatBlocked(cid);
|
return settings_ && settings_->isChatBlocked(cid);
|
||||||
});
|
});
|
||||||
|
// Sensitive conversations: the local mark gives instant effect; the DB routes those rows to the
|
||||||
|
// vault key. The vault itself stays LOCKED until the user enters its passphrase (the DB then
|
||||||
|
// withholds sensitive rows), so provisioning never auto-unlocks it.
|
||||||
|
chat_service_.setLocalSensitivePredicate([this](const std::string& cid) {
|
||||||
|
return settings_ && settings_->isChatSensitive(cid);
|
||||||
|
});
|
||||||
|
chat_sensitive_vault_.setPath(chatSensitiveVaultPath());
|
||||||
if (chat_db_.unlockWithSecret(trimmed)) {
|
if (chat_db_.unlockWithSecret(trimmed)) {
|
||||||
chat_service_.loadFromDatabase();
|
chat_service_.loadFromDatabase();
|
||||||
// Baseline unread: treat everything already in the store at load as read, so only messages
|
// Baseline unread: treat everything already in the store at load as read, so only messages
|
||||||
@@ -4596,9 +4660,11 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
|||||||
}
|
}
|
||||||
|
|
||||||
chat::OutgoingChatMemos memos;
|
chat::OutgoingChatMemos memos;
|
||||||
if (chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos)
|
const auto composeStatus =
|
||||||
!= chat::ChatComposeStatus::Ok) {
|
chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos);
|
||||||
ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
|
if (composeStatus != chat::ChatComposeStatus::Ok) {
|
||||||
|
ui::Notifications::instance().error(TR(composeStatus == chat::ChatComposeStatus::TooLong
|
||||||
|
? "chat_toast_too_long" : "chat_toast_compose_failed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
chat::ChatMessage echo;
|
chat::ChatMessage echo;
|
||||||
@@ -4618,6 +4684,109 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
|||||||
enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
|
enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void App::sendChatControl(const std::string& conversationId, const std::string& op, const std::string& label)
|
||||||
|
{
|
||||||
|
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||||
|
if (conversationId.empty() || op.empty()) return;
|
||||||
|
|
||||||
|
// A control signal is addressed to our OWN reply address — it never reaches the peer; only this
|
||||||
|
// wallet's seed (on any device, including a fresh import) harvests and reduces it.
|
||||||
|
const std::string myReply = chatReplyZaddr();
|
||||||
|
if (myReply.empty()) {
|
||||||
|
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::string myPubKey = chat_service_.identityPublicKeyHex();
|
||||||
|
if (myPubKey.empty()) return;
|
||||||
|
|
||||||
|
// The label must be a non-empty, non-'{' hint; fall back to the cid if the caller has no name.
|
||||||
|
const std::string hint = label.empty() ? conversationId : label;
|
||||||
|
|
||||||
|
chat::OutgoingChatMemos memos;
|
||||||
|
if (chat::buildOutgoingControl(myPubKey, myReply, conversationId, op, hint, memos)
|
||||||
|
!= chat::ChatComposeStatus::Ok) {
|
||||||
|
return; // never block the local delete/restore on a compose edge case; it is cosmetic cross-device
|
||||||
|
}
|
||||||
|
// Reuse the chat send coordinator exactly as a message does (0-value, kChatMinFeeDrgx fee, two memo
|
||||||
|
// outputs). No echo bubble: pass an empty echoLocalId — resolveOutgoing("") is a no-op, and the memo,
|
||||||
|
// harvested back to us, is routed to the reducer (not shown as a message).
|
||||||
|
enqueueChatSend(LiteOpKind::ChatSend, memos, /*echoLocalId=*/"");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string App::chatSensitiveVaultPath() const
|
||||||
|
{
|
||||||
|
return (std::filesystem::path(util::Platform::getConfigDir()) / "chat_sensitive.vault").string();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::chatSensitiveVaultConfigured() const
|
||||||
|
{
|
||||||
|
chat::ChatSensitiveVault probe(chatSensitiveVaultPath());
|
||||||
|
return probe.exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
App::ChatVaultState App::chatSensitiveVaultState() const
|
||||||
|
{
|
||||||
|
if (!chatSensitiveVaultConfigured()) return ChatVaultState::NotConfigured;
|
||||||
|
return chat_db_.hasSensitiveKey() ? ChatVaultState::Unlocked : ChatVaultState::Locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::createChatSensitiveVault(const std::string& passphrase)
|
||||||
|
{
|
||||||
|
chat_sensitive_vault_.setPath(chatSensitiveVaultPath());
|
||||||
|
std::array<unsigned char, 32> key{};
|
||||||
|
const bool ok = chat_sensitive_vault_.create(passphrase, key);
|
||||||
|
if (ok) chat_db_.setSensitiveKey(key); // the DB now encrypts sensitive rows under this key
|
||||||
|
sodium_memzero(key.data(), key.size());
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::unlockChatSensitiveVault(const std::string& passphrase)
|
||||||
|
{
|
||||||
|
chat_sensitive_vault_.setPath(chatSensitiveVaultPath());
|
||||||
|
std::array<unsigned char, 32> key{};
|
||||||
|
const bool ok = chat_sensitive_vault_.unlock(passphrase, key);
|
||||||
|
if (ok) {
|
||||||
|
chat_db_.setSensitiveKey(key);
|
||||||
|
chat_service_.loadFromDatabase(); // sensitive rows now decrypt → pull them into the store
|
||||||
|
chat_note_rescan_requested_ = true; // re-harvest any that were withheld while the vault was locked
|
||||||
|
}
|
||||||
|
sodium_memzero(key.data(), key.size());
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::lockChatSensitiveVault()
|
||||||
|
{
|
||||||
|
chat_db_.lockSensitive();
|
||||||
|
// Drop the now-undecryptable sensitive threads from the view at once: reload (the DB withholds any
|
||||||
|
// row it can't decrypt without the vault key), keeping the in-memory store == what is readable.
|
||||||
|
chat_service_.store().clear();
|
||||||
|
chat_service_.loadFromDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::markChatConversationSensitive(const std::string& cid, const std::string& peerAddr)
|
||||||
|
{
|
||||||
|
if (cid.empty() || chatSensitiveVaultState() != ChatVaultState::Unlocked) return false;
|
||||||
|
if (settings_) { settings_->setChatSensitive(cid, peerAddr, true); settings_->save(); }
|
||||||
|
// Migrate this conversation's EXISTING rows from the seed key to the vault key: the predicate is now
|
||||||
|
// true, so upsert() re-encrypts each under the vault key — so the WHOLE thread hides when the vault
|
||||||
|
// locks (new messages already go to the vault key). Incoming history stays re-harvestable from chain
|
||||||
|
// (the honest caveat the UI warns about); this protects the local at-rest copy.
|
||||||
|
for (const auto& m : chat_service_.store().conversation(cid)) chat_db_.upsert(m);
|
||||||
|
sendChatControl(cid, "sensitive", peerAddr.empty() ? cid : peerAddr); // signal other devices / fresh import
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::unmarkChatConversationSensitive(const std::string& cid)
|
||||||
|
{
|
||||||
|
if (cid.empty()) return;
|
||||||
|
if (settings_) { settings_->setChatSensitive(cid, "", false); settings_->save(); }
|
||||||
|
// Migrate rows back to the seed key (predicate now false). Needs the vault unlocked so the rows are in
|
||||||
|
// the store to re-write; if it is locked there is nothing to migrate (they re-key on the next scan).
|
||||||
|
if (chat_db_.hasSensitiveKey())
|
||||||
|
for (const auto& m : chat_service_.store().conversation(cid)) chat_db_.upsert(m);
|
||||||
|
sendChatControl(cid, "unsensitive", cid);
|
||||||
|
}
|
||||||
|
|
||||||
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
||||||
{
|
{
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||||
@@ -5414,6 +5583,69 @@ bool App::lockLiteWallet()
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool App::unlockLiteWallet(std::string passphrase, std::string* err)
|
||||||
|
{
|
||||||
|
const auto fail = [&](const std::string& m) { if (err) *err = m; };
|
||||||
|
const auto waitMsg = [](long long secs) {
|
||||||
|
char msg[128];
|
||||||
|
snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), (double)secs);
|
||||||
|
return std::string(msg);
|
||||||
|
};
|
||||||
|
if (!lite_wallet_ || !lite_wallet_->walletOpen() || passphrase.empty()) {
|
||||||
|
wallet::secureWipeLiteSecret(passphrase);
|
||||||
|
fail(TR("lite_unlock_failed"));
|
||||||
|
return false; // nothing to attempt: not a wrong-credential failure, not counted
|
||||||
|
}
|
||||||
|
// Restore the persisted attempt count once per session (the full-node lock screen does the same).
|
||||||
|
if (!lock_state_restored_) {
|
||||||
|
lock_state_restored_ = true;
|
||||||
|
restoreLockoutState();
|
||||||
|
}
|
||||||
|
// Enforce the lockout from the persisted wall-clock expiry: lite has no per-frame lockout timer.
|
||||||
|
if (settings_ && settings_->getLockStateWallet() == settings_->getActiveWalletFile()) {
|
||||||
|
const long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
|
||||||
|
if (remaining > 0) {
|
||||||
|
wallet::secureWipeLiteSecret(passphrase);
|
||||||
|
fail(waitMsg(remaining));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock_lockout_timer_ = 0.0f; // expired: don't let a stale in-memory value re-arm the curve
|
||||||
|
|
||||||
|
const bool ok = lite_wallet_->unlockWallet(std::move(passphrase)); // wipes the passphrase
|
||||||
|
if (!ok) {
|
||||||
|
const auto st = lite_wallet_->encryptionStatus();
|
||||||
|
if (st.ok && !(st.encrypted && st.locked)) {
|
||||||
|
// Not a wrong credential: already unlocked (e.g. a racing prompt) or not encrypted. Don't count it.
|
||||||
|
state_.encrypted = st.encrypted;
|
||||||
|
state_.locked = false;
|
||||||
|
if (st.encrypted) { idle_.noteActivity(); return true; }
|
||||||
|
fail(TR("lite_unlock_failed"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
applyUnlockFailure(TR("lite_unlock_failed")); // bump counter, arm lockout curve, persist
|
||||||
|
fail(lock_lockout_timer_ > 0.0f ? waitMsg((long long)std::ceil(lock_lockout_timer_))
|
||||||
|
: std::string(TR("lite_unlock_failed")));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: clear the attempt state (and its persisted copy) and reflect the unlock NOW — the next poll lags.
|
||||||
|
lock_unlock_in_progress_ = false;
|
||||||
|
lock_error_msg_.clear();
|
||||||
|
lock_attempts_ = 0;
|
||||||
|
lock_lockout_timer_ = 0.0f;
|
||||||
|
if (settings_ && settings_->getLockFailedAttempts() != 0) {
|
||||||
|
settings_->setLockFailedAttempts(0);
|
||||||
|
settings_->setLockoutUntilEpoch(0);
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
|
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||||
|
idle_.noteActivity();
|
||||||
|
state_.encrypted = true;
|
||||||
|
state_.locked = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool App::seedChatDemoData()
|
bool App::seedChatDemoData()
|
||||||
{
|
{
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
|
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
|
||||||
@@ -5626,6 +5858,48 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
|||||||
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import a whole wallet-key dump file (the output of the OLD wallet's z_exportwallet / dumpwallet, or
|
||||||
|
// SilentDragon's "export all private keys") via z_importwallet: the daemon reads every key from the
|
||||||
|
// file and does ONE rescan — the right tool for recovering a legacy wallet.dat the new client can't open,
|
||||||
|
// and far faster than importing keys one by one. Only the PATH touches our memory, never the keys. Runs on
|
||||||
|
// the long-call lane (no client timeout: the rescan can take a long time). Full-node only.
|
||||||
|
void App::importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback)
|
||||||
|
{
|
||||||
|
if (!state_.connected || !rpc_ || !worker_) {
|
||||||
|
if (callback) callback(false, TR("import_key_need_node"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::string p(path);
|
||||||
|
while (!p.empty() && (p.front()==' '||p.front()=='\t'||p.front()=='\n'||p.front()=='\r')) p.erase(p.begin());
|
||||||
|
while (!p.empty() && (p.back()==' '||p.back()=='\t'||p.back()=='\n'||p.back()=='\r')) p.pop_back();
|
||||||
|
if (p.empty()) { if (callback) callback(false, TR("import_wallet_file_need_path")); return; }
|
||||||
|
{
|
||||||
|
std::string why; const char* trKey = nullptr;
|
||||||
|
if (longWalletOpRefused(&why, &trKey)) { if (callback) callback(false, TR(trKey)); return; }
|
||||||
|
}
|
||||||
|
const bool started = runLongWalletTask([this, p, callback](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
|
||||||
|
std::string err;
|
||||||
|
try {
|
||||||
|
rpc::RPCClient::TraceScope trace("Settings / Import wallet file");
|
||||||
|
c.call("z_importwallet", {p}, 0L); // reads the dump + one rescan; 0 = no client timeout
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
err = e.what();
|
||||||
|
if (!err.empty() && err.find("Cannot open") != std::string::npos)
|
||||||
|
err = TR("import_wallet_file_not_found"); // the most common cause: wrong/missing path
|
||||||
|
} catch (...) {
|
||||||
|
err = "Import failed (unknown error)";
|
||||||
|
}
|
||||||
|
return [this, err, callback]() {
|
||||||
|
if (!err.empty()) { if (callback) callback(false, err); return; }
|
||||||
|
invalidateAddressValidationCache();
|
||||||
|
refreshAddresses();
|
||||||
|
markWalletMutated(/*forceBalance=*/true); // keys added: balance changes with no tip/mempool signal
|
||||||
|
if (callback) callback(true, "");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!started && callback) callback(false, TR("appx_wallet_op_running"));
|
||||||
|
}
|
||||||
|
|
||||||
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
||||||
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
||||||
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
||||||
@@ -5727,12 +6001,12 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
||||||
|
|
||||||
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
||||||
|
// Let an RPC failure PROPAGATE to the outer catch (→ a real error) instead of swallowing it as
|
||||||
|
// 0.0, which would misclassify a funded key as "__NOFUNDS__" after a transient timeout/warmup.
|
||||||
auto balAt = [&](int minconf) -> double {
|
auto balAt = [&](int minconf) -> double {
|
||||||
try {
|
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
||||||
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
return b.is_number() ? b.get<double>()
|
||||||
return b.is_number() ? b.get<double>()
|
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
||||||
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
|
||||||
} catch (...) { return 0.0; }
|
|
||||||
};
|
};
|
||||||
double bal = balAt(1);
|
double bal = balAt(1);
|
||||||
if (bal <= 0.0) {
|
if (bal <= 0.0) {
|
||||||
@@ -6386,7 +6660,27 @@ void App::beginSweepToSeedWallet()
|
|||||||
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
||||||
opid = res.value("opid", std::string());
|
opid = res.value("opid", std::string());
|
||||||
if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
|
if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
|
||||||
} catch (const std::exception& e) { err = e.what(); }
|
} catch (const std::exception& e) {
|
||||||
|
// z_mergetoaddress deliberately ignores coinbase UTXOs, so a miner whose legacy balance is all
|
||||||
|
// mined coinbase gets "Could not find any funds to merge". Fall back to z_shieldcoinbase, which
|
||||||
|
// DOES move coinbase, to the same shielded destination. It returns an opid exactly like the merge,
|
||||||
|
// so the adopt gate (one opid -> txid -> confirm -> balance ~0 -> adopt) is unchanged; a batch
|
||||||
|
// limit or a mixed coinbase+other wallet is drained by the existing "Sweep remaining" re-sweep.
|
||||||
|
const std::string mergeErr = e.what();
|
||||||
|
if (mergeErr.find("find any funds to merge") != std::string::npos) {
|
||||||
|
try {
|
||||||
|
// Fixed-decimal fee string (ParseFixedPoint rejects a double that serializes as "5e-05").
|
||||||
|
const std::string feeStr = util::formatAmountFixed(DRAGONX_DEFAULT_FEE);
|
||||||
|
auto sres = rpc_->call("z_shieldcoinbase", {std::string("*"), dest, feeStr, 50});
|
||||||
|
opid = sres.value("opid", std::string());
|
||||||
|
if (opid.empty()) err = "The daemon returned no operation id for the coinbase shield.";
|
||||||
|
} catch (const std::exception& e2) {
|
||||||
|
err = e2.what(); // e.g. "Could not find any coinbase funds to shield" = genuinely empty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = mergeErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
return [this, opid, err]() {
|
return [this, opid, err]() {
|
||||||
if (!err.empty() || opid.empty()) {
|
if (!err.empty() || opid.empty()) {
|
||||||
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
||||||
@@ -6562,6 +6856,57 @@ void App::locateSweepTxid()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final gate before the swap: on the RPC worker, RE-READ the sweep tx's confirmations (when a sweep txid
|
||||||
|
// exists) and z_gettotalbalance with minconf 0 (includes unconfirmed, unlike the default minconf 1), then
|
||||||
|
// - only if both are reorg-safe, the node is not syncing and the legacy balance is ~0 - call
|
||||||
|
// beginAdoptSeedWallet() on the main thread, so the disconnect + daemon stop follow these reads at once.
|
||||||
|
// Read-only RPCs; any failure blocks adopt (nothing is stopped or swapped) and the user may retry.
|
||||||
|
void App::verifyThenAdoptSeedWallet()
|
||||||
|
{
|
||||||
|
if (capture_mode_) { beginAdoptSeedWallet(); return; } // UI sweep: unchanged behaviour
|
||||||
|
if (seed_migration_confirm_in_flight_) return;
|
||||||
|
if (!rpc_ || !worker_ || !state_.connected) {
|
||||||
|
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seed_migration_confirm_in_flight_ = true;
|
||||||
|
const std::string txid = seed_migration_sweep_txid_;
|
||||||
|
const bool needTx = !txid.empty();
|
||||||
|
const bool syncing = effectivelySyncing(); // main-thread snapshot for the worker
|
||||||
|
worker_->post([this, txid, needTx, syncing]() -> rpc::RPCWorker::MainCb {
|
||||||
|
int confs = 0; double total = -1.0; bool ok = false;
|
||||||
|
rpc::RPCClient::TraceScope trace("Migrate / pre-adopt verify");
|
||||||
|
try {
|
||||||
|
if (needTx) {
|
||||||
|
auto tx = rpc_->call("gettransaction", {txid});
|
||||||
|
if (!tx.contains("confirmations") || !tx["confirmations"].is_number()) throw std::runtime_error("no confirmations");
|
||||||
|
confs = tx["confirmations"].get<int>();
|
||||||
|
}
|
||||||
|
auto bal = rpc_->call("z_gettotalbalance", nlohmann::json::array({0})); // minconf 0
|
||||||
|
if (bal.contains("total") && bal["total"].is_string()) {
|
||||||
|
total = std::stod(bal["total"].get<std::string>());
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch (...) { ok = false; }
|
||||||
|
return [this, needTx, syncing, confs, total, ok]() {
|
||||||
|
seed_migration_confirm_in_flight_ = false;
|
||||||
|
if (ok) {
|
||||||
|
if (needTx) seed_migration_sweep_confs_ = confs;
|
||||||
|
seed_migration_legacy_remaining_ = total;
|
||||||
|
}
|
||||||
|
// Only act while still on a step that offers Adopt (the user may have closed/changed it).
|
||||||
|
const bool onAdoptStep = seed_migration_step_ == SeedMigrationStep::Confirming ||
|
||||||
|
seed_migration_step_ == SeedMigrationStep::Sweep;
|
||||||
|
if (ok && onAdoptStep &&
|
||||||
|
dragonx::adoptReverifyPasses(needTx, confs, total, syncing, DRAGONX_DEFAULT_FEE)) {
|
||||||
|
beginAdoptSeedWallet(); // unchanged: stop daemon -> swap -> restart with -rescan
|
||||||
|
} else if (onAdoptStep) {
|
||||||
|
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"), 12.0f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
||||||
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
||||||
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
||||||
@@ -6623,21 +6968,53 @@ void App::beginAdoptSeedWallet()
|
|||||||
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
||||||
if (!fs::exists(newWallet)) {
|
if (!fs::exists(newWallet)) {
|
||||||
err = "The new wallet file is missing; your wallet was not changed.";
|
err = "The new wallet file is missing; your wallet was not changed.";
|
||||||
|
} else if (!util::probeWalletFile(newWallet).isBerkeleyDB) {
|
||||||
|
err = "The new wallet file is not a valid wallet; your wallet was not changed.";
|
||||||
} else {
|
} else {
|
||||||
bool movedLegacy = false;
|
// Crash-safe order: wallet.dat is only ever REPLACED by one atomic rename of a complete,
|
||||||
if (fs::exists(legacy)) {
|
// size-verified staged copy, and only AFTER a complete size-verified .bak of the legacy
|
||||||
fs::rename(legacy, backup, ec);
|
// wallet exists. A hard kill at any point leaves wallet.dat as either the whole old or the
|
||||||
if (ec) err = "Could not back up your current wallet; nothing was changed.";
|
// whole new wallet - never missing or half-written. Nothing of the user's is deleted.
|
||||||
else movedLegacy = true;
|
const std::string staged = legacy + ".adopt-tmp";
|
||||||
}
|
auto sameSize = [](const std::string& a, const std::string& b) {
|
||||||
if (err.empty()) {
|
std::error_code e1, e2;
|
||||||
fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec);
|
const auto sa = fs::file_size(a, e1);
|
||||||
if (ec) {
|
const auto sb = fs::file_size(b, e2);
|
||||||
if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); }
|
return !e1 && !e2 && sa == sb && sa > 0;
|
||||||
err = "Could not install the new wallet; your original wallet was restored.";
|
};
|
||||||
} else {
|
#ifndef _WIN32
|
||||||
swapDone = true;
|
auto syncFile = [](const std::string& p) { // best-effort flush to disk
|
||||||
|
int fd = ::open(p.c_str(), O_RDONLY);
|
||||||
|
if (fd >= 0) { ::fsync(fd); ::close(fd); }
|
||||||
|
};
|
||||||
|
#else
|
||||||
|
auto syncFile = [](const std::string&) {};
|
||||||
|
#endif
|
||||||
|
bool backupCreated = false;
|
||||||
|
// (a) Stage a full copy of the new wallet beside wallet.dat (same dir => atomic rename).
|
||||||
|
fs::copy_file(newWallet, staged, fs::copy_options::overwrite_existing, ec);
|
||||||
|
if (ec || !sameSize(newWallet, staged)) {
|
||||||
|
err = "Could not stage the new wallet; nothing was changed.";
|
||||||
|
} else {
|
||||||
|
syncFile(staged);
|
||||||
|
// (b) COPY (not rename) the legacy wallet to the timestamped .bak and verify it.
|
||||||
|
if (fs::exists(legacy)) {
|
||||||
|
backupCreated = !fs::exists(backup);
|
||||||
|
fs::copy_file(legacy, backup, fs::copy_options::none, ec); // never overwrites an existing .bak
|
||||||
|
if (ec || !sameSize(legacy, backup)) err = "Could not back up your current wallet; nothing was changed.";
|
||||||
|
else syncFile(backup);
|
||||||
}
|
}
|
||||||
|
// (c) Atomically replace wallet.dat with the staged wallet.
|
||||||
|
if (err.empty()) {
|
||||||
|
fs::rename(staged, legacy, ec);
|
||||||
|
if (ec) err = "Could not install the new wallet; your original wallet was left unchanged.";
|
||||||
|
else swapDone = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!err.empty()) { // failed before the replace: original untouched; drop only OUR scratch files
|
||||||
|
std::error_code e3;
|
||||||
|
fs::remove(staged, e3);
|
||||||
|
if (backupCreated) fs::remove(backup, e3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7118,7 +7495,14 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
|||||||
wallet::LiteSendRequest req;
|
wallet::LiteSendRequest req;
|
||||||
wallet::LiteSendRecipient recipient;
|
wallet::LiteSendRecipient recipient;
|
||||||
recipient.address = to;
|
recipient.address = to;
|
||||||
recipient.amountZatoshis = static_cast<std::uint64_t>(std::llround(amount * 100000000.0));
|
// Guard the double→zatoshi conversion: a negative/NaN amount would wrap to a huge uint64, and a
|
||||||
|
// sub-zatoshi amount rounds to 0. Reject rather than broadcast a bogus value.
|
||||||
|
std::uint64_t zat = 0;
|
||||||
|
if (!util::coinsToZatoshis(amount, zat)) {
|
||||||
|
if (callback) callback(false, "Invalid send amount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recipient.amountZatoshis = zat;
|
||||||
recipient.memo = memo;
|
recipient.memo = memo;
|
||||||
req.recipients.push_back(std::move(recipient));
|
req.recipients.push_back(std::move(recipient));
|
||||||
if (!lite_wallet_->sendTransaction(req)) {
|
if (!lite_wallet_->sendTransaction(req)) {
|
||||||
@@ -7202,6 +7586,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
||||||
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
|
bool maybeInFlight = false; // reply lost after the request left: the node may already hold the op
|
||||||
std::string result_str;
|
std::string result_str;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||||
@@ -7210,12 +7595,13 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
ok = true;
|
ok = true;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
result_str = e.what();
|
result_str = e.what();
|
||||||
|
maybeInFlight = rpc::sendReplyMayBeLost(e);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
// Non-std throw must not escape into the worker — the main-thread callback
|
// Non-std throw must not escape into the worker — the main-thread callback
|
||||||
// below would never run, leaving a stuck "Sending…" spinner.
|
// below would never run, leaving a stuck "Sending…" spinner.
|
||||||
result_str = "Send failed (unknown error)";
|
result_str = "Send failed (unknown error)";
|
||||||
}
|
}
|
||||||
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
return [this, callback, ok, maybeInFlight, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
||||||
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// z_sendmany only returned an opid: the transaction is built/signed/
|
// z_sendmany only returned an opid: the transaction is built/signed/
|
||||||
@@ -7238,7 +7624,17 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!background) send_progress_active_ = false;
|
if (!background) send_progress_active_ = false;
|
||||||
if (callback) callback(false, result_str);
|
if (maybeInFlight && !background) {
|
||||||
|
// Ambiguous: z_sendmany's reply never arrived, so the op may be running. Surface the
|
||||||
|
// distinct "check History before resending" outcome and re-read the wallet so a
|
||||||
|
// committed send shows up promptly. Definitive rejections stay plain failures below.
|
||||||
|
DEBUG_LOGF("[send] z_sendmany reply lost (%s): outcome unknown, not a definitive failure\n",
|
||||||
|
result_str.c_str());
|
||||||
|
markWalletMutated(/*forceBalance=*/true);
|
||||||
|
if (callback) callback(false, rpc::markSendMaybeInFlight(result_str));
|
||||||
|
} else if (callback) {
|
||||||
|
callback(false, result_str);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -548,6 +548,15 @@ void App::applyUnlockNotCounted(const std::string& msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||||
|
if (lite_wallet_) { // lite: backend unlock with the shared attempt-lockout (no RPC/daemon)
|
||||||
|
std::string liteErr;
|
||||||
|
if (!unlockLiteWallet(passphrase, &liteErr)) {
|
||||||
|
lock_error_msg_ = liteErr;
|
||||||
|
lock_error_timer_ = 3.0f;
|
||||||
|
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||||
lock_unlock_in_progress_ = true;
|
lock_unlock_in_progress_ = true;
|
||||||
|
|
||||||
|
|||||||
+52
-10
@@ -97,6 +97,7 @@ void ChatDatabase::lock()
|
|||||||
{
|
{
|
||||||
sodium_memzero(key_.data(), key_.size());
|
sodium_memzero(key_.data(), key_.size());
|
||||||
key_ready_ = false;
|
key_ready_ = false;
|
||||||
|
lockSensitive(); // also drop the sensitive-conversation vault key
|
||||||
wallet_tag_.clear();
|
wallet_tag_.clear();
|
||||||
tombstones_.clear();
|
tombstones_.clear();
|
||||||
}
|
}
|
||||||
@@ -106,6 +107,10 @@ bool ChatDatabase::append(const ChatMessage& message)
|
|||||||
if (!key_ready_ || !ensureOpen()) return false;
|
if (!key_ready_ || !ensureOpen()) return false;
|
||||||
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
|
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
|
||||||
|
|
||||||
|
bool skip = false;
|
||||||
|
const unsigned char* wkey = writeKeyFor(message.conversation_id, skip);
|
||||||
|
if (skip) return false; // sensitive conversation, vault locked — don't persist (re-imports when unlocked)
|
||||||
|
|
||||||
std::vector<unsigned char> nonce;
|
std::vector<unsigned char> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
std::string plain;
|
std::string plain;
|
||||||
@@ -114,7 +119,7 @@ bool ChatDatabase::append(const ChatMessage& message)
|
|||||||
// serialize() dumps strictly, so an invalid-UTF-8 (attacker-influenceable) body THROWS here;
|
// serialize() dumps strictly, so an invalid-UTF-8 (attacker-influenceable) body THROWS here;
|
||||||
// skip the row instead of letting it escape the write path and crash (appendBatch already does).
|
// skip the row instead of letting it escape the write path and crash (appendBatch already does).
|
||||||
plain = serialize(message);
|
plain = serialize(message);
|
||||||
encrypted = encrypt(plain, nonce, cipher);
|
encrypted = encrypt(wkey, plain, nonce, cipher);
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
DEBUG_LOGF("[chat] append skipped a malformed message: %s\n", e.what());
|
DEBUG_LOGF("[chat] append skipped a malformed message: %s\n", e.what());
|
||||||
@@ -152,10 +157,13 @@ int ChatDatabase::appendBatch(const std::vector<ChatMessage>& messages)
|
|||||||
rows.reserve(messages.size());
|
rows.reserve(messages.size());
|
||||||
for (const auto& m : messages) {
|
for (const auto& m : messages) {
|
||||||
if (isTombstoned(m)) continue; // locally deleted — don't re-persist
|
if (isTombstoned(m)) continue; // locally deleted — don't re-persist
|
||||||
|
bool skip = false;
|
||||||
|
const unsigned char* wkey = writeKeyFor(m.conversation_id, skip);
|
||||||
|
if (skip) continue; // sensitive + vault locked — don't persist
|
||||||
try {
|
try {
|
||||||
std::string plain = serialize(m); // strict json.dump() may throw on non-UTF-8 body
|
std::string plain = serialize(m); // strict json.dump() may throw on non-UTF-8 body
|
||||||
std::vector<unsigned char> nonce, cipher;
|
std::vector<unsigned char> nonce, cipher;
|
||||||
const bool ok = encrypt(plain, nonce, cipher);
|
const bool ok = encrypt(wkey, plain, nonce, cipher);
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
if (!ok) continue;
|
if (!ok) continue;
|
||||||
rows.push_back({ dedupHash(m.txid, m.payload_position), std::move(nonce), std::move(cipher) });
|
rows.push_back({ dedupHash(m.txid, m.payload_position), std::move(nonce), std::move(cipher) });
|
||||||
@@ -195,13 +203,17 @@ bool ChatDatabase::upsert(const ChatMessage& message)
|
|||||||
{
|
{
|
||||||
if (!key_ready_ || !ensureOpen()) return false;
|
if (!key_ready_ || !ensureOpen()) return false;
|
||||||
|
|
||||||
|
bool skip = false;
|
||||||
|
const unsigned char* wkey = writeKeyFor(message.conversation_id, skip);
|
||||||
|
if (skip) return false; // sensitive conversation, vault locked — don't persist the echo under the seed key
|
||||||
|
|
||||||
std::vector<unsigned char> nonce;
|
std::vector<unsigned char> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
std::string plain;
|
std::string plain;
|
||||||
bool encrypted = false;
|
bool encrypted = false;
|
||||||
try {
|
try {
|
||||||
plain = serialize(message); // strict dump → a malformed body throws; skip rather than crash
|
plain = serialize(message); // strict dump → a malformed body throws; skip rather than crash
|
||||||
encrypted = encrypt(plain, nonce, cipher);
|
encrypted = encrypt(wkey, plain, nonce, cipher);
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
DEBUG_LOGF("[chat] upsert skipped a malformed message: %s\n", e.what());
|
DEBUG_LOGF("[chat] upsert skipped a malformed message: %s\n", e.what());
|
||||||
@@ -250,7 +262,13 @@ std::vector<ChatMessage> ChatDatabase::load()
|
|||||||
std::vector<unsigned char> nonce(noncePtr, noncePtr + nonceLen);
|
std::vector<unsigned char> nonce(noncePtr, noncePtr + nonceLen);
|
||||||
std::vector<unsigned char> cipher(cipherPtr, cipherPtr + cipherLen);
|
std::vector<unsigned char> cipher(cipherPtr, cipherPtr + cipherLen);
|
||||||
std::string plain;
|
std::string plain;
|
||||||
if (!decrypt(nonce, cipher, plain)) continue; // wrong wallet / tampered — skip
|
// Try the seed key first; a sensitive row only decrypts under the vault key (and is silently
|
||||||
|
// withheld when the vault is locked / on a fresh seed import that has no vault key). A wrong
|
||||||
|
// wallet / tampered row fails both and is skipped.
|
||||||
|
if (!decrypt(key_.data(), nonce, cipher, plain) &&
|
||||||
|
!(sensitive_ready_ && decrypt(sensitive_key_.data(), nonce, cipher, plain))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
ChatMessage message;
|
ChatMessage message;
|
||||||
if (deserialize(plain, message)) out.push_back(std::move(message));
|
if (deserialize(plain, message)) out.push_back(std::move(message));
|
||||||
@@ -460,11 +478,34 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatDatabase::encrypt(const std::string& plain,
|
void ChatDatabase::setSensitiveKey(const std::array<unsigned char, 32>& key)
|
||||||
|
{
|
||||||
|
sensitive_key_ = key; // copy; the caller still owns & wipes its own copy
|
||||||
|
sensitive_ready_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatDatabase::lockSensitive()
|
||||||
|
{
|
||||||
|
sodium_memzero(sensitive_key_.data(), sensitive_key_.size());
|
||||||
|
sensitive_ready_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsigned char* ChatDatabase::writeKeyFor(const std::string& cid, bool& skip) const
|
||||||
|
{
|
||||||
|
skip = false;
|
||||||
|
if (sensitive_pred_ && sensitive_pred_(cid)) {
|
||||||
|
if (!sensitive_ready_) { skip = true; return nullptr; } // don't persist under the seed key
|
||||||
|
return sensitive_key_.data();
|
||||||
|
}
|
||||||
|
return key_.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatDatabase::encrypt(const unsigned char* key,
|
||||||
|
const std::string& plain,
|
||||||
std::vector<unsigned char>& nonce,
|
std::vector<unsigned char>& nonce,
|
||||||
std::vector<unsigned char>& cipher) const
|
std::vector<unsigned char>& cipher) const
|
||||||
{
|
{
|
||||||
if (!key_ready_) return false;
|
if (!key) return false;
|
||||||
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||||
randombytes_buf(nonce.data(), nonce.size());
|
randombytes_buf(nonce.data(), nonce.size());
|
||||||
|
|
||||||
@@ -475,18 +516,19 @@ bool ChatDatabase::encrypt(const std::string& plain,
|
|||||||
cipher.data(), &cipherLen,
|
cipher.data(), &cipherLen,
|
||||||
reinterpret_cast<const unsigned char*>(plain.data()), plain.size(),
|
reinterpret_cast<const unsigned char*>(plain.data()), plain.size(),
|
||||||
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
||||||
nullptr, nonce.data(), key_.data()) != 0) {
|
nullptr, nonce.data(), key) != 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
cipher.resize(static_cast<std::size_t>(cipherLen));
|
cipher.resize(static_cast<std::size_t>(cipherLen));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatDatabase::decrypt(const std::vector<unsigned char>& nonce,
|
bool ChatDatabase::decrypt(const unsigned char* key,
|
||||||
|
const std::vector<unsigned char>& nonce,
|
||||||
const std::vector<unsigned char>& cipher,
|
const std::vector<unsigned char>& cipher,
|
||||||
std::string& plain) const
|
std::string& plain) const
|
||||||
{
|
{
|
||||||
if (!key_ready_) return false;
|
if (!key) return false;
|
||||||
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) return false;
|
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) return false;
|
||||||
if (cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) return false;
|
if (cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) return false;
|
||||||
|
|
||||||
@@ -497,7 +539,7 @@ bool ChatDatabase::decrypt(const std::vector<unsigned char>& nonce,
|
|||||||
out.data(), &outLen, nullptr,
|
out.data(), &outLen, nullptr,
|
||||||
cipher.data(), cipher.size(),
|
cipher.data(), cipher.size(),
|
||||||
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
||||||
nonce.data(), key_.data()) != 0) {
|
nonce.data(), key) != 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
plain.assign(reinterpret_cast<const char*>(out.data()), static_cast<std::size_t>(outLen));
|
plain.assign(reinterpret_cast<const char*>(out.data()), static_cast<std::size_t>(outLen));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -40,6 +41,17 @@ public:
|
|||||||
void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op
|
void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op
|
||||||
bool hasKey() const { return key_ready_; }
|
bool hasKey() const { return key_ready_; }
|
||||||
|
|
||||||
|
// --- Sensitive conversations (per-conversation local-key encryption) ---
|
||||||
|
// Provide the local vault key (from ChatSensitiveVault). Rows of conversations the sensitive
|
||||||
|
// predicate marks sensitive are AEAD-encrypted under THIS key instead of the seed-derived key, so
|
||||||
|
// they can't be read without it (even by the seed). The caller still owns and should wipe `key`.
|
||||||
|
void setSensitiveKey(const std::array<unsigned char, 32>& key);
|
||||||
|
void lockSensitive(); // wipe the sensitive key; sensitive rows then withheld
|
||||||
|
bool hasSensitiveKey() const { return sensitive_ready_; }
|
||||||
|
// Predicate: true for a conversation id that should be stored under the sensitive key. Consulted on
|
||||||
|
// WRITE to pick the key; on READ both keys are tried, so a predicate isn't needed to decrypt.
|
||||||
|
void setSensitivePredicate(std::function<bool(const std::string&)> pred) { sensitive_pred_ = std::move(pred); }
|
||||||
|
|
||||||
// Persist one message (INSERT OR IGNORE, deduped by a keyed hash of txid+payload_position).
|
// Persist one message (INSERT OR IGNORE, deduped by a keyed hash of txid+payload_position).
|
||||||
// Returns true if newly inserted; false on duplicate or while locked.
|
// Returns true if newly inserted; false on duplicate or while locked.
|
||||||
bool append(const ChatMessage& message);
|
bool append(const ChatMessage& message);
|
||||||
@@ -80,12 +92,18 @@ private:
|
|||||||
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
||||||
std::string serialize(const ChatMessage& message) const;
|
std::string serialize(const ChatMessage& message) const;
|
||||||
bool deserialize(const std::string& json, ChatMessage& out) const;
|
bool deserialize(const std::string& json, ChatMessage& out) const;
|
||||||
bool encrypt(const std::string& plain,
|
bool encrypt(const unsigned char* key,
|
||||||
|
const std::string& plain,
|
||||||
std::vector<unsigned char>& nonce,
|
std::vector<unsigned char>& nonce,
|
||||||
std::vector<unsigned char>& cipher) const;
|
std::vector<unsigned char>& cipher) const;
|
||||||
bool decrypt(const std::vector<unsigned char>& nonce,
|
bool decrypt(const unsigned char* key,
|
||||||
|
const std::vector<unsigned char>& nonce,
|
||||||
const std::vector<unsigned char>& cipher,
|
const std::vector<unsigned char>& cipher,
|
||||||
std::string& plain) const;
|
std::string& plain) const;
|
||||||
|
// The key a row for `cid` is written under. Returns nullptr and sets skip=true when the conversation
|
||||||
|
// is sensitive but the vault is locked — the row must NOT be persisted (under the seed key it would be
|
||||||
|
// readable without the vault, defeating the point); it re-imports from chain once the vault is unlocked.
|
||||||
|
const unsigned char* writeKeyFor(const std::string& cid, bool& skip) const;
|
||||||
void close();
|
void close();
|
||||||
|
|
||||||
sqlite3* db_ = nullptr;
|
sqlite3* db_ = nullptr;
|
||||||
@@ -93,6 +111,9 @@ private:
|
|||||||
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
||||||
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
||||||
bool key_ready_ = false;
|
bool key_ready_ = false;
|
||||||
|
std::array<unsigned char, 32> sensitive_key_{}; // local vault key for sensitive conversations' rows
|
||||||
|
bool sensitive_ready_ = false;
|
||||||
|
std::function<bool(const std::string&)> sensitive_pred_; // true => store this cid under the sensitive key
|
||||||
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
|
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+78
-16
@@ -17,31 +17,47 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
|
|||||||
const char* type,
|
const char* type,
|
||||||
const std::string& streamHeaderHex,
|
const std::string& streamHeaderHex,
|
||||||
const std::string& publicKeyHex,
|
const std::string& publicKeyHex,
|
||||||
std::int64_t sentAt)
|
std::int64_t sentAt,
|
||||||
|
const std::string& op = "",
|
||||||
|
int parts = 1)
|
||||||
{
|
{
|
||||||
nlohmann::json header;
|
nlohmann::json header;
|
||||||
header["h"] = 1; // header number (>= 1)
|
header["h"] = 1; // header number (>= 1)
|
||||||
header["v"] = kHushChatSupportedVersion; // 0
|
header["v"] = kHushChatSupportedVersion; // 0
|
||||||
header["z"] = replyZaddr; // where the peer should reply (my address)
|
header["z"] = replyZaddr; // where the peer should reply (my address)
|
||||||
header["cid"] = conversationId;
|
header["cid"] = conversationId;
|
||||||
header["t"] = type; // "Memo" or "Cont"
|
header["t"] = type; // "Memo" / "Cont" / "Ctrl"
|
||||||
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
|
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont, Ctrl)
|
||||||
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
||||||
|
if (!op.empty()) header["op"] = op; // Control only; alphabetically sorts to cid,e,h,op,p,t,v,z
|
||||||
|
if (parts > 1) header["n"] = parts; // multi-part Message only; omitted (==1) keeps 1-part byte-identical
|
||||||
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
|
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
|
||||||
return header.dump();
|
return header.dump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The N 2-hex index prefix for a multi-part ciphertext chunk (index 0..kHushChatMaxMessageParts-1).
|
||||||
|
std::string partIndexPrefix(std::size_t i)
|
||||||
|
{
|
||||||
|
static const char* kHex = "0123456789abcdef";
|
||||||
|
std::string p;
|
||||||
|
p += kHex[(i >> 4) & 0xF];
|
||||||
|
p += kHex[i & 0xF];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
bool present(const std::string& value) { return !value.empty(); }
|
bool present(const std::string& value) { return !value.empty(); }
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::array<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
|
std::vector<ChatSendOutput> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
|
||||||
{
|
{
|
||||||
const std::string prefix = utf8Prefix ? "utf8:" : "";
|
const std::string prefix = utf8Prefix ? "utf8:" : "";
|
||||||
return {{
|
std::vector<ChatSendOutput> outs;
|
||||||
{ memos.recipientZaddr, prefix + memos.headerMemo }, // header — the lower memo position
|
outs.reserve(1 + memos.payloadMemos.size());
|
||||||
{ memos.recipientZaddr, prefix + memos.payloadMemo },
|
outs.push_back({ memos.recipientZaddr, prefix + memos.headerMemo }); // header (position doesn't matter;
|
||||||
}};
|
for (const auto& p : memos.payloadMemos) // the receiver reassembles by index)
|
||||||
|
outs.push_back({ memos.recipientZaddr, prefix + p });
|
||||||
|
return outs;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
||||||
@@ -67,15 +83,32 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
|||||||
return ChatComposeStatus::EncryptFailed;
|
return ChatComposeStatus::EncryptFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chunk the ciphertext hex across payload memos. A single memo → the classic 1-part message, byte-
|
||||||
|
// identical on the wire (no "n", no index prefix). Longer → N index-prefixed chunks (tier 2), capped
|
||||||
|
// at kHushChatMaxMessageParts; beyond that it is refused as TooLong (tier 3 would lift the ceiling).
|
||||||
|
std::vector<std::string> payloads;
|
||||||
|
int parts = 1;
|
||||||
|
if (ciphertextHex.size() <= kHushChatMemoByteLimit) {
|
||||||
|
payloads.push_back(ciphertextHex);
|
||||||
|
} else {
|
||||||
|
const std::size_t chunkHex = (kHushChatMemoByteLimit - kHushChatPartIndexHexLen) & ~std::size_t(1);
|
||||||
|
const std::size_t n = (ciphertextHex.size() + chunkHex - 1) / chunkHex;
|
||||||
|
if (static_cast<int>(n) > kHushChatMaxMessageParts) return ChatComposeStatus::TooLong;
|
||||||
|
parts = static_cast<int>(n);
|
||||||
|
payloads.reserve(n);
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
payloads.push_back(partIndexPrefix(i) + ciphertextHex.substr(i * chunkHex, chunkHex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OutgoingChatMemos memos;
|
OutgoingChatMemos memos;
|
||||||
memos.recipientZaddr = peerZaddr;
|
memos.recipientZaddr = peerZaddr;
|
||||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
|
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
|
||||||
static_cast<std::int64_t>(std::time(nullptr)));
|
static_cast<std::int64_t>(std::time(nullptr)), "", parts);
|
||||||
memos.payloadMemo = ciphertextHex;
|
memos.payloadMemos = std::move(payloads);
|
||||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
|
||||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
for (const auto& p : memos.payloadMemos)
|
||||||
return ChatComposeStatus::TooLong;
|
if (p.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
|
||||||
}
|
|
||||||
out = std::move(memos);
|
out = std::move(memos);
|
||||||
return ChatComposeStatus::Ok;
|
return ChatComposeStatus::Ok;
|
||||||
}
|
}
|
||||||
@@ -100,9 +133,38 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
|||||||
memos.recipientZaddr = peerZaddr;
|
memos.recipientZaddr = peerZaddr;
|
||||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
|
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
|
||||||
static_cast<std::int64_t>(std::time(nullptr)));
|
static_cast<std::int64_t>(std::time(nullptr)));
|
||||||
memos.payloadMemo = requestText;
|
memos.payloadMemos = { requestText };
|
||||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
|
||||||
|
return ChatComposeStatus::TooLong;
|
||||||
|
}
|
||||||
|
out = std::move(memos);
|
||||||
|
return ChatComposeStatus::Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
|
||||||
|
const std::string& selfZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& op,
|
||||||
|
const std::string& label,
|
||||||
|
OutgoingChatMemos& out)
|
||||||
|
{
|
||||||
|
// A control signal is addressed to SELF: only this wallet (any device holding the seed) harvests
|
||||||
|
// it, and it never reaches the peer. op carries the action; the label is a plaintext hint (peer
|
||||||
|
// address / display name) so a fresh import can show the conversation it refers to.
|
||||||
|
if (label.empty()) return ChatComposeStatus::EmptyBody;
|
||||||
|
if (label.front() == '{') return ChatComposeStatus::BadRequestText; // would parse as a header
|
||||||
|
if (!present(op) || !present(myPublicKeyHex) || !present(selfZaddr) || !present(conversationId)) {
|
||||||
|
return ChatComposeStatus::MissingField;
|
||||||
|
}
|
||||||
|
|
||||||
|
OutgoingChatMemos memos;
|
||||||
|
memos.recipientZaddr = selfZaddr;
|
||||||
|
memos.headerMemo = buildHeaderMemo(selfZaddr, conversationId, "Ctrl", "", myPublicKeyHex,
|
||||||
|
static_cast<std::int64_t>(std::time(nullptr)), op);
|
||||||
|
memos.payloadMemos = { label };
|
||||||
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||||
|
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
|
||||||
return ChatComposeStatus::TooLong;
|
return ChatComposeStatus::TooLong;
|
||||||
}
|
}
|
||||||
out = std::move(memos);
|
out = std::move(memos);
|
||||||
|
|||||||
@@ -12,13 +12,17 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace dragonx::chat {
|
namespace dragonx::chat {
|
||||||
|
|
||||||
struct OutgoingChatMemos {
|
struct OutgoingChatMemos {
|
||||||
std::string recipientZaddr; // the peer's z-address (recipient of both memo outputs)
|
std::string recipientZaddr; // the peer's z-address (recipient of every memo output)
|
||||||
std::string headerMemo; // JSON header — MUST occupy the lower memo position on the wire
|
std::string headerMemo; // JSON header
|
||||||
std::string payloadMemo; // ciphertext hex (Message) or plaintext (ContactRequest)
|
// One payload (ciphertext hex for a Message, plaintext for a ContactRequest/Control), OR — for a
|
||||||
|
// multi-part Message (header "n" = N > 1) — N index-prefixed ciphertext chunks (a 2-hex part index
|
||||||
|
// then that chunk's ciphertext hex), reassembled by the receiver regardless of on-chain order.
|
||||||
|
std::vector<std::string> payloadMemos;
|
||||||
};
|
};
|
||||||
|
|
||||||
// One of the two 0-value memo outputs a HushChat send produces (amount is always 0).
|
// One of the two 0-value memo outputs a HushChat send produces (amount is always 0).
|
||||||
@@ -31,7 +35,7 @@ struct ChatSendOutput {
|
|||||||
// `utf8Prefix` prepends the daemon's "utf8:" marker required by full-node z_sendmany (which then
|
// `utf8Prefix` prepends the daemon's "utf8:" marker required by full-node z_sendmany (which then
|
||||||
// UTF-8-encodes the bytes on-chain, byte-identical to SDXLite's Memo::from_str); lite backends take
|
// UTF-8-encodes the bytes on-chain, byte-identical to SDXLite's Memo::from_str); lite backends take
|
||||||
// raw UTF-8, so pass false there.
|
// raw UTF-8, so pass false there.
|
||||||
std::array<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix);
|
std::vector<ChatSendOutput> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix);
|
||||||
|
|
||||||
enum class ChatComposeStatus {
|
enum class ChatComposeStatus {
|
||||||
Ok,
|
Ok,
|
||||||
@@ -63,4 +67,15 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
|||||||
const std::string& requestText,
|
const std::string& requestText,
|
||||||
OutgoingChatMemos& out);
|
OutgoingChatMemos& out);
|
||||||
|
|
||||||
|
// Build a self-addressed control signal (delete / restore / mark-sensitive). It is sent to your OWN
|
||||||
|
// z-address, so only this wallet's seed can harvest it and it never reaches the peer. `op` is the
|
||||||
|
// action ("del"/"res"/...); `label` is a plaintext hint (peer address or name) carried as the payload
|
||||||
|
// so a fresh seed import can name the conversation the signal refers to.
|
||||||
|
ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
|
||||||
|
const std::string& selfZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& op,
|
||||||
|
const std::string& label,
|
||||||
|
OutgoingChatMemos& out);
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ const char* hushChatHeaderTypeName(HushChatHeaderType type)
|
|||||||
return "Memo";
|
return "Memo";
|
||||||
case HushChatHeaderType::ContactRequest:
|
case HushChatHeaderType::ContactRequest:
|
||||||
return "Cont";
|
return "Cont";
|
||||||
|
case HushChatHeaderType::Control:
|
||||||
|
return "Ctrl";
|
||||||
}
|
}
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
@@ -138,7 +140,15 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
|
|||||||
header.type = HushChatHeaderType::Message;
|
header.type = HushChatHeaderType::Message;
|
||||||
} else if (type == "Cont") {
|
} else if (type == "Cont") {
|
||||||
header.type = HushChatHeaderType::ContactRequest;
|
header.type = HushChatHeaderType::ContactRequest;
|
||||||
|
} else if (type == "Ctrl") {
|
||||||
|
header.type = HushChatHeaderType::Control;
|
||||||
|
// A control signal carries a non-empty op ("del"/"res"/...); the op VALUE isn't restricted here so
|
||||||
|
// a future op reaches newer clients and is ignored by the reducer on older ones (forward-compat).
|
||||||
|
if (!readRequiredString(object, "op", header.op, error)) return fail(error);
|
||||||
|
if (header.op.empty()) return fail("control op is empty");
|
||||||
} else {
|
} else {
|
||||||
|
// Unknown type (older client seeing a newer memo type): fail here so it is grouped as an invalid
|
||||||
|
// header and ignored, never crashing. Keep this the catch-all.
|
||||||
return fail("unknown HushChat header type");
|
return fail("unknown HushChat header type");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +161,13 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
|
|||||||
!isHexString(header.secretstream_header_hex)) {
|
!isHexString(header.secretstream_header_hex)) {
|
||||||
return fail("message header must include a 24 byte secretstream header encoded as hex");
|
return fail("message header must include a 24 byte secretstream header encoded as hex");
|
||||||
}
|
}
|
||||||
|
// Optional part count ("n"); absent/1 = classic single-payload message. Bound it so a garbage or
|
||||||
|
// future-oversized header can't make the grouper try to collect an unbounded number of payloads.
|
||||||
|
if (auto it = object.find("n"); it != object.end() && it->is_number_integer()) {
|
||||||
|
const int n = it->get<int>();
|
||||||
|
if (n < 1 || n > kHushChatMaxMessageParts) return fail("message part count out of range");
|
||||||
|
header.parts = n;
|
||||||
|
}
|
||||||
} else if (!header.secretstream_header_hex.empty()) {
|
} else if (!header.secretstream_header_hex.empty()) {
|
||||||
return fail("contact request header must not include a secretstream header");
|
return fail("contact request header must not include a secretstream header");
|
||||||
}
|
}
|
||||||
@@ -179,6 +196,76 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMe
|
|||||||
});
|
});
|
||||||
|
|
||||||
HushChatMemoGroupingResult result;
|
HushChatMemoGroupingResult result;
|
||||||
|
|
||||||
|
// --- Larger-messages tier 2: one message split across N payload outputs in a SINGLE tx. ---
|
||||||
|
// A lone Message header with part count ("n") > 1 means its ciphertext was chunked across N payload
|
||||||
|
// outputs, each prefixed with a 2-hex part index so order survives the daemon's output shuffle.
|
||||||
|
// Reassemble here and emit one pair. Only a clean one-header + exactly-N-chunks tx takes this path;
|
||||||
|
// anything else falls through to the classic single-pair logic. An older client lacks this branch,
|
||||||
|
// mis-pairs the first chunk, fails to decrypt, and drops it — graceful degradation.
|
||||||
|
{
|
||||||
|
std::optional<HushChatHeader> multiHeader;
|
||||||
|
std::size_t multiHeaderPos = 0;
|
||||||
|
int validHeaders = 0;
|
||||||
|
bool anyOversize = false;
|
||||||
|
std::vector<const HushChatMemoOutput*> payloads;
|
||||||
|
for (const auto& entry : ordered) {
|
||||||
|
const auto& o = entry.output;
|
||||||
|
if (o.memo.size() > kHushChatMemoByteLimit) { anyOversize = true; break; }
|
||||||
|
if (!o.memo.empty() && o.memo.front() == '{') {
|
||||||
|
auto parsed = parseHushChatHeaderMemo(o.memo);
|
||||||
|
if (parsed.ok) {
|
||||||
|
++validHeaders;
|
||||||
|
if (parsed.header.type == HushChatHeaderType::Message && parsed.header.parts > 1) {
|
||||||
|
multiHeader = std::move(parsed.header);
|
||||||
|
multiHeaderPos = o.position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!o.memo.empty()) {
|
||||||
|
payloads.push_back(&o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyOversize && validHeaders == 1 && multiHeader) {
|
||||||
|
const int want = multiHeader->parts;
|
||||||
|
auto hexVal = [](char c) -> int {
|
||||||
|
if (c >= '0' && c <= '9') return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||||
|
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
std::vector<std::string> chunks(static_cast<std::size_t>(want));
|
||||||
|
std::vector<bool> seen(static_cast<std::size_t>(want), false);
|
||||||
|
bool ok = (static_cast<int>(payloads.size()) == want);
|
||||||
|
std::size_t lowPos = 0; bool haveLow = false;
|
||||||
|
for (const auto* p : payloads) {
|
||||||
|
if (!ok) break;
|
||||||
|
const std::string& m = p->memo;
|
||||||
|
if (m.size() < kHushChatPartIndexHexLen || (m.size() % 2) != 0 || !isHexString(m)) { ok = false; break; }
|
||||||
|
const int idx = hexVal(m[0]) * 16 + hexVal(m[1]);
|
||||||
|
if (idx < 0 || idx >= want || seen[static_cast<std::size_t>(idx)]) { ok = false; break; }
|
||||||
|
seen[static_cast<std::size_t>(idx)] = true;
|
||||||
|
chunks[static_cast<std::size_t>(idx)] = m.substr(kHushChatPartIndexHexLen);
|
||||||
|
if (!haveLow || p->position < lowPos) { lowPos = p->position; haveLow = true; }
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
std::string full;
|
||||||
|
for (const auto& c : chunks) full += c;
|
||||||
|
if (!full.empty() && (full.size() % 2) == 0) {
|
||||||
|
HushChatMemoPair pair;
|
||||||
|
pair.header = std::move(*multiHeader);
|
||||||
|
pair.header_position = multiHeaderPos;
|
||||||
|
pair.payload_position = haveLow ? lowPos : 0;
|
||||||
|
pair.payload_memo = std::move(full); // the reassembled full ciphertext hex
|
||||||
|
result.pairs.push_back(std::move(pair));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.issues.push_back(HushChatMemoGroupingIssueInfo{
|
||||||
|
HushChatMemoGroupingIssue::MissingPayload, multiHeaderPos,
|
||||||
|
"multi-part message did not reassemble"});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
std::optional<HushChatMemoOutput> pending_header_output;
|
std::optional<HushChatMemoOutput> pending_header_output;
|
||||||
std::optional<HushChatHeader> pending_header;
|
std::optional<HushChatHeader> pending_header;
|
||||||
// A payload memo seen BEFORE its header. The full-node daemon shuffles the two 0-value memo
|
// A payload memo seen BEFORE its header. The full-node daemon shuffles the two 0-value memo
|
||||||
@@ -299,6 +386,7 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
|
|||||||
HushChatTransactionMetadata metadata;
|
HushChatTransactionMetadata metadata;
|
||||||
metadata.txid = transaction.txid;
|
metadata.txid = transaction.txid;
|
||||||
metadata.type = pair.header.type;
|
metadata.type = pair.header.type;
|
||||||
|
metadata.op = pair.header.op; // Control only; empty for Message/ContactRequest
|
||||||
metadata.conversation_id = pair.header.conversation_id;
|
metadata.conversation_id = pair.header.conversation_id;
|
||||||
metadata.reply_zaddr = pair.header.reply_zaddr;
|
metadata.reply_zaddr = pair.header.reply_zaddr;
|
||||||
metadata.header_position = pair.header_position;
|
metadata.header_position = pair.header_position;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ namespace dragonx::chat {
|
|||||||
|
|
||||||
enum class HushChatHeaderType {
|
enum class HushChatHeaderType {
|
||||||
Message,
|
Message,
|
||||||
ContactRequest
|
ContactRequest,
|
||||||
|
Control // a self-addressed control signal (delete / restore / mark sensitive); carries an `op`
|
||||||
};
|
};
|
||||||
|
|
||||||
struct HushChatHeader {
|
struct HushChatHeader {
|
||||||
@@ -21,8 +22,13 @@ struct HushChatHeader {
|
|||||||
std::string reply_zaddr;
|
std::string reply_zaddr;
|
||||||
std::string conversation_id;
|
std::string conversation_id;
|
||||||
HushChatHeaderType type = HushChatHeaderType::Message;
|
HushChatHeaderType type = HushChatHeaderType::Message;
|
||||||
|
std::string op; // Control only ("del" / "res" / "sensitive" / "unsensitive" / future ops); empty otherwise
|
||||||
std::string secretstream_header_hex;
|
std::string secretstream_header_hex;
|
||||||
std::string public_key_hex;
|
std::string public_key_hex;
|
||||||
|
// Message only: how many payload outputs this message's ciphertext is split across (header "n").
|
||||||
|
// 1 = the classic single-payload message (field omitted on the wire). >1 means the payloads are
|
||||||
|
// index-prefixed ciphertext chunks reassembled in order (larger-messages tier 2, one transaction).
|
||||||
|
int parts = 1;
|
||||||
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
|
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
|
||||||
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
|
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
|
||||||
std::int64_t sent_at = 0;
|
std::int64_t sent_at = 0;
|
||||||
@@ -73,6 +79,7 @@ struct HushChatTransactionInput {
|
|||||||
struct HushChatTransactionMetadata {
|
struct HushChatTransactionMetadata {
|
||||||
std::string txid;
|
std::string txid;
|
||||||
HushChatHeaderType type = HushChatHeaderType::Message;
|
HushChatHeaderType type = HushChatHeaderType::Message;
|
||||||
|
std::string op; // Control only: the control op ("del"/"res"/...); empty otherwise
|
||||||
std::string conversation_id;
|
std::string conversation_id;
|
||||||
std::string reply_zaddr;
|
std::string reply_zaddr;
|
||||||
std::size_t header_position = 0;
|
std::size_t header_position = 0;
|
||||||
@@ -95,6 +102,13 @@ struct HushChatTransactionExtractionResult {
|
|||||||
|
|
||||||
constexpr int kHushChatSupportedVersion = 0;
|
constexpr int kHushChatSupportedVersion = 0;
|
||||||
constexpr std::size_t kHushChatMemoByteLimit = 512;
|
constexpr std::size_t kHushChatMemoByteLimit = 512;
|
||||||
|
// Larger-messages tier 2: the most payload outputs a single message may be split across (one tx).
|
||||||
|
// Keeps the chat tx well under the shielded max_tx_size after Sietch decoy padding + proving cost.
|
||||||
|
// A message that would need more parts than this is refused at compose time (tier 3 would lift it).
|
||||||
|
constexpr int kHushChatMaxMessageParts = 16;
|
||||||
|
// Each multi-part payload chunk is prefixed with a 2-hex-char (1-byte) part index so the receiver can
|
||||||
|
// reorder chunks after the daemon shuffles a tx's outputs. 2 hex covers indices 0..255 (>> the cap).
|
||||||
|
constexpr std::size_t kHushChatPartIndexHexLen = 2;
|
||||||
constexpr std::size_t kHushChatPublicKeyHexLength = 64;
|
constexpr std::size_t kHushChatPublicKeyHexLength = 64;
|
||||||
constexpr std::size_t kHushChatSecretstreamHeaderHexLength = 48;
|
constexpr std::size_t kHushChatSecretstreamHeaderHexLength = 48;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// DragonX Wallet - HushChat sensitive-conversation local key vault (implementation).
|
||||||
|
|
||||||
|
#include "chat_sensitive_vault.h"
|
||||||
|
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace dragonx::chat {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr unsigned char kVersion = 0x01;
|
||||||
|
constexpr std::size_t kSaltLen = 16; // crypto_pwhash_SALTBYTES
|
||||||
|
constexpr std::size_t kNonceLen = 24; // crypto_secretbox_NONCEBYTES
|
||||||
|
constexpr std::size_t kKeyLen = 32; // the vault key / crypto_secretbox_KEYBYTES
|
||||||
|
constexpr std::size_t kMacLen = 16; // crypto_secretbox_MACBYTES
|
||||||
|
constexpr std::size_t kCipherLen = kKeyLen + kMacLen; // 48
|
||||||
|
constexpr std::size_t kFileLen = 1 + kSaltLen + kNonceLen + kCipherLen; // 89
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ChatSensitiveVault::ChatSensitiveVault(std::string vaultPath) : path_(std::move(vaultPath)) {}
|
||||||
|
|
||||||
|
void ChatSensitiveVault::setPath(std::string vaultPath) { path_ = std::move(vaultPath); }
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::deriveKek(const std::string& passphrase, const unsigned char* salt,
|
||||||
|
unsigned char* kek) const {
|
||||||
|
// Fixed MODERATE limits (≈0.7s / 256 MiB) resist brute-forcing a weak passphrase; because they are
|
||||||
|
// fixed, the file carries no KDF parameters and unlock derives with exactly the same cost.
|
||||||
|
return crypto_pwhash(kek, kKeyLen, passphrase.c_str(), passphrase.size(), salt,
|
||||||
|
crypto_pwhash_OPSLIMIT_MODERATE, crypto_pwhash_MEMLIMIT_MODERATE,
|
||||||
|
crypto_pwhash_ALG_ARGON2ID13) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::writeVault(const unsigned char* salt, const unsigned char* nonce,
|
||||||
|
const unsigned char* cipher, std::size_t cipherLen) const {
|
||||||
|
if (path_.empty()) return false;
|
||||||
|
const std::string tmp = path_ + ".tmp";
|
||||||
|
{
|
||||||
|
std::ofstream f(tmp, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!f) return false;
|
||||||
|
f.put(static_cast<char>(kVersion));
|
||||||
|
f.write(reinterpret_cast<const char*>(salt), kSaltLen);
|
||||||
|
f.write(reinterpret_cast<const char*>(nonce), kNonceLen);
|
||||||
|
f.write(reinterpret_cast<const char*>(cipher), static_cast<std::streamsize>(cipherLen));
|
||||||
|
if (!f) { f.close(); std::remove(tmp.c_str()); return false; }
|
||||||
|
}
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
::chmod(tmp.c_str(), 0600); // best-effort: the key is passphrase-wrapped, so this is defense-in-depth
|
||||||
|
#endif
|
||||||
|
if (std::rename(tmp.c_str(), path_.c_str()) != 0) { std::remove(tmp.c_str()); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::exists() const {
|
||||||
|
if (path_.empty()) return false;
|
||||||
|
std::ifstream f(path_, std::ios::binary);
|
||||||
|
if (!f) return false;
|
||||||
|
char ver = 0;
|
||||||
|
if (!f.get(ver) || static_cast<unsigned char>(ver) != kVersion) return false;
|
||||||
|
f.seekg(0, std::ios::end);
|
||||||
|
return f.good() && static_cast<std::size_t>(f.tellg()) == kFileLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::create(const std::string& passphrase, std::array<unsigned char, 32>& keyOut) {
|
||||||
|
keyOut.fill(0);
|
||||||
|
if (path_.empty() || passphrase.empty()) return false;
|
||||||
|
if (exists()) return false; // never clobber an existing vault
|
||||||
|
if (sodium_init() < 0) return false;
|
||||||
|
|
||||||
|
unsigned char salt[kSaltLen], nonce[kNonceLen], kek[kKeyLen];
|
||||||
|
unsigned char cipher[kCipherLen];
|
||||||
|
randombytes_buf(salt, kSaltLen);
|
||||||
|
randombytes_buf(nonce, kNonceLen);
|
||||||
|
randombytes_buf(keyOut.data(), kKeyLen); // the random vault key
|
||||||
|
|
||||||
|
bool ok = deriveKek(passphrase, salt, kek);
|
||||||
|
if (ok) {
|
||||||
|
crypto_secretbox_easy(cipher, keyOut.data(), kKeyLen, nonce, kek);
|
||||||
|
ok = writeVault(salt, nonce, cipher, kCipherLen);
|
||||||
|
}
|
||||||
|
sodium_memzero(kek, sizeof kek);
|
||||||
|
sodium_memzero(cipher, sizeof cipher);
|
||||||
|
if (!ok) keyOut.fill(0);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::unlock(const std::string& passphrase, std::array<unsigned char, 32>& keyOut) const {
|
||||||
|
keyOut.fill(0);
|
||||||
|
if (path_.empty() || passphrase.empty()) return false;
|
||||||
|
if (sodium_init() < 0) return false;
|
||||||
|
|
||||||
|
std::ifstream f(path_, std::ios::binary);
|
||||||
|
if (!f) return false;
|
||||||
|
std::vector<unsigned char> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||||||
|
if (buf.size() != kFileLen || buf[0] != kVersion) return false;
|
||||||
|
|
||||||
|
const unsigned char* salt = buf.data() + 1;
|
||||||
|
const unsigned char* nonce = salt + kSaltLen;
|
||||||
|
const unsigned char* cipher = nonce + kNonceLen;
|
||||||
|
|
||||||
|
unsigned char kek[kKeyLen];
|
||||||
|
if (!deriveKek(passphrase, salt, kek)) { sodium_memzero(kek, sizeof kek); return false; }
|
||||||
|
int rc = crypto_secretbox_open_easy(keyOut.data(), cipher, kCipherLen, nonce, kek);
|
||||||
|
sodium_memzero(kek, sizeof kek);
|
||||||
|
if (rc != 0) { keyOut.fill(0); return false; } // wrong passphrase or corrupt file
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::changePassphrase(const std::string& oldPassphrase, const std::string& newPassphrase) {
|
||||||
|
if (newPassphrase.empty()) return false;
|
||||||
|
std::array<unsigned char, 32> key{};
|
||||||
|
if (!unlock(oldPassphrase, key)) return false; // wrong old passphrase
|
||||||
|
|
||||||
|
unsigned char salt[kSaltLen], nonce[kNonceLen], kek[kKeyLen], cipher[kCipherLen];
|
||||||
|
randombytes_buf(salt, kSaltLen);
|
||||||
|
randombytes_buf(nonce, kNonceLen);
|
||||||
|
bool ok = deriveKek(newPassphrase, salt, kek);
|
||||||
|
if (ok) {
|
||||||
|
crypto_secretbox_easy(cipher, key.data(), kKeyLen, nonce, kek);
|
||||||
|
ok = writeVault(salt, nonce, cipher, kCipherLen);
|
||||||
|
}
|
||||||
|
sodium_memzero(kek, sizeof kek);
|
||||||
|
sodium_memzero(cipher, sizeof cipher);
|
||||||
|
sodium_memzero(key.data(), key.size());
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::remove() {
|
||||||
|
if (path_.empty()) return true;
|
||||||
|
std::error_code ec;
|
||||||
|
(void)ec;
|
||||||
|
if (std::remove(path_.c_str()) != 0) {
|
||||||
|
// Treat "already absent" as success; anything else is a real failure only if the file is still there.
|
||||||
|
std::ifstream f(path_, std::ios::binary);
|
||||||
|
return !f.good();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dragonx::chat
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// DragonX Wallet - HushChat "sensitive conversation" local key vault.
|
||||||
|
//
|
||||||
|
// A per-wallet random 32-byte key, wrapped under a USER PASSPHRASE (Argon2id-derived KEK +
|
||||||
|
// XSalsa20-Poly1305), stored in a small local file. It encrypts the at-rest rows of conversations the
|
||||||
|
// user marks "sensitive" in ChatDatabase, INDEPENDENTLY of the seed-derived chat key — so those rows
|
||||||
|
// cannot be read by someone who has the wallet seed but not this passphrase, and a wallet freshly
|
||||||
|
// restored from the seed alone cannot read them at all. Losing the passphrase (or the file) makes the
|
||||||
|
// sensitive messages unrecoverable BY DESIGN. Not thread-safe; drive from the main thread.
|
||||||
|
//
|
||||||
|
// File format (version 1), 89 bytes:
|
||||||
|
// [1] version (0x01)
|
||||||
|
// [16] Argon2id salt
|
||||||
|
// [24] XSalsa20-Poly1305 nonce
|
||||||
|
// [48] ciphertext = 32-byte vault key + 16-byte Poly1305 tag
|
||||||
|
// Argon2id uses MODERATE ops/mem limits (fixed), so the format needs to carry no KDF parameters.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace dragonx::chat {
|
||||||
|
|
||||||
|
class ChatSensitiveVault {
|
||||||
|
public:
|
||||||
|
ChatSensitiveVault() = default;
|
||||||
|
explicit ChatSensitiveVault(std::string vaultPath);
|
||||||
|
|
||||||
|
void setPath(std::string vaultPath);
|
||||||
|
const std::string& path() const { return path_; }
|
||||||
|
|
||||||
|
// True if a well-formed vault file exists (version byte + exact size). No passphrase / Argon2.
|
||||||
|
bool exists() const;
|
||||||
|
|
||||||
|
// Create a NEW vault: generate a random 32-byte key, wrap it under `passphrase`, write the file, and
|
||||||
|
// return the key in keyOut. Refuses (returns false) if a vault already exists, the passphrase is
|
||||||
|
// empty, or on IO/crypto error. keyOut is zeroed on any failure.
|
||||||
|
bool create(const std::string& passphrase, std::array<unsigned char, 32>& keyOut);
|
||||||
|
|
||||||
|
// Unlock: derive the KEK from `passphrase`, decrypt the stored key into keyOut. Returns false on a
|
||||||
|
// wrong passphrase, a missing/corrupt file, or crypto failure; keyOut is zeroed unless it succeeds.
|
||||||
|
bool unlock(const std::string& passphrase, std::array<unsigned char, 32>& keyOut) const;
|
||||||
|
|
||||||
|
// Re-wrap the existing key under a new passphrase (the key is unchanged). False if old is wrong.
|
||||||
|
bool changePassphrase(const std::string& oldPassphrase, const std::string& newPassphrase);
|
||||||
|
|
||||||
|
// Delete the vault file. Returns true if removed or already absent.
|
||||||
|
bool remove();
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool deriveKek(const std::string& passphrase, const unsigned char* salt, unsigned char* kek) const;
|
||||||
|
bool writeVault(const unsigned char* salt, const unsigned char* nonce,
|
||||||
|
const unsigned char* cipher, std::size_t cipherLen) const;
|
||||||
|
|
||||||
|
std::string path_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dragonx::chat
|
||||||
@@ -40,6 +40,16 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
int added = 0;
|
int added = 0;
|
||||||
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
|
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
|
||||||
for (const auto& meta : metadata) {
|
for (const auto& meta : metadata) {
|
||||||
|
// A self-addressed control signal (delete / restore / ...). Reduce it and move on: it is never a
|
||||||
|
// message, and it must be processed even though WE are its sender — unlike ordinary self-sent memos,
|
||||||
|
// which the guard below skips. The label (peer hint) rides the payload.
|
||||||
|
if (meta.type == HushChatHeaderType::Control) {
|
||||||
|
const auto ctlTimeIt = txTimestamps.find(meta.txid);
|
||||||
|
const std::int64_t ctlRef = ctlTimeIt != txTimestamps.end() ? ctlTimeIt->second : fallbackTimestamp;
|
||||||
|
applyControl(meta.conversation_id, meta.op, meta.payload_memo,
|
||||||
|
meta.sent_at > 0 ? meta.sent_at : ctlRef, meta.txid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
|
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
|
||||||
// decrypt for all but the new messages.
|
// decrypt for all but the new messages.
|
||||||
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
||||||
@@ -77,6 +87,11 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
// deleted with "revive on new message", so only that exact message is skipped — a new message
|
// deleted with "revive on new message", so only that exact message is skipped — a new message
|
||||||
// in the same conversation has a different txid and flows through normally.
|
// in the same conversation has a different txid and flows through normally.
|
||||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||||
|
if (conversationHiddenOnChain(message.conversation_id)) continue; // hidden by an on-chain delete
|
||||||
|
// Withhold a sensitive conversation's messages while its local vault key is absent (locked vault or
|
||||||
|
// a fresh seed import). Fail-closed: if we can't store it under the vault key we don't surface it.
|
||||||
|
// They re-import and persist under the vault key once it is unlocked and a scan runs.
|
||||||
|
if (isConversationSensitive(message.conversation_id) && !(db_ && db_->hasSensitiveKey())) continue;
|
||||||
if (db_ && db_->isTombstoned(message)) continue;
|
if (db_ && db_->isTombstoned(message)) continue;
|
||||||
|
|
||||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||||
@@ -116,6 +131,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
|
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
|
||||||
|
// Enforce on-chain deletions against the store view: erase any conversation whose latest control op is
|
||||||
|
// "del". This catches rows appended earlier in this very batch (before their delete signal was seen in
|
||||||
|
// the metadata order) and rows rehydrated from the DB at startup before any control was reduced. The DB
|
||||||
|
// rows are left intact so a later "res" — or a rescan — can restore the thread.
|
||||||
|
for (const auto& entry : control_) {
|
||||||
|
if (entry.second.op == "del") store_.eraseConversation(entry.first);
|
||||||
|
}
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +168,60 @@ bool ChatService::deleteConversation(const std::string& conversationId, bool blo
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ChatService::applyControl(const std::string& cid, const std::string& op, const std::string& label,
|
||||||
|
std::int64_t timestamp, const std::string& txid) {
|
||||||
|
// Route the op to its axis; ignore unknown future ops so they never pollute either reduction.
|
||||||
|
std::unordered_map<std::string, ControlState>* target = nullptr;
|
||||||
|
if (op == "del" || op == "res") target = &control_;
|
||||||
|
else if (op == "sensitive" || op == "unsensitive") target = &sensitive_control_;
|
||||||
|
else return;
|
||||||
|
|
||||||
|
auto it = target->find(cid);
|
||||||
|
if (it != target->end()) {
|
||||||
|
const ControlState& cur = it->second;
|
||||||
|
// A later signal wins; equal timestamps break by txid so the reduction is deterministic and a
|
||||||
|
// replay of an older op can never flip the resolved state.
|
||||||
|
if (timestamp < cur.timestamp || (timestamp == cur.timestamp && txid <= cur.txid)) return;
|
||||||
|
it->second = ControlState{op, label, timestamp, txid};
|
||||||
|
} else {
|
||||||
|
target->emplace(cid, ControlState{op, label, timestamp, txid});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatService::conversationHiddenOnChain(const std::string& conversationId) const {
|
||||||
|
auto it = control_.find(conversationId);
|
||||||
|
return it != control_.end() && it->second.op == "del";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::pair<std::string, std::string>> ChatService::onChainDeletedConversations() const {
|
||||||
|
std::vector<std::pair<std::string, std::string>> out;
|
||||||
|
for (const auto& entry : control_) {
|
||||||
|
if (entry.second.op == "del") out.emplace_back(entry.first, entry.second.label);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
bool ChatService::conversationSensitiveOnChain(const std::string& conversationId) const {
|
||||||
|
auto it = sensitive_control_.find(conversationId);
|
||||||
|
return it != sensitive_control_.end() && it->second.op == "sensitive";
|
||||||
|
}
|
||||||
|
bool ChatService::isConversationSensitive(const std::string& conversationId) const {
|
||||||
|
if (conversationSensitiveOnChain(conversationId)) return true;
|
||||||
|
return local_sensitive_pred_ && local_sensitive_pred_(conversationId);
|
||||||
|
}
|
||||||
|
void ChatService::setPersistence(ChatDatabase* db) {
|
||||||
|
db_ = db;
|
||||||
|
// The DB picks the per-row key by this predicate: a sensitive conversation's rows are stored under the
|
||||||
|
// local vault key. Combines the on-chain reduction with the local (instant) mark.
|
||||||
|
if (db_) db_->setSensitivePredicate([this](const std::string& cid) { return isConversationSensitive(cid); });
|
||||||
|
}
|
||||||
|
std::vector<std::pair<std::string, std::string>> ChatService::onChainSensitiveConversations() const {
|
||||||
|
std::vector<std::pair<std::string, std::string>> out;
|
||||||
|
for (const auto& entry : sensitive_control_) {
|
||||||
|
if (entry.second.op == "sensitive") out.emplace_back(entry.first, entry.second.label);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
std::string ChatService::identityPublicKeyHex() const {
|
std::string ChatService::identityPublicKeyHex() const {
|
||||||
if (!has_identity_) return {};
|
if (!has_identity_) return {};
|
||||||
return chatIdentityPublicKeyHex(identity_);
|
return chatIdentityPublicKeyHex(identity_);
|
||||||
|
|||||||
+46
-1
@@ -15,6 +15,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace dragonx::chat {
|
namespace dragonx::chat {
|
||||||
@@ -52,7 +53,8 @@ public:
|
|||||||
|
|
||||||
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
|
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
|
||||||
// from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it.
|
// from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it.
|
||||||
void setPersistence(ChatDatabase* db) { db_ = db; }
|
// Also wires the DB's per-conversation sensitivity predicate to isConversationSensitive().
|
||||||
|
void setPersistence(ChatDatabase* db);
|
||||||
// Load previously-persisted messages (already decrypted at ingest, re-encrypted at rest under
|
// Load previously-persisted messages (already decrypted at ingest, re-encrypted at rest under
|
||||||
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
||||||
void loadFromDatabase();
|
void loadFromDatabase();
|
||||||
@@ -70,6 +72,34 @@ public:
|
|||||||
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
|
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
|
||||||
bool deleteConversation(const std::string& conversationId, bool block);
|
bool deleteConversation(const std::string& conversationId, bool block);
|
||||||
|
|
||||||
|
// On-chain control signals (self-addressed Control memos) reduced per conversation: the latest op,
|
||||||
|
// by sender compose time with a txid tiebreak, wins. A conversation whose latest op is "del" is hidden
|
||||||
|
// on-chain — ingest() drops its messages and erases it from the store view, on every device and on a
|
||||||
|
// fresh seed import — until a later "res" restores it. The state is re-derived from chain each session
|
||||||
|
// (the control memos re-harvest on every scan), so it needs no local persistence.
|
||||||
|
bool conversationHiddenOnChain(const std::string& conversationId) const;
|
||||||
|
// Conversations whose latest on-chain control op is "del" (for the restore picker), each paired with
|
||||||
|
// the label carried in its control memo (the peer address / display name at delete time).
|
||||||
|
std::vector<std::pair<std::string, std::string>> onChainDeletedConversations() const;
|
||||||
|
|
||||||
|
// Sensitive axis (independent of hide/restore): a conversation whose latest on-chain control op is
|
||||||
|
// "sensitive" is marked sensitive — its local rows are stored under the local vault key and withheld
|
||||||
|
// on a device without that key (including a fresh seed import), until "unsensitive". Re-derived from
|
||||||
|
// chain each session, like the hide axis.
|
||||||
|
bool conversationSensitiveOnChain(const std::string& conversationId) const;
|
||||||
|
// Conversations whose latest on-chain control op is "sensitive", each paired with its control label.
|
||||||
|
std::vector<std::pair<std::string, std::string>> onChainSensitiveConversations() const;
|
||||||
|
|
||||||
|
// A local "sensitive" predicate (typically Settings::isChatSensitive) giving INSTANT effect the moment
|
||||||
|
// the user marks a conversation, before the on-chain signal is harvested back. OR-combined with the
|
||||||
|
// on-chain reduction by isConversationSensitive().
|
||||||
|
void setLocalSensitivePredicate(std::function<bool(const std::string&)> pred) {
|
||||||
|
local_sensitive_pred_ = std::move(pred);
|
||||||
|
}
|
||||||
|
// True if this conversation is sensitive (locally marked OR on-chain). Its rows are stored under the
|
||||||
|
// local vault key; while that key is absent its messages are withheld from the store on ingest/load.
|
||||||
|
bool isConversationSensitive(const std::string& conversationId) const;
|
||||||
|
|
||||||
// --- Outgoing (compose) ---
|
// --- Outgoing (compose) ---
|
||||||
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
||||||
std::string identityPublicKeyHex() const;
|
std::string identityPublicKeyHex() const;
|
||||||
@@ -112,6 +142,21 @@ private:
|
|||||||
ChatStore store_;
|
ChatStore store_;
|
||||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||||
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||||
|
std::function<bool(const std::string&)> local_sensitive_pred_; // true => cid locally marked sensitive
|
||||||
|
|
||||||
|
// Reduced on-chain control state, cid -> latest signal. Not persisted (re-derived from chain).
|
||||||
|
struct ControlState {
|
||||||
|
std::string op; // "del" / "res" / future ops
|
||||||
|
std::string label; // the control memo's payload hint (peer address / display name)
|
||||||
|
std::int64_t timestamp = 0;
|
||||||
|
std::string txid;
|
||||||
|
};
|
||||||
|
std::unordered_map<std::string, ControlState> control_; // hide axis: del / res
|
||||||
|
std::unordered_map<std::string, ControlState> sensitive_control_; // sensitive axis: sensitive / unsensitive
|
||||||
|
// Monotonic reduce: route the op to its axis (hide vs sensitive), ignore unknown ops (forward-compat),
|
||||||
|
// and within an axis a later (timestamp, txid) wins so a replay of an older op never flips the state.
|
||||||
|
void applyControl(const std::string& cid, const std::string& op, const std::string& label,
|
||||||
|
std::int64_t timestamp, const std::string& txid);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ bool Settings::load(const std::string& path)
|
|||||||
(c.contains("name") && c["name"].is_string())
|
(c.contains("name") && c["name"].is_string())
|
||||||
? c["name"].get<std::string>() : std::string()});
|
? c["name"].get<std::string>() : std::string()});
|
||||||
}
|
}
|
||||||
|
if (j.contains("sensitive_chat_convs") && j["sensitive_chat_convs"].is_array()) {
|
||||||
|
sensitive_chat_convs_.clear();
|
||||||
|
for (const auto& c : j["sensitive_chat_convs"])
|
||||||
|
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
|
||||||
|
sensitive_chat_convs_.push_back({c["cid"].get<std::string>(),
|
||||||
|
(c.contains("name") && c["name"].is_string())
|
||||||
|
? c["name"].get<std::string>() : std::string()});
|
||||||
|
}
|
||||||
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
||||||
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
|
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
|
||||||
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
||||||
@@ -544,6 +552,13 @@ bool Settings::save(const std::string& path)
|
|||||||
o["name"] = b.name;
|
o["name"] = b.name;
|
||||||
j["blocked_chat_convs"].push_back(o);
|
j["blocked_chat_convs"].push_back(o);
|
||||||
}
|
}
|
||||||
|
j["sensitive_chat_convs"] = json::array();
|
||||||
|
for (const auto& s : sensitive_chat_convs_) {
|
||||||
|
json o;
|
||||||
|
o["cid"] = s.cid;
|
||||||
|
o["name"] = s.name;
|
||||||
|
j["sensitive_chat_convs"].push_back(o);
|
||||||
|
}
|
||||||
j["chat_emoji_color"] = chat_emoji_color_;
|
j["chat_emoji_color"] = chat_emoji_color_;
|
||||||
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
||||||
j["chat_bubble_style"] = chat_bubble_style_;
|
j["chat_bubble_style"] = chat_bubble_style_;
|
||||||
|
|||||||
@@ -182,6 +182,26 @@ public:
|
|||||||
}
|
}
|
||||||
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
|
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
|
||||||
|
|
||||||
|
// Sensitive chat conversations (by cid): their local rows are stored under the local chat vault key
|
||||||
|
// (not the seed key) and are withheld without it. Kept locally for INSTANT effect the moment the user
|
||||||
|
// marks one; the mark is also signaled on-chain so other devices / a fresh seed import gate it too.
|
||||||
|
// The last-known peer name labels the sensitive-conversations list.
|
||||||
|
struct SensitiveChatConv { std::string cid; std::string name; };
|
||||||
|
bool isChatSensitive(const std::string& cid) const {
|
||||||
|
for (const auto& s : sensitive_chat_convs_) if (s.cid == cid) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
void setChatSensitive(const std::string& cid, const std::string& name, bool sensitive) {
|
||||||
|
const bool already = isChatSensitive(cid);
|
||||||
|
if (sensitive && !already) sensitive_chat_convs_.push_back({cid, name});
|
||||||
|
else if (!sensitive && already)
|
||||||
|
sensitive_chat_convs_.erase(
|
||||||
|
std::remove_if(sensitive_chat_convs_.begin(), sensitive_chat_convs_.end(),
|
||||||
|
[&](const SensitiveChatConv& s) { return s.cid == cid; }),
|
||||||
|
sensitive_chat_convs_.end());
|
||||||
|
}
|
||||||
|
const std::vector<SensitiveChatConv>& sensitiveChatConversations() const { return sensitive_chat_convs_; }
|
||||||
|
|
||||||
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
||||||
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
||||||
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
||||||
@@ -694,6 +714,7 @@ private:
|
|||||||
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
||||||
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
||||||
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
|
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
|
||||||
|
std::vector<SensitiveChatConv> sensitive_chat_convs_; // sensitive chat conversations (cid + last-known name)
|
||||||
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
|
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
|
||||||
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
|
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
|
||||||
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
||||||
|
|||||||
@@ -703,8 +703,8 @@ bool EmbeddedDaemon::start(const std::string& binary_path, int lockWaitPolls)
|
|||||||
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
||||||
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
||||||
// string that would then be all getLastError()/the UI ever sees.
|
// string that would then be all getLastError()/the UI ever sees.
|
||||||
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
|
std::string detail = getLastError();
|
||||||
: last_error_;
|
if (detail.empty()) detail = "Failed to start dragonxd process";
|
||||||
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
||||||
setState(State::Error, detail);
|
setState(State::Error, detail);
|
||||||
return false;
|
return false;
|
||||||
@@ -822,7 +822,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
char errBuf[256] = {0};
|
char errBuf[256] = {0};
|
||||||
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||||
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
||||||
last_error_ = "CreateProcess failed with error " + std::to_string(err) + ": " + errBuf;
|
setLastError("CreateProcess failed with error " + std::to_string(err) + ": " + errBuf);
|
||||||
DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str());
|
DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1232,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
// Create pipe for stdout/stderr
|
// Create pipe for stdout/stderr
|
||||||
int pipefd[2];
|
int pipefd[2];
|
||||||
if (pipe(pipefd) == -1) {
|
if (pipe(pipefd) == -1) {
|
||||||
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
|
setLastError("Failed to create pipe: " + std::string(strerror(errno)));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,7 +1245,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
// macOS, which has no pipe2().
|
// macOS, which has no pipe2().
|
||||||
int execpipe[2];
|
int execpipe[2];
|
||||||
if (pipe(execpipe) == -1) {
|
if (pipe(execpipe) == -1) {
|
||||||
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
|
setLastError("Failed to create exec-status pipe: " + std::string(strerror(errno)));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
return false;
|
return false;
|
||||||
@@ -1255,7 +1255,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
|
|
||||||
pid_t pid = fork();
|
pid_t pid = fork();
|
||||||
if (pid == -1) {
|
if (pid == -1) {
|
||||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
setLastError("Fork failed: " + std::string(strerror(errno)));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
close(execpipe[0]);
|
close(execpipe[0]);
|
||||||
@@ -1370,8 +1370,8 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
int status;
|
int status;
|
||||||
waitpid(pid, &status, 0);
|
waitpid(pid, &status, 0);
|
||||||
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
setLastError("dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
||||||
" — not executable or wrong architecture";
|
" — not executable or wrong architecture");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -504,6 +504,8 @@ private:
|
|||||||
std::atomic<bool> external_daemon_detected_{false};
|
std::atomic<bool> external_daemon_detected_{false};
|
||||||
std::string last_error_;
|
std::string last_error_;
|
||||||
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
||||||
|
// Locked writer for last_error_ (startProcess() runs on the caller thread while the monitor may call setState()).
|
||||||
|
void setLastError(std::string message) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(message); }
|
||||||
mutable std::mutex output_mutex_; // protects process_output_
|
mutable std::mutex output_mutex_; // protects process_output_
|
||||||
std::string process_output_;
|
std::string process_output_;
|
||||||
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
||||||
@@ -511,14 +513,14 @@ private:
|
|||||||
|
|
||||||
// Process handle
|
// Process handle
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
HANDLE process_handle_ = nullptr;
|
std::atomic<HANDLE> process_handle_{nullptr}; // written by start/stop/monitor threads, read by accessors
|
||||||
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
||||||
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
||||||
std::string launch_binary_; // binary path used (for diagnostics)
|
std::string launch_binary_; // binary path used (for diagnostics)
|
||||||
std::string launch_workdir_; // working directory used (for diagnostics)
|
std::string launch_workdir_; // working directory used (for diagnostics)
|
||||||
#else
|
#else
|
||||||
pid_t process_pid_ = 0;
|
std::atomic<pid_t> process_pid_{0}; // written by start/stop paths and the monitor (reap), read by accessors
|
||||||
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
std::atomic<int> stdout_fd_{-1}; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
||||||
void tailDebugLog();
|
void tailDebugLog();
|
||||||
|
|
||||||
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
||||||
|
|||||||
@@ -42,4 +42,24 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
|
|||||||
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||||
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
||||||
|
|
||||||
|
// Reorg-safe depth before the adopt swap is enabled. Matches the History "confirmed" threshold
|
||||||
|
// (>= 10, data/transaction_history_store_policy.h): a 1-block reorg must not be able to un-mine the
|
||||||
|
// sweep after wallet.dat has been moved aside, which would strand the funds in the .bak.
|
||||||
|
inline constexpr int kSweepAdoptMinConfs = 10;
|
||||||
|
inline bool sweepConfsAreReorgSafe(int confs) { return confs >= kSweepAdoptMinConfs; }
|
||||||
|
|
||||||
|
// Final pre-stop re-verification, run on fresh RPC reads immediately before the daemon is stopped.
|
||||||
|
// needSweepTx : a sweep txid exists, so its confirmations must be reorg-safe (false = genuinely
|
||||||
|
// empty wallet, no sweep ever submitted).
|
||||||
|
// confs : fresh gettransaction confirmations (negative = conflicted).
|
||||||
|
// balanceMinconf0 : fresh z_gettotalbalance with minconf 0 (INCLUDES unconfirmed), < 0 = unknown.
|
||||||
|
// syncing : the node is still catching up, so balance reads are not trustworthy.
|
||||||
|
inline bool adoptReverifyPasses(bool needSweepTx, int confs, double balanceMinconf0,
|
||||||
|
bool syncing, double dust)
|
||||||
|
{
|
||||||
|
if (syncing) return false;
|
||||||
|
if (needSweepTx && !sweepConfsAreReorgSafe(confs)) return false;
|
||||||
|
return balanceMinconf0 >= 0.0 && balanceMinconf0 <= dust;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
+32
-2
@@ -75,6 +75,13 @@ struct TransactionInfo {
|
|||||||
std::string getTypeDisplay() const;
|
std::string getTypeDisplay() const;
|
||||||
bool isConfirmed() const { return confirmations >= 1; }
|
bool isConfirmed() const { return confirmations >= 1; }
|
||||||
bool isMature() const { return confirmations >= 100; }
|
bool isMature() const { return confirmations >= 100; }
|
||||||
|
// gettransaction reports confirmations < 0 for a tx conflicted with the chain (double-spent / reorged out).
|
||||||
|
bool isConflicted() const { return confirmations < 0; }
|
||||||
|
// Unconfirmed for longer than maxAgeSec since the wallet first saw it: most likely dropped from the
|
||||||
|
// mempool or expired. A display heuristic only (timestamp 0 = unknown -> never flagged).
|
||||||
|
bool isStalePending(int64_t now, int64_t maxAgeSec = 3600) const {
|
||||||
|
return confirmations == 0 && timestamp > 0 && now - timestamp > maxAgeSec;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
||||||
@@ -86,6 +93,15 @@ inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo&
|
|||||||
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
||||||
a.from_address == b.from_address && a.memo == b.memo;
|
a.from_address == b.from_address && a.memo == b.memo;
|
||||||
}
|
}
|
||||||
|
// Same row except the confirmation COUNT: the pending (confirmations==0) state must still match because it drives the
|
||||||
|
// History tab's pending-first sort and shield-leg merge. A delta for which every row passes this changes nothing the
|
||||||
|
// tab's merged/sorted display list is built from, so it can patch confirmations in place instead of rebuilding.
|
||||||
|
inline bool sameTransactionLayout(const TransactionInfo& a, const TransactionInfo& b) {
|
||||||
|
return a.timestamp == b.timestamp && (a.confirmations == 0) == (b.confirmations == 0) &&
|
||||||
|
std::memcmp(&a.amount, &b.amount, sizeof(double)) == 0 &&
|
||||||
|
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
||||||
|
a.from_address == b.from_address && a.memo == b.memo;
|
||||||
|
}
|
||||||
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
|
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
|
||||||
if (a.size() != b.size()) return false;
|
if (a.size() != b.size()) return false;
|
||||||
for (std::size_t i = 0; i < a.size(); ++i)
|
for (std::size_t i = 0; i < a.size(); ++i)
|
||||||
@@ -189,6 +205,11 @@ struct SyncInfo {
|
|||||||
bool tip_known = false;
|
bool tip_known = false;
|
||||||
|
|
||||||
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||||
|
|
||||||
|
// Send precondition: the wallet is caught up to a KNOWN peer-derived tip. A fresh (re)connect or a
|
||||||
|
// peerless node has tip_known == false, so its local height can't be trusted as the network tip.
|
||||||
|
// The lite backend never populates tip_known, so lite is exempt (it has its own sync gate).
|
||||||
|
bool sendTipReady(bool liteBuild) const { return liteBuild || (tip_known && !syncing && blocks > 0); }
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -370,12 +391,21 @@ struct WalletState {
|
|||||||
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
||||||
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
||||||
std::uint64_t tx_revision = 1;
|
std::uint64_t tx_revision = 1;
|
||||||
void touchTransactions() { ++tx_revision; }
|
// Bumps with tx_revision EXCEPT for a pure confirmation-count change (see sameTransactionLayout): the History tab's
|
||||||
|
// O(N) merge+sort cache keys on this and just patches confirmations when only tx_revision moved. Every writer that
|
||||||
|
// is not setTransactionsIfChanged goes through touchTransactions()/replaceTransactions() and bumps both (safe default).
|
||||||
|
std::uint64_t tx_structure_revision = 1;
|
||||||
|
void touchTransactions() { ++tx_revision; ++tx_structure_revision; }
|
||||||
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
|
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
|
||||||
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
|
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
|
||||||
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
||||||
if (sameTransactionList(transactions, next)) return false;
|
if (sameTransactionList(transactions, next)) return false;
|
||||||
replaceTransactions(std::move(next));
|
bool layoutSame = transactions.size() == next.size();
|
||||||
|
for (std::size_t i = 0; layoutSame && i < next.size(); ++i)
|
||||||
|
layoutSame = sameTransactionLayout(transactions[i], next[i]);
|
||||||
|
transactions = std::move(next);
|
||||||
|
++tx_revision;
|
||||||
|
if (!layoutSame) ++tx_structure_revision;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
int64_t last_peer_update = 0;
|
int64_t last_peer_update = 0;
|
||||||
|
|||||||
@@ -216,7 +216,8 @@ bool RPCClient::open(const std::string& host, const std::string& port,
|
|||||||
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
|
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
||||||
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
|
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
|
||||||
clearAbort(); // a fresh connection must not start in the aborted state
|
// NOTE: abort is deliberately NOT cleared here: a requestAbort() issued while this connect was queued
|
||||||
|
// must still cut it short. Reconnect callers (App::tryConnect / onConnected) call clearAbort() first.
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
|
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
|
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
|
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -152,6 +153,54 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
|||||||
return RpcFailureKind::Other;
|
return RpcFailureKind::Other;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Did a failed z_sendmany (or any submit call) possibly reach the node and take effect? True only for a
|
||||||
|
// transport failure AFTER the request left us with no HTTP status back (reply timeout, connection cut
|
||||||
|
// mid-reply, our own abort): the daemon may already hold the operation, so the caller must not invite an
|
||||||
|
// immediate resend. Definitive answers/rejections (AppError, QueueFull, Auth, Warmup, Deferred,
|
||||||
|
// NotListening, ConnectTimeout, any HTTP status) mean nothing was queued and stay plain failures.
|
||||||
|
inline bool sendReplyMayBeLost(const std::exception& e)
|
||||||
|
{
|
||||||
|
const auto* t = dynamic_cast<const RpcTransportError*>(&e);
|
||||||
|
return t && t->requestSent && t->httpStatus == 0 && t->curlCode != CURLE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lite broadcast errors are plain backend text (tonic/gRPC). Ambiguous = a transport-style failure while
|
||||||
|
// talking to the lightwalletd server (the server may have accepted the tx before the reply was lost).
|
||||||
|
// Connect-phase failures and an explicit server rejection ("error_code") are definitive. Heuristic by
|
||||||
|
// design: a false positive only shows a more cautious message, never changes the send.
|
||||||
|
inline bool liteSendErrorMayBeInFlight(const std::string& err)
|
||||||
|
{
|
||||||
|
std::string lc = err;
|
||||||
|
for (char& c : lc) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
auto has = [&](const char* n) { return lc.find(n) != std::string::npos; };
|
||||||
|
if (has("error_code") || has("error trying to connect") || has("connection refused") ||
|
||||||
|
has("dns error") || has("insufficient") || has("locked"))
|
||||||
|
return false;
|
||||||
|
return has("timed out") || has("timeout") || has("deadline") || has("transport error") ||
|
||||||
|
has("connection reset") || has("broken pipe") || has("h2 protocol error") ||
|
||||||
|
has("unexpected eof") || has("status: unknown") || has("status: cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag carried in the failure text of a send callback (bool, string) so the Send tab can show the distinct
|
||||||
|
// "may have been broadcast - check History before resending" outcome without changing the callback type.
|
||||||
|
// The prefix is readable English so callers that just print the text still say something accurate.
|
||||||
|
inline const char* sendMaybeInFlightPrefix()
|
||||||
|
{
|
||||||
|
return "Send status unknown - it may already have been broadcast: ";
|
||||||
|
}
|
||||||
|
inline bool isSendMaybeInFlight(const std::string& s)
|
||||||
|
{
|
||||||
|
return s.rfind(sendMaybeInFlightPrefix(), 0) == 0;
|
||||||
|
}
|
||||||
|
inline std::string markSendMaybeInFlight(const std::string& detail)
|
||||||
|
{
|
||||||
|
return isSendMaybeInFlight(detail) ? detail : std::string(sendMaybeInFlightPrefix()) + detail;
|
||||||
|
}
|
||||||
|
inline std::string stripSendMaybeInFlight(const std::string& s)
|
||||||
|
{
|
||||||
|
return isSendMaybeInFlight(s) ? s.substr(std::string(sendMaybeInFlightPrefix()).size()) : s;
|
||||||
|
}
|
||||||
|
|
||||||
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
||||||
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
|
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
|
||||||
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "rpc/rpc_busy_gate.h"
|
#include "rpc/rpc_busy_gate.h"
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
|
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
|
||||||
|
#include "../util/amount_format.h" // util::toSats: integer-satoshi accumulation
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -34,7 +35,20 @@ std::optional<T> readOptional(const json& source, const char* key)
|
|||||||
std::optional<double> readBalanceString(const json& source, const char* key)
|
std::optional<double> readBalanceString(const json& source, const char* key)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (source.contains(key)) return std::stod(source[key].get<std::string>());
|
if (source.contains(key)) {
|
||||||
|
const std::string text = source[key].get<std::string>();
|
||||||
|
// Strict: plain decimal only (digits, one '.', optional leading '-'). std::stod would accept
|
||||||
|
// '1.5abc', whitespace, '+', hex, exponents and nan/inf, silently.
|
||||||
|
if (text.empty()) return std::nullopt;
|
||||||
|
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||||
|
const char c = text[i];
|
||||||
|
if (!((c >= '0' && c <= '9') || c == '.' || (c == '-' && i == 0))) return std::nullopt;
|
||||||
|
}
|
||||||
|
char* end = nullptr;
|
||||||
|
const double value = std::strtod(text.c_str(), &end);
|
||||||
|
if (end != text.c_str() + text.size() || !std::isfinite(value)) return std::nullopt;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
@@ -52,15 +66,17 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
||||||
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
||||||
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
||||||
std::map<std::string, double> total;
|
// Accumulated in integer satoshis (not doubles) so many notes can't drift by an ulp per addition.
|
||||||
std::map<std::string, double> spendable;
|
std::map<std::string, std::int64_t> total;
|
||||||
|
std::map<std::string, std::int64_t> spendable;
|
||||||
for (const auto& output : unspent) {
|
for (const auto& output : unspent) {
|
||||||
auto address = readOptional<std::string>(output, "address");
|
auto address = readOptional<std::string>(output, "address");
|
||||||
auto amount = readOptional<double>(output, "amount");
|
auto amount = readOptional<double>(output, "amount");
|
||||||
if (!address || !amount) continue;
|
if (!address || !amount || !std::isfinite(*amount)) continue;
|
||||||
total[*address] += *amount;
|
const std::int64_t sats = util::toSats(*amount);
|
||||||
|
total[*address] += sats;
|
||||||
auto conf = readOptional<int>(output, "confirmations");
|
auto conf = readOptional<int>(output, "confirmations");
|
||||||
if (conf && *conf >= 1) spendable[*address] += *amount;
|
if (conf && *conf >= 1) spendable[*address] += sats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
||||||
@@ -68,8 +84,8 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
for (auto& info : addresses) {
|
for (auto& info : addresses) {
|
||||||
auto t = total.find(info.address);
|
auto t = total.find(info.address);
|
||||||
auto s = spendable.find(info.address);
|
auto s = spendable.find(info.address);
|
||||||
info.balance = (t != total.end()) ? t->second : 0.0;
|
info.balance = (t != total.end()) ? static_cast<double>(t->second) / 1e8 : 0.0;
|
||||||
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
|
info.spendableBalance = (s != spendable.end()) ? static_cast<double>(s->second) / 1e8 : 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,8 +1056,13 @@ NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseVie
|
|||||||
|
|
||||||
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
const std::string& txid,
|
const std::string& txid,
|
||||||
const TransactionViewCacheEntry& entry)
|
const TransactionViewCacheEntry& entry,
|
||||||
|
int currentHeight)
|
||||||
{
|
{
|
||||||
|
// A mined entry's confirmations grow by one per block since it was sampled; never go backwards.
|
||||||
|
int entryConfirmations = entry.confirmations;
|
||||||
|
if (entry.confirmations >= 1 && entry.observed_height >= 0 && currentHeight > entry.observed_height)
|
||||||
|
entryConfirmations += currentHeight - entry.observed_height;
|
||||||
for (const auto& out : entry.outgoing_outputs) {
|
for (const auto& out : entry.outgoing_outputs) {
|
||||||
bool alreadyTracked = false;
|
bool alreadyTracked = false;
|
||||||
for (const auto& existing : transactions) {
|
for (const auto& existing : transactions) {
|
||||||
@@ -1073,7 +1094,7 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!foundExistingMetadata) {
|
if (!foundExistingMetadata) {
|
||||||
info.confirmations = entry.confirmations;
|
info.confirmations = entryConfirmations;
|
||||||
info.timestamp = entry.timestamp;
|
info.timestamp = entry.timestamp;
|
||||||
}
|
}
|
||||||
transactions.push_back(std::move(info));
|
transactions.push_back(std::move(info));
|
||||||
@@ -1460,7 +1481,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
auto cached = snapshot.viewTxCache.find(txid);
|
auto cached = snapshot.viewTxCache.find(txid);
|
||||||
if (cached != snapshot.viewTxCache.end()) {
|
if (cached != snapshot.viewTxCache.end()) {
|
||||||
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
||||||
appendViewTransactionOutputs(result.transactions, txid, cached->second);
|
appendViewTransactionOutputs(result.transactions, txid, cached->second, currentBlockHeight);
|
||||||
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
|
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
|
||||||
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
|
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
|
||||||
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
|
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
|
||||||
@@ -1493,6 +1514,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
||||||
|
entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip
|
||||||
const bool storedRow = rowBacklog.count(txid) > 0;
|
const bool storedRow = rowBacklog.count(txid) > 0;
|
||||||
if (storedRow && entry.timestamp == 0) {
|
if (storedRow && entry.timestamp == 0) {
|
||||||
const auto settled = settledStoredMeta.find(txid);
|
const auto settled = settledStoredMeta.find(txid);
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ public:
|
|||||||
std::string from_address;
|
std::string from_address;
|
||||||
std::int64_t timestamp = 0;
|
std::int64_t timestamp = 0;
|
||||||
int confirmations = 0;
|
int confirmations = 0;
|
||||||
|
int observed_height = -1; // chain tip when `confirmations` was read (-1 unknown): lets a cached entry advance
|
||||||
struct Output {
|
struct Output {
|
||||||
std::string address;
|
std::string address;
|
||||||
double value = 0.0;
|
double value = 0.0;
|
||||||
@@ -427,9 +428,12 @@ public:
|
|||||||
const std::set<std::string>& miningAddresses = {},
|
const std::set<std::string>& miningAddresses = {},
|
||||||
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
||||||
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
||||||
|
// currentHeight (>=0): the present chain tip; a cached entry that was already mined then reports
|
||||||
|
// confirmations advanced by the blocks added since it was observed (display only).
|
||||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
const std::string& txid,
|
const std::string& txid,
|
||||||
const TransactionViewCacheEntry& entry);
|
const TransactionViewCacheEntry& entry,
|
||||||
|
int currentHeight = -1);
|
||||||
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
||||||
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
||||||
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ bool ExplorerBlockCache::ensureOpen()
|
|||||||
}
|
}
|
||||||
|
|
||||||
db_ = openedDb;
|
db_ = openedDb;
|
||||||
|
++revision_;
|
||||||
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
|
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
|
||||||
// freezing the frame for 2 s.
|
// freezing the frame for 2 s.
|
||||||
sqlite3_busy_timeout(db_, 250);
|
sqlite3_busy_timeout(db_, 250);
|
||||||
@@ -176,6 +177,20 @@ std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight,
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const std::map<int, ExplorerBlockSummary>& ExplorerBlockCache::loadRangeCached(int minHeight, int maxHeight)
|
||||||
|
{
|
||||||
|
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
|
||||||
|
if (range_cache_.size() >= 16) range_cache_.clear();
|
||||||
|
const std::pair<int, int> key(minHeight, maxHeight);
|
||||||
|
auto it = range_cache_.find(key);
|
||||||
|
if (it == range_cache_.end()) {
|
||||||
|
auto loaded = loadRange(minHeight, maxHeight); // may open the DB (bumps revision_)
|
||||||
|
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
|
||||||
|
it = range_cache_.emplace(key, std::move(loaded)).first;
|
||||||
|
}
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
||||||
{
|
{
|
||||||
std::vector<ExplorerBlockSummary> results;
|
std::vector<ExplorerBlockSummary> results;
|
||||||
@@ -256,6 +271,7 @@ bool ExplorerBlockCache::storeBlockRow(const ExplorerBlockSummary& block)
|
|||||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||||
{
|
{
|
||||||
if (block.height < 1 || block.hash.empty()) return false;
|
if (block.height < 1 || block.hash.empty()) return false;
|
||||||
|
++revision_;
|
||||||
const bool stored = storeBlockRow(block);
|
const bool stored = storeBlockRow(block);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
spill_.erase(block.height);
|
spill_.erase(block.height);
|
||||||
@@ -297,6 +313,7 @@ int ExplorerBlockCache::cachedBlockCount()
|
|||||||
|
|
||||||
void ExplorerBlockCache::clearBlocks()
|
void ExplorerBlockCache::clearBlocks()
|
||||||
{
|
{
|
||||||
|
++revision_;
|
||||||
spill_.clear();
|
spill_.clear();
|
||||||
if (!ensureOpen()) return;
|
if (!ensureOpen()) return;
|
||||||
exec("DELETE FROM explorer_blocks");
|
exec("DELETE FROM explorer_blocks");
|
||||||
@@ -305,6 +322,7 @@ void ExplorerBlockCache::clearBlocks()
|
|||||||
void ExplorerBlockCache::pruneTo(int maxRows)
|
void ExplorerBlockCache::pruneTo(int maxRows)
|
||||||
{
|
{
|
||||||
if (maxRows < 1 || !db_) return;
|
if (maxRows < 1 || !db_) return;
|
||||||
|
++revision_;
|
||||||
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
|
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
|
||||||
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
|
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
|
||||||
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
|
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
|
||||||
@@ -333,8 +351,10 @@ ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
|||||||
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
|
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
|
||||||
if (!spill_.empty() && !currentBestHash.empty() &&
|
if (!spill_.empty() && !currentBestHash.empty() &&
|
||||||
(currentHeight < spill_tip_height_ ||
|
(currentHeight < spill_tip_height_ ||
|
||||||
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_)))
|
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_))) {
|
||||||
spill_.clear();
|
spill_.clear();
|
||||||
|
++revision_;
|
||||||
|
}
|
||||||
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
||||||
return validation;
|
return validation;
|
||||||
}
|
}
|
||||||
@@ -526,6 +546,7 @@ void ExplorerBlockCache::close()
|
|||||||
{
|
{
|
||||||
tip_known_ = false;
|
tip_known_ = false;
|
||||||
if (!db_) return;
|
if (!db_) return;
|
||||||
|
++revision_;
|
||||||
sqlite3_close(db_);
|
sqlite3_close(db_);
|
||||||
db_ = nullptr;
|
db_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
struct sqlite3;
|
struct sqlite3;
|
||||||
@@ -44,6 +45,11 @@ public:
|
|||||||
const std::string& databasePath() const { return database_path_; }
|
const std::string& databasePath() const { return database_path_; }
|
||||||
|
|
||||||
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
||||||
|
// loadRange memoized per (min,max) and invalidated by revision() so the per-frame Explorer list only hits SQLite
|
||||||
|
// after the cache actually changed (store / clear / prune / spill reset / open / close). The reference is valid
|
||||||
|
// until the next call; copy it if you call again before using it.
|
||||||
|
const std::map<int, ExplorerBlockSummary>& loadRangeCached(int minHeight, int maxHeight);
|
||||||
|
std::uint64_t revision() const { return revision_; }
|
||||||
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
||||||
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
||||||
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
||||||
@@ -102,6 +108,11 @@ private:
|
|||||||
int open_attempts_ = 0;
|
int open_attempts_ = 0;
|
||||||
int stores_since_prune_ = 0;
|
int stores_since_prune_ = 0;
|
||||||
|
|
||||||
|
// Bumped by every mutation that can change what loadRange returns; keys the loadRangeCached memo.
|
||||||
|
std::uint64_t revision_ = 1;
|
||||||
|
std::uint64_t range_cache_revision_ = 0;
|
||||||
|
std::map<std::pair<int, int>, std::map<int, ExplorerBlockSummary>> range_cache_;
|
||||||
|
|
||||||
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
|
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
|
||||||
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
||||||
static constexpr std::size_t kMaxSpillBlocks = 512;
|
static constexpr std::size_t kMaxSpillBlocks = 512;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ enum class NodeBannerAction {
|
|||||||
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
||||||
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
||||||
OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login
|
OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login
|
||||||
|
RecoverLegacyFunds, // a degraded legacy wallet with stuck transparent funds: open migrate-to-seed
|
||||||
};
|
};
|
||||||
|
|
||||||
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
||||||
@@ -35,6 +36,8 @@ enum class NodeBannerReason {
|
|||||||
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
||||||
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
||||||
AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf)
|
AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf)
|
||||||
|
LegacyFundsStuck, // a DEGRADED legacy wallet (can't derive a z-address) holding transparent funds it
|
||||||
|
// therefore can't shield/spend — offer the migrate-to-seed recovery
|
||||||
};
|
};
|
||||||
|
|
||||||
struct NodeBannerState {
|
struct NodeBannerState {
|
||||||
@@ -75,6 +78,11 @@ struct NodeBannerInputs {
|
|||||||
|
|
||||||
bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword)
|
bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword)
|
||||||
|
|
||||||
|
// The wallet opened DEGRADED (no usable HD seed, so z_getnewaddress / shielding fail) AND it holds
|
||||||
|
// transparent funds that are therefore stuck. App sets this ONLY from wallet_degraded_ + a transparent
|
||||||
|
// balance — never from merely-non-mnemonic status, which is a functional wallet and must not be flagged.
|
||||||
|
bool legacy_funds_stuck = false;
|
||||||
|
|
||||||
std::string connection_status; // human-readable status line (already translated)
|
std::string connection_status; // human-readable status line (already translated)
|
||||||
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||||
std::string lite_open_error; // lite: last wallet-open failure reason
|
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||||
@@ -134,6 +142,16 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
|||||||
? NodeBannerAction::RestartNode
|
? NodeBannerAction::RestartNode
|
||||||
: NodeBannerAction::None;
|
: NodeBannerAction::None;
|
||||||
s.detail.clear();
|
s.detail.clear();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
// Connected and synced, but a degraded legacy wallet can't shield its transparent funds. Offer the
|
||||||
|
// recovery flow. Checked after NoPeers (can't recover until the node can see the chain anyway).
|
||||||
|
if (in.legacy_funds_stuck) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Warning;
|
||||||
|
s.reason = NodeBannerReason::LegacyFundsStuck;
|
||||||
|
s.action = NodeBannerAction::RecoverLegacyFunds;
|
||||||
|
s.detail.clear();
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1490,6 +1490,9 @@ void RenderSettingsPage(App* app) {
|
|||||||
last = cy + bh; cy += bh + bgp;
|
last = cy + bh; cy += bh + bgp;
|
||||||
if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog();
|
if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
|
||||||
|
last = cy + bh; cy += bh + bgp;
|
||||||
|
if (cardBtn(cx, cy, cw, "##imp_file", TR("settings_import_wallet_file"), ICON_MD_UPLOAD_FILE)) app->showImportWalletFileDialog();
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_wallet_file"));
|
||||||
last = cy + bh;
|
last = cy + bh;
|
||||||
if (fullNode) {
|
if (fullNode) {
|
||||||
cy += bh + bgp;
|
cy += bh + bgp;
|
||||||
@@ -1708,7 +1711,11 @@ void RenderSettingsPage(App* app) {
|
|||||||
|
|
||||||
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
||||||
bool isEncryptedPIN = app->state().isEncrypted();
|
bool isEncryptedPIN = app->state().isEncrypted();
|
||||||
if (isEncryptedPIN) {
|
// Lite has no PIN quick-unlock: its unlock paths (Settings, send-time prompt) take the passphrase
|
||||||
|
// directly and never consult a PIN vault, so these controls would be dead. Hide them.
|
||||||
|
if (app->isLiteBuild()) {
|
||||||
|
// nothing to show
|
||||||
|
} else if (isEncryptedPIN) {
|
||||||
bool hasPIN = app->hasPinVault();
|
bool hasPIN = app->hasPinVault();
|
||||||
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
||||||
|
|
||||||
@@ -2172,9 +2179,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
|
||||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||||
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||||
const bool ok = app->liteWallet()->unlockWallet(s_settingsState.lite_enc_pass);
|
std::string unlockErr;
|
||||||
|
const bool ok = app->unlockLiteWallet(std::string(s_settingsState.lite_enc_pass), &unlockErr);
|
||||||
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
||||||
s_settingsState.lite_encryption_status = ok ? TR("lite_wallet_unlocked") : TR("lite_unlock_failed");
|
s_settingsState.lite_encryption_status = ok ? std::string(TR("lite_wallet_unlocked")) : unlockErr;
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock"));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+336
-5
@@ -63,15 +63,18 @@ int s_msgsel_head = 0; // drag-current byte offset
|
|||||||
bool s_msgsel_dragging = false;
|
bool s_msgsel_dragging = false;
|
||||||
|
|
||||||
// Composer + new-conversation UI state.
|
// Composer + new-conversation UI state.
|
||||||
char s_compose[512] = "";
|
char s_compose[4100] = ""; // must be >= kChatBodyMaxBytes + 1 (static_assert'd below)
|
||||||
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
|
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
|
||||||
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
|
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
|
||||||
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
|
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
|
||||||
// instead of always appending. -1 = unknown/never-focused => append at the end.
|
// instead of always appending. -1 = unknown/never-focused => append at the end.
|
||||||
int s_composeCursor = -1;
|
int s_composeCursor = -1;
|
||||||
// On-chain chat body cap in bytes = (512 − len("utf8:"))/2 − secretstream ABYTES (see chat_outgoing.cpp).
|
// On-chain chat body cap in bytes. Larger-messages tier 2 splits the ciphertext across up to
|
||||||
// The composer hard-caps input to this; the emoji picker respects it too.
|
// kHushChatMaxMessageParts (16) memo outputs in one tx, so the body can be a few KB (protocol max ~4063:
|
||||||
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
|
// 16 × 255 cipher bytes − 17 secretstream tag). Kept a touch under that so the composer never produces a
|
||||||
|
// body that compose would reject as TooLong. The composer hard-caps input here; the emoji picker too.
|
||||||
|
constexpr int kChatBodyMaxBytes = 4000;
|
||||||
|
static_assert(sizeof(s_compose) >= kChatBodyMaxBytes + 1, "compose buffer too small for the body cap");
|
||||||
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
|
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
|
||||||
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
|
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
|
||||||
|
|
||||||
@@ -104,6 +107,15 @@ bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (
|
|||||||
std::string s_delete_cid; // conversation targeted by the delete confirm
|
std::string s_delete_cid; // conversation targeted by the delete confirm
|
||||||
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
|
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
|
||||||
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
|
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
|
||||||
|
bool s_show_restore = false; // restore-conversations overlay (reverse an on-chain delete)
|
||||||
|
char s_restore_addr[128] = ""; // paste-a-chat-address buffer in the restore overlay
|
||||||
|
bool s_show_sensitive = false; // sensitive mark/unmark/setup/unlock overlay
|
||||||
|
std::string s_sensitive_cid; // target conversation (empty => "unlock the vault only", from the opener)
|
||||||
|
std::string s_sensitive_name; // peer display name (for the label)
|
||||||
|
std::string s_sensitive_peeraddr; // peer z-address (carried as the control label, for restore-by-address parity)
|
||||||
|
bool s_sensitive_unmark = false; // true => the dialog is UNmarking a currently-sensitive conversation
|
||||||
|
char s_sensitive_pass[128] = ""; // passphrase (setup / unlock)
|
||||||
|
char s_sensitive_pass2[128] = ""; // confirm passphrase (setup only)
|
||||||
bool s_show_export_confirm = false; // "export as plaintext" confirm overlay
|
bool s_show_export_confirm = false; // "export as plaintext" confirm overlay
|
||||||
std::string s_export_cid; // conversation targeted by the export confirm
|
std::string s_export_cid; // conversation targeted by the export confirm
|
||||||
bool s_show_fingerprint = false; // key-fingerprint verification dialog
|
bool s_show_fingerprint = false; // key-fingerprint verification dialog
|
||||||
@@ -961,6 +973,47 @@ void RenderChatTab(App* app)
|
|||||||
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
|
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
}
|
}
|
||||||
|
// Restore opener — reverse an on-chain "Delete everywhere". Shown when a delete is known from chain
|
||||||
|
// (works on a fresh seed import, which has no local state) or something is locally blocked; the
|
||||||
|
// dialog also offers paste-by-address + a quick scan for deletes made on another device.
|
||||||
|
const int onChainDelCount = (int)app->chatService().onChainDeletedConversations().size();
|
||||||
|
if (onChainDelCount > 0 || blockedCount > 0) {
|
||||||
|
if (blockedCount > 0) ImGui::SameLine();
|
||||||
|
const std::string rl = onChainDelCount > 0
|
||||||
|
? std::string(TR("chat_restore_manage")) + " (" + std::to_string(onChainDelCount) + ")"
|
||||||
|
: std::string(TR("chat_restore_manage"));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
if (ImGui::SmallButton(rl.c_str())) s_show_restore = true;
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
|
// Sensitive-vault opener: lock / unlock the local key for sensitive conversations. Shown only once
|
||||||
|
// a vault exists (it's created on the first "Make sensitive"). While locked it shows the count of
|
||||||
|
// sensitive conversations currently withheld.
|
||||||
|
{
|
||||||
|
const auto vs = app->chatSensitiveVaultState();
|
||||||
|
if (vs != App::ChatVaultState::NotConfigured) {
|
||||||
|
if (blockedCount > 0 || onChainDelCount > 0) ImGui::SameLine();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
if (vs == App::ChatVaultState::Unlocked) {
|
||||||
|
if (ImGui::SmallButton(TR("chat_sensitive_lock_btn"))) {
|
||||||
|
app->lockChatSensitiveVault();
|
||||||
|
s_convsKey = ~0ull; // sensitive threads vanish — rebuild the list
|
||||||
|
}
|
||||||
|
} else { // Locked
|
||||||
|
const int sn = app->settings() ? (int)app->settings()->sensitiveChatConversations().size() : 0;
|
||||||
|
const std::string ul = sn > 0
|
||||||
|
? std::string(TR("chat_sensitive_unlock_btn")) + " (" + std::to_string(sn) + ")"
|
||||||
|
: std::string(TR("chat_sensitive_unlock_btn"));
|
||||||
|
if (ImGui::SmallButton(ul.c_str())) {
|
||||||
|
s_sensitive_cid.clear(); s_sensitive_name.clear(); s_sensitive_peeraddr.clear();
|
||||||
|
s_sensitive_unmark = false;
|
||||||
|
s_sensitive_pass[0] = '\0'; s_sensitive_pass2[0] = '\0';
|
||||||
|
s_show_sensitive = true; // empty cid => unlock-only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
|
}
|
||||||
// "Copy my chat address" — the address others use to reach this user. The conversation header
|
// "Copy my chat address" — the address others use to reach this user. The conversation header
|
||||||
// only copies a PEER's address, so without this there's no way to hand out your own. Copies on
|
// only copies a PEER's address, so without this there's no way to hand out your own. Copies on
|
||||||
// click only (chatMyAddress may persist a freshly chosen address).
|
// click only (chatMyAddress may persist a freshly chosen address).
|
||||||
@@ -1156,7 +1209,7 @@ void RenderChatTab(App* app)
|
|||||||
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
|
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
|
||||||
// shown whenever there's an address to save the contact under; the settings "notch" gear is
|
// shown whenever there's an address to save the contact under; the settings "notch" gear is
|
||||||
// always the rightmost icon.
|
// always the rightmost icon.
|
||||||
const int nBtns = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename)
|
const int nBtns = 6 + (hasAddr ? 1 : 0); // export, mute, hide, sensitive, delete, settings (+rename)
|
||||||
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
|
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
|
||||||
|
|
||||||
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
|
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
|
||||||
@@ -1299,6 +1352,24 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
bx += ib + gap;
|
bx += ib + gap;
|
||||||
}
|
}
|
||||||
|
// Sensitive — per-conversation local-key encryption. Opens a dialog that (by vault state)
|
||||||
|
// sets up / unlocks the vault and marks or unmarks this conversation.
|
||||||
|
{
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(bx, by));
|
||||||
|
material::IconButtonStyle a = base;
|
||||||
|
const bool isSens = app->chatService().isConversationSensitive(sel->cid);
|
||||||
|
a.tooltip = isSens ? TR("chat_sensitive_unmark_tip") : TR("chat_sensitive_mark_tip");
|
||||||
|
if (material::IconButton("##hdr_sensitive",
|
||||||
|
isSens ? ICON_MD_LOCK : ICON_MD_ENHANCED_ENCRYPTION, ifont, ImVec2(ib, ib), a)) {
|
||||||
|
s_sensitive_cid = sel->cid;
|
||||||
|
s_sensitive_name = sel->peerName;
|
||||||
|
s_sensitive_peeraddr = sel->peerZaddr;
|
||||||
|
s_sensitive_unmark = isSens;
|
||||||
|
s_sensitive_pass[0] = '\0'; s_sensitive_pass2[0] = '\0';
|
||||||
|
s_show_sensitive = true;
|
||||||
|
}
|
||||||
|
bx += ib + gap;
|
||||||
|
}
|
||||||
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
|
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
|
||||||
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
|
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
|
||||||
{
|
{
|
||||||
@@ -2342,6 +2413,57 @@ void RenderChatTab(App* app)
|
|||||||
s_delete_cid.clear(); s_delete_name.clear();
|
s_delete_cid.clear(); s_delete_name.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// "Delete everywhere": block locally (instant, persistent — no sync flicker) AND broadcast a
|
||||||
|
// self-addressed Control(del) so other devices + a fresh seed import hide it too. Carries the
|
||||||
|
// peer z-address as the control label so Restore can match it by address.
|
||||||
|
auto doDeleteEverywhere = [&]() {
|
||||||
|
const std::string cid = s_delete_cid, name = s_delete_name;
|
||||||
|
std::string peerAddr;
|
||||||
|
for (const auto& m : app->chatService().store().conversation(cid)) {
|
||||||
|
if (!m.peer_zaddr.empty()) { peerAddr = m.peer_zaddr; break; }
|
||||||
|
}
|
||||||
|
if (!app->chatService().deleteConversation(cid, /*block=*/true)) {
|
||||||
|
Notifications::instance().error(TR("chat_delete_failed"));
|
||||||
|
s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (app->settings()) {
|
||||||
|
app->settings()->setChatBlocked(cid, name, true);
|
||||||
|
app->settings()->setChatHidden(cid, false);
|
||||||
|
app->settings()->save();
|
||||||
|
}
|
||||||
|
app->sendChatControl(cid, "del", peerAddr.empty() ? name : peerAddr);
|
||||||
|
if (s_selected_cid == cid) s_selected_cid.clear();
|
||||||
|
Notifications::instance().info(TR("chat_delete_everywhere_toast"));
|
||||||
|
s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full-width on-chain delete above the local footer, gated on a fully-synced wallet (so a
|
||||||
|
// conversation can't flicker in/out mid-sync while the delete signal is still catching up).
|
||||||
|
// Full node requires tip_known (isSynced); lite never sets it, so use its own "sync complete
|
||||||
|
// + at tip" signal there — otherwise the button would be permanently disabled in lite.
|
||||||
|
const auto& syncSt = app->state().sync;
|
||||||
|
const bool synced = app->isLiteBuild()
|
||||||
|
? (!syncSt.syncing && syncSt.blocks > 0 && syncSt.blocks >= syncSt.headers - 2)
|
||||||
|
: syncSt.isSynced();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205)));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235)));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnError()));
|
||||||
|
ImGui::BeginDisabled(!synced);
|
||||||
|
const bool doEvery = material::TactileButton(TR("chat_delete_everywhere"),
|
||||||
|
ImVec2(ImGui::GetContentRegionAvail().x, 0));
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::PopStyleColor(4);
|
||||||
|
if (doEvery) doDeleteEverywhere();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::TextUnformatted(synced ? TR("chat_delete_everywhere_note") : TR("chat_delete_needs_sync"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
|
||||||
auto textW = [&](const char* t) {
|
auto textW = [&](const char* t) {
|
||||||
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
|
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
|
||||||
};
|
};
|
||||||
@@ -2413,6 +2535,215 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Restore conversations (reverse an on-chain "Delete everywhere") ----
|
||||||
|
if (s_show_restore) {
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
material::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("chat_restore_title");
|
||||||
|
ov.p_open = &s_show_restore;
|
||||||
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 520.0f; ov.idSuffix = "chatrestore";
|
||||||
|
if (material::BeginOverlayDialog(ov)) {
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(TR("chat_restore_desc"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
|
// Restore: clear any local block/hide here AND broadcast Control("res") so every device un-hides.
|
||||||
|
// The messages re-import from the chain on the next scan (the reducer no longer hides the cid).
|
||||||
|
auto doRestore = [&](const std::string& cid, const std::string& label) {
|
||||||
|
if (cid.empty()) return;
|
||||||
|
if (app->settings()) {
|
||||||
|
app->settings()->setChatBlocked(cid, "", false);
|
||||||
|
app->settings()->setChatHidden(cid, false);
|
||||||
|
app->settings()->save();
|
||||||
|
}
|
||||||
|
app->forgetChatConversationSeen(cid);
|
||||||
|
app->sendChatControl(cid, "res", label.empty() ? cid : label);
|
||||||
|
Notifications::instance().info(TR("chat_restore_toast"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chain-derived deleted conversations — present even on a wallet freshly restored from seed.
|
||||||
|
const auto deleted = app->chatService().onChainDeletedConversations();
|
||||||
|
if (deleted.empty()) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::TextUnformatted(TR("chat_restore_none"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
} else {
|
||||||
|
std::string restoreCid, restoreLabel;
|
||||||
|
for (const auto& d : deleted) {
|
||||||
|
ImGui::PushID(d.first.c_str());
|
||||||
|
const std::string label = d.second.empty() ? shorten(d.first, 10, 6) : shorten(d.second, 12, 6);
|
||||||
|
const float bw = ImGui::CalcTextSize(TR("chat_restore_btn")).x +
|
||||||
|
ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp;
|
||||||
|
ImGui::AlignTextToFramePadding();
|
||||||
|
ImGui::TextUnformatted(label.c_str());
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw);
|
||||||
|
if (material::TactileButton(TR("chat_restore_btn"), ImVec2(bw, 0))) {
|
||||||
|
restoreCid = d.first; restoreLabel = d.second;
|
||||||
|
}
|
||||||
|
ImGui::PopID();
|
||||||
|
}
|
||||||
|
if (!restoreCid.empty()) doRestore(restoreCid, restoreLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
ImGui::Separator();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
|
// Paste the chat (z) address to restore a conversation not in the list (matched against the
|
||||||
|
// address each delete signal carries; otherwise the input is treated as a raw conversation id).
|
||||||
|
ImGui::TextUnformatted(TR("chat_restore_paste_label"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
|
ImGui::InputTextWithHint("##chatrestoreaddr", TR("chat_restore_paste_hint"),
|
||||||
|
s_restore_addr, sizeof(s_restore_addr));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
const float gap = Layout::spacingSm();
|
||||||
|
const float wPaste = std::max(120.0f * dp, ImGui::CalcTextSize(TR("chat_restore_btn")).x + 44.0f * dp);
|
||||||
|
const float wScan = std::max(120.0f * dp, ImGui::CalcTextSize(TR("chat_restore_scan")).x + 44.0f * dp);
|
||||||
|
if (material::TactileButton(TR("chat_restore_btn"), ImVec2(wPaste, 0))) {
|
||||||
|
std::string input(s_restore_addr);
|
||||||
|
while (!input.empty() && (input.front() == ' ' || input.front() == '\n' || input.front() == '\t')) input.erase(input.begin());
|
||||||
|
while (!input.empty() && (input.back() == ' ' || input.back() == '\n' || input.back() == '\t')) input.pop_back();
|
||||||
|
if (!input.empty()) {
|
||||||
|
std::string cid, label;
|
||||||
|
for (const auto& d : deleted) { if (d.second == input) { cid = d.first; label = d.second; break; } }
|
||||||
|
if (cid.empty()) { cid = input; label = input; } // not in the list — treat as a raw cid
|
||||||
|
doRestore(cid, label);
|
||||||
|
s_restore_addr[0] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::SameLine(0, gap);
|
||||||
|
if (material::TactileButton(TR("chat_restore_scan"), ImVec2(wScan, 0))) {
|
||||||
|
app->noteWalletMutation(); // re-run the change-gated scans → re-harvest control memos
|
||||||
|
Notifications::instance().info(TR("chat_restore_scanning"));
|
||||||
|
}
|
||||||
|
material::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sensitive: setup / unlock / mark / unmark (per-conversation local-key encryption) ----
|
||||||
|
if (s_show_sensitive) {
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
const auto vs = app->chatSensitiveVaultState();
|
||||||
|
const bool unlockOnly = s_sensitive_cid.empty();
|
||||||
|
const bool needSetup = (vs == App::ChatVaultState::NotConfigured);
|
||||||
|
const bool needUnlock = (vs == App::ChatVaultState::Locked);
|
||||||
|
material::OverlayDialogSpec ov;
|
||||||
|
ov.title = unlockOnly ? TR("chat_sensitive_unlock_title")
|
||||||
|
: s_sensitive_unmark ? TR("chat_sensitive_unmark_title")
|
||||||
|
: TR("chat_sensitive_mark_title");
|
||||||
|
ov.p_open = &s_show_sensitive;
|
||||||
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 520.0f; ov.idSuffix = "chatsensitive";
|
||||||
|
if (material::BeginOverlayDialog(ov)) {
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
if (unlockOnly) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_unlock_body"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
} else if (s_sensitive_unmark) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_unmark_body"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
} else {
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_warn_1"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_warn_lose"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_warn_dir"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_warn_hist"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
|
if (needSetup) {
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_setup_label"));
|
||||||
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
|
ImGui::InputTextWithHint("##senspass", TR("chat_sensitive_pass_hint"), s_sensitive_pass,
|
||||||
|
sizeof(s_sensitive_pass), ImGuiInputTextFlags_Password);
|
||||||
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
|
ImGui::InputTextWithHint("##senspass2", TR("chat_sensitive_pass2_hint"), s_sensitive_pass2,
|
||||||
|
sizeof(s_sensitive_pass2), ImGuiInputTextFlags_Password);
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
} else if (needUnlock) {
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_unlock_label"));
|
||||||
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
|
ImGui::InputTextWithHint("##senspass", TR("chat_sensitive_pass_hint"), s_sensitive_pass,
|
||||||
|
sizeof(s_sensitive_pass), ImGuiInputTextFlags_Password);
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* actLabel =
|
||||||
|
needSetup ? TR("chat_sensitive_btn_setup")
|
||||||
|
: needUnlock ? (unlockOnly ? TR("chat_sensitive_btn_unlock")
|
||||||
|
: s_sensitive_unmark ? TR("chat_sensitive_btn_unlock_unmark")
|
||||||
|
: TR("chat_sensitive_btn_unlock_mark"))
|
||||||
|
: (unlockOnly ? TR("chat_sensitive_btn_unlock")
|
||||||
|
: s_sensitive_unmark ? TR("chat_sensitive_btn_unmark") : TR("chat_sensitive_btn_mark"));
|
||||||
|
|
||||||
|
auto closeWipe = [&]() {
|
||||||
|
std::memset(s_sensitive_pass, 0, sizeof(s_sensitive_pass));
|
||||||
|
std::memset(s_sensitive_pass2, 0, sizeof(s_sensitive_pass2));
|
||||||
|
s_show_sensitive = false;
|
||||||
|
s_sensitive_cid.clear(); s_sensitive_name.clear(); s_sensitive_peeraddr.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const float gap2 = Layout::spacingSm();
|
||||||
|
const float wAct = std::max(160.0f * dp, ImGui::CalcTextSize(actLabel).x + 40.0f * dp);
|
||||||
|
const float wCan = std::max(90.0f * dp, ImGui::CalcTextSize(TR("chat_cancel")).x + 32.0f * dp);
|
||||||
|
material::BeginOverlayDialogFooter(wAct + wCan + gap2, /*drawSeparator=*/false);
|
||||||
|
const bool doAct = material::TactileButton(actLabel, ImVec2(wAct, 0));
|
||||||
|
ImGui::SameLine(0, gap2);
|
||||||
|
const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));
|
||||||
|
|
||||||
|
if (doAct) {
|
||||||
|
bool ok = true;
|
||||||
|
if (needSetup) {
|
||||||
|
const std::string p1(s_sensitive_pass), p2(s_sensitive_pass2);
|
||||||
|
if (p1.empty() || p1 != p2) {
|
||||||
|
Notifications::instance().error(TR("chat_sensitive_err_pass")); ok = false;
|
||||||
|
} else if (!app->createChatSensitiveVault(p1)) {
|
||||||
|
Notifications::instance().error(TR("chat_sensitive_err_create")); ok = false;
|
||||||
|
}
|
||||||
|
} else if (needUnlock) {
|
||||||
|
const std::string p1(s_sensitive_pass);
|
||||||
|
if (p1.empty() || !app->unlockChatSensitiveVault(p1)) {
|
||||||
|
Notifications::instance().error(TR("chat_sensitive_err_unlock")); ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
if (!unlockOnly) {
|
||||||
|
if (s_sensitive_unmark) {
|
||||||
|
app->unmarkChatConversationSensitive(s_sensitive_cid);
|
||||||
|
Notifications::instance().info(TR("chat_sensitive_toast_unmarked"));
|
||||||
|
} else {
|
||||||
|
app->markChatConversationSensitive(s_sensitive_cid, s_sensitive_peeraddr);
|
||||||
|
Notifications::instance().info(TR("chat_sensitive_toast_marked"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Notifications::instance().info(TR("chat_sensitive_toast_unlocked"));
|
||||||
|
}
|
||||||
|
s_convsKey = ~0ull; // sensitive threads appear/disappear — rebuild the list
|
||||||
|
closeWipe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (doCancel) closeWipe();
|
||||||
|
material::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
|
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
|
||||||
if (s_show_chat_settings) {
|
if (s_show_chat_settings) {
|
||||||
material::OverlayDialogSpec ov;
|
material::OverlayDialogSpec ov;
|
||||||
|
|||||||
@@ -846,7 +846,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
|||||||
|
|
||||||
std::map<int, BlockSummary> cachedBlocks;
|
std::map<int, BlockSummary> cachedBlocks;
|
||||||
if (!pageHeights.empty()) {
|
if (!pageHeights.empty()) {
|
||||||
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front());
|
// Memoized on the cache revision: SQLite is only hit after the cache changed, not every frame.
|
||||||
|
cachedBlocks = s_recent_block_cache.loadRangeCached(pageHeights.back(), pageHeights.front());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
|
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
|
||||||
@@ -890,7 +891,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
|||||||
}
|
}
|
||||||
std::map<int, BlockSummary> nextCachedBlocks;
|
std::map<int, BlockSummary> nextCachedBlocks;
|
||||||
if (!nextPageHeights.empty()) {
|
if (!nextPageHeights.empty()) {
|
||||||
nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front());
|
nextCachedBlocks = s_recent_block_cache.loadRangeCached(nextPageHeights.back(), nextPageHeights.front());
|
||||||
}
|
}
|
||||||
for (int height : nextPageHeights) {
|
for (int height : nextPageHeights) {
|
||||||
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
||||||
|
|||||||
@@ -333,10 +333,11 @@ static void RenderAddressDropdown(App* app, float width) {
|
|||||||
} else {
|
} else {
|
||||||
s_generating_address = true;
|
s_generating_address = true;
|
||||||
if (s_addr_type_filter != 2) {
|
if (s_addr_type_filter != 2) {
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_generating_address = false;
|
s_generating_address = false;
|
||||||
if (addr.empty())
|
if (addr.empty())
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
else {
|
else {
|
||||||
s_pending_select_address = addr;
|
s_pending_select_address = addr;
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
@@ -400,10 +401,11 @@ static void RenderMiningAddressNotice(App* app, const WalletState& state, float
|
|||||||
s_cached_qr_data.clear();
|
s_cached_qr_data.clear();
|
||||||
} else {
|
} else {
|
||||||
s_generating_address = true; // no shielded address yet: make one, as "New" does
|
s_generating_address = true; // no shielded address yet: make one, as "New" does
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_generating_address = false;
|
s_generating_address = false;
|
||||||
if (addr.empty())
|
if (addr.empty())
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
else {
|
else {
|
||||||
s_pending_select_address = addr;
|
s_pending_select_address = addr;
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
|
|||||||
@@ -119,10 +119,11 @@ void RequestPaymentDialog::render(App* app)
|
|||||||
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
||||||
if (app->isConnected()) {
|
if (app->isConnected()) {
|
||||||
s_creating_address = true;
|
s_creating_address = true;
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_creating_address = false;
|
s_creating_address = false;
|
||||||
if (addr.empty()) {
|
if (addr.empty()) {
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
} else {
|
} else {
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
// Pre-select the new address if the dialog is still open and none chosen.
|
// Pre-select the new address if the dialog is still open and none chosen.
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
#include "../../util/address_validation.h"
|
#include "../../util/address_validation.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/text_format.h"
|
#include "../../util/text_format.h"
|
||||||
|
#include "../../util/amount_format.h" // toSats / canAfford — integer-satoshi affordability check
|
||||||
|
#include "../../rpc/rpc_failure.h" // isSendMaybeInFlight — lost-reply (outcome unknown) send tag
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../layout.h"
|
#include "../layout.h"
|
||||||
#include "contact_picker.h"
|
#include "contact_picker.h"
|
||||||
@@ -915,8 +917,9 @@ void RenderSendConfirmPopup(App* app) {
|
|||||||
// since Review. Don't broadcast a now-invalid transaction.
|
// since Review. Don't broadcast a now-invalid transaction.
|
||||||
const auto& liveState = app->getWalletState();
|
const auto& liveState = app->getWalletState();
|
||||||
double liveAvail = GetAvailableBalance(app);
|
double liveAvail = GetAvailableBalance(app);
|
||||||
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 ||
|
if (!app->isConnected() || liveState.sync.syncing ||
|
||||||
(s_amount + s_fee) > liveAvail) {
|
!liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 ||
|
||||||
|
!util::canAfford(s_amount, s_fee, liveAvail)) {
|
||||||
s_tx_status = TR("send_cannot_send_now");
|
s_tx_status = TR("send_cannot_send_now");
|
||||||
s_status_success = false;
|
s_status_success = false;
|
||||||
s_status_timestamp = ImGui::GetTime();
|
s_status_timestamp = ImGui::GetTime();
|
||||||
@@ -954,6 +957,18 @@ void RenderSendConfirmPopup(App* app) {
|
|||||||
s_amount = 0.0;
|
s_amount = 0.0;
|
||||||
s_memo[0] = '\0';
|
s_memo[0] = '\0';
|
||||||
s_send_max = false;
|
s_send_max = false;
|
||||||
|
} else if (rpc::isSendMaybeInFlight(result)) {
|
||||||
|
// Outcome unknown: the reply was lost after the request left. Do NOT present a
|
||||||
|
// plain failure (it invites a retry/double-send). Warn, point at History, and
|
||||||
|
// clear the form so a resend has to be retyped deliberately.
|
||||||
|
s_tx_status = std::string(TR("send_maybe_sent")) + "\n\n" + rpc::stripSendMaybeInFlight(result);
|
||||||
|
s_result_txid.clear();
|
||||||
|
s_status_success = false;
|
||||||
|
Notifications::instance().warning(TR("send_maybe_sent"), 15.0f);
|
||||||
|
s_to_address[0] = '\0';
|
||||||
|
s_amount = 0.0;
|
||||||
|
s_memo[0] = '\0';
|
||||||
|
s_send_max = false;
|
||||||
} else {
|
} else {
|
||||||
// Show a friendly cause in the toast; keep the raw backend text below it in
|
// Show a friendly cause in the toast; keep the raw backend text below it in
|
||||||
// the overlay (copyable) so the detail isn't lost for diagnostics.
|
// the overlay (copyable) so the detail isn't lost for diagnostics.
|
||||||
@@ -1034,10 +1049,11 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
}
|
}
|
||||||
bool can_send = app->isConnected() &&
|
bool can_send = app->isConnected() &&
|
||||||
!state.sync.syncing &&
|
!state.sync.syncing &&
|
||||||
|
state.sync.sendTipReady(app->isLiteBuild()) &&
|
||||||
is_valid_address &&
|
is_valid_address &&
|
||||||
s_amount > 0 &&
|
s_amount > 0 &&
|
||||||
s_from_address[0] != '\0' &&
|
s_from_address[0] != '\0' &&
|
||||||
total <= available &&
|
util::canAfford(s_amount, s_fee, available) &&
|
||||||
sourceSpendable &&
|
sourceSpendable &&
|
||||||
!s_sending;
|
!s_sending;
|
||||||
|
|
||||||
@@ -1058,7 +1074,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
|
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
|
||||||
|
|
||||||
// Explain why Review/Send is greyed while syncing (the fields above are usable now).
|
// Explain why Review/Send is greyed while syncing (the fields above are usable now).
|
||||||
if (state.sync.syncing) {
|
if (state.sync.syncing || (app->isConnected() && !state.sync.sendTipReady(app->isLiteBuild()))) {
|
||||||
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
|
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1103,8 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
|
material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
|
||||||
else if (state.sync.syncing)
|
else if (state.sync.syncing)
|
||||||
material::Tooltip("%s", TR("send_tooltip_syncing"));
|
material::Tooltip("%s", TR("send_tooltip_syncing"));
|
||||||
|
else if (!state.sync.sendTipReady(app->isLiteBuild()))
|
||||||
|
material::Tooltip("%s", TR("send_tooltip_no_tip"));
|
||||||
else if (s_from_address[0] == '\0')
|
else if (s_from_address[0] == '\0')
|
||||||
material::Tooltip("%s", TR("send_tooltip_select_source"));
|
material::Tooltip("%s", TR("send_tooltip_select_source"));
|
||||||
else if (is_mining_addr)
|
else if (is_mining_addr)
|
||||||
@@ -1095,7 +1113,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
||||||
else if (s_amount <= 0)
|
else if (s_amount <= 0)
|
||||||
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
|
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
|
||||||
else if (total > available)
|
else if (!util::canAfford(s_amount, s_fee, available))
|
||||||
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
||||||
else if (!sourceSpendable)
|
else if (!sourceSpendable)
|
||||||
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
||||||
@@ -1530,6 +1548,7 @@ void RenderSendTab(App* app)
|
|||||||
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
||||||
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
|
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
|
||||||
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
|
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
|
||||||
|
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||||
}
|
}
|
||||||
// Draw DRGX equivalent inside the input field (right-aligned overlay)
|
// Draw DRGX equivalent inside the input field (right-aligned overlay)
|
||||||
{
|
{
|
||||||
@@ -1555,6 +1574,7 @@ void RenderSendTab(App* app)
|
|||||||
if (s_amount < 0) s_amount = 0;
|
if (s_amount < 0) s_amount = 0;
|
||||||
if (market.price_usd > 0)
|
if (market.price_usd > 0)
|
||||||
s_usd_amount = s_amount * market.price_usd;
|
s_usd_amount = s_amount * market.price_usd;
|
||||||
|
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||||
}
|
}
|
||||||
// Draw USD equivalent inside the input field (right-aligned overlay)
|
// Draw USD equivalent inside the input field (right-aligned overlay)
|
||||||
if (market.price_usd > 0 && s_amount > 0) {
|
if (market.price_usd > 0 && s_amount > 0) {
|
||||||
@@ -1619,7 +1639,7 @@ void RenderSendTab(App* app)
|
|||||||
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
||||||
|
|
||||||
// Amount error
|
// Amount error
|
||||||
if (s_amount > 0 && s_amount + s_fee > available && available > 0) {
|
if (s_amount > 0 && !util::canAfford(s_amount, s_fee, available) && available > 0) {
|
||||||
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
|
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
|
||||||
Type().textColored(TypeStyle::Caption, Error(), buf);
|
Type().textColored(TypeStyle::Caption, Error(), buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,10 +219,11 @@ void RenderTransactionsTab(App* app)
|
|||||||
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
|
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
|
||||||
// (a sort-combo change re-sorts without regrouping).
|
// (a sort-combo change re-sorts without regrouping).
|
||||||
static history::TxidGroups s_groups;
|
static history::TxidGroups s_groups;
|
||||||
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows
|
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_structure_revision starts at 1 and only grows
|
||||||
if (s_groups_key != contentKey) {
|
const std::uint64_t structKey = state.tx_structure_revision; // ignores pure confirmation-count bumps
|
||||||
|
if (s_groups_key != structKey) {
|
||||||
s_groups = history::groupByTxid(state.transactions);
|
s_groups = history::groupByTxid(state.transactions);
|
||||||
s_groups_key = contentKey;
|
s_groups_key = structKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
||||||
@@ -415,13 +416,14 @@ void RenderTransactionsTab(App* app)
|
|||||||
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
|
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
|
||||||
// the refresh service, but we re-sort here to apply the "pending first" ordering.
|
// the refresh service, but we re-sort here to apply the "pending first" ordering.
|
||||||
static std::vector<DisplayTx> s_display_cache;
|
static std::vector<DisplayTx> s_display_cache;
|
||||||
static std::uint64_t s_display_cache_key = 0; // tx_revision the cache was built from
|
static std::uint64_t s_display_cache_key = 0; // tx_structure_revision the cache was built from
|
||||||
|
static std::uint64_t s_display_conf_rev = 0; // tx_revision the cached confirmations were last synced to
|
||||||
static int s_display_cache_sort = 0; // sort mode the cache was built with
|
static int s_display_cache_sort = 0; // sort mode the cache was built with
|
||||||
static bool s_display_cache_valid = false;
|
static bool s_display_cache_valid = false;
|
||||||
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
|
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
|
||||||
|
|
||||||
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
|
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
|
||||||
if (!s_display_cache_valid || contentKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
|
if (!s_display_cache_valid || structKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
|
||||||
++s_display_gen;
|
++s_display_gen;
|
||||||
s_display_cache.clear();
|
s_display_cache.clear();
|
||||||
std::vector<DisplayTx>& display_txns = s_display_cache;
|
std::vector<DisplayTx>& display_txns = s_display_cache;
|
||||||
@@ -524,9 +526,27 @@ void RenderTransactionsTab(App* app)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
s_display_cache_key = contentKey;
|
s_display_cache_key = structKey;
|
||||||
|
s_display_conf_rev = contentKey; // freshly built from the current list
|
||||||
s_display_cache_sort = s_sort_mode;
|
s_display_cache_sort = s_sort_mode;
|
||||||
s_display_cache_valid = true;
|
s_display_cache_valid = true;
|
||||||
|
} else if (s_display_conf_rev != contentKey) {
|
||||||
|
// Confirmation-count-only delta (setTransactionsIfChanged kept tx_structure_revision): the merged rows, their
|
||||||
|
// order and the indices into state.transactions are unchanged, so refresh just the count (same rule as the
|
||||||
|
// build above: a shield shows the larger of its two legs). Sort order cannot change: mode 0 only reads
|
||||||
|
// conf==0, which the layout check holds constant, and the other modes ignore confirmations.
|
||||||
|
for (DisplayTx& dtx : s_display_cache) {
|
||||||
|
if (dtx.is_shield) {
|
||||||
|
dtx.confirmations = std::max(state.transactions[dtx.send_idx].confirmations,
|
||||||
|
state.transactions[dtx.recv_idx].confirmations);
|
||||||
|
} else {
|
||||||
|
dtx.confirmations = state.transactions[dtx.orig_idx].confirmations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The filtered-index cache keys on s_display_gen (unchanged, correct: filters ignore confirmations) and
|
||||||
|
// the snapshot's indices remain valid; only re-stamp it so 'export current filter' stays usable.
|
||||||
|
if (s_filter_snapshot.valid) s_filter_snapshot.txRevision = contentKey;
|
||||||
|
s_display_conf_rev = contentKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
|
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
|
||||||
@@ -835,7 +855,12 @@ void RenderTransactionsTab(App* app)
|
|||||||
{
|
{
|
||||||
const char* statusStr;
|
const char* statusStr;
|
||||||
ImU32 statusCol;
|
ImU32 statusCol;
|
||||||
if (tx.confirmations == 0) {
|
const int64_t nowSec = static_cast<int64_t>(std::time(nullptr));
|
||||||
|
if (tx.confirmations < 0) { // gettransaction < 0: conflicted / double-spent / reorged out
|
||||||
|
statusStr = TR("tx_dropped"); statusCol = Error();
|
||||||
|
} else if (tx.confirmations == 0 && tx.timestamp > 0 && nowSec - tx.timestamp > 3600) {
|
||||||
|
statusStr = TR("tx_stale_pending"); statusCol = Warning();
|
||||||
|
} else if (tx.confirmations == 0) {
|
||||||
statusStr = TR("pending"); statusCol = Warning();
|
statusStr = TR("pending"); statusCol = Warning();
|
||||||
} else if (tx.confirmations < 10) {
|
} else if (tx.confirmations < 10) {
|
||||||
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
|
|
||||||
std::string formatAmountFixed(double amount, int decimals = 8);
|
std::string formatAmountFixed(double amount, int decimals = 8);
|
||||||
|
|
||||||
|
// DRGX amounts are 8-decimal. Converting to int64 satoshis and comparing there avoids the IEEE-double
|
||||||
|
// rounding that could block a valid exact-max send (amount+fee computed in doubles landed 1 ulp over).
|
||||||
|
inline std::int64_t toSats(double coins) { return static_cast<std::int64_t>(std::llround(coins * 1e8)); }
|
||||||
|
|
||||||
|
// True when amount + fee fits within available, compared in integer satoshis (not doubles).
|
||||||
|
inline bool canAfford(double amount, double fee, double available) {
|
||||||
|
return toSats(amount) + toSats(fee) <= toSats(available);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a coin amount to zatoshis for a send, rejecting non-finite / non-positive / rounds-to-zero /
|
||||||
|
// over-supply values. Returns false (and leaves out untouched) on any of those.
|
||||||
|
inline bool coinsToZatoshis(double coins, std::uint64_t& out) {
|
||||||
|
if (!std::isfinite(coins) || coins <= 0.0) return false;
|
||||||
|
const std::int64_t z = toSats(coins);
|
||||||
|
if (z <= 0 || z > static_cast<std::int64_t>(21000000) * 100000000LL) return false;
|
||||||
|
out = static_cast<std::uint64_t>(z);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ void I18n::overlayTranslations(const nlohmann::json& translations)
|
|||||||
|
|
||||||
bool I18n::loadLanguage(const std::string& locale)
|
bool I18n::loadLanguage(const std::string& locale)
|
||||||
{
|
{
|
||||||
|
// Exclusive for the whole load: strings_ is cleared/rebuilt below, and translateCopy() readers on
|
||||||
|
// other threads (daemon monitor) must never observe it mid-rewrite. loadBuiltinEnglish() and
|
||||||
|
// overlayTranslations() are called only from here and do not lock (no recursion).
|
||||||
|
std::unique_lock<std::shared_mutex> lock(strings_mutex_);
|
||||||
|
|
||||||
// Try to load from file: CWD-relative first, then exe-relative
|
// Try to load from file: CWD-relative first, then exe-relative
|
||||||
std::string rel_path = "res/lang/" + locale + ".json";
|
std::string rel_path = "res/lang/" + locale + ".json";
|
||||||
const std::string exe_dir = Platform::getExecutableDirectory();
|
const std::string exe_dir = Platform::getExecutableDirectory();
|
||||||
@@ -260,6 +265,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_toast_lite_busy"] = "A send is already in progress, or no wallet is open.";
|
strings_["chat_toast_lite_busy"] = "A send is already in progress, or no wallet is open.";
|
||||||
strings_["chat_toast_waiting_reply"] = "Waiting for the contact to reply before you can message them.";
|
strings_["chat_toast_waiting_reply"] = "Waiting for the contact to reply before you can message them.";
|
||||||
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
|
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
|
||||||
|
strings_["chat_toast_too_long"] = "Message too long — shorten it and send again.";
|
||||||
strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?).";
|
strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?).";
|
||||||
strings_["chat_toast_request_queued"] = "Contact request queued.";
|
strings_["chat_toast_request_queued"] = "Contact request queued.";
|
||||||
strings_["chat_toast_need_funds"] = "Need a small shielded balance to send chat (to cover the fee).";
|
strings_["chat_toast_need_funds"] = "Need a small shielded balance to send chat (to cover the fee).";
|
||||||
@@ -307,11 +313,54 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_deleted_toast"] = "Conversation deleted";
|
strings_["chat_deleted_toast"] = "Conversation deleted";
|
||||||
strings_["chat_delete_failed"] = "Couldn't delete the conversation — nothing was changed.";
|
strings_["chat_delete_failed"] = "Couldn't delete the conversation — nothing was changed.";
|
||||||
strings_["chat_blocked_toast"] = "Conversation deleted & blocked";
|
strings_["chat_blocked_toast"] = "Conversation deleted & blocked";
|
||||||
|
strings_["chat_delete_everywhere"] = "Delete everywhere";
|
||||||
|
strings_["chat_delete_everywhere_note"] = "Also tells your other devices - and a future restore from your seed phrase - to hide this conversation. It sends a tiny on-chain signal (costs the usual chat fee). You can bring it back later from Restore.";
|
||||||
|
strings_["chat_delete_everywhere_toast"] = "Deleting everywhere - signal sent";
|
||||||
|
strings_["chat_delete_needs_sync"] = "Wait until the wallet is fully synced to delete everywhere.";
|
||||||
strings_["chat_blocked_manage"] = "Blocked";
|
strings_["chat_blocked_manage"] = "Blocked";
|
||||||
strings_["chat_blocked_title"] = "Blocked conversations";
|
strings_["chat_blocked_title"] = "Blocked conversations";
|
||||||
strings_["chat_blocked_desc"] = "Blocked conversations are removed and their messages are dropped — old and new — until you unblock. Unblocking re-imports the conversation from the chain.";
|
strings_["chat_blocked_desc"] = "Blocked conversations are removed and their messages are dropped — old and new — until you unblock. Unblocking re-imports the conversation from the chain.";
|
||||||
strings_["chat_unblock"] = "Unblock";
|
strings_["chat_unblock"] = "Unblock";
|
||||||
strings_["chat_unblocked_toast"] = "Conversation unblocked";
|
strings_["chat_unblocked_toast"] = "Conversation unblocked";
|
||||||
|
strings_["chat_restore_manage"] = "Restore";
|
||||||
|
strings_["chat_restore_title"] = "Restore conversations";
|
||||||
|
strings_["chat_restore_desc"] = "Conversations you deleted everywhere can be brought back here - on this device or after importing your seed phrase. Restoring re-imports the messages from the chain and tells your other devices to show it again.";
|
||||||
|
strings_["chat_restore_none"] = "No deleted conversations found yet. If you deleted one on another device, try Quick scan or paste its chat address below.";
|
||||||
|
strings_["chat_restore_btn"] = "Restore";
|
||||||
|
strings_["chat_restore_scan"] = "Quick scan";
|
||||||
|
strings_["chat_restore_scanning"] = "Scanning the chain for deleted conversations...";
|
||||||
|
strings_["chat_restore_toast"] = "Restoring conversation - it reappears after the next scan";
|
||||||
|
strings_["chat_restore_paste_label"] = "Restore by chat address";
|
||||||
|
strings_["chat_restore_paste_hint"] = "Paste the conversation's chat (z) address";
|
||||||
|
strings_["chat_sensitive_mark_tip"] = "Make sensitive";
|
||||||
|
strings_["chat_sensitive_unmark_tip"] = "Sensitive - tap to change";
|
||||||
|
strings_["chat_sensitive_mark_title"] = "Make this conversation sensitive?";
|
||||||
|
strings_["chat_sensitive_unmark_title"] = "Make this conversation normal?";
|
||||||
|
strings_["chat_sensitive_unlock_title"] = "Unlock sensitive conversations";
|
||||||
|
strings_["chat_sensitive_warn_1"] = "Its messages will be encrypted on this device under a separate passphrase - not your seed phrase. Anyone who later restores your wallet from your seed alone won't see this conversation.";
|
||||||
|
strings_["chat_sensitive_warn_lose"] = "If you lose this passphrase, these messages are gone forever - they can't be recovered from your seed.";
|
||||||
|
strings_["chat_sensitive_warn_dir"] = "Your sent messages here are protected completely. Received messages are still on the blockchain and could, in theory, be recovered by someone who has your seed and other software - DragonX itself will never show them without your passphrase.";
|
||||||
|
strings_["chat_sensitive_warn_hist"] = "Messages already in this conversation stay recoverable from the chain; this fully protects messages from here on.";
|
||||||
|
strings_["chat_sensitive_unmark_body"] = "This conversation will no longer be encrypted under your sensitive-chat passphrase. New messages will be stored like any other conversation.";
|
||||||
|
strings_["chat_sensitive_unlock_body"] = "Enter your sensitive-chat passphrase to show your sensitive conversations again.";
|
||||||
|
strings_["chat_sensitive_setup_label"] = "Create a passphrase for sensitive conversations";
|
||||||
|
strings_["chat_sensitive_unlock_label"] = "Enter your sensitive-chat passphrase";
|
||||||
|
strings_["chat_sensitive_pass_hint"] = "Passphrase";
|
||||||
|
strings_["chat_sensitive_pass2_hint"] = "Confirm passphrase";
|
||||||
|
strings_["chat_sensitive_btn_setup"] = "Set up & make sensitive";
|
||||||
|
strings_["chat_sensitive_btn_mark"] = "Make sensitive";
|
||||||
|
strings_["chat_sensitive_btn_unmark"] = "Make normal";
|
||||||
|
strings_["chat_sensitive_btn_unlock"] = "Unlock";
|
||||||
|
strings_["chat_sensitive_btn_unlock_mark"] = "Unlock & make sensitive";
|
||||||
|
strings_["chat_sensitive_btn_unlock_unmark"] = "Unlock & make normal";
|
||||||
|
strings_["chat_sensitive_lock_btn"] = "Lock sensitive";
|
||||||
|
strings_["chat_sensitive_unlock_btn"] = "Unlock sensitive";
|
||||||
|
strings_["chat_sensitive_toast_marked"] = "Conversation is now sensitive";
|
||||||
|
strings_["chat_sensitive_toast_unmarked"] = "Conversation is no longer sensitive";
|
||||||
|
strings_["chat_sensitive_toast_unlocked"] = "Sensitive conversations unlocked";
|
||||||
|
strings_["chat_sensitive_err_pass"] = "Passphrases don't match, or are empty.";
|
||||||
|
strings_["chat_sensitive_err_unlock"] = "Wrong passphrase.";
|
||||||
|
strings_["chat_sensitive_err_create"] = "Couldn't set up the sensitive-chat vault.";
|
||||||
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
|
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
|
||||||
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
|
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
|
||||||
strings_["chat_contact_needs_z"] = "Needs a shielded (z) address to chat";
|
strings_["chat_contact_needs_z"] = "Needs a shielded (z) address to chat";
|
||||||
@@ -507,6 +556,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_to"] = "To: ";
|
strings_["mig_to"] = "To: ";
|
||||||
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
|
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
|
||||||
strings_["mig_sweep_all"] = "Sweep all funds";
|
strings_["mig_sweep_all"] = "Sweep all funds";
|
||||||
|
strings_["mig_unlock_prompt"] = "Your wallet is locked. Enter your passphrase to unlock it, then sweep.";
|
||||||
|
strings_["mig_unlock_btn"] = "Unlock";
|
||||||
strings_["mig_later"] = "Later";
|
strings_["mig_later"] = "Later";
|
||||||
strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…";
|
strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…";
|
||||||
strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved.";
|
strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved.";
|
||||||
@@ -521,6 +572,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_sweep_confirm"] = "Send %.8f DRGX from your current wallet to the new seed wallet in a single transaction? This moves all of your funds.";
|
strings_["mig_sweep_confirm"] = "Send %.8f DRGX from your current wallet to the new seed wallet in a single transaction? This moves all of your funds.";
|
||||||
strings_["mig_sweep_confirm_yes"] = "Yes, sweep funds";
|
strings_["mig_sweep_confirm_yes"] = "Yes, sweep funds";
|
||||||
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
|
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
|
||||||
|
strings_["mig_waiting_depth"] = "Sweep mined. Waiting for %d confirmations before switching wallets, so a short chain reorganisation cannot undo it…";
|
||||||
|
strings_["mig_adopt_reverify_failed"] = "Could not confirm it is safe to switch wallets yet (the node may still be syncing, the sweep is not deep enough, or funds remain). Nothing was changed - wait and try again.";
|
||||||
strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
|
strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
|
||||||
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";
|
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";
|
||||||
strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder.";
|
strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder.";
|
||||||
@@ -621,6 +674,15 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["settings_merge_to_address"] = "Merge to Address…";
|
strings_["settings_merge_to_address"] = "Merge to Address…";
|
||||||
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
|
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
|
||||||
strings_["settings_import_key"] = "Import Private Key…";
|
strings_["settings_import_key"] = "Import Private Key…";
|
||||||
|
strings_["settings_import_wallet_file"] = "Import From Backup File…";
|
||||||
|
strings_["import_wallet_file_title"] = "Import keys from a backup file";
|
||||||
|
strings_["import_wallet_file_note"] = "Imports every private key from an old wallet's export file (its z_exportwallet / dumpwallet output, or SilentDragon's \"Export all private keys\") and rescans once — the fastest way to recover a legacy wallet.dat this client can't open.";
|
||||||
|
strings_["import_wallet_file_path_label"] = "Path to the key dump file:";
|
||||||
|
strings_["import_wallet_file_import_btn"] = "Import and rescan";
|
||||||
|
strings_["import_wallet_file_importing"] = "Importing keys and rescanning — this can take a while";
|
||||||
|
strings_["import_wallet_file_ok"] = "Keys imported. Once the rescan finishes, shield any mined (coinbase) transparent funds from the Shield screen to make them spendable.";
|
||||||
|
strings_["import_wallet_file_need_path"] = "Enter the path to the key dump file.";
|
||||||
|
strings_["import_wallet_file_not_found"] = "Couldn't open that file — check the path is correct and the file is readable.";
|
||||||
strings_["settings_import_viewkey"] = "Import Viewing Key…";
|
strings_["settings_import_viewkey"] = "Import Viewing Key…";
|
||||||
strings_["settings_export_key"] = "Export Key…";
|
strings_["settings_export_key"] = "Export Key…";
|
||||||
strings_["settings_export_all"] = "Export All…";
|
strings_["settings_export_all"] = "Export All…";
|
||||||
@@ -1090,6 +1152,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
|
|
||||||
// Settings: additional tooltips (keys/data row)
|
// Settings: additional tooltips (keys/data row)
|
||||||
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
|
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
|
||||||
|
strings_["tt_import_wallet_file"] = "Import all keys from an old wallet's exported dump file (one rescan)";
|
||||||
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
|
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
|
||||||
strings_["tt_export_key"] = "Export the private key for the selected address";
|
strings_["tt_export_key"] = "Export the private key for the selected address";
|
||||||
strings_["tt_export_all"] = "Export all private keys to a file";
|
strings_["tt_export_all"] = "Export all private keys to a file";
|
||||||
@@ -1352,6 +1415,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["lite_unlock_btn"] = "Unlock";
|
strings_["lite_unlock_btn"] = "Unlock";
|
||||||
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
|
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
|
||||||
strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?";
|
strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?";
|
||||||
|
strings_["lite_encrypt_failed_unencrypted"] = "Wallet encryption failed — your wallet may still be UNENCRYPTED. Open Settings > Security and set the passphrase again.";
|
||||||
|
|
||||||
// Send Tab
|
// Send Tab
|
||||||
strings_["pay_from"] = "Pay From";
|
strings_["pay_from"] = "Pay From";
|
||||||
@@ -1503,6 +1567,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["confirmations"] = "Confirmations";
|
strings_["confirmations"] = "Confirmations";
|
||||||
strings_["confirmed"] = "Confirmed";
|
strings_["confirmed"] = "Confirmed";
|
||||||
strings_["pending"] = "Pending";
|
strings_["pending"] = "Pending";
|
||||||
|
strings_["tx_dropped"] = "Dropped";
|
||||||
|
strings_["tx_stale_pending"] = "Expired?";
|
||||||
strings_["baltab_pending_chip"] = "Pending +%.4f";
|
strings_["baltab_pending_chip"] = "Pending +%.4f";
|
||||||
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
|
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
|
||||||
strings_["sent"] = "sent";
|
strings_["sent"] = "sent";
|
||||||
@@ -1595,6 +1661,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_mining_hs"] = "%.1f H/s";
|
strings_["sb_mining_hs"] = "%.1f H/s";
|
||||||
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
||||||
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
|
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
|
||||||
|
strings_["sb_lite_sync_error"] = "Sync error: %.80s";
|
||||||
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
|
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
|
||||||
strings_["sb_rescanning"] = "Rescanning";
|
strings_["sb_rescanning"] = "Rescanning";
|
||||||
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
||||||
@@ -1628,6 +1695,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["node_banner_open_config"] = "Open config folder";
|
strings_["node_banner_open_config"] = "Open config folder";
|
||||||
strings_["node_banner_auth_title"] = "Wallet can't sign in to the node";
|
strings_["node_banner_auth_title"] = "Wallet can't sign in to the node";
|
||||||
strings_["node_banner_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf.";
|
strings_["node_banner_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf.";
|
||||||
|
strings_["node_banner_legacy_title"] = "Your transparent funds can't be moved yet";
|
||||||
|
strings_["node_banner_legacy_detail"] = "This wallet has no usable seed, so it can't create a shielded address to move its transparent funds. Recover them into a fresh seed-backed wallet.";
|
||||||
|
strings_["node_banner_recover"] = "Recover funds";
|
||||||
// Refresh-staleness badge (W6-2) on the Total Balance card.
|
// Refresh-staleness badge (W6-2) on the Total Balance card.
|
||||||
strings_["data_stale_prefix"] = "Updated";
|
strings_["data_stale_prefix"] = "Updated";
|
||||||
strings_["data_stale_tooltip"] =
|
strings_["data_stale_tooltip"] =
|
||||||
@@ -2694,10 +2764,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
||||||
strings_["send_tooltip_select_source"] = "Select a source address first";
|
strings_["send_tooltip_select_source"] = "Select a source address first";
|
||||||
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
|
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
|
||||||
|
strings_["send_tooltip_no_tip"] = "Waiting for peers to report the network height. Sending is disabled until the wallet knows it is caught up.";
|
||||||
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
|
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
|
||||||
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
|
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
|
||||||
strings_["send_total"] = "Total";
|
strings_["send_total"] = "Total";
|
||||||
strings_["send_tx_failed"] = "Transaction failed";
|
strings_["send_tx_failed"] = "Transaction failed";
|
||||||
|
strings_["send_maybe_sent"] = "This transaction may already have been broadcast (the reply was lost). Do NOT send again yet - check History first.";
|
||||||
strings_["send_tx_sent"] = "Transaction sent!";
|
strings_["send_tx_sent"] = "Transaction sent!";
|
||||||
strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
|
strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
|
||||||
strings_["send_tx_success"] = "Transaction sent successfully!";
|
strings_["send_tx_success"] = "Transaction sent successfully!";
|
||||||
@@ -3111,6 +3183,15 @@ const char* I18n::translate(const char* key) const
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string I18n::translateCopy(const char* key) const
|
||||||
|
{
|
||||||
|
if (!key) return std::string();
|
||||||
|
std::shared_lock<std::shared_mutex> lock(strings_mutex_);
|
||||||
|
auto it = strings_.find(key);
|
||||||
|
if (it != strings_.end()) return it->second;
|
||||||
|
return std::string(key);
|
||||||
|
}
|
||||||
|
|
||||||
void I18n::registerLanguage(const std::string& code, const std::string& name)
|
void I18n::registerLanguage(const std::string& code, const std::string& name)
|
||||||
{
|
{
|
||||||
// Check if already registered
|
// Check if already registered
|
||||||
|
|||||||
+22
-1
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <shared_mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -38,7 +40,16 @@ public:
|
|||||||
* @return Translated string, or key if not found
|
* @return Translated string, or key if not found
|
||||||
*/
|
*/
|
||||||
const char* translate(const char* key) const;
|
const char* translate(const char* key) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Thread-safe translate: returns a COPY under a read lock.
|
||||||
|
*
|
||||||
|
* translate() hands back a pointer into the string table, which loadLanguage() rewrites on the
|
||||||
|
* main thread. Any caller off the main thread (e.g. the EmbeddedDaemon monitor thread) MUST use
|
||||||
|
* this instead; main-thread UI code keeps using translate()/TR().
|
||||||
|
*/
|
||||||
|
std::string translateCopy(const char* key) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get current locale
|
* @brief Get current locale
|
||||||
*/
|
*/
|
||||||
@@ -66,6 +77,9 @@ private:
|
|||||||
void overlayTranslations(const nlohmann::json& translations);
|
void overlayTranslations(const nlohmann::json& translations);
|
||||||
|
|
||||||
std::string current_locale_ = "en";
|
std::string current_locale_ = "en";
|
||||||
|
// Guards strings_/current_locale_ against loadLanguage() (main thread, writer) racing
|
||||||
|
// translateCopy() (off-thread readers). The lock-free translate() is main-thread only.
|
||||||
|
mutable std::shared_mutex strings_mutex_;
|
||||||
std::unordered_map<std::string, std::string> strings_;
|
std::unordered_map<std::string, std::string> strings_;
|
||||||
std::vector<std::pair<std::string, std::string>> available_languages_;
|
std::vector<std::pair<std::string, std::string>> available_languages_;
|
||||||
};
|
};
|
||||||
@@ -79,6 +93,13 @@ inline const char* tr(const char* key) {
|
|||||||
return I18n::instance().translate(key);
|
return I18n::instance().translate(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Thread-safe translation returning an owned copy. Use from any non-main thread.
|
||||||
|
*/
|
||||||
|
inline std::string trCopy(const char* key) {
|
||||||
|
return I18n::instance().translateCopy(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
|
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
|
|||||||
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
|
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
|
||||||
// RAW filename — the hash disambiguates so two distinct files can never share a vault even if their
|
// RAW filename — the hash disambiguates so two distinct files can never share a vault even if their
|
||||||
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
|
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
|
||||||
if (walletFile.empty() || walletFile == "wallet.dat") { scope_.clear(); return; }
|
if (walletFile.empty() || walletFile == "wallet.dat") {
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope_.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
std::string s;
|
std::string s;
|
||||||
s.reserve(walletFile.size());
|
s.reserve(walletFile.size());
|
||||||
for (unsigned char c : walletFile)
|
for (unsigned char c : walletFile)
|
||||||
@@ -58,11 +62,18 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
|
|||||||
char suffix[9];
|
char suffix[9];
|
||||||
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
||||||
if (s.size() > 48) s.resize(48);
|
if (s.size() > 48) s.resize(48);
|
||||||
scope_ = s + "-" + suffix;
|
std::string newScope = s + "-" + suffix;
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope_ = std::move(newScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string SecureVault::getVaultPath() const {
|
std::string SecureVault::getVaultPath() const {
|
||||||
const std::string name = scope_.empty() ? std::string("vault.dat") : ("vault-" + scope_ + ".dat");
|
std::string scope;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope = scope_;
|
||||||
|
}
|
||||||
|
const std::string name = scope.empty() ? std::string("vault.dat") : ("vault-" + scope + ".dat");
|
||||||
return (fs::path(Platform::getConfigDir()) / name).string();
|
return (fs::path(Platform::getConfigDir()) / name).string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
@@ -101,6 +102,9 @@ private:
|
|||||||
const uint8_t* salt, size_t saltLen,
|
const uint8_t* salt, size_t saltLen,
|
||||||
uint8_t* key, size_t keyLen) const;
|
uint8_t* key, size_t keyLen) const;
|
||||||
|
|
||||||
|
// Guards scope_: setWalletScope() runs on the main thread (wallet switch) while retrieve()/hasVault()/
|
||||||
|
// changePin() run on RPC worker threads and read it via getVaultPath().
|
||||||
|
mutable std::mutex scopeMutex_;
|
||||||
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -94,11 +94,11 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
|
|||||||
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
||||||
: api_(other.api_),
|
: api_(other.api_),
|
||||||
unavailableReason_(std::move(other.unavailableReason_)),
|
unavailableReason_(std::move(other.unavailableReason_)),
|
||||||
shutdownCalled_(other.shutdownCalled_),
|
shutdownCalled_(other.shutdownCalled_.load(std::memory_order_acquire)),
|
||||||
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
||||||
{
|
{
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
||||||
@@ -107,11 +107,11 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
|||||||
shutdown();
|
shutdown();
|
||||||
api_ = other.api_;
|
api_ = other.api_;
|
||||||
unavailableReason_ = std::move(other.unavailableReason_);
|
unavailableReason_ = std::move(other.unavailableReason_);
|
||||||
shutdownCalled_ = other.shutdownCalled_;
|
shutdownCalled_.store(other.shutdownCalled_.load(std::memory_order_acquire), std::memory_order_release);
|
||||||
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
||||||
std::memory_order_release);
|
std::memory_order_release);
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ bool LiteClientBridge::checkServerOnline(const std::string& server) const
|
|||||||
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
|||||||
bool overwrite)
|
bool overwrite)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeNewFromPhrase(
|
return takeOwnedString(api_.initializeNewFromPhrase(
|
||||||
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
|
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
|
||||||
}
|
}
|
||||||
@@ -158,6 +160,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
|||||||
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
|
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +183,9 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
|||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
if (command.empty()) return {false, {}, "lite command is empty"};
|
if (command.empty()) return {false, {}, "lite command is empty"};
|
||||||
|
// A retired controller's bridge is shut down; litelib_execute would run on the NEW global client
|
||||||
|
// (a stale send/save/sync/console command). Refuse it here, once, for every detached thread.
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
||||||
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
||||||
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
||||||
@@ -188,11 +194,15 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
|||||||
|
|
||||||
void LiteClientBridge::shutdown()
|
void LiteClientBridge::shutdown()
|
||||||
{
|
{
|
||||||
if (shutdownCalled_) return;
|
if (shutdownCalled_.exchange(true, std::memory_order_acq_rel)) return; // exactly one caller shuts the client down
|
||||||
shutdownCalled_ = true;
|
|
||||||
if (available()) api_.shutdown();
|
if (available()) api_.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LiteBridgeStringResult LiteClientBridge::shutDownResult()
|
||||||
|
{
|
||||||
|
return {false, {}, "lite client was shut down"};
|
||||||
|
}
|
||||||
|
|
||||||
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
||||||
{
|
{
|
||||||
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ public:
|
|||||||
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
||||||
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
||||||
void shutdown();
|
void shutdown();
|
||||||
|
// True once shutdown() ran (retire() / destruction / move-from). After this every backend call
|
||||||
|
// (execute / initialize*) is refused: litelib_* acts on the process-global client, which may by
|
||||||
|
// then belong to a NEW controller.
|
||||||
|
bool isShutdown() const { return shutdownCalled_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
||||||
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
||||||
@@ -82,12 +86,13 @@ private:
|
|||||||
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
||||||
|
|
||||||
LiteBridgeStringResult unavailableResult() const;
|
LiteBridgeStringResult unavailableResult() const;
|
||||||
|
static LiteBridgeStringResult shutDownResult();
|
||||||
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
||||||
static bool commandIsReadOnly(const std::string& command);
|
static bool commandIsReadOnly(const std::string& command);
|
||||||
|
|
||||||
LiteClientBridgeApi api_;
|
LiteClientBridgeApi api_;
|
||||||
std::string unavailableReason_;
|
std::string unavailableReason_;
|
||||||
bool shutdownCalled_ = false;
|
std::atomic<bool> shutdownCalled_{false}; // read by detached threads in execute() while retire() sets it
|
||||||
std::atomic<std::uint64_t> mutationEpoch_{0};
|
std::atomic<std::uint64_t> mutationEpoch_{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ bool requireArray(const json& value, const std::string& path, Result& result)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 21,000,000 DRGX in zatoshi: no single backend-reported amount can legitimately exceed the supply.
|
||||||
|
constexpr std::int64_t kMaxMoneyZat = static_cast<std::int64_t>(21000000) * 100000000LL;
|
||||||
|
|
||||||
bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -282,9 +285,20 @@ bool parseSpendableOutput(const json& value,
|
|||||||
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
|
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
|
||||||
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
|
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
|
||||||
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
|
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
|
||||||
|
if (output.value > static_cast<std::uint64_t>(kMaxMoneyZat)) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".value", "note value exceeds the maximum supply");
|
||||||
|
}
|
||||||
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
|
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
|
||||||
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
|
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
|
||||||
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
|
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
|
||||||
|
// The backend's per-note 'spendable' (we hold the spending key AND the note is at/below the anchor
|
||||||
|
// height) is authoritative when present (Sapling notes only; transparent utxos carry no such field,
|
||||||
|
// so they keep the confirmed/unspent rule above). It can only narrow, never widen, spendability.
|
||||||
|
bool backendSpendable = true;
|
||||||
|
if (!readOptionalBoolField(value, "spendable", backendSpendable, result)) return false;
|
||||||
|
if (value.contains("spendable") && value.at("spendable").is_boolean()) {
|
||||||
|
output.spendable = output.spendable && backendSpendable;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,17 +358,35 @@ bool parseTransactionRecord(const json& value,
|
|||||||
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
|
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
|
||||||
// Reject negative values and guard the running sum: signed overflow is UB, and a hostile
|
// Reject negative values and guard the running sum: signed overflow is UB, and a hostile
|
||||||
// backend could otherwise overflow the total into a bogus (even negative) amount.
|
// backend could otherwise overflow the total into a bogus (even negative) amount.
|
||||||
if (parsedOutput.value < 0) return false;
|
const std::string outputPath = path + ".outgoing_metadata[" + std::to_string(index) + "]";
|
||||||
if (output.amount > std::numeric_limits<std::int64_t>::max() - parsedOutput.value) return false;
|
if (parsedOutput.value < 0 || parsedOutput.value > kMaxMoneyZat) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, outputPath + ".value", "outgoing output value is negative or exceeds the maximum supply");
|
||||||
|
}
|
||||||
|
if (output.amount > kMaxMoneyZat - parsedOutput.value) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".outgoing_metadata", "outgoing output total exceeds the maximum supply");
|
||||||
|
}
|
||||||
output.amount += parsedOutput.value;
|
output.amount += parsedOutput.value;
|
||||||
output.outgoingMetadata.push_back(std::move(parsedOutput));
|
output.outgoingMetadata.push_back(std::move(parsedOutput));
|
||||||
}
|
}
|
||||||
|
// A send this wallet did not author (seed restore / other device) has empty outgoing_metadata,
|
||||||
|
// so the sum is 0. Fall back to the record's net 'amount' magnitude (includes the fee) so Sent
|
||||||
|
// totals aren't understated. Display-only; tolerant of a missing/odd field.
|
||||||
|
if (output.amount == 0 && value.contains("amount") && !value.at("amount").is_null()) {
|
||||||
|
std::int64_t net = 0;
|
||||||
|
if (jsonToSigned(value.at("amount"), net) && net != std::numeric_limits<std::int64_t>::min()
|
||||||
|
&& (net < 0 ? -net : net) <= kMaxMoneyZat) {
|
||||||
|
output.amount = net < 0 ? -net : net;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
output.direction = LiteTransactionDirection::Receive;
|
output.direction = LiteTransactionDirection::Receive;
|
||||||
if (!readRequiredStringField(value, "address", output.address, result)) return false;
|
if (!readRequiredStringField(value, "address", output.address, result)) return false;
|
||||||
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
|
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
|
||||||
|
if (output.amount < 0 || output.amount > kMaxMoneyZat) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".amount", "received amount is negative or exceeds the maximum supply");
|
||||||
|
}
|
||||||
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
|
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
|
||||||
if (!readOptionalSignedField(value, "position", output.position, result)) return false;
|
if (!readOptionalSignedField(value, "position", output.position, result)) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -570,11 +602,20 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value)
|
|||||||
|
|
||||||
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
|
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
|
||||||
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
|
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
|
||||||
std::string syncing = "false";
|
std::optional<std::string> syncingField;
|
||||||
if (!readOptionalStringField(value, "syncing", syncing, result)) return result;
|
if (!readOptionalStringField(value, "syncing", syncingField, result)) return result;
|
||||||
|
const std::string syncing = syncingField.value_or(std::string());
|
||||||
const bool isSyncing = (syncing == "true" || syncing == "1");
|
const bool isSyncing = (syncing == "true" || syncing == "1");
|
||||||
|
const bool isIdle = (syncing == "false" || syncing == "0");
|
||||||
|
|
||||||
if (isSyncing) {
|
if (!isSyncing && !isIdle) {
|
||||||
|
// Missing/odd "syncing": we do not KNOW the wallet is caught up, so never default to synced.
|
||||||
|
result.syncStatus.syncedBlocks = 0;
|
||||||
|
result.syncStatus.totalBlocks = 0;
|
||||||
|
result.syncStatus.complete = false;
|
||||||
|
result.syncStatus.indeterminate = true;
|
||||||
|
result.syncStatus.progress = 0.0;
|
||||||
|
} else if (isSyncing) {
|
||||||
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
|
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
|
||||||
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
||||||
result.syncStatus.complete = false; // actively syncing
|
result.syncStatus.complete = false; // actively syncing
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ struct LiteSyncStatusResponse {
|
|||||||
std::uint64_t totalBlocks = 0;
|
std::uint64_t totalBlocks = 0;
|
||||||
double progress = 0.0;
|
double progress = 0.0;
|
||||||
bool complete = false;
|
bool complete = false;
|
||||||
|
bool indeterminate = false; // "syncing" missing/unrecognised: state unknown, NOT assumed synced
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when the wallet is encrypted AND currently unlocked. The backend's `save` re-locks such a wallet (do_save),
|
||||||
|
// so a background save must not run under a user who just unlocked. Fail-open: any error reads as "not unlocked" and
|
||||||
|
// the save proceeds as it always did.
|
||||||
|
bool liteWalletEncryptedAndUnlocked(LiteClientBridge& bridge)
|
||||||
|
{
|
||||||
|
const auto r = bridge.execute("encryptionstatus", "");
|
||||||
|
if (!r.ok) return false;
|
||||||
|
try {
|
||||||
|
const auto j = nlohmann::json::parse(r.value);
|
||||||
|
return j.is_object() && !j.contains("error") && j.value("encrypted", false) && !j.value("locked", false);
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
|
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
|
||||||
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
|
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
|
||||||
// every attempt. Captures shared state only.
|
// every attempt. Captures shared state only.
|
||||||
@@ -129,7 +144,12 @@ void runLiteScanThread(const std::shared_ptr<LiteClientBridge>& bridge,
|
|||||||
op.error = call.error.empty() ? "sync failed" : call.error;
|
op.error = call.error.empty() ? "sync failed" : call.error;
|
||||||
}
|
}
|
||||||
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
|
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
|
||||||
if (!bridge->execute("save", "").ok) // W5-2
|
// Not while the user has the wallet unlocked: the backend save re-locks an encrypted+unlocked wallet, which
|
||||||
|
// would yank the unlock out from under a spend/unlock in progress. The progress is saved by the next scan
|
||||||
|
// once locked (and by the shutdown save); a crash in between only costs a re-scan from the checkpoint.
|
||||||
|
if (liteWalletEncryptedAndUnlocked(*bridge))
|
||||||
|
liteLog("sync save deferred: wallet is unlocked (a save would re-lock it)");
|
||||||
|
else if (!bridge->execute("save", "").ok) // W5-2
|
||||||
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
||||||
if (op.ok) {
|
if (op.ok) {
|
||||||
scan->setError(std::string());
|
scan->setError(std::string());
|
||||||
@@ -358,13 +378,38 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
||||||
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
||||||
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
||||||
state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin;
|
// Full node defines unconfirmedBalance as total - spendable (the pending portion; balance_tab's "pending"
|
||||||
// Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed
|
// chip and the fast-poll hint read it that way). Lite: the not-yet-spendable shielded part (unverified /
|
||||||
// (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend
|
// unconfirmed notes) is that portion; the backend's own `unconfirmed` is mempool-incoming. Take the larger so
|
||||||
// validation isn't zeroed on lite (the actual lite spend gate is per-address, set below).
|
// neither is hidden and a note counted in both is not double-reported. Display-only; never feeds a spend gate.
|
||||||
state.spendablePrivateBalance = state.privateBalance;
|
{
|
||||||
state.spendableTransparentBalance = state.transparentBalance;
|
const std::uint64_t notYetSpendable = model.balance.shieldedZatoshis -
|
||||||
state.spendableTotalBalance = state.totalBalance;
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis);
|
||||||
|
state.unconfirmedBalance = static_cast<double>(
|
||||||
|
std::max(model.balance.unconfirmedZatoshis, notYetSpendable)) / kZatoshisPerCoin;
|
||||||
|
}
|
||||||
|
// Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the
|
||||||
|
// gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The
|
||||||
|
// spendable_zbalance field is required by the parser, so it's always present when hasBalance is
|
||||||
|
// true; clamp to the shielded total defensively. Transparent is already confirmed on lite.
|
||||||
|
const double spendableShielded = static_cast<double>(
|
||||||
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
|
||||||
|
state.spendablePrivateBalance = spendableShielded;
|
||||||
|
// Backend tbalance sums every utxo with spent==None (lightwallet.rs get_utxos), so a t-UTXO already spent by
|
||||||
|
// an unconfirmed tx of ours is still in transparentBalance. Subtract those from the SPENDABLE figure only
|
||||||
|
// (clamped; gross transparentBalance display unchanged). Needs this cycle's utxo list: if the notes call
|
||||||
|
// failed, nothing is subtracted (same as before).
|
||||||
|
std::uint64_t unconfirmedSpentTransparent = 0;
|
||||||
|
if (model.hasSpendableOutputs) {
|
||||||
|
for (const auto& output : model.spendableOutputs) {
|
||||||
|
if (output.kind == LiteSpendableOutputKind::Utxo && output.unconfirmedSpent && !output.spent)
|
||||||
|
unconfirmedSpentTransparent += output.valueZatoshis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::uint64_t spendableTransparentZat = model.balance.transparentZatoshis -
|
||||||
|
std::min(unconfirmedSpentTransparent, model.balance.transparentZatoshis);
|
||||||
|
state.spendableTransparentBalance = static_cast<double>(spendableTransparentZat) / kZatoshisPerCoin;
|
||||||
|
state.spendableTotalBalance = spendableShielded + state.spendableTransparentBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.hasAddresses) {
|
if (model.hasAddresses) {
|
||||||
@@ -373,10 +418,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
||||||
// the per-address figures match the confirmed balance the wallet treats as available.
|
// the per-address figures match the confirmed balance the wallet treats as available.
|
||||||
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
||||||
|
std::unordered_map<std::string, std::uint64_t> perAddressSpendableZatoshis; // backend 'spendable' only
|
||||||
if (model.hasSpendableOutputs) {
|
if (model.hasSpendableOutputs) {
|
||||||
for (const auto& output : model.spendableOutputs) {
|
for (const auto& output : model.spendableOutputs) {
|
||||||
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
||||||
perAddressZatoshis[output.address] += output.valueZatoshis;
|
perAddressZatoshis[output.address] += output.valueZatoshis;
|
||||||
|
if (output.spendable) perAddressSpendableZatoshis[output.address] += output.valueZatoshis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,8 +432,14 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
||||||
// like fund loss and breaks "send from this address".
|
// like fund loss and breaks "send from this address".
|
||||||
std::unordered_map<std::string, double> priorBalances;
|
std::unordered_map<std::string, double> priorBalances;
|
||||||
|
std::unordered_map<std::string, double> priorSpendable;
|
||||||
|
std::unordered_map<std::string, bool> priorHasKey;
|
||||||
if (!model.hasSpendableOutputs) {
|
if (!model.hasSpendableOutputs) {
|
||||||
for (const auto& a : state.addresses) priorBalances[a.address] = a.balance;
|
for (const auto& a : state.addresses) {
|
||||||
|
priorBalances[a.address] = a.balance;
|
||||||
|
priorSpendable[a.address] = a.spendableBalance;
|
||||||
|
priorHasKey[a.address] = a.has_spending_key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.addresses.clear();
|
state.addresses.clear();
|
||||||
@@ -404,9 +457,22 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
} else {
|
} else {
|
||||||
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
||||||
}
|
}
|
||||||
info.spendableBalance = info.balance; // lite per-address balance is already confirmed/spendable
|
// balance stays the gross confirmed-unspent sum; spendableBalance counts only outputs the backend
|
||||||
|
// marks spendable (spending key + anchor depth). Notes failed this cycle -> keep last-known.
|
||||||
|
if (model.hasSpendableOutputs) {
|
||||||
|
const auto sit = perAddressSpendableZatoshis.find(addr.address);
|
||||||
|
info.spendableBalance = sit != perAddressSpendableZatoshis.end()
|
||||||
|
? static_cast<double>(sit->second) / kZatoshisPerCoin : 0.0;
|
||||||
|
} else {
|
||||||
|
const auto psit = priorSpendable.find(addr.address);
|
||||||
|
info.spendableBalance = psit != priorSpendable.end() ? psit->second : 0.0;
|
||||||
|
}
|
||||||
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
||||||
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
||||||
|
if (!addr.spendabilityKnown && !model.hasSpendableOutputs) {
|
||||||
|
const auto pkit = priorHasKey.find(addr.address); // keep last-known view-only verdict
|
||||||
|
if (pkit != priorHasKey.end()) info.has_spending_key = pkit->second;
|
||||||
|
}
|
||||||
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
||||||
state.z_addresses.push_back(info);
|
state.z_addresses.push_back(info);
|
||||||
} else {
|
} else {
|
||||||
@@ -432,7 +498,9 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
} else {
|
} else {
|
||||||
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
|
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
|
||||||
}
|
}
|
||||||
tx.amount = static_cast<double>(record.amountZatoshis) / kZatoshisPerCoin;
|
// Signed like the full node: a send is a negative debit, a receive positive. Consumers
|
||||||
|
// (History/Details/CSV) pick sign + colour from this; the summary cards use std::abs.
|
||||||
|
tx.amount = static_cast<double>(record.signedAmountZatoshis) / kZatoshisPerCoin;
|
||||||
tx.timestamp = record.timestamp;
|
tx.timestamp = record.timestamp;
|
||||||
tx.address = record.address;
|
tx.address = record.address;
|
||||||
tx.memo = record.memo;
|
tx.memo = record.memo;
|
||||||
@@ -912,9 +980,11 @@ void LiteWalletController::applyPendingLifecycleSecurity()
|
|||||||
if (op == PendingSecurityOp::Encrypt) {
|
if (op == PendingSecurityOp::Encrypt) {
|
||||||
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
||||||
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
||||||
if (!enc.ok)
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // surfaced to the user by the App (takeEncryptionFailureNotice)
|
||||||
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
||||||
enc.error);
|
enc.error);
|
||||||
|
}
|
||||||
} else if (op == PendingSecurityOp::Unlock) {
|
} else if (op == PendingSecurityOp::Unlock) {
|
||||||
if (!unlockWallet(pendingLifecyclePassphrase_))
|
if (!unlockWallet(pendingLifecyclePassphrase_))
|
||||||
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
||||||
@@ -1044,7 +1114,12 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
|
|||||||
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
|
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
|
||||||
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
|
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
|
||||||
// surface it as the synced height/tip so the block count is correct at rest.
|
// surface it as the synced height/tip so the block count is correct at rest.
|
||||||
if (bridge_ && (!honorStop || running_.load())) {
|
// Do not force "synced" from the height read while syncstatus says the backend is still scanning (work remaining)
|
||||||
|
// or could not be interpreted: the height only proves the last scanned block, not that we reached the tip.
|
||||||
|
const bool backendBusy = syncResult.ok && !syncResult.syncStatus.complete &&
|
||||||
|
(syncResult.syncStatus.indeterminate ||
|
||||||
|
syncResult.syncStatus.syncedBlocks < syncResult.syncStatus.totalBlocks);
|
||||||
|
if (bridge_ && !backendBusy && (!honorStop || running_.load())) {
|
||||||
const auto h = bridge_->execute("height", "");
|
const auto h = bridge_->execute("height", "");
|
||||||
if (h.ok) {
|
if (h.ok) {
|
||||||
const auto parsed = parseLiteHeightResponse(h.value);
|
const auto parsed = parseLiteHeightResponse(h.value);
|
||||||
@@ -1068,7 +1143,14 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
|
|||||||
model.sync.progress = std::min(model.sync.progress, 0.999);
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A server-bound read failed this cycle (server lost after catch-up): not synced, and say why. Without this the
|
||||||
|
// height override above kept reporting Synced because only a failed *scan* (scan_->failed) demoted it.
|
||||||
|
if (refreshResult.syncReadFailed && model.hasSyncStatus) {
|
||||||
|
model.sync.complete = false;
|
||||||
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
||||||
|
}
|
||||||
model.syncError = scan_->errorCopy();
|
model.syncError = scan_->errorCopy();
|
||||||
|
if (model.syncError.empty() && refreshResult.syncReadFailed) model.syncError = "server unreachable";
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1559,6 +1641,13 @@ bool LiteWalletController::unlockWallet(std::string passphrase)
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool LiteWalletController::takeEncryptionFailureNotice()
|
||||||
|
{
|
||||||
|
const bool failed = lifecycleEncryptFailed_;
|
||||||
|
lifecycleEncryptFailed_ = false;
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
bool LiteWalletController::lockWallet()
|
bool LiteWalletController::lockWallet()
|
||||||
{
|
{
|
||||||
if (!walletOpen_.load() || !bridge_) return false;
|
if (!walletOpen_.load() || !bridge_) return false;
|
||||||
@@ -1607,6 +1696,7 @@ void LiteWalletController::workerLoop()
|
|||||||
{
|
{
|
||||||
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
||||||
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
||||||
|
std::optional<LiteWalletAppRefreshModel> lastModel; // last published full model (to demote on a failed probe)
|
||||||
while (running_.load()) {
|
while (running_.load()) {
|
||||||
if (walletOpen_.load()) {
|
if (walletOpen_.load()) {
|
||||||
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
||||||
@@ -1618,6 +1708,19 @@ void LiteWalletController::workerLoop()
|
|||||||
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
||||||
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
||||||
failStreak += 1;
|
failStreak += 1;
|
||||||
|
// The probe path never re-derived sync state, so the last 'Synced' model stayed on screen while the
|
||||||
|
// server was gone. Re-publish it demoted, with the reason.
|
||||||
|
if (lastModel && running_.load()) {
|
||||||
|
auto demoted = *lastModel;
|
||||||
|
if (demoted.hasSyncStatus) {
|
||||||
|
demoted.sync.complete = false;
|
||||||
|
demoted.sync.progress = std::min(demoted.sync.progress, 0.999);
|
||||||
|
}
|
||||||
|
if (demoted.syncError.empty()) demoted.syncError = "server unreachable";
|
||||||
|
lastModel = demoted;
|
||||||
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||||
|
pendingModel_ = std::move(demoted);
|
||||||
|
}
|
||||||
} else { // DoFull
|
} else { // DoFull
|
||||||
// Sample the change baselines BEFORE the data commands (race rule).
|
// Sample the change baselines BEFORE the data commands (race rule).
|
||||||
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
||||||
@@ -1635,6 +1738,7 @@ void LiteWalletController::workerLoop()
|
|||||||
gate.encrypted = m.encrypted;
|
gate.encrypted = m.encrypted;
|
||||||
gate.locked = m.locked;
|
gate.locked = m.locked;
|
||||||
gate.fullAt = std::chrono::steady_clock::now();
|
gate.fullAt = std::chrono::steady_clock::now();
|
||||||
|
lastModel = m;
|
||||||
std::lock_guard<std::mutex> lock(modelMutex_);
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||||
pendingModel_ = std::move(model);
|
pendingModel_ = std::move(model);
|
||||||
} else {
|
} else {
|
||||||
@@ -1714,7 +1818,10 @@ LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateReq
|
|||||||
// copy and wipes it.
|
// copy and wipes it.
|
||||||
if (walletOpen_.load() && !request.passphrase.empty()) {
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
const auto enc = encryptWallet(request.passphrase);
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error);
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
||||||
|
liteLog("wallet created but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
secureWipeLiteSecret(request.passphrase);
|
secureWipeLiteSecret(request.passphrase);
|
||||||
return result;
|
return result;
|
||||||
@@ -1744,7 +1851,10 @@ LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreR
|
|||||||
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
||||||
if (walletOpen_.load() && !request.passphrase.empty()) {
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
const auto enc = encryptWallet(request.passphrase);
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error);
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
||||||
|
liteLog("wallet restored but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
secureWipeLiteSecret(request.seedPhrase);
|
secureWipeLiteSecret(request.seedPhrase);
|
||||||
secureWipeLiteSecret(request.passphrase);
|
secureWipeLiteSecret(request.passphrase);
|
||||||
|
|||||||
@@ -363,6 +363,9 @@ public:
|
|||||||
bool lockWallet();
|
bool lockWallet();
|
||||||
LiteEncryptionResult decryptWallet(std::string passphrase);
|
LiteEncryptionResult decryptWallet(std::string passphrase);
|
||||||
LiteEncryptionStatus encryptionStatus();
|
LiteEncryptionStatus encryptionStatus();
|
||||||
|
// True once (then cleared) if the passphrase encryption of a just-created/restored wallet FAILED, leaving it
|
||||||
|
// unencrypted. Main thread; the App turns it into a visible error instead of a log line.
|
||||||
|
bool takeEncryptionFailureNotice();
|
||||||
|
|
||||||
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
|
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
|
||||||
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
|
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
|
||||||
@@ -488,6 +491,7 @@ private:
|
|||||||
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
||||||
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
||||||
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||||
|
bool lifecycleEncryptFailed_ = false; // main-thread only; see takeEncryptionFailureNotice()
|
||||||
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
||||||
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
||||||
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
||||||
|
|||||||
@@ -190,6 +190,32 @@ LiteWalletStateMapResult mapLiteWalletRefreshBundle(const LiteWalletRefreshBundl
|
|||||||
result.model.sync.complete = bundle.syncStatus.complete;
|
result.model.sync.complete = bundle.syncStatus.complete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-address spending-key evidence from the backend's per-note 'spendable' flag. Only POSITIVE evidence
|
||||||
|
// sets spendabilityKnown: a spendable note proves a key; a confirmed, long-mature (well past any anchor
|
||||||
|
// offset) unspent note that is still not spendable can only mean a view-only address. Fresh/immature
|
||||||
|
// notes, no notes, or no chain height stay unknown (the controller then keeps its spendable default).
|
||||||
|
if (result.model.hasAddresses && result.model.hasSpendableOutputs) {
|
||||||
|
constexpr std::int64_t kMatureDepth = 100;
|
||||||
|
const std::int64_t tip = result.model.hasSyncStatus
|
||||||
|
? static_cast<std::int64_t>(result.model.sync.chainHeight) : 0;
|
||||||
|
for (auto& addr : result.model.addresses) {
|
||||||
|
if (addr.kind != LiteWalletAppAddressKind::Shielded) continue;
|
||||||
|
bool anySpendable = false;
|
||||||
|
bool matureNonSpendable = false;
|
||||||
|
for (const auto& out : result.model.spendableOutputs) {
|
||||||
|
if (out.address != addr.address || out.kind != LiteSpendableOutputKind::UnspentNote) continue;
|
||||||
|
if (out.spent || out.unconfirmedSpent || out.pending) continue;
|
||||||
|
if (out.spendable) { anySpendable = true; break; }
|
||||||
|
if (tip > 0 && out.createdInBlock && *out.createdInBlock > 0 &&
|
||||||
|
tip - *out.createdInBlock >= kMatureDepth) {
|
||||||
|
matureNonSpendable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anySpendable) { addr.spendabilityKnown = true; addr.spendable = true; }
|
||||||
|
else if (matureNonSpendable) { addr.spendabilityKnown = true; addr.spendable = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!modelHasAnyMappedField(result.model)) {
|
if (!modelHasAnyMappedField(result.model)) {
|
||||||
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
|
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
|
||||||
result.error = result.issues.back().message;
|
result.error = result.issues.back().message;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ inline std::atomic<bool> g_liteFakeBlockBalance{false}; // "balance" blocks (a
|
|||||||
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
|
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
|
||||||
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
|
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
|
||||||
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
||||||
|
inline std::atomic<bool> g_liteFakeEncryptFails{false}; // "encrypt" returns an error and changes no state
|
||||||
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
||||||
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
||||||
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
|
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
|
||||||
@@ -261,6 +262,8 @@ inline char* liteFakeExecute(const char* command, const char* args)
|
|||||||
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
|
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
|
||||||
if (std::strcmp(c, "encrypt") == 0) {
|
if (std::strcmp(c, "encrypt") == 0) {
|
||||||
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
|
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
|
||||||
|
if (g_liteFakeEncryptFails.load())
|
||||||
|
return liteFakeDup("{\"result\":\"error\",\"error\":\"encrypt failed\"}");
|
||||||
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
|
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
|
||||||
return liteFakeDup("{\"result\":\"success\"}");
|
return liteFakeDup("{\"result\":\"success\"}");
|
||||||
}
|
}
|
||||||
|
|||||||
+960
-13
File diff suppressed because it is too large
Load Diff
+47
-6
@@ -2023,9 +2023,19 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
}
|
}
|
||||||
|
|
||||||
// unconfirmed_spent is left as it is: a pending send can still be mined on the
|
// unconfirmed_spent is left as it is: a pending send can still be mined on the
|
||||||
// new branch. Only a block that spends the note (scan_block) or a reload clears
|
// new branch. Only a block that spends the note (scan_block), the send expiring
|
||||||
// it, the same as for a send that expires unmined.
|
// unmined (cleanup_mempool) or a reload clears it.
|
||||||
})
|
});
|
||||||
|
|
||||||
|
// Same for transparent utxos: one whose spender was reorged out is unspent again
|
||||||
|
// (else it stays spent=Some(removed txid) and drops out of the balance until a rescan).
|
||||||
|
wtx.utxos.iter_mut()
|
||||||
|
.for_each(|utxo| {
|
||||||
|
if utxo.spent.is_some() && txids_to_remove.contains(&utxo.spent.unwrap()) {
|
||||||
|
utxo.spent = None;
|
||||||
|
}
|
||||||
|
// unconfirmed_spent is left as it is, for the same reason as on the notes.
|
||||||
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3007,16 +3017,47 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
// 2. The Tx has been added to the wallet via a mined block
|
// 2. The Tx has been added to the wallet via a mined block
|
||||||
pub fn cleanup_mempool(&self) {
|
pub fn cleanup_mempool(&self) {
|
||||||
const DEFAULT_TX_EXPIRY_DELTA: i32 = 20;
|
const DEFAULT_TX_EXPIRY_DELTA: i32 = 20;
|
||||||
|
// A send is built with Builder::new(tip) (wtx.block = tip), so its expiry_height is tip + 20 and it can
|
||||||
|
// still be mined through block tip+20 (consensus rejects it only once the height exceeds the expiry). It
|
||||||
|
// is provably dead once the chain reaches tip+21, so the pending lock on its inputs must be held until
|
||||||
|
// then — one block longer than the old drop point (tip+20), which would release the notes while the tx
|
||||||
|
// could still be mined. The point is that the lock IS released now; previously unconfirmed_spent never was.
|
||||||
|
const MEMPOOL_KEEP: i32 = DEFAULT_TX_EXPIRY_DELTA + 1;
|
||||||
|
|
||||||
let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0);
|
let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0);
|
||||||
|
|
||||||
|
let mut expired: HashSet<TxId> = HashSet::new();
|
||||||
{
|
{
|
||||||
// Remove all expired Txns
|
// Remove all expired Txns, remembering which ones so their inputs can be released below.
|
||||||
self.mempool_txs.write().unwrap().retain( | _, wtx| {
|
self.mempool_txs.write().unwrap().retain( | txid, wtx| {
|
||||||
current_height < (wtx.block + DEFAULT_TX_EXPIRY_DELTA)
|
let keep = current_height < (wtx.block + MEMPOOL_KEEP);
|
||||||
|
if !keep {
|
||||||
|
expired.insert(txid.clone());
|
||||||
|
}
|
||||||
|
keep
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !expired.is_empty() {
|
||||||
|
// An expired send can never be mined: release the notes/utxos it locked (unconfirmed_spent). The
|
||||||
|
// mempool_txs lock above is released first, so mempool_txs and txs are never held together.
|
||||||
|
let mut txs = self.txs.write().unwrap();
|
||||||
|
// A txid already in txs was mined; scan_block/scan_full_tx settled its inputs, so skip it.
|
||||||
|
expired.retain(|txid| !txs.contains_key(txid));
|
||||||
|
for wtx in txs.values_mut() {
|
||||||
|
for nd in wtx.notes.iter_mut() {
|
||||||
|
if nd.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
|
||||||
|
nd.unconfirmed_spent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for utxo in wtx.utxos.iter_mut() {
|
||||||
|
if utxo.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
|
||||||
|
utxo.unconfirmed_spent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
// Remove all txns where the txid is added to the wallet directly
|
// Remove all txns where the txid is added to the wallet directly
|
||||||
self.mempool_txs.write().unwrap().retain ( |txid, _| {
|
self.mempool_txs.write().unwrap().retain ( |txid, _| {
|
||||||
|
|||||||
-1
@@ -94,7 +94,6 @@ pub struct SaplingNoteData {
|
|||||||
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
|
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
|
||||||
pub memo: Option<Memo>,
|
pub memo: Option<Memo>,
|
||||||
pub is_change: bool,
|
pub is_change: bool,
|
||||||
// TODO: We need to remove the unconfirmed_spent (i.e., set it to None) if the Tx has expired
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -987,7 +987,28 @@ impl Sim {
|
|||||||
let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect();
|
let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect();
|
||||||
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
|
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
|
||||||
let rel = &self.rel;
|
let rel = &self.rel;
|
||||||
self.mempool.retain(|t, b| !ids.contains(t) && !rel.contains_key(t) && h < *b + TX_EXPIRY_DELTA);
|
// Mirror cleanup_mempool: it keeps a send for MEMPOOL_KEEP (= TX_EXPIRY_DELTA + 1, i.e. one block past the
|
||||||
|
// tx's expiry height, when it is provably dead) past the sending tip, and for a send dropped purely by
|
||||||
|
// expiry (never mined, i.e. not in `ids`/`rel`/the wallet's `txs`) it now RELEASES the pending lock on the
|
||||||
|
// inputs. So clear `pending` for those, exactly as the wallet clears `unconfirmed_spent`. The real tx
|
||||||
|
// expiry stays TX_EXPIRY_DELTA (used at verify_tx below).
|
||||||
|
const MEMPOOL_KEEP: i32 = TX_EXPIRY_DELTA + 1;
|
||||||
|
let mut expired: HashSet<TxId> = HashSet::new();
|
||||||
|
self.mempool.retain(|t, b| {
|
||||||
|
let mined = ids.contains(t) || rel.contains_key(t);
|
||||||
|
let keep = !mined && h < *b + MEMPOOL_KEEP;
|
||||||
|
if !keep && !mined {
|
||||||
|
expired.insert(*t);
|
||||||
|
}
|
||||||
|
keep
|
||||||
|
});
|
||||||
|
if !expired.is_empty() {
|
||||||
|
for n in self.notes.iter_mut() {
|
||||||
|
if n.pending.map_or(false, |p| expired.contains(&p)) {
|
||||||
|
n.pending = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h))
|
let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h))
|
||||||
.cloned().collect();
|
.cloned().collect();
|
||||||
|
|||||||
Reference in New Issue
Block a user