Compare commits
69
Commits
f4460beddd
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abbe5d8412 | ||
|
|
00d298453e | ||
|
|
38461f084c | ||
|
|
16d60a72f2 | ||
|
|
a5cd211e89 | ||
|
|
0ebd1ac5d5 | ||
|
|
94d41e2932 | ||
|
|
4cd27dcd4c | ||
|
|
4dfdf8a36f | ||
|
|
1a01de9791 | ||
|
|
3b828e8a49 | ||
|
|
7138f633a8 | ||
|
|
fc9f8d52c3 | ||
|
|
d83169c963 | ||
|
|
9335e2bffb | ||
|
|
112752e8af | ||
|
|
045289115d | ||
|
|
64294d1518 | ||
|
|
54f8359bd4 | ||
|
|
dba97c8b35 | ||
|
|
acb6446076 | ||
|
|
e61772a825 | ||
|
|
5e33bf2c41 | ||
|
|
cc08854237 | ||
|
|
1729f7dfad | ||
|
|
12c61bd796 | ||
|
|
63c284684c | ||
|
|
2d63a72098 | ||
|
|
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 |
+25
-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
|
||||||
@@ -993,6 +995,26 @@ target_compile_definitions(HushChatFixtureCheck PRIVATE
|
|||||||
DRAGONX_ENABLE_CHAT=0
|
DRAGONX_ENABLE_CHAT=0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Regtest chat smoke driver (dev tool; never shipped). Links the real chat library so a runner
|
||||||
|
# script can verify a chunked message + Control memo round-trip through a live regtest dragonxd.
|
||||||
|
# cmake --build build/linux --target chat_regtest_smoke
|
||||||
|
add_executable(chat_regtest_smoke EXCLUDE_FROM_ALL
|
||||||
|
tools/chat_regtest_smoke.cpp
|
||||||
|
src/chat/chat_crypto.cpp
|
||||||
|
src/chat/chat_identity.cpp
|
||||||
|
src/chat/chat_outgoing.cpp
|
||||||
|
src/chat/chat_protocol.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(chat_regtest_smoke PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/src
|
||||||
|
${SODIUM_INCLUDE_DIR}
|
||||||
|
)
|
||||||
|
target_link_libraries(chat_regtest_smoke PRIVATE
|
||||||
|
nlohmann_json::nlohmann_json
|
||||||
|
${SODIUM_LIBRARY}
|
||||||
|
)
|
||||||
|
target_compile_definitions(chat_regtest_smoke PRIVATE DRAGONX_ENABLE_CHAT=1)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Copy resources
|
# Copy resources
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -1224,6 +1246,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.
+66
-3
@@ -141,6 +141,7 @@
|
|||||||
"appx_wallet_op_running": "Ein Schlüsselimport oder Neuscan läuft noch. Versuchen Sie es erneut, wenn er abgeschlossen ist.",
|
"appx_wallet_op_running": "Ein Schlüsselimport oder Neuscan läuft noch. Versuchen Sie es erneut, wenn er abgeschlossen ist.",
|
||||||
"appx_wallet_op_shutting_down": "Die Wallet wird beendet.",
|
"appx_wallet_op_shutting_down": "Die Wallet wird beendet.",
|
||||||
"appx_wallet_open_failed_prefix": "Öffnen des Wallets fehlgeschlagen: ",
|
"appx_wallet_open_failed_prefix": "Öffnen des Wallets fehlgeschlagen: ",
|
||||||
|
"auto_consolidate": "Notes automatisch konsolidieren",
|
||||||
"auto_shield": "Mining automatisch abschirmen",
|
"auto_shield": "Mining automatisch abschirmen",
|
||||||
"autoshield_by_node": "Ihr Node schützt für Sie gemäß Coinbase automatisch.",
|
"autoshield_by_node": "Ihr Node schützt für Sie gemäß Coinbase automatisch.",
|
||||||
"autoshield_off_backup_seed": "Geschürfte Coinbase-Coins werden noch nicht automatisch geschützt. Sichern Sie Ihre Wiederherstellungsphrase (Einstellungen → Sicherung & Daten), damit Ihr Node dies sicher aktivieren kann.",
|
"autoshield_off_backup_seed": "Geschürfte Coinbase-Coins werden noch nicht automatisch geschützt. Sichern Sie Ihre Wiederherstellungsphrase (Einstellungen → Sicherung & Daten), damit Ihr Node dies sicher aktivieren kann.",
|
||||||
@@ -276,8 +277,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",
|
||||||
@@ -319,10 +324,24 @@
|
|||||||
"chat_opt_global_clock": "Globales Uhrzeitformat",
|
"chat_opt_global_clock": "Globales Uhrzeitformat",
|
||||||
"chat_opt_poll": "Abrufrate",
|
"chat_opt_poll": "Abrufrate",
|
||||||
"chat_opt_timestamp": "Zeitstempel",
|
"chat_opt_timestamp": "Zeitstempel",
|
||||||
|
"chat_parts_explain": "Diese lange Nachricht wird als eine Transaktion gesendet, aufgeteilt in diese Teile – sie kommt so schnell an wie eine normale Nachricht.",
|
||||||
|
"chat_parts_explain_multi": "Diese Nachricht ist zu groß für eine Transaktion und wird daher auf mehrere aufgeteilt. Der Empfänger sieht sie erst, wenn alle Teile angekommen sind – die Zustellung dauert länger und kostet pro Transaktion eine Netzwerkgebühr.",
|
||||||
|
"chat_parts_label": "Teile",
|
||||||
"chat_pick_contact": "Aus Kontakten wählen…",
|
"chat_pick_contact": "Aus Kontakten wählen…",
|
||||||
|
"chat_receiving_large": "Große Nachricht wird empfangen …",
|
||||||
"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 +350,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",
|
||||||
@@ -344,12 +392,14 @@
|
|||||||
"chat_toast_not_connected": "Nicht verbunden – Nachricht nicht gesendet.",
|
"chat_toast_not_connected": "Nicht verbunden – Nachricht nicht gesendet.",
|
||||||
"chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).",
|
"chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).",
|
||||||
"chat_toast_request_queued": "Kontaktanfrage in Warteschlange.",
|
"chat_toast_request_queued": "Kontaktanfrage in Warteschlange.",
|
||||||
|
"chat_toast_too_long": "Nachricht zu lang – kürzen Sie sie und senden Sie erneut.",
|
||||||
"chat_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.",
|
"chat_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.",
|
||||||
"chat_today": "Heute",
|
"chat_today": "Heute",
|
||||||
"chat_ts_12h": "12-Stunden",
|
"chat_ts_12h": "12-Stunden",
|
||||||
"chat_ts_24h": "24-Stunden",
|
"chat_ts_24h": "24-Stunden",
|
||||||
"chat_ts_global": "Global folgen",
|
"chat_ts_global": "Global folgen",
|
||||||
"chat_ts_global_short": "Global",
|
"chat_ts_global_short": "Global",
|
||||||
|
"chat_txns_label": "Tx",
|
||||||
"chat_unblock": "Blockierung aufheben",
|
"chat_unblock": "Blockierung aufheben",
|
||||||
"chat_unblocked_toast": "Blockierung der Unterhaltung aufgehoben",
|
"chat_unblocked_toast": "Blockierung der Unterhaltung aufgehoben",
|
||||||
"chat_unhide": "Einblenden",
|
"chat_unhide": "Einblenden",
|
||||||
@@ -370,11 +420,20 @@
|
|||||||
"click_to_copy": "Klicken zum Kopieren",
|
"click_to_copy": "Klicken zum Kopieren",
|
||||||
"clock_format": "Uhrzeitformat",
|
"clock_format": "Uhrzeitformat",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
|
"compact_needs_embedded": "Das Verdichten schreibt die wallet.dat neu, was die Wallet nur mit ihrem eingebauten Node kann. Sie sind mit einem dragonxd verbunden, den Sie selbst gestartet haben – aktivieren Sie in den Einstellungen \"Eingebetteten dragonxd verwenden\" oder starten Sie Ihren dragonxd mit -salvagewallet neu, um den Speicherplatz zurückzugewinnen.",
|
||||||
|
"compact_started": "Wallet wird verdichtet – der Node startet neu und scannt die Chain erneut.",
|
||||||
|
"compact_switch_stop_failed": "Ihr externer Node konnte nicht beendet werden – er läuft noch, daher wurde nichts geändert.",
|
||||||
|
"compact_switching": "Ihr Node wird beendet und zum eingebauten gewechselt…",
|
||||||
|
"compact_wallet_confirm": "Verdichten",
|
||||||
"conf_count": "%d Best.",
|
"conf_count": "%d Best.",
|
||||||
"confirm_and_send": "Bestätigen & Senden",
|
"confirm_and_send": "Bestätigen & Senden",
|
||||||
"confirm_clear_ztx_title": "Z-Tx-Verlauf löschen bestätigen",
|
"confirm_clear_ztx_title": "Z-Tx-Verlauf löschen bestätigen",
|
||||||
"confirm_clear_ztx_warning1": "Das Löschen des Z-Transaktionsverlaufs kann dazu führen, dass Ihr geschirmtes Guthaben als 0 angezeigt wird, bis ein Wallet-Rescan durchgeführt wird.",
|
"confirm_clear_ztx_warning1": "Das Löschen des Z-Transaktionsverlaufs kann dazu führen, dass Ihr geschirmtes Guthaben als 0 angezeigt wird, bis ein Wallet-Rescan durchgeführt wird.",
|
||||||
"confirm_clear_ztx_warning2": "Wenn dies geschieht, müssen Sie Ihre Z-Adresse-Privatschlüssel mit aktiviertem Rescan neu importieren, um Ihr Guthaben wiederherzustellen.",
|
"confirm_clear_ztx_warning2": "Wenn dies geschieht, müssen Sie Ihre Z-Adresse-Privatschlüssel mit aktiviertem Rescan neu importieren, um Ihr Guthaben wiederherzustellen.",
|
||||||
|
"confirm_compact_switch_msg": "Sie sind mit einem dragonxd verbunden, den Sie selbst gestartet haben. Zum Verdichten beendet die Wallet diesen Node, wechselt zu ihrem eigenen eingebauten Node und schreibt darauf die wallet.dat neu – dabei wird neu gestartet und die Blockchain erneut gescannt, was eine Weile dauern kann. Hinweis: Der eingebaute Node ist der dragonxd im eigenen Ordner der Wallet, daher ändert sich dadurch, welchen Node Sie verwenden.",
|
||||||
|
"confirm_compact_wallet_msg": "Das Konsolidieren hat Ihre Notes zusammengeführt, aber die Wallet-Datei kann nicht von selbst schrumpfen – der Node muss sie neu schreiben, um den freigewordenen Speicherplatz zurückzugewinnen. Dabei wird der Node neu gestartet und die Blockchain erneut gescannt, was bei einer großen Wallet eine Weile dauern kann.",
|
||||||
|
"confirm_compact_wallet_safe": "Ihre Mittel und Ihr Guthaben sind sicher – eine Sicherung der aktuellen Wallet mit Zeitstempel bleibt erhalten.",
|
||||||
|
"confirm_compact_wallet_title": "Wallet verdichten",
|
||||||
"confirm_delete_blockchain_msg": "Dies stoppt den Daemon, löscht alle Blockchain-Daten (blocks, chainstate, peers) und startet eine neue Synchronisierung. Dies kann mehrere Stunden dauern.",
|
"confirm_delete_blockchain_msg": "Dies stoppt den Daemon, löscht alle Blockchain-Daten (blocks, chainstate, peers) und startet eine neue Synchronisierung. Dies kann mehrere Stunden dauern.",
|
||||||
"confirm_delete_blockchain_safe": "Ihre wallet.dat, Konfiguration und Transaktionshistorie sind sicher und werden nicht gelöscht.",
|
"confirm_delete_blockchain_safe": "Ihre wallet.dat, Konfiguration und Transaktionshistorie sind sicher und werden nicht gelöscht.",
|
||||||
"confirm_delete_blockchain_title": "Blockchain-Daten löschen",
|
"confirm_delete_blockchain_title": "Blockchain-Daten löschen",
|
||||||
@@ -505,7 +564,7 @@
|
|||||||
"console_welcome": "Willkommen bei ObsidianDragon Konsole",
|
"console_welcome": "Willkommen bei ObsidianDragon Konsole",
|
||||||
"console_zoom_in": "Vergrößern",
|
"console_zoom_in": "Vergrößern",
|
||||||
"console_zoom_out": "Verkleinern",
|
"console_zoom_out": "Verkleinern",
|
||||||
"consolidate_desc": "Fassen Sie viele kleine Eingaben zu einer einzigen geschützten Note zusammen. Weniger Notes bedeuten eine kleinere Wallet-Datei und mehr Privatsphäre.",
|
"consolidate_desc": "Jede empfangene Zahlung wird als eigene „Note“ gespeichert – wie ein einzelner Geldschein in einer Geldbörse. Mit der Zeit häufen sie sich an, und jede Note fügt Witness-Daten hinzu, die die Synchronisierung der Wallet verlangsamen und sie immer weiter wachsen lassen. Das Konsolidieren fasst viele kleine Notes zu einer pro Adresse zusammen: Ihr Guthaben ändert sich nicht, aber die Wallet wird schneller durchsucht, wächst nicht weiter und verrät weniger über Ihre Mittel.",
|
||||||
"consolidate_funds_btn": "Konsolidieren",
|
"consolidate_funds_btn": "Konsolidieren",
|
||||||
"consolidate_title": "Guthaben konsolidieren",
|
"consolidate_title": "Guthaben konsolidieren",
|
||||||
"contact_avatar": "AVATAR",
|
"contact_avatar": "AVATAR",
|
||||||
@@ -2048,6 +2107,7 @@
|
|||||||
"tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer",
|
"tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer",
|
||||||
"tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten",
|
"tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten",
|
||||||
"tt_animate_avatars": "Animierte Kontakt-Avatare (GIF / WebP) abspielen; aus zeigt nur das erste Bild",
|
"tt_animate_avatars": "Animierte Kontakt-Avatare (GIF / WebP) abspielen; aus zeigt nur das erste Bild",
|
||||||
|
"tt_auto_consolidate": "Fasst kleine Notes im Hintergrund unauffällig in winzigen Durchgängen zusammen, solange die Wallet im Leerlauf ist. Langsamer als eine manuelle Konsolidierung, aber kaum bemerkbar – und die Mittel jeder Adresse bleiben getrennt. Gut geeignet für Mining-/Pool-Wallets, die Tausende von Notes ansammeln.",
|
||||||
"tt_auto_lock": "Wallet nach dieser Inaktivitätszeit sperren",
|
"tt_auto_lock": "Wallet nach dieser Inaktivitätszeit sperren",
|
||||||
"tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben",
|
"tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben",
|
||||||
"tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen",
|
"tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen",
|
||||||
@@ -2174,7 +2234,8 @@
|
|||||||
"tt_validate": "Prüfen, ob eine DragonX-Adresse gültig ist",
|
"tt_validate": "Prüfen, ob eine DragonX-Adresse gültig ist",
|
||||||
"tt_verbose": "Detaillierte Verbindungsdiagnosen,\\nDaemon-Status und Port-Besitzer-Info\\nin der Konsolen-Registerkarte protokollieren",
|
"tt_verbose": "Detaillierte Verbindungsdiagnosen,\\nDaemon-Status und Port-Besitzer-Info\\nin der Konsolen-Registerkarte protokollieren",
|
||||||
"tt_view_transactions": "Transaktionen mit dieser Adresse im Verlauf-Tab anzeigen",
|
"tt_view_transactions": "Transaktionen mit dieser Adresse im Verlauf-Tab anzeigen",
|
||||||
"tt_wallet_size_warn": "Geschützte Wallets wachsen mit den Witness-Daten jedes Notes — das Zusammenführen vieler Notes in eine Adresse verringert die Größe. Zuerst sichern.",
|
"tt_wallet_size_compact": "Gibt den durch das Konsolidieren freigewordenen Speicherplatz frei. Berkeley DB kann die Datei nicht an Ort und Stelle verkleinern; daher schreibt dies die wallet.dat in eine kleinere Datei um und startet den Node mit einem vollständigen Neuscan neu (kann eine Weile dauern). Eine Sicherung der aktuellen Wallet bleibt erhalten.",
|
||||||
|
"tt_wallet_size_warn": "Jede Zahlung kommt als eigene Shielded-Note an, und jede Note trägt Witness-Daten, die Scans verlangsamen und die Wallet-Datei immer weiter wachsen lassen. Das Zusammenfassen vieler Notes zu einer pro Adresse hält die Wallet schnell und stoppt das Wachstum – Ihre Mittel und Ihr Guthaben bleiben unverändert. Die Datei gibt den freigewordenen Speicherplatz erst frei, wenn Sie sie verdichten (Berkeley DB kann nicht an Ort und Stelle schrumpfen). Sichern Sie zuerst Ihre Seed-Phrase. Sie können in den Einstellungen auch „Notes automatisch konsolidieren“ aktivieren.",
|
||||||
"tt_wallets_button": "Ihre Wallet-Dateien auflisten und zwischen ihnen wechseln",
|
"tt_wallets_button": "Ihre Wallet-Dateien auflisten und zwischen ihnen wechseln",
|
||||||
"tt_website": "Die DragonX-Website öffnen",
|
"tt_website": "Die DragonX-Website öffnen",
|
||||||
"tt_window_opacity": "Hintergrund-Deckkraft (niedriger = Desktop durch Fenster sichtbar)",
|
"tt_window_opacity": "Hintergrund-Deckkraft (niedriger = Desktop durch Fenster sichtbar)",
|
||||||
@@ -2303,8 +2364,10 @@
|
|||||||
"wallet_restore_ok": "Ursprüngliche Wallet wiederhergestellt. Der Node lädt sie jetzt.",
|
"wallet_restore_ok": "Ursprüngliche Wallet wiederhergestellt. Der Node lädt sie jetzt.",
|
||||||
"wallet_restore_started": "Ihre Originaldatei wird wiederhergestellt — bitte schließen Sie dieses Fenster nicht.",
|
"wallet_restore_started": "Ihre Originaldatei wird wiederhergestellt — bitte schließen Sie dieses Fenster nicht.",
|
||||||
"wallet_restore_stop_failed": "Der Node hat nicht rechtzeitig gestoppt, daher wurde nichts geändert. Versuchen Sie es erneut.",
|
"wallet_restore_stop_failed": "Der Node hat nicht rechtzeitig gestoppt, daher wurde nichts geändert. Versuchen Sie es erneut.",
|
||||||
|
"wallet_size_auto_info": "Ihre Wallet hat sich viele Notes angesammelt – einzelne Teile empfangener Mittel, die die Synchronisierung verlangsamen und die Datei immer weiter wachsen lassen. Die automatische Konsolidierung fasst sie unauffällig im Hintergrund zusammen, damit die Wallet schnell bleibt und nicht weiter wächst. Sie müssen nichts tun.",
|
||||||
|
"wallet_size_compact": "Wallet verdichten…",
|
||||||
"wallet_size_consolidate": "Notizen konsolidieren…",
|
"wallet_size_consolidate": "Notizen konsolidieren…",
|
||||||
"wallet_size_warn": "Diese Wallet-Datei ist groß. Das Konsolidieren Ihrer Notizen kann weiteres Wachstum eindämmen.",
|
"wallet_size_warn": "Ihre Wallet hat sich viele Notes angesammelt. Jede empfangene Zahlung wird als eigene „Note“ gespeichert; wenn sich viele davon anhäufen, verlangsamen sie die Synchronisierung der Wallet und lassen sie immer weiter wachsen. Das Konsolidieren fasst sie zu weniger Notes zusammen, damit sie schneller durchsucht wird und nicht weiter wächst – Ihr Guthaben bleibt dabei exakt gleich.",
|
||||||
"wallets_active": "Aktiv",
|
"wallets_active": "Aktiv",
|
||||||
"wallets_add": "Ordner hinzufügen",
|
"wallets_add": "Ordner hinzufügen",
|
||||||
"wallets_add_folder": "Auch einen weiteren Ordner nach Wallet-Dateien durchsuchen:",
|
"wallets_add_folder": "Auch einen weiteren Ordner nach Wallet-Dateien durchsuchen:",
|
||||||
|
|||||||
+66
-3
@@ -141,6 +141,7 @@
|
|||||||
"appx_wallet_op_running": "Aún se está ejecutando una importación de clave o un reescaneo. Vuelve a intentarlo cuando termine.",
|
"appx_wallet_op_running": "Aún se está ejecutando una importación de clave o un reescaneo. Vuelve a intentarlo cuando termine.",
|
||||||
"appx_wallet_op_shutting_down": "La cartera se está cerrando.",
|
"appx_wallet_op_shutting_down": "La cartera se está cerrando.",
|
||||||
"appx_wallet_open_failed_prefix": "Error al abrir la cartera: ",
|
"appx_wallet_open_failed_prefix": "Error al abrir la cartera: ",
|
||||||
|
"auto_consolidate": "Autoconsolidar notas",
|
||||||
"auto_shield": "Auto-proteger minería",
|
"auto_shield": "Auto-proteger minería",
|
||||||
"autoshield_by_node": "Tu nodo blinda automáticamente el coinbase minado por ti.",
|
"autoshield_by_node": "Tu nodo blinda automáticamente el coinbase minado por ti.",
|
||||||
"autoshield_off_backup_seed": "El coinbase minado aún no se está blindando automáticamente. Respalda tu frase de recuperación (Ajustes → Copia de seguridad y datos) para que tu nodo pueda activarlo de forma segura.",
|
"autoshield_off_backup_seed": "El coinbase minado aún no se está blindando automáticamente. Respalda tu frase de recuperación (Ajustes → Copia de seguridad y datos) para que tu nodo pueda activarlo de forma segura.",
|
||||||
@@ -276,8 +277,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",
|
||||||
@@ -319,10 +324,24 @@
|
|||||||
"chat_opt_global_clock": "Formato de reloj global",
|
"chat_opt_global_clock": "Formato de reloj global",
|
||||||
"chat_opt_poll": "Frecuencia de sondeo",
|
"chat_opt_poll": "Frecuencia de sondeo",
|
||||||
"chat_opt_timestamp": "Marcas de tiempo",
|
"chat_opt_timestamp": "Marcas de tiempo",
|
||||||
|
"chat_parts_explain": "Este mensaje largo se envía como una sola transacción dividida en estas partes: llega tan rápido como un mensaje normal.",
|
||||||
|
"chat_parts_explain_multi": "Este mensaje es demasiado grande para una sola transacción, por lo que se divide en varias. El destinatario lo ve cuando han llegado todas las partes, así que tarda más en entregarse y cuesta una comisión de red por transacción.",
|
||||||
|
"chat_parts_label": "partes",
|
||||||
"chat_pick_contact": "Elegir de contactos…",
|
"chat_pick_contact": "Elegir de contactos…",
|
||||||
|
"chat_receiving_large": "Recibiendo un mensaje grande…",
|
||||||
"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 +350,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",
|
||||||
@@ -344,12 +392,14 @@
|
|||||||
"chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.",
|
"chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.",
|
||||||
"chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).",
|
"chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).",
|
||||||
"chat_toast_request_queued": "Solicitud de contacto en cola.",
|
"chat_toast_request_queued": "Solicitud de contacto en cola.",
|
||||||
|
"chat_toast_too_long": "Mensaje demasiado largo: acórtalo y envíalo de nuevo.",
|
||||||
"chat_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.",
|
"chat_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.",
|
||||||
"chat_today": "Hoy",
|
"chat_today": "Hoy",
|
||||||
"chat_ts_12h": "12 horas",
|
"chat_ts_12h": "12 horas",
|
||||||
"chat_ts_24h": "24 horas",
|
"chat_ts_24h": "24 horas",
|
||||||
"chat_ts_global": "Seguir global",
|
"chat_ts_global": "Seguir global",
|
||||||
"chat_ts_global_short": "Global",
|
"chat_ts_global_short": "Global",
|
||||||
|
"chat_txns_label": "tx",
|
||||||
"chat_unblock": "Desbloquear",
|
"chat_unblock": "Desbloquear",
|
||||||
"chat_unblocked_toast": "Conversación desbloqueada",
|
"chat_unblocked_toast": "Conversación desbloqueada",
|
||||||
"chat_unhide": "Mostrar",
|
"chat_unhide": "Mostrar",
|
||||||
@@ -370,11 +420,20 @@
|
|||||||
"click_to_copy": "Clic para copiar",
|
"click_to_copy": "Clic para copiar",
|
||||||
"clock_format": "Formato de hora",
|
"clock_format": "Formato de hora",
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
|
"compact_needs_embedded": "Compactar reescribe wallet.dat, y la cartera solo puede hacerlo con su nodo integrado. Estás conectado a un dragonxd que iniciaste tú mismo — activa \"Usar dragonxd integrado\" en Ajustes, o reinicia tu dragonxd con -salvagewallet para recuperar el espacio.",
|
||||||
|
"compact_started": "Compactando la cartera — el nodo se está reiniciando y volverá a escanear la cadena.",
|
||||||
|
"compact_switch_stop_failed": "No se pudo detener tu nodo externo — sigue en ejecución, así que no se cambió nada.",
|
||||||
|
"compact_switching": "Deteniendo tu nodo y cambiando al integrado…",
|
||||||
|
"compact_wallet_confirm": "Compactar",
|
||||||
"conf_count": "%d conf",
|
"conf_count": "%d conf",
|
||||||
"confirm_and_send": "Confirmar y Enviar",
|
"confirm_and_send": "Confirmar y Enviar",
|
||||||
"confirm_clear_ztx_title": "Confirmar limpieza del historial Z-Tx",
|
"confirm_clear_ztx_title": "Confirmar limpieza del historial Z-Tx",
|
||||||
"confirm_clear_ztx_warning1": "Limpiar el historial de z-transacciones puede hacer que su saldo blindado se muestre como 0 hasta que se realice un reescaneo de la billetera.",
|
"confirm_clear_ztx_warning1": "Limpiar el historial de z-transacciones puede hacer que su saldo blindado se muestre como 0 hasta que se realice un reescaneo de la billetera.",
|
||||||
"confirm_clear_ztx_warning2": "Si esto sucede, deberá reimportar las claves privadas de su dirección z con el reescaneo habilitado para recuperar su saldo.",
|
"confirm_clear_ztx_warning2": "Si esto sucede, deberá reimportar las claves privadas de su dirección z con el reescaneo habilitado para recuperar su saldo.",
|
||||||
|
"confirm_compact_switch_msg": "Estás conectado a un dragonxd que iniciaste tú mismo. Para compactar, la cartera detendrá ese nodo, cambiará a su propio nodo integrado y reescribirá wallet.dat en él — reiniciando y volviendo a escanear la blockchain, lo que puede tardar un buen rato. Nota: el nodo integrado es el dragonxd de la carpeta de la propia cartera, así que esto cambia el nodo que ejecutas.",
|
||||||
|
"confirm_compact_wallet_msg": "Al consolidar se fusionaron tus notas, pero el archivo de la cartera no puede reducirse por sí solo — el nodo tiene que reescribirlo para recuperar el espacio liberado. Esto reinicia el nodo y vuelve a escanear la blockchain, lo que puede tardar un buen rato en una cartera grande.",
|
||||||
|
"confirm_compact_wallet_safe": "Tus fondos y tu saldo están a salvo — se conserva una copia de seguridad con marca de tiempo de la cartera actual.",
|
||||||
|
"confirm_compact_wallet_title": "Compactar cartera",
|
||||||
"confirm_delete_blockchain_msg": "Esto detendrá el daemon, eliminará todos los datos de la blockchain (blocks, chainstate, peers) y comenzará una nueva sincronización desde cero. Esto puede tardar varias horas.",
|
"confirm_delete_blockchain_msg": "Esto detendrá el daemon, eliminará todos los datos de la blockchain (blocks, chainstate, peers) y comenzará una nueva sincronización desde cero. Esto puede tardar varias horas.",
|
||||||
"confirm_delete_blockchain_safe": "Su wallet.dat, configuración e historial de transacciones están seguros y no se eliminarán.",
|
"confirm_delete_blockchain_safe": "Su wallet.dat, configuración e historial de transacciones están seguros y no se eliminarán.",
|
||||||
"confirm_delete_blockchain_title": "Eliminar Datos de Blockchain",
|
"confirm_delete_blockchain_title": "Eliminar Datos de Blockchain",
|
||||||
@@ -505,7 +564,7 @@
|
|||||||
"console_welcome": "Bienvenido a la Consola de ObsidianDragon",
|
"console_welcome": "Bienvenido a la Consola de ObsidianDragon",
|
||||||
"console_zoom_in": "Acercar",
|
"console_zoom_in": "Acercar",
|
||||||
"console_zoom_out": "Alejar",
|
"console_zoom_out": "Alejar",
|
||||||
"consolidate_desc": "Combina muchas entradas pequeñas en una sola nota blindada. Menos notas significa un archivo de cartera más pequeño y mejor privacidad.",
|
"consolidate_desc": "Cada pago que recibes se almacena como una \"nota\" independiente — como un billete suelto en una cartera. Se acumulan con el tiempo, y cada nota añade datos de testigo que hacen que la cartera sea más lenta de sincronizar y que siga creciendo. Consolidar combina muchas notas pequeñas en una por dirección: tu saldo no cambia, pero la cartera escanea más rápido, deja de crecer y revela menos sobre tus fondos.",
|
||||||
"consolidate_funds_btn": "Consolidar",
|
"consolidate_funds_btn": "Consolidar",
|
||||||
"consolidate_title": "Consolidar fondos",
|
"consolidate_title": "Consolidar fondos",
|
||||||
"contact_avatar": "AVATAR",
|
"contact_avatar": "AVATAR",
|
||||||
@@ -2048,6 +2107,7 @@
|
|||||||
"tt_addr_url": "URL base para ver direcciones en un explorador de bloques",
|
"tt_addr_url": "URL base para ver direcciones en un explorador de bloques",
|
||||||
"tt_address_book": "Administrar direcciones guardadas para envío rápido",
|
"tt_address_book": "Administrar direcciones guardadas para envío rápido",
|
||||||
"tt_animate_avatars": "Reproduce avatares de contacto animados (GIF / WebP); desactivado muestra solo el primer fotograma",
|
"tt_animate_avatars": "Reproduce avatares de contacto animados (GIF / WebP); desactivado muestra solo el primer fotograma",
|
||||||
|
"tt_auto_consolidate": "Fusiona discretamente las notas pequeñas en segundo plano mientras la cartera está inactiva, en lotes diminutos. Es más lento que una consolidación manual, pero apenas se nota, y los fondos de cada dirección permanecen separados. Ideal para carteras de minería o de pool que acumulan miles de notas.",
|
||||||
"tt_auto_lock": "Bloquear billetera después de este tiempo de inactividad",
|
"tt_auto_lock": "Bloquear billetera después de este tiempo de inactividad",
|
||||||
"tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad",
|
"tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad",
|
||||||
"tt_backup": "Crear una copia de seguridad de su wallet.dat",
|
"tt_backup": "Crear una copia de seguridad de su wallet.dat",
|
||||||
@@ -2174,7 +2234,8 @@
|
|||||||
"tt_validate": "Comprobar si una dirección DragonX es válida",
|
"tt_validate": "Comprobar si una dirección DragonX es válida",
|
||||||
"tt_verbose": "Registrar diagnósticos detallados de conexión,\\nestado del daemon e info de propietario de puerto\\nen la pestaña de Consola",
|
"tt_verbose": "Registrar diagnósticos detallados de conexión,\\nestado del daemon e info de propietario de puerto\\nen la pestaña de Consola",
|
||||||
"tt_view_transactions": "Mostrar las transacciones relacionadas con esta dirección en la pestaña Historial",
|
"tt_view_transactions": "Mostrar las transacciones relacionadas con esta dirección en la pestaña Historial",
|
||||||
"tt_wallet_size_warn": "Las carteras blindadas crecen con los datos de testigo de cada nota — fusionar muchas notas en una sola dirección lo reduce. Haz una copia de seguridad primero.",
|
"tt_wallet_size_compact": "Recupera el espacio en disco liberado al consolidar. Berkeley DB no puede reducir el archivo en el mismo sitio, así que esto reescribe wallet.dat en un archivo más pequeño y reinicia el nodo con un re-escaneo completo (puede tardar un buen rato). Se conserva una copia de seguridad de la cartera actual.",
|
||||||
|
"tt_wallet_size_warn": "Cada pago llega como una nota blindada independiente, y cada nota lleva datos de testigo que ralentizan los escaneos y hacen que el archivo de la cartera siga creciendo. Fusionar muchas notas en una por dirección la mantiene rápida y detiene el crecimiento — tus fondos y tu saldo no cambian. El archivo solo recupera el espacio liberado cuando lo Compactas (Berkeley DB no puede reducirse en el mismo sitio). Haz primero una copia de seguridad de tu semilla. También puedes activar Autoconsolidar notas en Ajustes.",
|
||||||
"tt_wallets_button": "Enumera tus archivos de cartera y cambia entre ellos",
|
"tt_wallets_button": "Enumera tus archivos de cartera y cambia entre ellos",
|
||||||
"tt_website": "Abrir el sitio web de DragonX",
|
"tt_website": "Abrir el sitio web de DragonX",
|
||||||
"tt_window_opacity": "Opacidad del fondo (menor = escritorio visible a través de la ventana)",
|
"tt_window_opacity": "Opacidad del fondo (menor = escritorio visible a través de la ventana)",
|
||||||
@@ -2303,8 +2364,10 @@
|
|||||||
"wallet_restore_ok": "Cartera original restaurada. El nodo la está cargando ahora.",
|
"wallet_restore_ok": "Cartera original restaurada. El nodo la está cargando ahora.",
|
||||||
"wallet_restore_started": "Restaurando tu archivo original — por favor no cierres esta ventana.",
|
"wallet_restore_started": "Restaurando tu archivo original — por favor no cierres esta ventana.",
|
||||||
"wallet_restore_stop_failed": "El nodo no se detuvo a tiempo, así que no se cambió nada. Inténtalo de nuevo.",
|
"wallet_restore_stop_failed": "El nodo no se detuvo a tiempo, así que no se cambió nada. Inténtalo de nuevo.",
|
||||||
|
"wallet_size_auto_info": "Tu cartera ha acumulado muchas notas: piezas separadas de los fondos recibidos que ralentizan la sincronización y hacen que el archivo siga creciendo. La autoconsolidación las está fusionando discretamente en segundo plano para mantener la cartera rápida y evitar que crezca, así que no tienes que hacer nada.",
|
||||||
|
"wallet_size_compact": "Compactar cartera…",
|
||||||
"wallet_size_consolidate": "Consolidar notas…",
|
"wallet_size_consolidate": "Consolidar notas…",
|
||||||
"wallet_size_warn": "Este archivo de cartera es grande. Consolidar tus notas puede frenar un mayor crecimiento.",
|
"wallet_size_warn": "Tu cartera ha acumulado muchas notas. Cada pago que recibes se almacena como una \"nota\" independiente; al acumularse, ralentizan la sincronización de la cartera y hacen que siga creciendo. Consolidar las fusiona en menos notas para que escanee más rápido y deje de crecer — tu saldo no cambia en absoluto.",
|
||||||
"wallets_active": "Activa",
|
"wallets_active": "Activa",
|
||||||
"wallets_add": "Agregar carpeta",
|
"wallets_add": "Agregar carpeta",
|
||||||
"wallets_add_folder": "Escanear también otra carpeta en busca de archivos de cartera:",
|
"wallets_add_folder": "Escanear también otra carpeta en busca de archivos de cartera:",
|
||||||
|
|||||||
+66
-3
@@ -141,6 +141,7 @@
|
|||||||
"appx_wallet_op_running": "Une importation de clé ou une réanalyse est encore en cours. Réessayez une fois qu'elle est finie.",
|
"appx_wallet_op_running": "Une importation de clé ou une réanalyse est encore en cours. Réessayez une fois qu'elle est finie.",
|
||||||
"appx_wallet_op_shutting_down": "Le portefeuille est en cours de fermeture.",
|
"appx_wallet_op_shutting_down": "Le portefeuille est en cours de fermeture.",
|
||||||
"appx_wallet_open_failed_prefix": "Échec de l'ouverture du portefeuille : ",
|
"appx_wallet_open_failed_prefix": "Échec de l'ouverture du portefeuille : ",
|
||||||
|
"auto_consolidate": "Consolidation auto des notes",
|
||||||
"auto_shield": "Auto-blindage du minage",
|
"auto_shield": "Auto-blindage du minage",
|
||||||
"autoshield_by_node": "Votre nœud blinde automatiquement les coinbase minées pour vous.",
|
"autoshield_by_node": "Votre nœud blinde automatiquement les coinbase minées pour vous.",
|
||||||
"autoshield_off_backup_seed": "Les coinbase minées ne sont pas encore blindées automatiquement. Sauvegardez votre phrase de récupération (Paramètres → Sauvegarde et données) pour que votre nœud puisse l'activer en toute sécurité.",
|
"autoshield_off_backup_seed": "Les coinbase minées ne sont pas encore blindées automatiquement. Sauvegardez votre phrase de récupération (Paramètres → Sauvegarde et données) pour que votre nœud puisse l'activer en toute sécurité.",
|
||||||
@@ -276,8 +277,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",
|
||||||
@@ -319,10 +324,24 @@
|
|||||||
"chat_opt_global_clock": "Format d'horloge global",
|
"chat_opt_global_clock": "Format d'horloge global",
|
||||||
"chat_opt_poll": "Fréquence d'actualisation",
|
"chat_opt_poll": "Fréquence d'actualisation",
|
||||||
"chat_opt_timestamp": "Horodatage",
|
"chat_opt_timestamp": "Horodatage",
|
||||||
|
"chat_parts_explain": "Ce long message est envoyé en une seule transaction répartie entre ces parties — il arrive aussi vite qu'un message normal.",
|
||||||
|
"chat_parts_explain_multi": "Ce message est trop volumineux pour une seule transaction : il est donc réparti sur plusieurs. Le destinataire le voit une fois toutes les parties arrivées, sa livraison prend donc plus de temps et coûte des frais réseau par transaction.",
|
||||||
|
"chat_parts_label": "parties",
|
||||||
"chat_pick_contact": "Choisir dans les contacts…",
|
"chat_pick_contact": "Choisir dans les contacts…",
|
||||||
|
"chat_receiving_large": "Réception d'un message volumineux…",
|
||||||
"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 +350,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",
|
||||||
@@ -344,12 +392,14 @@
|
|||||||
"chat_toast_not_connected": "Non connecté — message non envoyé.",
|
"chat_toast_not_connected": "Non connecté — message non envoyé.",
|
||||||
"chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).",
|
"chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).",
|
||||||
"chat_toast_request_queued": "Demande de contact mise en file d'attente.",
|
"chat_toast_request_queued": "Demande de contact mise en file d'attente.",
|
||||||
|
"chat_toast_too_long": "Message trop long — raccourcissez-le et renvoyez-le.",
|
||||||
"chat_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.",
|
"chat_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.",
|
||||||
"chat_today": "Aujourd'hui",
|
"chat_today": "Aujourd'hui",
|
||||||
"chat_ts_12h": "12 heures",
|
"chat_ts_12h": "12 heures",
|
||||||
"chat_ts_24h": "24 heures",
|
"chat_ts_24h": "24 heures",
|
||||||
"chat_ts_global": "Suivre global",
|
"chat_ts_global": "Suivre global",
|
||||||
"chat_ts_global_short": "Global",
|
"chat_ts_global_short": "Global",
|
||||||
|
"chat_txns_label": "tx",
|
||||||
"chat_unblock": "Débloquer",
|
"chat_unblock": "Débloquer",
|
||||||
"chat_unblocked_toast": "Conversation débloquée",
|
"chat_unblocked_toast": "Conversation débloquée",
|
||||||
"chat_unhide": "Afficher",
|
"chat_unhide": "Afficher",
|
||||||
@@ -370,11 +420,20 @@
|
|||||||
"click_to_copy": "Cliquez pour copier",
|
"click_to_copy": "Cliquez pour copier",
|
||||||
"clock_format": "Format d'horloge",
|
"clock_format": "Format d'horloge",
|
||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
|
"compact_needs_embedded": "Le compactage réécrit wallet.dat, ce que le portefeuille ne peut faire qu'avec son nœud intégré. Vous êtes connecté à un dragonxd que vous avez lancé vous-même — activez « Utiliser le dragonxd intégré » dans Paramètres, ou redémarrez votre dragonxd avec -salvagewallet pour récupérer l'espace.",
|
||||||
|
"compact_started": "Compactage du portefeuille — le nœud redémarre et va re-scanner la chaîne.",
|
||||||
|
"compact_switch_stop_failed": "Impossible d'arrêter votre nœud externe — il est toujours en cours d'exécution, donc rien n'a été modifié.",
|
||||||
|
"compact_switching": "Arrêt de votre nœud et passage au nœud intégré…",
|
||||||
|
"compact_wallet_confirm": "Compacter",
|
||||||
"conf_count": "%d conf.",
|
"conf_count": "%d conf.",
|
||||||
"confirm_and_send": "Confirmer & Envoyer",
|
"confirm_and_send": "Confirmer & Envoyer",
|
||||||
"confirm_clear_ztx_title": "Confirmer l'effacement de l'historique Z-Tx",
|
"confirm_clear_ztx_title": "Confirmer l'effacement de l'historique Z-Tx",
|
||||||
"confirm_clear_ztx_warning1": "L'effacement de l'historique des z-transactions peut faire apparaître votre solde blindé à 0 jusqu'à ce qu'un rescan du portefeuille soit effectué.",
|
"confirm_clear_ztx_warning1": "L'effacement de l'historique des z-transactions peut faire apparaître votre solde blindé à 0 jusqu'à ce qu'un rescan du portefeuille soit effectué.",
|
||||||
"confirm_clear_ztx_warning2": "Si cela se produit, vous devrez réimporter les clés privées de votre adresse z avec le rescan activé pour récupérer votre solde.",
|
"confirm_clear_ztx_warning2": "Si cela se produit, vous devrez réimporter les clés privées de votre adresse z avec le rescan activé pour récupérer votre solde.",
|
||||||
|
"confirm_compact_switch_msg": "Vous êtes connecté à un dragonxd que vous avez lancé vous-même. Pour compacter, le portefeuille va arrêter ce nœud, passer à son propre nœud intégré et y réécrire wallet.dat — ce qui redémarre le nœud et re-scanne la blockchain, et peut prendre du temps. Remarque : le nœud intégré est le dragonxd situé dans le dossier propre au portefeuille, donc cela change le nœud que vous utilisez.",
|
||||||
|
"confirm_compact_wallet_msg": "La consolidation a fusionné vos notes, mais le fichier du portefeuille ne peut pas rétrécir de lui-même — le nœud doit le réécrire pour récupérer l'espace libéré. Cela redémarre le nœud et re-scanne la blockchain, ce qui peut prendre du temps sur un gros portefeuille.",
|
||||||
|
"confirm_compact_wallet_safe": "Vos fonds et votre solde sont en sécurité — une sauvegarde horodatée du portefeuille actuel est conservée.",
|
||||||
|
"confirm_compact_wallet_title": "Compacter le portefeuille",
|
||||||
"confirm_delete_blockchain_msg": "Cela arrêtera le daemon, supprimera toutes les données de la blockchain (blocks, chainstate, peers) et démarrera une nouvelle synchronisation. Cela peut prendre plusieurs heures.",
|
"confirm_delete_blockchain_msg": "Cela arrêtera le daemon, supprimera toutes les données de la blockchain (blocks, chainstate, peers) et démarrera une nouvelle synchronisation. Cela peut prendre plusieurs heures.",
|
||||||
"confirm_delete_blockchain_safe": "Votre wallet.dat, votre configuration et votre historique de transactions sont en sécurité et ne seront pas supprimés.",
|
"confirm_delete_blockchain_safe": "Votre wallet.dat, votre configuration et votre historique de transactions sont en sécurité et ne seront pas supprimés.",
|
||||||
"confirm_delete_blockchain_title": "Supprimer les données Blockchain",
|
"confirm_delete_blockchain_title": "Supprimer les données Blockchain",
|
||||||
@@ -505,7 +564,7 @@
|
|||||||
"console_welcome": "Bienvenue dans la console ObsidianDragon",
|
"console_welcome": "Bienvenue dans la console ObsidianDragon",
|
||||||
"console_zoom_in": "Agrandir",
|
"console_zoom_in": "Agrandir",
|
||||||
"console_zoom_out": "Réduire",
|
"console_zoom_out": "Réduire",
|
||||||
"consolidate_desc": "Combinez de nombreuses petites entrées en une seule note blindée. Moins de notes signifie un fichier de portefeuille plus petit et une meilleure confidentialité.",
|
"consolidate_desc": "Chaque paiement que vous recevez est stocké comme une « note » distincte — comme un billet individuel dans un portefeuille. Elles s'accumulent avec le temps, et chaque note ajoute des données de témoin qui ralentissent la synchronisation du portefeuille et le font grossir sans cesse. Consolider combine de nombreuses petites notes en une seule par adresse : votre solde ne change pas, mais le portefeuille s'analyse plus vite, cesse de grossir et révèle moins de choses sur vos fonds.",
|
||||||
"consolidate_funds_btn": "Consolider",
|
"consolidate_funds_btn": "Consolider",
|
||||||
"consolidate_title": "Consolider les fonds",
|
"consolidate_title": "Consolider les fonds",
|
||||||
"contact_avatar": "AVATAR",
|
"contact_avatar": "AVATAR",
|
||||||
@@ -2048,6 +2107,7 @@
|
|||||||
"tt_addr_url": "URL de base pour consulter les adresses dans un explorateur de blocs",
|
"tt_addr_url": "URL de base pour consulter les adresses dans un explorateur de blocs",
|
||||||
"tt_address_book": "Gérer les adresses enregistrées pour un envoi rapide",
|
"tt_address_book": "Gérer les adresses enregistrées pour un envoi rapide",
|
||||||
"tt_animate_avatars": "Lit les avatars de contact animés (GIF / WebP) ; désactivé n'affiche que la première image",
|
"tt_animate_avatars": "Lit les avatars de contact animés (GIF / WebP) ; désactivé n'affiche que la première image",
|
||||||
|
"tt_auto_consolidate": "Fusionne discrètement les petites notes en arrière-plan lorsque le portefeuille est inactif, par minuscules lots. Plus lent qu'une consolidation manuelle, mais à peine perceptible, et les fonds de chaque adresse restent séparés. Idéal pour les portefeuilles de minage/pool qui accumulent des milliers de notes.",
|
||||||
"tt_auto_lock": "Verrouiller le portefeuille après cette durée d'inactivité",
|
"tt_auto_lock": "Verrouiller le portefeuille après cette durée d'inactivité",
|
||||||
"tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité",
|
"tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité",
|
||||||
"tt_backup": "Créer une sauvegarde de votre wallet.dat",
|
"tt_backup": "Créer une sauvegarde de votre wallet.dat",
|
||||||
@@ -2174,7 +2234,8 @@
|
|||||||
"tt_validate": "Vérifier si une adresse DragonX est valide",
|
"tt_validate": "Vérifier si une adresse DragonX est valide",
|
||||||
"tt_verbose": "Journaliser les diagnostics de connexion détaillés,\\nl'état du daemon et les informations de propriétaire de port\\ndans l'onglet Console",
|
"tt_verbose": "Journaliser les diagnostics de connexion détaillés,\\nl'état du daemon et les informations de propriétaire de port\\ndans l'onglet Console",
|
||||||
"tt_view_transactions": "Afficher les transactions impliquant cette adresse dans l'onglet Historique",
|
"tt_view_transactions": "Afficher les transactions impliquant cette adresse dans l'onglet Historique",
|
||||||
"tt_wallet_size_warn": "Les portefeuilles blindés grandissent avec les données de témoin de chaque note — fusionner de nombreuses notes en une seule adresse réduit cette taille. Sauvegardez d'abord.",
|
"tt_wallet_size_compact": "Récupérez l'espace disque libéré par la consolidation. Berkeley DB ne peut pas réduire le fichier sur place : cette action réécrit wallet.dat dans un fichier plus petit et redémarre le nœud avec un re-scan complet (cela peut prendre du temps). Une sauvegarde du portefeuille actuel est conservée.",
|
||||||
|
"tt_wallet_size_warn": "Chaque paiement arrive sous forme de note blindée distincte, et chaque note porte des données de témoin qui ralentissent les analyses et font grossir le fichier du portefeuille. Fusionner de nombreuses notes en une seule par adresse le garde rapide et stoppe la croissance — vos fonds et votre solde restent inchangés. Le fichier ne récupère l'espace libéré que lorsque vous le compactez (Berkeley DB ne peut pas réduire un fichier sur place). Sauvegardez d'abord votre phrase de récupération. Vous pouvez aussi activer la consolidation auto des notes dans Paramètres.",
|
||||||
"tt_wallets_button": "Répertoriez vos fichiers de portefeuille et passez de l'un à l'autre",
|
"tt_wallets_button": "Répertoriez vos fichiers de portefeuille et passez de l'un à l'autre",
|
||||||
"tt_website": "Ouvrir le site web DragonX",
|
"tt_website": "Ouvrir le site web DragonX",
|
||||||
"tt_window_opacity": "Opacité de l'arrière-plan (plus bas = bureau visible à travers la fenêtre)",
|
"tt_window_opacity": "Opacité de l'arrière-plan (plus bas = bureau visible à travers la fenêtre)",
|
||||||
@@ -2303,8 +2364,10 @@
|
|||||||
"wallet_restore_ok": "Portefeuille original restauré. Le nœud le charge maintenant.",
|
"wallet_restore_ok": "Portefeuille original restauré. Le nœud le charge maintenant.",
|
||||||
"wallet_restore_started": "Restauration de votre fichier original — veuillez ne pas fermer cette fenêtre.",
|
"wallet_restore_started": "Restauration de votre fichier original — veuillez ne pas fermer cette fenêtre.",
|
||||||
"wallet_restore_stop_failed": "Le nœud ne s'est pas arrêté à temps, donc rien n'a été modifié. Réessayez.",
|
"wallet_restore_stop_failed": "Le nœud ne s'est pas arrêté à temps, donc rien n'a été modifié. Réessayez.",
|
||||||
|
"wallet_size_auto_info": "Votre portefeuille a accumulé beaucoup de notes — des morceaux distincts de fonds reçus qui ralentissent la synchronisation et font grossir le fichier. La consolidation auto des notes les fusionne discrètement en arrière-plan pour garder le portefeuille rapide et l'empêcher de grossir, vous n'avez donc rien à faire.",
|
||||||
|
"wallet_size_compact": "Compacter le portefeuille…",
|
||||||
"wallet_size_consolidate": "Consolider les notes…",
|
"wallet_size_consolidate": "Consolider les notes…",
|
||||||
"wallet_size_warn": "Ce fichier de portefeuille est volumineux. Consolider vos notes peut freiner sa croissance.",
|
"wallet_size_warn": "Votre portefeuille a accumulé beaucoup de notes. Chaque paiement que vous recevez est stocké comme une « note » distincte ; en s'accumulant, elles ralentissent la synchronisation du portefeuille et le font grossir sans cesse. Consolider les fusionne en moins de notes pour qu'il s'analyse plus vite et cesse de grossir — votre solde reste exactement le même.",
|
||||||
"wallets_active": "Actif",
|
"wallets_active": "Actif",
|
||||||
"wallets_add": "Ajouter le dossier",
|
"wallets_add": "Ajouter le dossier",
|
||||||
"wallets_add_folder": "Analyser aussi un autre dossier à la recherche de fichiers de portefeuille :",
|
"wallets_add_folder": "Analyser aussi un autre dossier à la recherche de fichiers de portefeuille :",
|
||||||
|
|||||||
+66
-3
@@ -141,6 +141,7 @@
|
|||||||
"appx_wallet_op_running": "鍵のインポートまたは再スキャンがまだ実行中です。完了してからもう一度お試しください。",
|
"appx_wallet_op_running": "鍵のインポートまたは再スキャンがまだ実行中です。完了してからもう一度お試しください。",
|
||||||
"appx_wallet_op_shutting_down": "ウォレットを終了しています。",
|
"appx_wallet_op_shutting_down": "ウォレットを終了しています。",
|
||||||
"appx_wallet_open_failed_prefix": "ウォレットを開けませんでした: ",
|
"appx_wallet_open_failed_prefix": "ウォレットを開けませんでした: ",
|
||||||
|
"auto_consolidate": "ノートを自動統合",
|
||||||
"auto_shield": "マイニング自動シールド",
|
"auto_shield": "マイニング自動シールド",
|
||||||
"autoshield_by_node": "あなたのノードは、マイニングしたコインベースを自動的にシールドします。",
|
"autoshield_by_node": "あなたのノードは、マイニングしたコインベースを自動的にシールドします。",
|
||||||
"autoshield_off_backup_seed": "マイニングしたコインベースはまだ自動シールドされていません。ノードが安全に有効化できるよう、シードフレーズをバックアップしてください(設定 → バックアップとデータ)。",
|
"autoshield_off_backup_seed": "マイニングしたコインベースはまだ自動シールドされていません。ノードが安全に有効化できるよう、シードフレーズをバックアップしてください(設定 → バックアップとデータ)。",
|
||||||
@@ -276,8 +277,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": "会話を削除しました",
|
||||||
@@ -319,10 +324,24 @@
|
|||||||
"chat_opt_global_clock": "全体の時刻形式",
|
"chat_opt_global_clock": "全体の時刻形式",
|
||||||
"chat_opt_poll": "取得間隔",
|
"chat_opt_poll": "取得間隔",
|
||||||
"chat_opt_timestamp": "タイムスタンプ",
|
"chat_opt_timestamp": "タイムスタンプ",
|
||||||
|
"chat_parts_explain": "この長いメッセージは、これらのパートに分割された1つのトランザクションとして送信されます。通常のメッセージと同じ速さで届きます。",
|
||||||
|
"chat_parts_explain_multi": "このメッセージは1つのトランザクションに収まらないため、複数に分割して送信されます。受信者はすべてのパートが届いてから表示できるため、配信に時間がかかり、トランザクションごとにネットワーク手数料がかかります。",
|
||||||
|
"chat_parts_label": "パート",
|
||||||
"chat_pick_contact": "連絡先から選択…",
|
"chat_pick_contact": "連絡先から選択…",
|
||||||
|
"chat_receiving_large": "大きなメッセージを受信中…",
|
||||||
"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 +350,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": "チャットのカスタマイズ",
|
||||||
@@ -344,12 +392,14 @@
|
|||||||
"chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。",
|
"chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。",
|
||||||
"chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。",
|
"chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。",
|
||||||
"chat_toast_request_queued": "連絡リクエストを送信待ちに追加しました。",
|
"chat_toast_request_queued": "連絡リクエストを送信待ちに追加しました。",
|
||||||
|
"chat_toast_too_long": "メッセージが長すぎます。短くしてもう一度送信してください。",
|
||||||
"chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。",
|
"chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。",
|
||||||
"chat_today": "今日",
|
"chat_today": "今日",
|
||||||
"chat_ts_12h": "12時間",
|
"chat_ts_12h": "12時間",
|
||||||
"chat_ts_24h": "24時間",
|
"chat_ts_24h": "24時間",
|
||||||
"chat_ts_global": "全体設定に従う",
|
"chat_ts_global": "全体設定に従う",
|
||||||
"chat_ts_global_short": "全体",
|
"chat_ts_global_short": "全体",
|
||||||
|
"chat_txns_label": "件",
|
||||||
"chat_unblock": "ブロック解除",
|
"chat_unblock": "ブロック解除",
|
||||||
"chat_unblocked_toast": "会話のブロックを解除しました",
|
"chat_unblocked_toast": "会話のブロックを解除しました",
|
||||||
"chat_unhide": "再表示",
|
"chat_unhide": "再表示",
|
||||||
@@ -370,11 +420,20 @@
|
|||||||
"click_to_copy": "クリックしてコピー",
|
"click_to_copy": "クリックしてコピー",
|
||||||
"clock_format": "時刻形式",
|
"clock_format": "時刻形式",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
|
"compact_needs_embedded": "コンパクト化は wallet.dat を書き直す操作で、ウォレットは内蔵ノードでのみ実行できます。現在、ご自身で起動した dragonxd に接続しています — 設定で「内蔵dragonxdを使用」をオンにするか、dragonxd を -salvagewallet 付きで再起動して、領域を回収してください。",
|
||||||
|
"compact_started": "ウォレットをコンパクト化しています — ノードを再起動し、チェーンを再スキャンします。",
|
||||||
|
"compact_switch_stop_failed": "外部ノードを停止できませんでした — まだ実行中のため、何も変更されていません。",
|
||||||
|
"compact_switching": "ノードを停止し、内蔵ノードに切り替えています…",
|
||||||
|
"compact_wallet_confirm": "コンパクト化",
|
||||||
"conf_count": "%d 確認",
|
"conf_count": "%d 確認",
|
||||||
"confirm_and_send": "確認して送金",
|
"confirm_and_send": "確認して送金",
|
||||||
"confirm_clear_ztx_title": "Z-Tx 履歴クリアの確認",
|
"confirm_clear_ztx_title": "Z-Tx 履歴クリアの確認",
|
||||||
"confirm_clear_ztx_warning1": "z-トランザクション履歴をクリアすると、ウォレットの再スキャンが実行されるまでシールド残高が0と表示される場合があります。",
|
"confirm_clear_ztx_warning1": "z-トランザクション履歴をクリアすると、ウォレットの再スキャンが実行されるまでシールド残高が0と表示される場合があります。",
|
||||||
"confirm_clear_ztx_warning2": "これが発生した場合、残高を回復するにはz-アドレスの秘密鍵を再スキャンを有効にして再インポートする必要があります。",
|
"confirm_clear_ztx_warning2": "これが発生した場合、残高を回復するにはz-アドレスの秘密鍵を再スキャンを有効にして再インポートする必要があります。",
|
||||||
|
"confirm_compact_switch_msg": "ご自身で起動した dragonxd に接続しています。コンパクト化するため、ウォレットはそのノードを停止し、独自の内蔵ノードに切り替えて、そのノード上で wallet.dat を書き直します — 再起動とブロックチェーンの再スキャンが行われるため、時間がかかる場合があります。注意: 内蔵ノードはウォレット自身のフォルダにある dragonxd のため、この操作で使用するノードが変わります。",
|
||||||
|
"confirm_compact_wallet_msg": "統合によってノートはまとまりましたが、ウォレットファイルは自動では縮小されません — 解放された領域を回収するには、ノードがファイルを書き直す必要があります。この操作ではノードが再起動され、ブロックチェーンが再スキャンされるため、大きなウォレットでは時間がかかる場合があります。",
|
||||||
|
"confirm_compact_wallet_safe": "資金と残高は安全です — 現在のウォレットのタイムスタンプ付きバックアップが保持されます。",
|
||||||
|
"confirm_compact_wallet_title": "ウォレットをコンパクト化",
|
||||||
"confirm_delete_blockchain_msg": "デーモンを停止し、すべてのブロックチェーンデータ(blocks、chainstate、peers)を削除して、最初から再同期を開始します。数時間かかる場合があります。",
|
"confirm_delete_blockchain_msg": "デーモンを停止し、すべてのブロックチェーンデータ(blocks、chainstate、peers)を削除して、最初から再同期を開始します。数時間かかる場合があります。",
|
||||||
"confirm_delete_blockchain_safe": "wallet.dat、設定、トランザクション履歴は安全で削除されません。",
|
"confirm_delete_blockchain_safe": "wallet.dat、設定、トランザクション履歴は安全で削除されません。",
|
||||||
"confirm_delete_blockchain_title": "ブロックチェーンデータを削除",
|
"confirm_delete_blockchain_title": "ブロックチェーンデータを削除",
|
||||||
@@ -505,7 +564,7 @@
|
|||||||
"console_welcome": "ObsidianDragonコンソールへようこそ",
|
"console_welcome": "ObsidianDragonコンソールへようこそ",
|
||||||
"console_zoom_in": "拡大",
|
"console_zoom_in": "拡大",
|
||||||
"console_zoom_out": "縮小",
|
"console_zoom_out": "縮小",
|
||||||
"consolidate_desc": "多数の小さな入力を1つのシールドノートにまとめます。ノート数が減るとウォレットファイルが小さくなり、プライバシーも向上します。",
|
"consolidate_desc": "受け取った支払いはそれぞれ個別の「ノート」として保存されます — 財布に入っている1枚1枚の紙幣のようなものです。ノートは時間とともに溜まり、どのノートもウィットネスデータを追加するため、ウォレットの同期が遅くなり、ファイルも大きくなり続けます。統合すると、多数の小さなノートがアドレスごとに1つにまとまります。残高は変わらず、ウォレットのスキャンは速くなり、肥大化も止まり、資金に関して明らかになる情報も減ります。",
|
||||||
"consolidate_funds_btn": "統合",
|
"consolidate_funds_btn": "統合",
|
||||||
"consolidate_title": "資金を統合",
|
"consolidate_title": "資金を統合",
|
||||||
"contact_avatar": "アバター",
|
"contact_avatar": "アバター",
|
||||||
@@ -2048,6 +2107,7 @@
|
|||||||
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
|
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
|
||||||
"tt_address_book": "クイック送信用の保存済みアドレスを管理",
|
"tt_address_book": "クイック送信用の保存済みアドレスを管理",
|
||||||
"tt_animate_avatars": "アニメーション連絡先アバター(GIF / WebP)を再生。オフでは最初のフレームのみ表示",
|
"tt_animate_avatars": "アニメーション連絡先アバター(GIF / WebP)を再生。オフでは最初のフレームのみ表示",
|
||||||
|
"tt_auto_consolidate": "ウォレットがアイドル状態の間、バックグラウンドで小さなノートを少しずつ静かに統合します。手動で統合するより時間はかかりますが、ほとんど気になりません。各アドレスの資金は分かれたままです。数千件のノートが溜まるマイニング/プールのウォレットに最適です。",
|
||||||
"tt_auto_lock": "この無操作時間後にウォレットをロック",
|
"tt_auto_lock": "この無操作時間後にウォレットをロック",
|
||||||
"tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動",
|
"tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動",
|
||||||
"tt_backup": "wallet.dat のバックアップを作成",
|
"tt_backup": "wallet.dat のバックアップを作成",
|
||||||
@@ -2174,7 +2234,8 @@
|
|||||||
"tt_validate": "DragonX アドレスが有効かどうかを確認",
|
"tt_validate": "DragonX アドレスが有効かどうかを確認",
|
||||||
"tt_verbose": "詳細な接続診断、デーモン状態、\\nポート所有者情報をコンソールタブに記録",
|
"tt_verbose": "詳細な接続診断、デーモン状態、\\nポート所有者情報をコンソールタブに記録",
|
||||||
"tt_view_transactions": "このアドレスに関連する取引を履歴タブに表示",
|
"tt_view_transactions": "このアドレスに関連する取引を履歴タブに表示",
|
||||||
"tt_wallet_size_warn": "シールドウォレットはノートごとのウィットネスデータによって肥大化します — 多数のノートを1つのアドレスに統合するとサイズを抑えられます。事前にバックアップしてください。",
|
"tt_wallet_size_compact": "統合によって解放されたディスク領域を回収します。Berkeley DB はファイルをその場で縮小できないため、wallet.dat をより小さいファイルに書き直し、ノードを再起動してフルの再スキャンを行います(時間がかかる場合があります)。現在のウォレットのバックアップは保持されます。",
|
||||||
|
"tt_wallet_size_warn": "支払いは1件ごとに個別のシールドノートとして届き、どのノートにもウィットネスデータが付随するため、スキャンが遅くなりウォレットファイルが大きくなり続けます。多数のノートをアドレスごとに1つへ統合すると、高速に保たれ肥大化も止まります — 資金と残高は変わりません。解放された領域がファイルに戻るのは、コンパクト化したときだけです(Berkeley DB はその場で縮小できません)。事前にシードをバックアップしてください。設定で「ノートを自動統合」をオンにすることもできます。",
|
||||||
"tt_wallets_button": "ウォレットファイルを一覧表示して切り替えます",
|
"tt_wallets_button": "ウォレットファイルを一覧表示して切り替えます",
|
||||||
"tt_website": "DragonX ウェブサイトを開く",
|
"tt_website": "DragonX ウェブサイトを開く",
|
||||||
"tt_window_opacity": "背景の不透明度(低い = デスクトップがウィンドウ越しに見える)",
|
"tt_window_opacity": "背景の不透明度(低い = デスクトップがウィンドウ越しに見える)",
|
||||||
@@ -2303,8 +2364,10 @@
|
|||||||
"wallet_restore_ok": "オリジナルのウォレットを復元しました。ノードが読み込み中です。",
|
"wallet_restore_ok": "オリジナルのウォレットを復元しました。ノードが読み込み中です。",
|
||||||
"wallet_restore_started": "オリジナルファイルを復元しています — このウィンドウを閉じないでください。",
|
"wallet_restore_started": "オリジナルファイルを復元しています — このウィンドウを閉じないでください。",
|
||||||
"wallet_restore_stop_failed": "ノードが時間内に停止しなかったため、何も変更されていません。もう一度お試しください。",
|
"wallet_restore_stop_failed": "ノードが時間内に停止しなかったため、何も変更されていません。もう一度お試しください。",
|
||||||
|
"wallet_size_auto_info": "このウォレットには大量のノート(受け取った資金の個別の断片)が溜まっており、同期が遅くなってファイルも大きくなり続けています。ノートの自動統合がバックグラウンドで静かに統合を進め、ウォレットを高速に保ちつつ肥大化を止めているため、操作は必要ありません。",
|
||||||
|
"wallet_size_compact": "ウォレットをコンパクト化…",
|
||||||
"wallet_size_consolidate": "ノートを統合…",
|
"wallet_size_consolidate": "ノートを統合…",
|
||||||
"wallet_size_warn": "このウォレットファイルは大きくなっています。ノートを統合すると、さらなる増加を抑えられます。",
|
"wallet_size_warn": "このウォレットには大量のノートが溜まっています。受け取った支払いはそれぞれ個別の「ノート」として保存され、増えるほどウォレットの同期が遅くなり、ファイルも大きくなり続けます。統合すると、ノートの数が減ってスキャンが速くなり、肥大化も止まります — 残高はまったく変わりません。",
|
||||||
"wallets_active": "アクティブ",
|
"wallets_active": "アクティブ",
|
||||||
"wallets_add": "フォルダを追加",
|
"wallets_add": "フォルダを追加",
|
||||||
"wallets_add_folder": "ウォレットファイルを検索する別のフォルダを追加:",
|
"wallets_add_folder": "ウォレットファイルを検索する別のフォルダを追加:",
|
||||||
|
|||||||
+66
-3
@@ -141,6 +141,7 @@
|
|||||||
"appx_wallet_op_running": "키 가져오기 또는 재스캔이 아직 실행 중입니다. 끝난 뒤 다시 시도하세요.",
|
"appx_wallet_op_running": "키 가져오기 또는 재스캔이 아직 실행 중입니다. 끝난 뒤 다시 시도하세요.",
|
||||||
"appx_wallet_op_shutting_down": "지갑을 종료하는 중입니다.",
|
"appx_wallet_op_shutting_down": "지갑을 종료하는 중입니다.",
|
||||||
"appx_wallet_open_failed_prefix": "지갑 열기 실패: ",
|
"appx_wallet_open_failed_prefix": "지갑 열기 실패: ",
|
||||||
|
"auto_consolidate": "노트 자동 통합",
|
||||||
"auto_shield": "채굴 자동 차폐",
|
"auto_shield": "채굴 자동 차폐",
|
||||||
"autoshield_by_node": "노드가 채굴한 코인베이스를 자동으로 보호 처리합니다.",
|
"autoshield_by_node": "노드가 채굴한 코인베이스를 자동으로 보호 처리합니다.",
|
||||||
"autoshield_off_backup_seed": "채굴한 코인베이스가 아직 자동으로 보호 처리되지 않고 있습니다. 노드가 안전하게 이를 활성화할 수 있도록 시드 문구를 백업하세요 (설정 → 백업 & 데이터).",
|
"autoshield_off_backup_seed": "채굴한 코인베이스가 아직 자동으로 보호 처리되지 않고 있습니다. 노드가 안전하게 이를 활성화할 수 있도록 시드 문구를 백업하세요 (설정 → 백업 & 데이터).",
|
||||||
@@ -276,8 +277,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": "대화를 삭제했습니다",
|
||||||
@@ -319,10 +324,24 @@
|
|||||||
"chat_opt_global_clock": "전역 시간 형식",
|
"chat_opt_global_clock": "전역 시간 형식",
|
||||||
"chat_opt_poll": "폴링 주기",
|
"chat_opt_poll": "폴링 주기",
|
||||||
"chat_opt_timestamp": "타임스탬프",
|
"chat_opt_timestamp": "타임스탬프",
|
||||||
|
"chat_parts_explain": "이 긴 메시지는 여러 조각으로 나뉜 하나의 거래로 전송됩니다. 일반 메시지만큼 빠르게 도착합니다.",
|
||||||
|
"chat_parts_explain_multi": "이 메시지는 하나의 거래에 담기에는 너무 커서 여러 거래로 나뉩니다. 받는 사람은 모든 조각이 도착한 후에야 볼 수 있으므로 전달에 시간이 더 걸리고 거래마다 네트워크 수수료가 듭니다.",
|
||||||
|
"chat_parts_label": "조각",
|
||||||
"chat_pick_contact": "연락처에서 선택…",
|
"chat_pick_contact": "연락처에서 선택…",
|
||||||
|
"chat_receiving_large": "큰 메시지를 받는 중…",
|
||||||
"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 +350,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": "채팅 사용자 지정",
|
||||||
@@ -344,12 +392,14 @@
|
|||||||
"chat_toast_not_connected": "연결되지 않음 — 채팅 메시지가 전송되지 않았습니다.",
|
"chat_toast_not_connected": "연결되지 않음 — 채팅 메시지가 전송되지 않았습니다.",
|
||||||
"chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).",
|
"chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).",
|
||||||
"chat_toast_request_queued": "연락 요청이 대기열에 추가되었습니다.",
|
"chat_toast_request_queued": "연락 요청이 대기열에 추가되었습니다.",
|
||||||
|
"chat_toast_too_long": "메시지가 너무 깁니다. 줄여서 다시 보내세요.",
|
||||||
"chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.",
|
"chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.",
|
||||||
"chat_today": "오늘",
|
"chat_today": "오늘",
|
||||||
"chat_ts_12h": "12시간",
|
"chat_ts_12h": "12시간",
|
||||||
"chat_ts_24h": "24시간",
|
"chat_ts_24h": "24시간",
|
||||||
"chat_ts_global": "전역 설정 따르기",
|
"chat_ts_global": "전역 설정 따르기",
|
||||||
"chat_ts_global_short": "전역",
|
"chat_ts_global_short": "전역",
|
||||||
|
"chat_txns_label": "건",
|
||||||
"chat_unblock": "차단 해제",
|
"chat_unblock": "차단 해제",
|
||||||
"chat_unblocked_toast": "대화 차단을 해제했습니다",
|
"chat_unblocked_toast": "대화 차단을 해제했습니다",
|
||||||
"chat_unhide": "다시 표시",
|
"chat_unhide": "다시 표시",
|
||||||
@@ -370,11 +420,20 @@
|
|||||||
"click_to_copy": "복사하려면 클릭",
|
"click_to_copy": "복사하려면 클릭",
|
||||||
"clock_format": "시간 형식",
|
"clock_format": "시간 형식",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
|
"compact_needs_embedded": "압축은 wallet.dat을 다시 쓰는 작업이며, 지갑은 내장 노드에서만 이 작업을 수행할 수 있습니다. 현재 직접 실행한 dragonxd에 연결되어 있습니다. 설정에서 \"내장 dragonxd 사용\"을 켜거나, dragonxd를 -salvagewallet 옵션으로 다시 시작하여 공간을 회수하세요.",
|
||||||
|
"compact_started": "지갑을 압축하는 중입니다. 노드가 재시작되며 체인을 다시 스캔합니다.",
|
||||||
|
"compact_switch_stop_failed": "외부 노드를 중지하지 못했습니다. 노드가 여전히 실행 중이므로 변경된 것은 없습니다.",
|
||||||
|
"compact_switching": "노드를 중지하고 내장 노드로 전환하는 중…",
|
||||||
|
"compact_wallet_confirm": "압축",
|
||||||
"conf_count": "%d 확인",
|
"conf_count": "%d 확인",
|
||||||
"confirm_and_send": "확인 후 전송",
|
"confirm_and_send": "확인 후 전송",
|
||||||
"confirm_clear_ztx_title": "Z-Tx 기록 삭제 확인",
|
"confirm_clear_ztx_title": "Z-Tx 기록 삭제 확인",
|
||||||
"confirm_clear_ztx_warning1": "z-트랜잭션 기록을 삭제하면 지갑 재스캔이 수행될 때까지 차폐 잔액이 0으로 표시될 수 있습니다.",
|
"confirm_clear_ztx_warning1": "z-트랜잭션 기록을 삭제하면 지갑 재스캔이 수행될 때까지 차폐 잔액이 0으로 표시될 수 있습니다.",
|
||||||
"confirm_clear_ztx_warning2": "이런 경우, 잔액을 복구하려면 재스캔을 활성화하여 z-주소 개인키를 다시 가져와야 합니다.",
|
"confirm_clear_ztx_warning2": "이런 경우, 잔액을 복구하려면 재스캔을 활성화하여 z-주소 개인키를 다시 가져와야 합니다.",
|
||||||
|
"confirm_compact_switch_msg": "직접 실행한 dragonxd에 연결되어 있습니다. 압축하려면 지갑이 해당 노드를 중지하고 자체 내장 노드로 전환한 뒤, 그 노드에서 wallet.dat을 다시 씁니다. 이 과정에서 노드가 재시작되고 블록체인을 다시 스캔하며, 시간이 오래 걸릴 수 있습니다. 참고: 내장 노드는 지갑 폴더에 있는 dragonxd이므로, 실행하는 노드가 바뀌게 됩니다.",
|
||||||
|
"confirm_compact_wallet_msg": "통합으로 노트가 병합되었지만 지갑 파일은 저절로 줄어들지 않습니다. 비워진 공간을 회수하려면 노드가 파일을 다시 써야 합니다. 이 작업은 노드를 재시작하고 블록체인을 다시 스캔하며, 큰 지갑에서는 시간이 오래 걸릴 수 있습니다.",
|
||||||
|
"confirm_compact_wallet_safe": "자금과 잔액은 안전합니다. 현재 지갑의 타임스탬프 백업이 보관됩니다.",
|
||||||
|
"confirm_compact_wallet_title": "지갑 압축",
|
||||||
"confirm_delete_blockchain_msg": "데몬을 중지하고 모든 블록체인 데이터(blocks, chainstate, peers)를 삭제한 후 처음부터 다시 동기화합니다. 몇 시간이 걸릴 수 있습니다.",
|
"confirm_delete_blockchain_msg": "데몬을 중지하고 모든 블록체인 데이터(blocks, chainstate, peers)를 삭제한 후 처음부터 다시 동기화합니다. 몇 시간이 걸릴 수 있습니다.",
|
||||||
"confirm_delete_blockchain_safe": "wallet.dat, 설정 및 거래 내역은 안전하며 삭제되지 않습니다.",
|
"confirm_delete_blockchain_safe": "wallet.dat, 설정 및 거래 내역은 안전하며 삭제되지 않습니다.",
|
||||||
"confirm_delete_blockchain_title": "블록체인 데이터 삭제",
|
"confirm_delete_blockchain_title": "블록체인 데이터 삭제",
|
||||||
@@ -505,7 +564,7 @@
|
|||||||
"console_welcome": "ObsidianDragon 콘솔에 오신 것을 환영합니다",
|
"console_welcome": "ObsidianDragon 콘솔에 오신 것을 환영합니다",
|
||||||
"console_zoom_in": "확대",
|
"console_zoom_in": "확대",
|
||||||
"console_zoom_out": "축소",
|
"console_zoom_out": "축소",
|
||||||
"consolidate_desc": "여러 개의 작은 입력을 하나의 보호된 노트로 합칩니다. 노트 수가 줄면 지갑 파일이 작아지고 프라이버시도 향상됩니다.",
|
"consolidate_desc": "받은 결제는 각각 별도의 \"노트\"로 저장됩니다. 지갑 속의 개별 지폐와 비슷합니다. 노트는 시간이 지나면서 쌓이고, 노트마다 위트니스 데이터가 추가되어 지갑 동기화는 느려지고 지갑 파일은 계속 커집니다. 통합하면 여러 개의 작은 노트가 주소당 하나로 합쳐집니다. 잔액은 변하지 않지만 지갑 스캔은 빨라지고, 더 이상 커지지 않으며, 자금에 대해 드러나는 정보도 줄어듭니다.",
|
||||||
"consolidate_funds_btn": "통합",
|
"consolidate_funds_btn": "통합",
|
||||||
"consolidate_title": "자금 통합",
|
"consolidate_title": "자금 통합",
|
||||||
"contact_avatar": "아바타",
|
"contact_avatar": "아바타",
|
||||||
@@ -2048,6 +2107,7 @@
|
|||||||
"tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL",
|
"tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL",
|
||||||
"tt_address_book": "빠른 전송을 위해 저장된 주소 관리",
|
"tt_address_book": "빠른 전송을 위해 저장된 주소 관리",
|
||||||
"tt_animate_avatars": "애니메이션 연락처 아바타(GIF / WebP) 재생, 끄면 첫 프레임만 표시",
|
"tt_animate_avatars": "애니메이션 연락처 아바타(GIF / WebP) 재생, 끄면 첫 프레임만 표시",
|
||||||
|
"tt_auto_consolidate": "지갑이 유휴 상태일 때 백그라운드에서 작은 노트를 아주 작은 배치로 조용히 병합합니다. 수동 통합보다 느리지만 거의 눈에 띄지 않으며, 각 주소의 자금은 그대로 분리되어 유지됩니다. 수천 개의 노트가 쌓이는 채굴/풀 지갑에 적합합니다.",
|
||||||
"tt_auto_lock": "이 비활성 시간 후 지갑 잠금",
|
"tt_auto_lock": "이 비활성 시간 후 지갑 잠금",
|
||||||
"tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동",
|
"tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동",
|
||||||
"tt_backup": "wallet.dat 백업 만들기",
|
"tt_backup": "wallet.dat 백업 만들기",
|
||||||
@@ -2174,7 +2234,8 @@
|
|||||||
"tt_validate": "DragonX 주소가 유효한지 확인",
|
"tt_validate": "DragonX 주소가 유효한지 확인",
|
||||||
"tt_verbose": "콘솔 탭에 상세 연결 진단,\\n데몬 상태 및 포트 소유자 정보 기록",
|
"tt_verbose": "콘솔 탭에 상세 연결 진단,\\n데몬 상태 및 포트 소유자 정보 기록",
|
||||||
"tt_view_transactions": "이 주소와 관련된 거래를 기록 탭에 표시",
|
"tt_view_transactions": "이 주소와 관련된 거래를 기록 탭에 표시",
|
||||||
"tt_wallet_size_warn": "보호 지갑은 각 노트의 위트니스 데이터로 인해 커집니다 — 여러 노트를 하나의 주소로 병합하면 크기를 줄일 수 있습니다. 먼저 백업하세요.",
|
"tt_wallet_size_compact": "통합으로 비워진 디스크 공간을 회수합니다. Berkeley DB는 파일을 제자리에서 줄일 수 없으므로, wallet.dat을 더 작은 파일로 다시 쓰고 전체 재스캔과 함께 노드를 재시작합니다(시간이 걸릴 수 있습니다). 현재 지갑의 백업은 보관됩니다.",
|
||||||
|
"tt_wallet_size_warn": "받은 결제는 각각 별도의 차폐 노트로 저장되며, 노트마다 위트니스 데이터가 있어 스캔이 느려지고 지갑 파일이 계속 커집니다. 여러 노트를 주소당 하나로 병합하면 지갑이 빠르게 유지되고 더 이상 커지지 않으며, 자금과 잔액은 그대로입니다. 비워진 공간은 지갑을 압축할 때에만 파일에서 회수됩니다(Berkeley DB는 파일을 제자리에서 줄일 수 없습니다). 먼저 시드를 백업하세요. 설정에서 노트 자동 통합을 켤 수도 있습니다.",
|
||||||
"tt_wallets_button": "지갑 파일 목록을 보고 전환합니다",
|
"tt_wallets_button": "지갑 파일 목록을 보고 전환합니다",
|
||||||
"tt_website": "DragonX 웹사이트 열기",
|
"tt_website": "DragonX 웹사이트 열기",
|
||||||
"tt_window_opacity": "배경 불투명도 (낮을수록 = 창을 통해 바탕 화면이 보임)",
|
"tt_window_opacity": "배경 불투명도 (낮을수록 = 창을 통해 바탕 화면이 보임)",
|
||||||
@@ -2303,8 +2364,10 @@
|
|||||||
"wallet_restore_ok": "원본 지갑이 복원되었습니다. 노드가 지금 불러오고 있습니다.",
|
"wallet_restore_ok": "원본 지갑이 복원되었습니다. 노드가 지금 불러오고 있습니다.",
|
||||||
"wallet_restore_started": "원본 파일을 복원하는 중입니다 — 이 창을 닫지 마세요.",
|
"wallet_restore_started": "원본 파일을 복원하는 중입니다 — 이 창을 닫지 마세요.",
|
||||||
"wallet_restore_stop_failed": "노드가 제시간에 멈추지 않아 아무것도 변경되지 않았습니다. 다시 시도하세요.",
|
"wallet_restore_stop_failed": "노드가 제시간에 멈추지 않아 아무것도 변경되지 않았습니다. 다시 시도하세요.",
|
||||||
|
"wallet_size_auto_info": "지갑에 노트가 많이 쌓였습니다. 노트는 받은 자금의 개별 조각으로, 동기화를 느리게 만들고 파일을 계속 키웁니다. 노트 자동 통합이 백그라운드에서 조용히 노트를 병합하여 지갑을 빠르게 유지하고 더 이상 커지지 않게 하고 있으니, 따로 하실 일은 없습니다.",
|
||||||
|
"wallet_size_compact": "지갑 압축…",
|
||||||
"wallet_size_consolidate": "노트 통합…",
|
"wallet_size_consolidate": "노트 통합…",
|
||||||
"wallet_size_warn": "이 지갑 파일이 큽니다. 노트를 통합하면 추가 증가를 억제할 수 있습니다.",
|
"wallet_size_warn": "지갑에 노트가 많이 쌓였습니다. 받은 결제는 각각 별도의 \"노트\"로 저장되며, 노트가 쌓일수록 지갑 동기화가 느려지고 지갑 파일은 계속 커집니다. 통합하면 노트를 더 적은 수로 병합하여 스캔이 빨라지고 더 이상 커지지 않습니다. 잔액은 정확히 그대로 유지됩니다.",
|
||||||
"wallets_active": "활성",
|
"wallets_active": "활성",
|
||||||
"wallets_add": "폴더 추가",
|
"wallets_add": "폴더 추가",
|
||||||
"wallets_add_folder": "지갑 파일을 검색할 폴더를 추가로 지정:",
|
"wallets_add_folder": "지갑 파일을 검색할 폴더를 추가로 지정:",
|
||||||
|
|||||||
+66
-3
@@ -141,6 +141,7 @@
|
|||||||
"appx_wallet_op_running": "Ainda há uma importação de chave ou um reescaneamento em andamento. Tente novamente quando terminar.",
|
"appx_wallet_op_running": "Ainda há uma importação de chave ou um reescaneamento em andamento. Tente novamente quando terminar.",
|
||||||
"appx_wallet_op_shutting_down": "A carteira está sendo encerrada.",
|
"appx_wallet_op_shutting_down": "A carteira está sendo encerrada.",
|
||||||
"appx_wallet_open_failed_prefix": "Falha ao abrir a carteira: ",
|
"appx_wallet_open_failed_prefix": "Falha ao abrir a carteira: ",
|
||||||
|
"auto_consolidate": "Consolidar notas automaticamente",
|
||||||
"auto_shield": "Auto-blindar mineração",
|
"auto_shield": "Auto-blindar mineração",
|
||||||
"autoshield_by_node": "Seu node blinda automaticamente o coinbase minerado para você.",
|
"autoshield_by_node": "Seu node blinda automaticamente o coinbase minerado para você.",
|
||||||
"autoshield_off_backup_seed": "O coinbase minerado ainda não está sendo blindado automaticamente. Faça backup da sua frase de recuperação (Configurações → Backup e Dados) para que seu node possa ativar isso com segurança.",
|
"autoshield_off_backup_seed": "O coinbase minerado ainda não está sendo blindado automaticamente. Faça backup da sua frase de recuperação (Configurações → Backup e Dados) para que seu node possa ativar isso com segurança.",
|
||||||
@@ -276,8 +277,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",
|
||||||
@@ -319,10 +324,24 @@
|
|||||||
"chat_opt_global_clock": "Formato de relógio global",
|
"chat_opt_global_clock": "Formato de relógio global",
|
||||||
"chat_opt_poll": "Taxa de atualização",
|
"chat_opt_poll": "Taxa de atualização",
|
||||||
"chat_opt_timestamp": "Carimbos de data/hora",
|
"chat_opt_timestamp": "Carimbos de data/hora",
|
||||||
|
"chat_parts_explain": "Esta mensagem longa é enviada como uma única transação dividida nestas partes — ela chega tão rápido quanto uma mensagem normal.",
|
||||||
|
"chat_parts_explain_multi": "Esta mensagem é grande demais para uma única transação, por isso é dividida em várias. O destinatário a vê somente depois que todas as partes chegam, então leva mais tempo para ser entregue e custa uma taxa de rede por transação.",
|
||||||
|
"chat_parts_label": "partes",
|
||||||
"chat_pick_contact": "Escolher dos contatos…",
|
"chat_pick_contact": "Escolher dos contatos…",
|
||||||
|
"chat_receiving_large": "Recebendo uma mensagem grande…",
|
||||||
"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 +350,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",
|
||||||
@@ -344,12 +392,14 @@
|
|||||||
"chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.",
|
"chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.",
|
||||||
"chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).",
|
"chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).",
|
||||||
"chat_toast_request_queued": "Solicitação de contato na fila.",
|
"chat_toast_request_queued": "Solicitação de contato na fila.",
|
||||||
|
"chat_toast_too_long": "Mensagem muito longa — encurte-a e envie novamente.",
|
||||||
"chat_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.",
|
"chat_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.",
|
||||||
"chat_today": "Hoje",
|
"chat_today": "Hoje",
|
||||||
"chat_ts_12h": "12 horas",
|
"chat_ts_12h": "12 horas",
|
||||||
"chat_ts_24h": "24 horas",
|
"chat_ts_24h": "24 horas",
|
||||||
"chat_ts_global": "Seguir global",
|
"chat_ts_global": "Seguir global",
|
||||||
"chat_ts_global_short": "Global",
|
"chat_ts_global_short": "Global",
|
||||||
|
"chat_txns_label": "tx",
|
||||||
"chat_unblock": "Desbloquear",
|
"chat_unblock": "Desbloquear",
|
||||||
"chat_unblocked_toast": "Conversa desbloqueada",
|
"chat_unblocked_toast": "Conversa desbloqueada",
|
||||||
"chat_unhide": "Mostrar",
|
"chat_unhide": "Mostrar",
|
||||||
@@ -370,11 +420,20 @@
|
|||||||
"click_to_copy": "Clique para copiar",
|
"click_to_copy": "Clique para copiar",
|
||||||
"clock_format": "Formato de hora",
|
"clock_format": "Formato de hora",
|
||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
|
"compact_needs_embedded": "Compactar reescreve o wallet.dat, o que a carteira só consegue fazer com o seu nó integrado. Você está conectado a um dragonxd que iniciou por conta própria — ative \"Usar dragonxd integrado\" em Ajustes, ou reinicie o seu dragonxd com -salvagewallet para recuperar o espaço.",
|
||||||
|
"compact_started": "Compactando a carteira — o nó está reiniciando e vai escanear a cadeia novamente.",
|
||||||
|
"compact_switch_stop_failed": "Não foi possível parar o seu nó externo — ele ainda está em execução, então nada foi alterado.",
|
||||||
|
"compact_switching": "Parando o seu nó e mudando para o integrado…",
|
||||||
|
"compact_wallet_confirm": "Compactar",
|
||||||
"conf_count": "%d conf.",
|
"conf_count": "%d conf.",
|
||||||
"confirm_and_send": "Confirmar & Enviar",
|
"confirm_and_send": "Confirmar & Enviar",
|
||||||
"confirm_clear_ztx_title": "Confirmar limpeza do histórico Z-Tx",
|
"confirm_clear_ztx_title": "Confirmar limpeza do histórico Z-Tx",
|
||||||
"confirm_clear_ztx_warning1": "Limpar o histórico de z-transações pode fazer com que seu saldo blindado apareça como 0 até que um reescaneamento da carteira seja realizado.",
|
"confirm_clear_ztx_warning1": "Limpar o histórico de z-transações pode fazer com que seu saldo blindado apareça como 0 até que um reescaneamento da carteira seja realizado.",
|
||||||
"confirm_clear_ztx_warning2": "Se isso acontecer, você precisará reimportar as chaves privadas do seu endereço z com reescaneamento habilitado para recuperar seu saldo.",
|
"confirm_clear_ztx_warning2": "Se isso acontecer, você precisará reimportar as chaves privadas do seu endereço z com reescaneamento habilitado para recuperar seu saldo.",
|
||||||
|
"confirm_compact_switch_msg": "Você está conectado a um dragonxd que iniciou por conta própria. Para compactar, a carteira vai parar esse nó, mudar para o seu próprio nó integrado e reescrever o wallet.dat nele — reiniciando e escaneando o blockchain novamente, o que pode demorar. Observação: o nó integrado é o dragonxd da pasta da própria carteira, então isso muda qual nó você usa.",
|
||||||
|
"confirm_compact_wallet_msg": "A consolidação mesclou suas notas, mas o arquivo da carteira não encolhe sozinho — o nó precisa reescrevê-lo para recuperar o espaço liberado. Isso reinicia o nó e escaneia o blockchain novamente, o que pode demorar em uma carteira grande.",
|
||||||
|
"confirm_compact_wallet_safe": "Seus fundos e saldo estão seguros — um backup com data e hora da carteira atual é mantido.",
|
||||||
|
"confirm_compact_wallet_title": "Compactar carteira",
|
||||||
"confirm_delete_blockchain_msg": "Isso irá parar o daemon, excluir todos os dados da blockchain (blocks, chainstate, peers) e iniciar uma nova sincronização do zero. Isso pode levar várias horas.",
|
"confirm_delete_blockchain_msg": "Isso irá parar o daemon, excluir todos os dados da blockchain (blocks, chainstate, peers) e iniciar uma nova sincronização do zero. Isso pode levar várias horas.",
|
||||||
"confirm_delete_blockchain_safe": "Seu wallet.dat, configuração e histórico de transações estão seguros e não serão excluídos.",
|
"confirm_delete_blockchain_safe": "Seu wallet.dat, configuração e histórico de transações estão seguros e não serão excluídos.",
|
||||||
"confirm_delete_blockchain_title": "Excluir Dados da Blockchain",
|
"confirm_delete_blockchain_title": "Excluir Dados da Blockchain",
|
||||||
@@ -505,7 +564,7 @@
|
|||||||
"console_welcome": "Bem-vindo ao Console ObsidianDragon",
|
"console_welcome": "Bem-vindo ao Console ObsidianDragon",
|
||||||
"console_zoom_in": "Aumentar zoom",
|
"console_zoom_in": "Aumentar zoom",
|
||||||
"console_zoom_out": "Diminuir zoom",
|
"console_zoom_out": "Diminuir zoom",
|
||||||
"consolidate_desc": "Combine muitas entradas pequenas em uma única nota blindada. Menos notas significa um arquivo de carteira menor e mais privacidade.",
|
"consolidate_desc": "Cada pagamento que você recebe é armazenado como uma \"nota\" separada — como uma cédula individual em uma carteira. Elas se acumulam com o tempo, e cada nota adiciona dados de testemunha que deixam a carteira mais lenta para sincronizar e fazem com que continue crescendo. Consolidar combina muitas notas pequenas em uma por endereço: seu saldo não muda, mas a carteira escaneia mais rápido, para de crescer e revela menos sobre os seus fundos.",
|
||||||
"consolidate_funds_btn": "Consolidar",
|
"consolidate_funds_btn": "Consolidar",
|
||||||
"consolidate_title": "Consolidar fundos",
|
"consolidate_title": "Consolidar fundos",
|
||||||
"contact_avatar": "AVATAR",
|
"contact_avatar": "AVATAR",
|
||||||
@@ -2048,6 +2107,7 @@
|
|||||||
"tt_addr_url": "URL base para visualizar endereços em um explorador de blocos",
|
"tt_addr_url": "URL base para visualizar endereços em um explorador de blocos",
|
||||||
"tt_address_book": "Gerenciar endereços salvos para envio rápido",
|
"tt_address_book": "Gerenciar endereços salvos para envio rápido",
|
||||||
"tt_animate_avatars": "Reproduz avatares de contato animados (GIF / WebP); desativado mostra só o primeiro quadro",
|
"tt_animate_avatars": "Reproduz avatares de contato animados (GIF / WebP); desativado mostra só o primeiro quadro",
|
||||||
|
"tt_auto_consolidate": "Mescla discretamente as notas pequenas em segundo plano enquanto a carteira está ociosa, em lotes minúsculos. Mais lento que uma consolidação manual, mas quase imperceptível, e os fundos de cada endereço continuam separados. Ideal para carteiras de mineração/pool que acumulam milhares de notas.",
|
||||||
"tt_auto_lock": "Bloquear carteira após este tempo de inatividade",
|
"tt_auto_lock": "Bloquear carteira após este tempo de inatividade",
|
||||||
"tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade",
|
"tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade",
|
||||||
"tt_backup": "Criar um backup do seu wallet.dat",
|
"tt_backup": "Criar um backup do seu wallet.dat",
|
||||||
@@ -2174,7 +2234,8 @@
|
|||||||
"tt_validate": "Verificar se um endereço DragonX é válido",
|
"tt_validate": "Verificar se um endereço DragonX é válido",
|
||||||
"tt_verbose": "Registrar diagnósticos detalhados de conexão,\\nestado do daemon e info de proprietário de porta\\nna aba Console",
|
"tt_verbose": "Registrar diagnósticos detalhados de conexão,\\nestado do daemon e info de proprietário de porta\\nna aba Console",
|
||||||
"tt_view_transactions": "Mostrar as transações que envolvem este endereço na aba Histórico",
|
"tt_view_transactions": "Mostrar as transações que envolvem este endereço na aba Histórico",
|
||||||
"tt_wallet_size_warn": "Carteiras blindadas crescem com os dados de testemunha de cada nota — mesclar várias notas em um único endereço reduz esse tamanho. Faça backup antes.",
|
"tt_wallet_size_compact": "Recupere o espaço em disco liberado pela consolidação. O Berkeley DB não consegue encolher o arquivo no próprio local, então isto reescreve o wallet.dat em um arquivo menor e reinicia o nó com um reescaneamento completo (pode demorar). Um backup da carteira atual é mantido.",
|
||||||
|
"tt_wallet_size_warn": "Cada pagamento chega como uma nota blindada separada, e cada nota carrega dados de testemunha que deixam as varreduras lentas e fazem o arquivo da carteira continuar crescendo. Mesclar muitas notas em uma por endereço mantém a carteira rápida e interrompe o crescimento — seus fundos e saldo não mudam. O arquivo só recupera o espaço liberado quando você o Compacta (o Berkeley DB não consegue encolher no próprio arquivo). Faça backup da sua seed primeiro. Você também pode ativar a opção Consolidar notas automaticamente em Ajustes.",
|
||||||
"tt_wallets_button": "Liste os arquivos de carteira e alterne entre eles",
|
"tt_wallets_button": "Liste os arquivos de carteira e alterne entre eles",
|
||||||
"tt_website": "Abrir o site do DragonX",
|
"tt_website": "Abrir o site do DragonX",
|
||||||
"tt_window_opacity": "Opacidade do fundo (menor = área de trabalho visível através da janela)",
|
"tt_window_opacity": "Opacidade do fundo (menor = área de trabalho visível através da janela)",
|
||||||
@@ -2303,8 +2364,10 @@
|
|||||||
"wallet_restore_ok": "Carteira original restaurada. O node está carregando-a agora.",
|
"wallet_restore_ok": "Carteira original restaurada. O node está carregando-a agora.",
|
||||||
"wallet_restore_started": "Restaurando seu arquivo original — por favor não feche esta janela.",
|
"wallet_restore_started": "Restaurando seu arquivo original — por favor não feche esta janela.",
|
||||||
"wallet_restore_stop_failed": "O node não parou a tempo, então nada foi alterado. Tente novamente.",
|
"wallet_restore_stop_failed": "O node não parou a tempo, então nada foi alterado. Tente novamente.",
|
||||||
|
"wallet_size_auto_info": "Sua carteira acumulou muitas notas — partes separadas dos fundos recebidos que deixam a sincronização lenta e fazem o arquivo continuar crescendo. A consolidação automática as está mesclando discretamente em segundo plano para manter a carteira rápida e impedir que ela cresça, então não há nada que você precise fazer.",
|
||||||
|
"wallet_size_compact": "Compactar carteira…",
|
||||||
"wallet_size_consolidate": "Consolidar notas…",
|
"wallet_size_consolidate": "Consolidar notas…",
|
||||||
"wallet_size_warn": "Este arquivo de carteira está grande. Consolidar suas notas pode conter o crescimento.",
|
"wallet_size_warn": "Sua carteira acumulou muitas notas. Cada pagamento que você recebe é armazenado como uma \"nota\" separada; conforme se acumulam, elas deixam a carteira mais lenta para sincronizar e fazem com que continue crescendo. Consolidar as mescla em menos notas para que ela escaneie mais rápido e pare de crescer — seu saldo permanece exatamente o mesmo.",
|
||||||
"wallets_active": "Ativa",
|
"wallets_active": "Ativa",
|
||||||
"wallets_add": "Adicionar pasta",
|
"wallets_add": "Adicionar pasta",
|
||||||
"wallets_add_folder": "Verificar também outra pasta em busca de arquivos de carteira:",
|
"wallets_add_folder": "Verificar também outra pasta em busca de arquivos de carteira:",
|
||||||
|
|||||||
+66
-3
@@ -141,6 +141,7 @@
|
|||||||
"appx_wallet_op_running": "Импорт ключа или повторное сканирование ещё выполняется. Повторите попытку, когда оно завершится.",
|
"appx_wallet_op_running": "Импорт ключа или повторное сканирование ещё выполняется. Повторите попытку, когда оно завершится.",
|
||||||
"appx_wallet_op_shutting_down": "Кошелёк завершает работу.",
|
"appx_wallet_op_shutting_down": "Кошелёк завершает работу.",
|
||||||
"appx_wallet_open_failed_prefix": "Не удалось открыть кошелёк: ",
|
"appx_wallet_open_failed_prefix": "Не удалось открыть кошелёк: ",
|
||||||
|
"auto_consolidate": "Автообъединение заметок",
|
||||||
"auto_shield": "Авто-экранирование майнинга",
|
"auto_shield": "Авто-экранирование майнинга",
|
||||||
"autoshield_by_node": "Ваш узел автоматически защищает добытый coinbase за вас.",
|
"autoshield_by_node": "Ваш узел автоматически защищает добытый coinbase за вас.",
|
||||||
"autoshield_off_backup_seed": "Добытый coinbase пока не защищается автоматически. Сделайте резервную копию сид-фразы (Настройки → Резервное копирование и данные), чтобы узел мог безопасно включить эту функцию.",
|
"autoshield_off_backup_seed": "Добытый coinbase пока не защищается автоматически. Сделайте резервную копию сид-фразы (Настройки → Резервное копирование и данные), чтобы узел мог безопасно включить эту функцию.",
|
||||||
@@ -276,8 +277,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": "Разговор удалён",
|
||||||
@@ -319,10 +324,24 @@
|
|||||||
"chat_opt_global_clock": "Глобальный формат времени",
|
"chat_opt_global_clock": "Глобальный формат времени",
|
||||||
"chat_opt_poll": "Частота опроса",
|
"chat_opt_poll": "Частота опроса",
|
||||||
"chat_opt_timestamp": "Метки времени",
|
"chat_opt_timestamp": "Метки времени",
|
||||||
|
"chat_parts_explain": "Это длинное сообщение отправляется как одна транзакция, разделённая на эти части, — оно доходит так же быстро, как обычное сообщение.",
|
||||||
|
"chat_parts_explain_multi": "Это сообщение слишком велико для одной транзакции, поэтому оно разбивается на несколько. Получатель увидит его только после того, как придут все части, поэтому доставка занимает больше времени и за каждую транзакцию взимается сетевая комиссия.",
|
||||||
|
"chat_parts_label": "частей",
|
||||||
"chat_pick_contact": "Выбрать из контактов…",
|
"chat_pick_contact": "Выбрать из контактов…",
|
||||||
|
"chat_receiving_large": "Получение большого сообщения…",
|
||||||
"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 +350,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": "Настройка чата",
|
||||||
@@ -344,12 +392,14 @@
|
|||||||
"chat_toast_not_connected": "Нет подключения — сообщение не отправлено.",
|
"chat_toast_not_connected": "Нет подключения — сообщение не отправлено.",
|
||||||
"chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).",
|
"chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).",
|
||||||
"chat_toast_request_queued": "Запрос контакта поставлен в очередь.",
|
"chat_toast_request_queued": "Запрос контакта поставлен в очередь.",
|
||||||
|
"chat_toast_too_long": "Сообщение слишком длинное — сократите его и отправьте снова.",
|
||||||
"chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.",
|
"chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.",
|
||||||
"chat_today": "Сегодня",
|
"chat_today": "Сегодня",
|
||||||
"chat_ts_12h": "12 часов",
|
"chat_ts_12h": "12 часов",
|
||||||
"chat_ts_24h": "24 часа",
|
"chat_ts_24h": "24 часа",
|
||||||
"chat_ts_global": "Как глобально",
|
"chat_ts_global": "Как глобально",
|
||||||
"chat_ts_global_short": "Общий",
|
"chat_ts_global_short": "Общий",
|
||||||
|
"chat_txns_label": "транз.",
|
||||||
"chat_unblock": "Разблокировать",
|
"chat_unblock": "Разблокировать",
|
||||||
"chat_unblocked_toast": "Разговор разблокирован",
|
"chat_unblocked_toast": "Разговор разблокирован",
|
||||||
"chat_unhide": "Показать",
|
"chat_unhide": "Показать",
|
||||||
@@ -370,11 +420,20 @@
|
|||||||
"click_to_copy": "Нажмите для копирования",
|
"click_to_copy": "Нажмите для копирования",
|
||||||
"clock_format": "Формат времени",
|
"clock_format": "Формат времени",
|
||||||
"close": "Закрыть",
|
"close": "Закрыть",
|
||||||
|
"compact_needs_embedded": "Сжатие перезаписывает wallet.dat, а кошелёк может сделать это только с помощью встроенного узла. Вы подключены к dragonxd, который запустили сами — включите «Использовать встроенный dragonxd» в разделе «Настройки» или перезапустите свой dragonxd с параметром -salvagewallet, чтобы вернуть место.",
|
||||||
|
"compact_started": "Сжатие кошелька — узел перезапускается и заново просканирует блокчейн.",
|
||||||
|
"compact_switch_stop_failed": "Не удалось остановить ваш внешний узел — он всё ещё работает, поэтому ничего не изменено.",
|
||||||
|
"compact_switching": "Остановка вашего узла и переключение на встроенный…",
|
||||||
|
"compact_wallet_confirm": "Сжать",
|
||||||
"conf_count": "%d подтв.",
|
"conf_count": "%d подтв.",
|
||||||
"confirm_and_send": "Подтвердить и отправить",
|
"confirm_and_send": "Подтвердить и отправить",
|
||||||
"confirm_clear_ztx_title": "Подтвердить очистку истории Z-Tx",
|
"confirm_clear_ztx_title": "Подтвердить очистку истории Z-Tx",
|
||||||
"confirm_clear_ztx_warning1": "Очистка истории z-транзакций может привести к отображению защищённого баланса как 0, пока не будет выполнено пересканирование кошелька.",
|
"confirm_clear_ztx_warning1": "Очистка истории z-транзакций может привести к отображению защищённого баланса как 0, пока не будет выполнено пересканирование кошелька.",
|
||||||
"confirm_clear_ztx_warning2": "Если это произойдёт, вам потребуется повторно импортировать приватные ключи вашего z-адреса с включённым пересканированием для восстановления баланса.",
|
"confirm_clear_ztx_warning2": "Если это произойдёт, вам потребуется повторно импортировать приватные ключи вашего z-адреса с включённым пересканированием для восстановления баланса.",
|
||||||
|
"confirm_compact_switch_msg": "Вы подключены к dragonxd, который запустили сами. Чтобы выполнить сжатие, кошелёк остановит этот узел, переключится на собственный встроенный узел и перезапишет на нём wallet.dat — с перезапуском и повторным сканированием блокчейна, что может занять много времени. Обратите внимание: встроенный узел — это dragonxd из собственной папки кошелька, поэтому это меняет то, какой узел вы используете.",
|
||||||
|
"confirm_compact_wallet_msg": "Объединение слило ваши заметки, но файл кошелька сам по себе не уменьшается — чтобы вернуть освободившееся место, узел должен перезаписать его. Это перезапускает узел и заново сканирует блокчейн, что может занять много времени для большого кошелька.",
|
||||||
|
"confirm_compact_wallet_safe": "Ваши средства и баланс в безопасности — сохраняется резервная копия текущего кошелька с отметкой времени.",
|
||||||
|
"confirm_compact_wallet_title": "Сжать кошелёк",
|
||||||
"confirm_delete_blockchain_msg": "Это остановит демон, удалит все данные блокчейна (blocks, chainstate, peers) и начнёт синхронизацию с нуля. Это может занять несколько часов.",
|
"confirm_delete_blockchain_msg": "Это остановит демон, удалит все данные блокчейна (blocks, chainstate, peers) и начнёт синхронизацию с нуля. Это может занять несколько часов.",
|
||||||
"confirm_delete_blockchain_safe": "Ваш wallet.dat, конфигурация и история транзакций в безопасности и не будут удалены.",
|
"confirm_delete_blockchain_safe": "Ваш wallet.dat, конфигурация и история транзакций в безопасности и не будут удалены.",
|
||||||
"confirm_delete_blockchain_title": "Удалить данные блокчейна",
|
"confirm_delete_blockchain_title": "Удалить данные блокчейна",
|
||||||
@@ -505,7 +564,7 @@
|
|||||||
"console_welcome": "Добро пожаловать в консоль ObsidianDragon",
|
"console_welcome": "Добро пожаловать в консоль ObsidianDragon",
|
||||||
"console_zoom_in": "Увеличить",
|
"console_zoom_in": "Увеличить",
|
||||||
"console_zoom_out": "Уменьшить",
|
"console_zoom_out": "Уменьшить",
|
||||||
"consolidate_desc": "Объедините множество мелких входов в одну защищённую заметку. Меньше заметок означает файл кошелька меньшего размера и лучшую приватность.",
|
"consolidate_desc": "Каждый полученный платёж хранится как отдельная «заметка» — словно отдельная купюра в кошельке. Со временем они накапливаются, и каждая заметка добавляет данные свидетеля, из-за которых кошелёк медленнее синхронизируется и продолжает расти. Объединение сливает множество мелких заметок в одну на каждый адрес: баланс не меняется, но кошелёк сканируется быстрее, перестаёт расти и раскрывает меньше информации о ваших средствах.",
|
||||||
"consolidate_funds_btn": "Объединить",
|
"consolidate_funds_btn": "Объединить",
|
||||||
"consolidate_title": "Объединение средств",
|
"consolidate_title": "Объединение средств",
|
||||||
"contact_avatar": "АВАТАР",
|
"contact_avatar": "АВАТАР",
|
||||||
@@ -2048,6 +2107,7 @@
|
|||||||
"tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков",
|
"tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков",
|
||||||
"tt_address_book": "Управление сохранёнными адресами для быстрой отправки",
|
"tt_address_book": "Управление сохранёнными адресами для быстрой отправки",
|
||||||
"tt_animate_avatars": "Воспроизводить анимированные аватары контактов (GIF / WebP); при отключении показывается только первый кадр",
|
"tt_animate_avatars": "Воспроизводить анимированные аватары контактов (GIF / WebP); при отключении показывается только первый кадр",
|
||||||
|
"tt_auto_consolidate": "Незаметно объединять мелкие заметки в фоновом режиме, пока кошелёк простаивает, небольшими партиями. Медленнее ручного объединения, но почти не ощущается, а средства каждого адреса остаются раздельными. Подходит для кошельков майнинга/пула, где накапливаются тысячи заметок.",
|
||||||
"tt_auto_lock": "Заблокировать кошелёк после этого времени бездействия",
|
"tt_auto_lock": "Заблокировать кошелёк после этого времени бездействия",
|
||||||
"tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности",
|
"tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности",
|
||||||
"tt_backup": "Создать резервную копию вашего wallet.dat",
|
"tt_backup": "Создать резервную копию вашего wallet.dat",
|
||||||
@@ -2174,7 +2234,8 @@
|
|||||||
"tt_validate": "Проверить, действителен ли адрес DragonX",
|
"tt_validate": "Проверить, действителен ли адрес DragonX",
|
||||||
"tt_verbose": "Записывать подробную диагностику подключений,\\nсостояние демона и информацию о владельце порта\\nна вкладке Консоль",
|
"tt_verbose": "Записывать подробную диагностику подключений,\\nсостояние демона и информацию о владельце порта\\nна вкладке Консоль",
|
||||||
"tt_view_transactions": "Показать транзакции с этим адресом на вкладке «История»",
|
"tt_view_transactions": "Показать транзакции с этим адресом на вкладке «История»",
|
||||||
"tt_wallet_size_warn": "Защищённые кошельки растут за счёт данных witness каждой записи — объединение множества записей в один адрес уменьшает его размер. Сначала сделайте резервную копию.",
|
"tt_wallet_size_compact": "Вернуть место на диске, освобождённое после объединения. Berkeley DB не умеет уменьшать файл на месте, поэтому wallet.dat перезаписывается в файл меньшего размера, а узел перезапускается с полным пересканированием (это может занять много времени). Резервная копия текущего кошелька сохраняется.",
|
||||||
|
"tt_wallet_size_warn": "Каждый платёж поступает как отдельная экранированная заметка, и каждая заметка несёт данные свидетеля, которые замедляют сканирование и заставляют файл кошелька расти. Объединение множества заметок в одну на каждый адрес сохраняет быстродействие и останавливает рост — ваши средства и баланс не меняются. Освободившееся место файл возвращает только после сжатия (Berkeley DB не умеет уменьшать файл на месте). Сначала сделайте резервную копию seed-фразы. Также можно включить автообъединение заметок в разделе «Настройки».",
|
||||||
"tt_wallets_button": "Показать файлы кошельков и переключаться между ними",
|
"tt_wallets_button": "Показать файлы кошельков и переключаться между ними",
|
||||||
"tt_website": "Открыть сайт DragonX",
|
"tt_website": "Открыть сайт DragonX",
|
||||||
"tt_window_opacity": "Непрозрачность фона (ниже = рабочий стол виден сквозь окно)",
|
"tt_window_opacity": "Непрозрачность фона (ниже = рабочий стол виден сквозь окно)",
|
||||||
@@ -2303,8 +2364,10 @@
|
|||||||
"wallet_restore_ok": "Исходный кошелёк восстановлен. Узел сейчас его загружает.",
|
"wallet_restore_ok": "Исходный кошелёк восстановлен. Узел сейчас его загружает.",
|
||||||
"wallet_restore_started": "Восстановление вашего исходного файла — пожалуйста, не закрывайте это окно.",
|
"wallet_restore_started": "Восстановление вашего исходного файла — пожалуйста, не закрывайте это окно.",
|
||||||
"wallet_restore_stop_failed": "Узел не остановился вовремя, поэтому ничего не изменено. Попробуйте снова.",
|
"wallet_restore_stop_failed": "Узел не остановился вовремя, поэтому ничего не изменено. Попробуйте снова.",
|
||||||
|
"wallet_size_auto_info": "В вашем кошельке накопилось много заметок — отдельных частей полученных средств, которые замедляют синхронизацию и заставляют файл расти. Автообъединение незаметно объединяет их в фоновом режиме, чтобы кошелёк оставался быстрым и перестал расти, так что вам ничего делать не нужно.",
|
||||||
|
"wallet_size_compact": "Сжать кошелёк…",
|
||||||
"wallet_size_consolidate": "Объединить заметки…",
|
"wallet_size_consolidate": "Объединить заметки…",
|
||||||
"wallet_size_warn": "Файл этого кошелька велик. Объединение ваших заметок может сдержать дальнейший рост.",
|
"wallet_size_warn": "В вашем кошельке накопилось много заметок. Каждый полученный платёж хранится как отдельная «заметка»; по мере накопления они замедляют синхронизацию кошелька и заставляют его расти. Объединение сливает их в меньшее число заметок, чтобы кошелёк сканировался быстрее и перестал расти — ваш баланс при этом остаётся точно таким же.",
|
||||||
"wallets_active": "Активен",
|
"wallets_active": "Активен",
|
||||||
"wallets_add": "Добавить папку",
|
"wallets_add": "Добавить папку",
|
||||||
"wallets_add_folder": "Также сканировать другую папку на наличие файлов кошельков:",
|
"wallets_add_folder": "Также сканировать другую папку на наличие файлов кошельков:",
|
||||||
|
|||||||
+66
-3
@@ -141,6 +141,7 @@
|
|||||||
"appx_wallet_op_running": "密钥导入或重新扫描仍在进行中。请在完成后重试。",
|
"appx_wallet_op_running": "密钥导入或重新扫描仍在进行中。请在完成后重试。",
|
||||||
"appx_wallet_op_shutting_down": "钱包正在关闭。",
|
"appx_wallet_op_shutting_down": "钱包正在关闭。",
|
||||||
"appx_wallet_open_failed_prefix": "钱包打开失败:",
|
"appx_wallet_open_failed_prefix": "钱包打开失败:",
|
||||||
|
"auto_consolidate": "自动合并票据",
|
||||||
"auto_shield": "自动屏蔽挖矿",
|
"auto_shield": "自动屏蔽挖矿",
|
||||||
"autoshield_by_node": "您的节点会自动为您屏蔽挖出的 coinbase。",
|
"autoshield_by_node": "您的节点会自动为您屏蔽挖出的 coinbase。",
|
||||||
"autoshield_off_backup_seed": "挖出的 coinbase 尚未自动屏蔽。请备份您的助记词(设置 → 备份与数据),以便节点可以安全启用此功能。",
|
"autoshield_off_backup_seed": "挖出的 coinbase 尚未自动屏蔽。请备份您的助记词(设置 → 备份与数据),以便节点可以安全启用此功能。",
|
||||||
@@ -276,8 +277,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": "会话已删除",
|
||||||
@@ -319,10 +324,24 @@
|
|||||||
"chat_opt_global_clock": "全局时间格式",
|
"chat_opt_global_clock": "全局时间格式",
|
||||||
"chat_opt_poll": "轮询频率",
|
"chat_opt_poll": "轮询频率",
|
||||||
"chat_opt_timestamp": "时间戳",
|
"chat_opt_timestamp": "时间戳",
|
||||||
|
"chat_parts_explain": "这条长消息会作为一笔交易发送,拆分成这些部分——它和普通消息一样快送达。",
|
||||||
|
"chat_parts_explain_multi": "这条消息太大,无法放入一笔交易,因此会拆分到多笔交易中发送。接收方需等所有部分都到达后才能看到,因此送达较慢,且每笔交易都会产生网络手续费。",
|
||||||
|
"chat_parts_label": "部分",
|
||||||
"chat_pick_contact": "从联系人中选择…",
|
"chat_pick_contact": "从联系人中选择…",
|
||||||
|
"chat_receiving_large": "正在接收大消息…",
|
||||||
"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 +350,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": "聊天自定义",
|
||||||
@@ -344,12 +392,14 @@
|
|||||||
"chat_toast_not_connected": "未连接——聊天消息未发送。",
|
"chat_toast_not_connected": "未连接——聊天消息未发送。",
|
||||||
"chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。",
|
"chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。",
|
||||||
"chat_toast_request_queued": "联系人请求已排队。",
|
"chat_toast_request_queued": "联系人请求已排队。",
|
||||||
|
"chat_toast_too_long": "消息过长——请缩短后重新发送。",
|
||||||
"chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
|
"chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
|
||||||
"chat_today": "今天",
|
"chat_today": "今天",
|
||||||
"chat_ts_12h": "12小时",
|
"chat_ts_12h": "12小时",
|
||||||
"chat_ts_24h": "24小时",
|
"chat_ts_24h": "24小时",
|
||||||
"chat_ts_global": "跟随全局",
|
"chat_ts_global": "跟随全局",
|
||||||
"chat_ts_global_short": "全局",
|
"chat_ts_global_short": "全局",
|
||||||
|
"chat_txns_label": "笔",
|
||||||
"chat_unblock": "取消屏蔽",
|
"chat_unblock": "取消屏蔽",
|
||||||
"chat_unblocked_toast": "会话已取消屏蔽",
|
"chat_unblocked_toast": "会话已取消屏蔽",
|
||||||
"chat_unhide": "取消隐藏",
|
"chat_unhide": "取消隐藏",
|
||||||
@@ -370,11 +420,20 @@
|
|||||||
"click_to_copy": "点击复制",
|
"click_to_copy": "点击复制",
|
||||||
"clock_format": "时间格式",
|
"clock_format": "时间格式",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
|
"compact_needs_embedded": "压缩会重写 wallet.dat,而钱包只有使用其内置节点才能做到这一点。您当前连接的是自己启动的 dragonxd——请在“设置”中开启\"使用内置 dragonxd\",或使用 -salvagewallet 重启您的 dragonxd 以回收空间。",
|
||||||
|
"compact_started": "正在压缩钱包——节点正在重启,并将重新扫描区块链。",
|
||||||
|
"compact_switch_stop_failed": "无法停止您的外部节点——它仍在运行,因此未做任何更改。",
|
||||||
|
"compact_switching": "正在停止您的节点并切换到内置节点…",
|
||||||
|
"compact_wallet_confirm": "压缩",
|
||||||
"conf_count": "%d 确认",
|
"conf_count": "%d 确认",
|
||||||
"confirm_and_send": "确认并发送",
|
"confirm_and_send": "确认并发送",
|
||||||
"confirm_clear_ztx_title": "确认清除 Z-Tx 历史",
|
"confirm_clear_ztx_title": "确认清除 Z-Tx 历史",
|
||||||
"confirm_clear_ztx_warning1": "清除 z-交易历史可能导致您的屏蔽余额显示为 0,直到执行钱包重新扫描。",
|
"confirm_clear_ztx_warning1": "清除 z-交易历史可能导致您的屏蔽余额显示为 0,直到执行钱包重新扫描。",
|
||||||
"confirm_clear_ztx_warning2": "如果发生这种情况,您需要在启用重新扫描的情况下重新导入 z-地址私钥以恢复余额。",
|
"confirm_clear_ztx_warning2": "如果发生这种情况,您需要在启用重新扫描的情况下重新导入 z-地址私钥以恢复余额。",
|
||||||
|
"confirm_compact_switch_msg": "您当前连接的是自己启动的 dragonxd。为了进行压缩,钱包将停止该节点,切换到自带的内置节点,并在其上重写 wallet.dat——这会重启节点并重新扫描区块链,可能需要一段时间。注意:内置节点是钱包自身文件夹中的 dragonxd,因此这会改变您所运行的节点。",
|
||||||
|
"confirm_compact_wallet_msg": "合并已将您的票据合成较少的票据,但钱包文件无法自行缩小——节点必须重写文件才能回收释放出的空间。这会重启节点并重新扫描区块链,钱包较大时可能需要一段时间。",
|
||||||
|
"confirm_compact_wallet_safe": "您的资金和余额是安全的——当前钱包的带时间戳备份会被保留。",
|
||||||
|
"confirm_compact_wallet_title": "压缩钱包",
|
||||||
"confirm_delete_blockchain_msg": "这将停止守护进程,删除所有区块链数据(blocks、chainstate、peers),并从头开始重新同步。这可能需要几个小时。",
|
"confirm_delete_blockchain_msg": "这将停止守护进程,删除所有区块链数据(blocks、chainstate、peers),并从头开始重新同步。这可能需要几个小时。",
|
||||||
"confirm_delete_blockchain_safe": "您的 wallet.dat、配置和交易历史是安全的,不会被删除。",
|
"confirm_delete_blockchain_safe": "您的 wallet.dat、配置和交易历史是安全的,不会被删除。",
|
||||||
"confirm_delete_blockchain_title": "删除区块链数据",
|
"confirm_delete_blockchain_title": "删除区块链数据",
|
||||||
@@ -505,7 +564,7 @@
|
|||||||
"console_welcome": "欢迎使用 ObsidianDragon 控制台",
|
"console_welcome": "欢迎使用 ObsidianDragon 控制台",
|
||||||
"console_zoom_in": "放大",
|
"console_zoom_in": "放大",
|
||||||
"console_zoom_out": "缩小",
|
"console_zoom_out": "缩小",
|
||||||
"consolidate_desc": "将多个小额输入合并为单个隐私票据。票据越少,钱包文件越小,隐私性也越好。",
|
"consolidate_desc": "您收到的每笔付款都会存为一张单独的“票据”——就像钱包里的一张张零钱。它们会随时间不断堆积,而且每张票据都会增加见证数据,使钱包同步变慢并不断变大。合并会将许多小额票据合成每个地址一张:您的余额不会改变,但钱包扫描更快、不再继续变大,并且泄露的资金信息更少。",
|
||||||
"consolidate_funds_btn": "合并",
|
"consolidate_funds_btn": "合并",
|
||||||
"consolidate_title": "合并资金",
|
"consolidate_title": "合并资金",
|
||||||
"contact_avatar": "头像",
|
"contact_avatar": "头像",
|
||||||
@@ -2048,6 +2107,7 @@
|
|||||||
"tt_addr_url": "在区块浏览器中查看地址的基础 URL",
|
"tt_addr_url": "在区块浏览器中查看地址的基础 URL",
|
||||||
"tt_address_book": "管理已保存的地址以快速发送",
|
"tt_address_book": "管理已保存的地址以快速发送",
|
||||||
"tt_animate_avatars": "播放动画联系人头像(GIF / WebP);关闭时仅显示第一帧",
|
"tt_animate_avatars": "播放动画联系人头像(GIF / WebP);关闭时仅显示第一帧",
|
||||||
|
"tt_auto_consolidate": "在钱包空闲时于后台悄悄地分小批合并零散的小额票据。比手动合并慢,但几乎无感,而且各地址的资金仍保持独立。适合会累积成千上万张票据的挖矿/矿池钱包。",
|
||||||
"tt_auto_lock": "在此不活动时间后锁定钱包",
|
"tt_auto_lock": "在此不活动时间后锁定钱包",
|
||||||
"tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私",
|
"tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私",
|
||||||
"tt_backup": "创建 wallet.dat 的备份",
|
"tt_backup": "创建 wallet.dat 的备份",
|
||||||
@@ -2174,7 +2234,8 @@
|
|||||||
"tt_validate": "检查 DragonX 地址是否有效",
|
"tt_validate": "检查 DragonX 地址是否有效",
|
||||||
"tt_verbose": "将详细连接诊断、守护进程状态\\n和端口所有者信息记录到控制台选项卡",
|
"tt_verbose": "将详细连接诊断、守护进程状态\\n和端口所有者信息记录到控制台选项卡",
|
||||||
"tt_view_transactions": "在历史记录标签页中显示涉及此地址的交易",
|
"tt_view_transactions": "在历史记录标签页中显示涉及此地址的交易",
|
||||||
"tt_wallet_size_warn": "屏蔽钱包会随每个票据的见证数据增长——将多个票据合并到一个地址可以缩小体积。请先备份。",
|
"tt_wallet_size_compact": "回收合并后释放出的磁盘空间。Berkeley DB 无法原地缩小文件,因此此操作会将 wallet.dat 重写为更小的文件,并重启节点进行完整重新扫描(可能需要一段时间)。当前钱包的备份会被保留。",
|
||||||
|
"tt_wallet_size_warn": "每笔付款都会作为一张单独的屏蔽票据到账,而每张票据都带有见证数据,会拖慢扫描并使钱包文件不断变大。将多张票据合并为每个地址一张,即可保持快速并停止变大——您的资金和余额不会改变。只有在您执行“压缩”后,文件才会回收释放出的空间(Berkeley DB 无法原地缩小)。请先备份您的助记词。您也可以在设置中开启“自动合并票据”。",
|
||||||
"tt_wallets_button": "列出您的钱包文件并在它们之间切换",
|
"tt_wallets_button": "列出您的钱包文件并在它们之间切换",
|
||||||
"tt_website": "打开 DragonX 网站",
|
"tt_website": "打开 DragonX 网站",
|
||||||
"tt_window_opacity": "背景不透明度(越低 = 桌面透过窗口可见)",
|
"tt_window_opacity": "背景不透明度(越低 = 桌面透过窗口可见)",
|
||||||
@@ -2303,8 +2364,10 @@
|
|||||||
"wallet_restore_ok": "原始钱包已还原。节点正在加载中。",
|
"wallet_restore_ok": "原始钱包已还原。节点正在加载中。",
|
||||||
"wallet_restore_started": "正在还原您的原始文件——请勿关闭此窗口。",
|
"wallet_restore_started": "正在还原您的原始文件——请勿关闭此窗口。",
|
||||||
"wallet_restore_stop_failed": "节点未能及时停止,因此未做任何更改。请重试。",
|
"wallet_restore_stop_failed": "节点未能及时停止,因此未做任何更改。请重试。",
|
||||||
|
"wallet_size_auto_info": "您的钱包已累积了大量票据——这些是分散的已收资金,会拖慢同步并使文件不断变大。自动合并票据正在后台悄悄地合并它们,让钱包保持快速并停止变大,您无需任何操作。",
|
||||||
|
"wallet_size_compact": "压缩钱包…",
|
||||||
"wallet_size_consolidate": "合并笔记…",
|
"wallet_size_consolidate": "合并笔记…",
|
||||||
"wallet_size_warn": "此钱包文件较大。合并您的笔记可以抑制其进一步增长。",
|
"wallet_size_warn": "您的钱包已累积了大量票据。您收到的每笔付款都会存为一张单独的“票据”;票据越积越多,钱包同步就会变慢,文件也会不断变大。合并会将它们合成较少的票据,让钱包扫描更快、不再继续变大——您的余额完全不变。",
|
||||||
"wallets_active": "活跃",
|
"wallets_active": "活跃",
|
||||||
"wallets_add": "添加文件夹",
|
"wallets_add": "添加文件夹",
|
||||||
"wallets_add_folder": "同时扫描另一个文件夹中的钱包文件:",
|
"wallets_add_folder": "同时扫描另一个文件夹中的钱包文件:",
|
||||||
|
|||||||
+152
-12
@@ -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;
|
||||||
@@ -1585,6 +1592,13 @@ void App::update()
|
|||||||
if (send_progress_active_ && !addressScanCanRunNow()) maybeFinishTransactionSendProgress();
|
if (send_progress_active_ && !addressScanCanRunNow()) maybeFinishTransactionSendProgress();
|
||||||
// C210: the "Keep in each address" consolidation run, if one is going (paced; one RPC at a time).
|
// C210: the "Keep in each address" consolidation run, if one is going (paced; one RPC at a time).
|
||||||
pumpConsolidation();
|
pumpConsolidation();
|
||||||
|
// Background auto-consolidation (settings toggle): gently trickle-merge notes when the wallet is idle.
|
||||||
|
pumpAutoConsolidation();
|
||||||
|
// While a USER-started consolidation is running, pause the heavy routine wallet scans (balance /
|
||||||
|
// addresses / history / recent-tx) so its merges get more of the node's cs_main budget and finish
|
||||||
|
// sooner — the end-of-run refresh repaints. An auto (background) run stays gentle and is left alone, and
|
||||||
|
// explicit needs (the user's own send, a never-loaded list) still go through. Idempotent per frame.
|
||||||
|
scan_governor_.suppressRoutineWalletScans(consolidation_.active() && !auto_consolidate_run_);
|
||||||
|
|
||||||
// A Settings bootstrap that finished while its dialog wasn't drawn (clicked away, minimized): process it
|
// A Settings bootstrap that finished while its dialog wasn't drawn (clicked away, minimized): process it
|
||||||
// here so the connect and reconcile gates release and the post-bootstrap rescan is armed.
|
// here so the connect and reconcile gates release and the post-bootstrap rescan is armed.
|
||||||
@@ -2576,6 +2590,10 @@ void App::render()
|
|||||||
renderImportKeyDialog();
|
renderImportKeyDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (show_import_wallet_file_) {
|
||||||
|
renderImportWalletFileDialog();
|
||||||
|
}
|
||||||
|
|
||||||
if (show_export_key_) {
|
if (show_export_key_) {
|
||||||
renderExportKeyDialog();
|
renderExportKeyDialog();
|
||||||
}
|
}
|
||||||
@@ -2709,6 +2727,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 +2769,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 +2780,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 +2788,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 +2878,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 +3133,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 +3852,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 +3960,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 +5146,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 +5171,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 +5226,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 +5247,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 +5272,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 +6204,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);
|
||||||
@@ -6090,6 +6222,14 @@ bool App::startEmbeddedDaemon()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compact-via-switch: the user chose to compact after switching from an external node to this built-in
|
||||||
|
// one. Arm -salvagewallet on this start so the fresh node rewrites wallet.dat into a compact file
|
||||||
|
// (-salvagewallet implies a rescan). One-shot: it fires only for the start that follows the switch.
|
||||||
|
if (compact_on_embedded_start_.exchange(false)) {
|
||||||
|
daemon_controller_->setSalvageOnNextStart(true);
|
||||||
|
user_initiated_rescan_ = true; // the implied rescan is wallet-triggered → its completion should toast
|
||||||
|
}
|
||||||
|
|
||||||
// Only a process we actually launched starts a new story: a start that finds a node already running (and
|
// Only a process we actually launched starts a new story: a start that finds a node already running (and
|
||||||
// launches nothing) must not wipe what the tracker knows about that node from its debug.log.
|
// launches nothing) must not wipe what the tracker knows about that node from its debug.log.
|
||||||
const bool launched = daemon_controller_->start(
|
const bool launched = daemon_controller_->start(
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -770,6 +801,20 @@ public:
|
|||||||
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
|
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
|
||||||
*/
|
*/
|
||||||
void restartDaemon();
|
void restartDaemon();
|
||||||
|
// Compact wallet.dat to reclaim the space freed by consolidating notes: Berkeley DB never shrinks a
|
||||||
|
// file in place, so a restart with -salvagewallet rewrites it into a fresh, compact file (keeping a
|
||||||
|
// backup). -salvagewallet implies a full rescan, so this restarts the node and re-scans the chain.
|
||||||
|
void compactWallet();
|
||||||
|
// True when Compact can actually run: a full-node build whose EMBEDDED node the wallet manages (it
|
||||||
|
// restarts it with -salvagewallet). False for lite or when connected to an external/standalone dragonxd.
|
||||||
|
bool canCompactWallet() const {
|
||||||
|
return supportsFullNodeLifecycleActions() && isUsingEmbeddedDaemon() && daemon_controller_ != nullptr;
|
||||||
|
}
|
||||||
|
// Compact when connected to an EXTERNAL node: stop it (graceful RPC "stop"), switch the wallet to its
|
||||||
|
// built-in node, and start that with -salvagewallet so it compacts. Needs a full-node build with
|
||||||
|
// embedded-daemon support. A one-shot flag arms the salvage on the resulting embedded start.
|
||||||
|
void compactViaEmbeddedSwitch();
|
||||||
|
std::atomic<bool> compact_on_embedded_start_{false};
|
||||||
// A newer daemon binary is installed but the running node is still the old one until a restart.
|
// A newer daemon binary is installed but the running node is still the old one until a restart.
|
||||||
void markDaemonRestartPending() { daemon_restart_pending_.store(true); }
|
void markDaemonRestartPending() { daemon_restart_pending_.store(true); }
|
||||||
bool daemonRestartPending() const { return daemon_restart_pending_.load(); }
|
bool daemonRestartPending() const { return daemon_restart_pending_.load(); }
|
||||||
@@ -1009,6 +1054,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
|
||||||
@@ -1062,6 +1109,10 @@ private:
|
|||||||
};
|
};
|
||||||
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
|
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
|
||||||
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
|
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
|
||||||
|
// Larger-messages tier 3: a message split across several transactions shares one echo. This counts the
|
||||||
|
// not-yet-broadcast parts per echo id; the echo flips to Sent only when the last part lands, and to
|
||||||
|
// Failed (with the remaining parts purged) if any part fails. Absent/one ⇒ an ordinary single-tx send.
|
||||||
|
std::unordered_map<std::string, int> chat_send_parts_remaining_;
|
||||||
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
|
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
|
||||||
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
|
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
|
||||||
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
|
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
|
||||||
@@ -1188,6 +1239,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 +1396,11 @@ 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
|
||||||
|
// We armed -salvagewallet ourselves to COMPACT a healthy wallet (not to recover a corrupt one), so the
|
||||||
|
// resulting salvage must NOT raise the "needs a quick repair" dialog — detectWalletAutoRecovery consumes
|
||||||
|
// this and shows a calm "compacted" toast instead. Atomic: compaction can be armed off the UI thread.
|
||||||
|
std::atomic<bool> compaction_salvage_expected_{false};
|
||||||
|
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 +1466,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;
|
||||||
@@ -1897,6 +1960,15 @@ private:
|
|||||||
void postConsolidationCall(int addrIdx);
|
void postConsolidationCall(int addrIdx);
|
||||||
void postConsolidationPoll();
|
void postConsolidationPoll();
|
||||||
void finishConsolidation();
|
void finishConsolidation();
|
||||||
|
// Automatic background consolidation (settings-gated): drives the SAME paced ConsolidationRun in tiny,
|
||||||
|
// gentle cycles when the wallet is idle, so a mining/pool wallet sheds notes without the user ever
|
||||||
|
// clicking Consolidate. pumpAutoConsolidation() is pumped from update() right after pumpConsolidation().
|
||||||
|
void pumpAutoConsolidation();
|
||||||
|
void postAutoConsolidationScan(); // a quiet z_listunspent scan → startConsolidationQuiet on candidates
|
||||||
|
bool startConsolidationQuiet(const std::vector<services::NoteGroup>& groups, services::RunConfig cfg);
|
||||||
|
bool auto_consolidate_run_ = false; // the active run is ours (silent; gentle config)
|
||||||
|
bool auto_consolidate_scan_in_flight_ = false; // an auto note scan is posted
|
||||||
|
double auto_consolidate_next_at_ = 0.0; // steadySeconds() before which no new cycle/scan starts
|
||||||
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
|
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
|
||||||
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
|
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
|
||||||
int consecutive_core_failures_ = 0;
|
int consecutive_core_failures_ = 0;
|
||||||
@@ -2050,6 +2122,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)
|
||||||
|
|||||||
+645
-31
@@ -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
|
||||||
@@ -237,8 +241,15 @@ void App::detectWalletAutoRecovery()
|
|||||||
if (wallet_auto_recovered_warned_) return;
|
if (wallet_auto_recovered_warned_) return;
|
||||||
if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return;
|
if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return;
|
||||||
if (!daemon::walletAutoRecovered(daemon_controller_->daemon()->getOutput())) return;
|
if (!daemon::walletAutoRecovered(daemon_controller_->daemon()->getOutput())) return;
|
||||||
|
wallet_auto_recovered_warned_ = true; // consume the signature either way — only surfaced once per session
|
||||||
|
// Our own Compact armed this salvage to shrink a HEALTHY wallet — it's not a corruption recovery, so don't
|
||||||
|
// raise the alarming "needs a quick repair" dialog (whose "Restore original" would undo the compaction).
|
||||||
|
if (compaction_salvage_expected_.exchange(false)) {
|
||||||
|
ui::Notifications::instance().success(TR("compact_done"), 12.0f);
|
||||||
|
VERBOSE_LOGF("[recovery] Salvage was our intentional compaction — suppressing the recovery dialog\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
wallet_auto_recovered_ = true;
|
wallet_auto_recovered_ = true;
|
||||||
wallet_auto_recovered_warned_ = true;
|
|
||||||
show_wallet_recovered_dialog_ = true;
|
show_wallet_recovered_dialog_ = true;
|
||||||
ui::Notifications::instance().info(TR("wallet_recovered_notify"), 30.0f); // calm, not red — coins are safe
|
ui::Notifications::instance().info(TR("wallet_recovered_notify"), 30.0f); // calm, not red — coins are safe
|
||||||
VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n");
|
VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n");
|
||||||
@@ -559,7 +570,12 @@ 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();
|
||||||
@@ -567,7 +583,18 @@ void App::tryConnect()
|
|||||||
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 +623,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 +866,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 +926,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 +987,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 +1155,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 +3964,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, 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();
|
||||||
}
|
}
|
||||||
return [this, callback, addr, shielded, typeStr]() {
|
|
||||||
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 +4224,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
|
||||||
@@ -4191,6 +4262,10 @@ void App::resetChatSession()
|
|||||||
auto orphanedSend = std::move(lite_send_callback_);
|
auto orphanedSend = std::move(lite_send_callback_);
|
||||||
lite_send_callback_ = nullptr;
|
lite_send_callback_ = nullptr;
|
||||||
chat_send_queue_.clear();
|
chat_send_queue_.clear();
|
||||||
|
chat_send_parts_remaining_.clear(); // no queued parts survive a reset — drop their outstanding counts
|
||||||
|
auto_consolidate_run_ = false; // any auto run is torn down with the connection; start fresh next wallet
|
||||||
|
auto_consolidate_scan_in_flight_ = false;
|
||||||
|
auto_consolidate_next_at_ = 0.0;
|
||||||
inflight_op_ = LiteInflightOp{};
|
inflight_op_ = LiteInflightOp{};
|
||||||
chat_verified_note_budget_ = 0;
|
chat_verified_note_budget_ = 0;
|
||||||
chat_pipeline_note_count_ = 0;
|
chat_pipeline_note_count_ = 0;
|
||||||
@@ -4595,10 +4670,14 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
chat::OutgoingChatMemos memos;
|
// Compose into one OutgoingChatMemos per transaction: short messages yield one (tier 1/2), a long one
|
||||||
if (chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos)
|
// several (tier 3), each a self-contained chat send carrying a slice of the one ciphertext.
|
||||||
!= chat::ChatComposeStatus::Ok) {
|
std::vector<chat::OutgoingChatMemos> parts;
|
||||||
ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
|
const auto composeStatus =
|
||||||
|
chat_service_.composeMessageParts(myReply, peerPubKey, peerZaddr, conversationId, text, parts);
|
||||||
|
if (composeStatus != chat::ChatComposeStatus::Ok || parts.empty()) {
|
||||||
|
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;
|
||||||
@@ -4613,9 +4692,117 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
|||||||
echo.payload_position = 0;
|
echo.payload_position = 0;
|
||||||
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
|
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
|
||||||
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
|
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
|
||||||
// Both variants: hand to the note-buffer coordinator, which drains the queue against verified notes
|
// A tier-3 message rides several transactions under one echo: track the remaining parts so the echo
|
||||||
|
// flips to Sent only when the LAST part lands (see onChatBroadcastResult). A single-part send is left
|
||||||
|
// out of the map and resolves immediately as before.
|
||||||
|
if (parts.size() > 1) chat_send_parts_remaining_[echo.txid] = static_cast<int>(parts.size());
|
||||||
|
// Hand each part to the note-buffer coordinator, which drains the queue against verified notes
|
||||||
// (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result.
|
// (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result.
|
||||||
enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
|
for (auto& p : parts)
|
||||||
|
enqueueChatSend(LiteOpKind::ChatSend, p, 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)
|
||||||
@@ -5064,8 +5251,19 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st
|
|||||||
const bool frontMatches = !chat_send_queue_.empty()
|
const bool frontMatches = !chat_send_queue_.empty()
|
||||||
&& chat_send_queue_.front().echoLocalId == op.echoLocalId;
|
&& chat_send_queue_.front().echoLocalId == op.echoLocalId;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent);
|
|
||||||
if (frontMatches) chat_send_queue_.pop_front();
|
if (frontMatches) chat_send_queue_.pop_front();
|
||||||
|
// Tier-3: a message split across several transactions keeps the echo at "Sending" until its LAST
|
||||||
|
// part has been broadcast. Decrement the outstanding-part count and only resolve on the last one.
|
||||||
|
auto it = chat_send_parts_remaining_.find(op.echoLocalId);
|
||||||
|
if (it != chat_send_parts_remaining_.end()) {
|
||||||
|
if (--it->second > 0) {
|
||||||
|
wallet::liteLog("chat: tier-3 part broadcast; " + std::to_string(it->second) +
|
||||||
|
" part(s) of this message still to send");
|
||||||
|
return; // not fully sent yet — echo stays "Sending"
|
||||||
|
}
|
||||||
|
chat_send_parts_remaining_.erase(it);
|
||||||
|
}
|
||||||
|
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent);
|
||||||
wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) +
|
wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) +
|
||||||
" still queued");
|
" still queued");
|
||||||
return;
|
return;
|
||||||
@@ -5073,16 +5271,26 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st
|
|||||||
if (frontMatches && isTransientVerifiedFundsError(error)
|
if (frontMatches && isTransientVerifiedFundsError(error)
|
||||||
&& chat_send_queue_.front().retries < kMaxChatSendRetries) {
|
&& chat_send_queue_.front().retries < kMaxChatSendRetries) {
|
||||||
// The optimistic budget was wrong (change not confirmed yet). Stop draining until the next refresh
|
// The optimistic budget was wrong (change not confirmed yet). Stop draining until the next refresh
|
||||||
// restores the true verified-note count; keep the message queued ("Sending") and retry then.
|
// restores the true verified-note count; keep the message queued ("Sending") and retry then. A
|
||||||
|
// tier-3 part defers in place like any other — its outstanding-part count is untouched.
|
||||||
chat_verified_note_budget_ = 0;
|
chat_verified_note_budget_ = 0;
|
||||||
++chat_send_queue_.front().retries;
|
++chat_send_queue_.front().retries;
|
||||||
wallet::liteLog("chat: send deferred — funds still confirming (retry " +
|
wallet::liteLog("chat: send deferred — funds still confirming (retry " +
|
||||||
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
|
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
|
||||||
return; // leave at front
|
return; // leave at front
|
||||||
}
|
}
|
||||||
|
// Hard failure. For a tier-3 message, drop its remaining queued parts: a message missing any slice can
|
||||||
|
// never reassemble, so broadcasting the rest only wastes fees. Fail the whole message once.
|
||||||
|
auto it = chat_send_parts_remaining_.find(op.echoLocalId);
|
||||||
|
if (it != chat_send_parts_remaining_.end()) {
|
||||||
|
chat_send_parts_remaining_.erase(it);
|
||||||
|
chat_send_queue_.erase(std::remove_if(chat_send_queue_.begin(), chat_send_queue_.end(),
|
||||||
|
[&](const QueuedChatOp& q) { return q.echoLocalId == op.echoLocalId; }), chat_send_queue_.end());
|
||||||
|
} else if (frontMatches) {
|
||||||
|
chat_send_queue_.pop_front();
|
||||||
|
}
|
||||||
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed,
|
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed,
|
||||||
util::friendlyRpcError(error).substr(0, 160)); // hard failure -> Retry affordance + reason
|
util::friendlyRpcError(error).substr(0, 160)); // hard failure -> Retry affordance + reason
|
||||||
if (frontMatches) chat_send_queue_.pop_front();
|
|
||||||
wallet::liteLog("chat: send FAILED: " + error);
|
wallet::liteLog("chat: send FAILED: " + error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5414,6 +5622,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 +5897,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
|
||||||
@@ -5918,6 +6231,8 @@ bool App::startConsolidation(const std::vector<services::NoteGroup>& groups, dou
|
|||||||
n.warning(TR("merge_keep_each_select"));
|
n.warning(TR("merge_keep_each_select"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
auto_consolidate_run_ = false; // a user-started run is never treated as a silent auto cycle, even if an
|
||||||
|
// auto run was torn down (abort) with its flag not yet cleared
|
||||||
n.success(TR("merge_started"));
|
n.success(TR("merge_started"));
|
||||||
return true; // syncing, the lock, a busy node and pending sends are the run's own pauses
|
return true; // syncing, the lock, a busy node and pending sends are the run's own pauses
|
||||||
}
|
}
|
||||||
@@ -6037,6 +6352,14 @@ void App::finishConsolidation()
|
|||||||
// 2E: a batch of user ops re-reads the wallet and forces one balance, at its end.
|
// 2E: a batch of user ops re-reads the wallet and forces one balance, at its end.
|
||||||
if (s.batchesSucceeded > 0) markWalletMutated(/*forceBalance=*/true);
|
if (s.batchesSucceeded > 0) markWalletMutated(/*forceBalance=*/true);
|
||||||
else if (s.batchesSubmitted > 0) noteWalletMutation();
|
else if (s.batchesSubmitted > 0) noteWalletMutation();
|
||||||
|
// A background auto-consolidation cycle surfaces nothing per cycle — it is meant to be invisible.
|
||||||
|
// pumpAutoConsolidation() schedules the next cycle and dismisses the finished run. Log for diagnostics.
|
||||||
|
if (auto_consolidate_run_) {
|
||||||
|
wallet::liteLog("auto-consolidate: cycle done — " + std::to_string(s.batchesSucceeded) + "/" +
|
||||||
|
std::to_string(s.batchesSubmitted) + " batch(es), " + std::to_string(s.notesMerged) +
|
||||||
|
" note(s) merged");
|
||||||
|
return;
|
||||||
|
}
|
||||||
std::string err = s.firstError.empty() ? std::string(TR("shield_unknown_error")) : s.firstError;
|
std::string err = s.firstError.empty() ? std::string(TR("shield_unknown_error")) : s.firstError;
|
||||||
if (err.size() > 160) err = err.substr(0, 160) + "...";
|
if (err.size() > 160) err = err.substr(0, 160) + "...";
|
||||||
auto& n = ui::Notifications::instance();
|
auto& n = ui::Notifications::instance();
|
||||||
@@ -6056,6 +6379,101 @@ void App::finishConsolidation()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the shared ConsolidationRun with the SAME guards as startConsolidation but SILENTLY and with a
|
||||||
|
// caller-chosen (gentle) config — the background driver's entry point. No toasts; marks the run as ours.
|
||||||
|
// Like the manual "keep in each address" mode, this run merges every address's notes back into THAT SAME
|
||||||
|
// address (z_mergetoaddress [addr] -> addr in postConsolidationCall), so auto-consolidation NEVER moves
|
||||||
|
// funds between addresses — it only sheds notes. It never uses the "into one address" sweep.
|
||||||
|
bool App::startConsolidationQuiet(const std::vector<services::NoteGroup>& groups, services::RunConfig cfg)
|
||||||
|
{
|
||||||
|
if (isLiteBuild() || capture_mode_ || shutting_down_.load()) return false;
|
||||||
|
if (consolidation_.active() || consolidation_.hasRun()) return false; // never fight a manual run
|
||||||
|
if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) return false;
|
||||||
|
if (!rpc_ || !rpc_->isConnected() || !worker_ || !state_.connected || isWalletSwitchInProgress()) return false;
|
||||||
|
if (!consolidation_.start(groups, cfg, services::steadySeconds())) return false;
|
||||||
|
auto_consolidate_run_ = true;
|
||||||
|
wallet::liteLog("auto-consolidate: starting a gentle cycle over " + std::to_string(groups.size()) +
|
||||||
|
" address(es), limit " + std::to_string(cfg.limit) + ", " + std::to_string(cfg.maxBatches) +
|
||||||
|
" batch(es) each");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quiet whole-wallet note scan for the auto driver (the manual dialog's loadScope, minus the UI state).
|
||||||
|
// On result: nothing worth merging → go dormant; candidates → start a gentle run; failure → back off.
|
||||||
|
void App::postAutoConsolidationScan()
|
||||||
|
{
|
||||||
|
if (!worker_ || !rpc_) return;
|
||||||
|
const std::uint64_t session = rpc_session_;
|
||||||
|
const services::ScanTicket ticket = beginAdhocWalletScan(); // z_listunspent is a whole-wallet scan
|
||||||
|
auto_consolidate_scan_in_flight_ = true;
|
||||||
|
worker_->post([this, rpc = rpc_.get(), session, ticket]() -> rpc::RPCWorker::MainCb {
|
||||||
|
std::vector<services::NoteGroup> groups;
|
||||||
|
std::string error;
|
||||||
|
rpc::RpcFailureKind kind = rpc::RpcFailureKind::None;
|
||||||
|
const auto t0 = std::chrono::steady_clock::now();
|
||||||
|
try {
|
||||||
|
MaintenanceCallScope maint(*this);
|
||||||
|
rpc::RPCClient::TraceScope trace("Auto-consolidate / note scan");
|
||||||
|
const nlohmann::json zs = rpc->call("z_listunspent", nlohmann::json::array({0}),
|
||||||
|
services::kScopeCallTimeoutSec);
|
||||||
|
groups = services::groupSpendableNotes(zs);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
error = e.what(); if (error.empty()) error = "error";
|
||||||
|
kind = services::mergeCallFailureKind(e);
|
||||||
|
} catch (...) { error = "unknown error"; kind = rpc::RpcFailureKind::Other; }
|
||||||
|
const double secs = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
||||||
|
return [this, groups = std::move(groups), error, kind, secs, session, ticket]() {
|
||||||
|
endAdhocWalletScan(ticket, secs * 1000.0, services::chatScanOutcome(error.empty(), kind));
|
||||||
|
auto_consolidate_scan_in_flight_ = false;
|
||||||
|
if (session != rpc_session_) return; // wallet switched under us — drop
|
||||||
|
const double now = services::steadySeconds();
|
||||||
|
if (!error.empty()) { // scan failed — back off and try later
|
||||||
|
auto_consolidate_next_at_ = now + services::kAutoConsolidateRetrySec;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const double fee = services::RunConfig{}.fee; // 0.0001
|
||||||
|
const auto cand = services::autoConsolidateCandidates(groups, services::kAutoConsolidateMinNotes, fee);
|
||||||
|
if (cand.empty()) { // tidy enough — go dormant, re-scan rarely
|
||||||
|
auto_consolidate_next_at_ = now + services::kAutoConsolidateIdleRescanSec;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
services::RunConfig cfg;
|
||||||
|
cfg.fee = fee;
|
||||||
|
cfg.limit = services::kAutoConsolidateLimit;
|
||||||
|
cfg.maxBatches = services::kAutoConsolidateMaxBatches;
|
||||||
|
if (!startConsolidationQuiet(cand, cfg)) // a manual run grabbed it, or a transient guard
|
||||||
|
auto_consolidate_next_at_ = now + services::kAutoConsolidateCooldownSec;
|
||||||
|
// On success the cooldown is set when the run finishes (pumpAutoConsolidation).
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive the background auto-consolidation: finalize a finished cycle, else start a new one when idle and the
|
||||||
|
// cooldown has elapsed. Pumped from update() right after pumpConsolidation().
|
||||||
|
void App::pumpAutoConsolidation()
|
||||||
|
{
|
||||||
|
// Finalize a cycle we started once its run is no longer active (whether it ended via Finish or an abort):
|
||||||
|
// schedule the next cycle and dismiss the finished run so the manual dialog never shows a stale result.
|
||||||
|
if (auto_consolidate_run_ && !consolidation_.active()) {
|
||||||
|
const services::RunSummary s = consolidation_.summary();
|
||||||
|
const bool trouble = consolidation_.end() == services::RunEnd::Halted ||
|
||||||
|
consolidation_.end() == services::RunEnd::Aborted || s.batchesSucceeded == 0;
|
||||||
|
auto_consolidate_next_at_ = services::steadySeconds() +
|
||||||
|
(trouble ? services::kAutoConsolidateRetrySec : services::kAutoConsolidateCooldownSec);
|
||||||
|
consolidation_.dismiss();
|
||||||
|
auto_consolidate_run_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!settings_ || !settings_->getAutoConsolidate()) return;
|
||||||
|
if (auto_consolidate_scan_in_flight_) return;
|
||||||
|
if (consolidation_.active() || consolidation_.hasRun()) return; // a manual run owns the engine
|
||||||
|
if (show_seed_migration_ || settings_->getSeedMigrationPending()) return;
|
||||||
|
if (!walletScanReady() || walletScanInFlight()) return; // connected, synced, unlocked, not busy/rescanning
|
||||||
|
if (hasTransactionSendProgress()) return; // our own sends go first
|
||||||
|
if (services::steadySeconds() < auto_consolidate_next_at_) return; // cooldown / dormancy
|
||||||
|
postAutoConsolidationScan();
|
||||||
|
}
|
||||||
|
|
||||||
void App::maybeWarnLargeWallet()
|
void App::maybeWarnLargeWallet()
|
||||||
{
|
{
|
||||||
if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat
|
if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat
|
||||||
@@ -6077,12 +6495,93 @@ void App::maybeWarnLargeWallet()
|
|||||||
if (settings_->getLargeWalletWarned()) return; // already warned once for this bloat episode
|
if (settings_->getLargeWalletWarned()) return; // already warned once for this bloat episode
|
||||||
settings_->setLargeWalletWarned(true);
|
settings_->setLargeWalletWarned(true);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
|
// The message itself explains what notes are and why consolidating helps (it wraps in full in the
|
||||||
|
// bell/alert panel and the Settings banner). With auto-consolidation on, reassure the user it's being
|
||||||
|
// handled in the background rather than nudging manual action; otherwise offer the Consolidate dialog.
|
||||||
|
if (settings_->getAutoConsolidate())
|
||||||
|
ui::Notifications::instance().info(TR("wallet_size_auto_info"), 12.0f);
|
||||||
|
else
|
||||||
ui::Notifications::instance().action(
|
ui::Notifications::instance().action(
|
||||||
TR("wallet_size_warn"), ui::NotificationType::Warning,
|
TR("wallet_size_warn"), ui::NotificationType::Warning,
|
||||||
[]() { ui::ShieldDialog::showConsolidate(); },
|
[]() { ui::ShieldDialog::showConsolidate(); },
|
||||||
TR("wallet_size_consolidate"), 12.0f);
|
TR("wallet_size_consolidate"), 12.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void App::compactWallet()
|
||||||
|
{
|
||||||
|
auto& n = ui::Notifications::instance();
|
||||||
|
if (!supportsFullNodeLifecycleActions()) {
|
||||||
|
n.warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Compacting rewrites wallet.dat by restarting the node with -salvagewallet — only possible when the
|
||||||
|
// wallet MANAGES the node (the embedded daemon). With an external dragonxd there is no controller to
|
||||||
|
// restart, so tell the user how to do it themselves instead of the misleading lite-build message.
|
||||||
|
if (!isUsingEmbeddedDaemon() || !daemon_controller_) {
|
||||||
|
n.warning(TR("compact_needs_embedded"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Consolidation frees internal BDB pages but never returns the space to the OS; -salvagewallet on the
|
||||||
|
// next start rewrites wallet.dat into a fresh, compact file (and keeps the old one as a timestamped
|
||||||
|
// .bak). It implies a full rescan, so restartDaemon() takes the node down and back up re-scanning.
|
||||||
|
daemon_controller_->setSalvageOnNextStart(true);
|
||||||
|
compaction_salvage_expected_.store(true); // this salvage is a compaction, not a recovery — don't alarm
|
||||||
|
restartDaemon(); // does its own full-node / busy / lifecycle guards and will refuse if the node is busy
|
||||||
|
if (daemon_restarting_.load()) {
|
||||||
|
n.info(TR("compact_started"));
|
||||||
|
} else {
|
||||||
|
daemon_controller_->setSalvageOnNextStart(false); // the restart was refused — disarm the stray salvage
|
||||||
|
compaction_salvage_expected_.store(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::compactViaEmbeddedSwitch()
|
||||||
|
{
|
||||||
|
auto& n = ui::Notifications::instance();
|
||||||
|
if (!supportsFullNodeLifecycleActions() || !supportsEmbeddedDaemon()) {
|
||||||
|
n.warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canCompactWallet()) { compactWallet(); return; } // already on the built-in node — just compact
|
||||||
|
if (daemon_restarting_.load() || isWalletSwitchInProgress() || show_seed_migration_ ||
|
||||||
|
(settings_ && settings_->getSeedMigrationPending())) {
|
||||||
|
n.warning(TR("merge_run_busy"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!worker_) return;
|
||||||
|
|
||||||
|
// Gate the connect loop / auto-start while we swap nodes, then drop the RPC connection to the external
|
||||||
|
// node (we're about to tell it to stop). onDisconnected shows the loading overlay.
|
||||||
|
daemon_restarting_ = true;
|
||||||
|
connection_status_ = TR("compact_switching");
|
||||||
|
if (rpc_ && rpc_->isConnected()) { abortMaintenanceCalls(); rpc_->disconnect(); }
|
||||||
|
onDisconnected("Compact: switch to the built-in node");
|
||||||
|
n.info(TR("compact_switching"), 12.0f);
|
||||||
|
|
||||||
|
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||||
|
// Graceful RPC "stop" to the external node + wait (up to ~20 s) for its RPC port to free. Blocking,
|
||||||
|
// so it runs on the worker; no RPC is needed meanwhile (we just disconnected).
|
||||||
|
const bool stopped = stopDaemonForWalletSwitch();
|
||||||
|
return [this, stopped]() { // back on the main thread
|
||||||
|
daemon_restarting_ = false;
|
||||||
|
if (shutting_down_.load()) return;
|
||||||
|
if (!stopped) {
|
||||||
|
ui::Notifications::instance().error(TR("compact_switch_stop_failed"));
|
||||||
|
return; // the external node is still up; nothing was changed
|
||||||
|
}
|
||||||
|
// Switch the wallet to its built-in node, arm the compacting start, and bring it up. The fresh
|
||||||
|
// node starts with -salvagewallet (rewrite + rescan); the connect loop reconnects to it. (Embedded
|
||||||
|
// mode is a runtime flag that defaults on for a full-node build, so this just ensures it's set —
|
||||||
|
// after the switch our own process is the running node.)
|
||||||
|
setUseEmbeddedDaemon(true);
|
||||||
|
compact_on_embedded_start_.store(true);
|
||||||
|
compaction_salvage_expected_.store(true); // the coming salvage is a compaction — don't alarm
|
||||||
|
ui::Notifications::instance().info(TR("compact_started"));
|
||||||
|
startEmbeddedDaemon();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it
|
// Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it
|
||||||
// happened on a prior run, or under an external daemon whose startup output we never captured, so
|
// happened on a prior run, or under an external daemon whose startup output we never captured, so
|
||||||
// detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in
|
// detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in
|
||||||
@@ -6386,7 +6885,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 +7081,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,22 +7193,54 @@ 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,
|
||||||
|
// size-verified staged copy, and only AFTER a complete size-verified .bak of the legacy
|
||||||
|
// wallet exists. A hard kill at any point leaves wallet.dat as either the whole old or the
|
||||||
|
// whole new wallet - never missing or half-written. Nothing of the user's is deleted.
|
||||||
|
const std::string staged = legacy + ".adopt-tmp";
|
||||||
|
auto sameSize = [](const std::string& a, const std::string& b) {
|
||||||
|
std::error_code e1, e2;
|
||||||
|
const auto sa = fs::file_size(a, e1);
|
||||||
|
const auto sb = fs::file_size(b, e2);
|
||||||
|
return !e1 && !e2 && sa == sb && sa > 0;
|
||||||
|
};
|
||||||
|
#ifndef _WIN32
|
||||||
|
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)) {
|
if (fs::exists(legacy)) {
|
||||||
fs::rename(legacy, backup, ec);
|
backupCreated = !fs::exists(backup);
|
||||||
if (ec) err = "Could not back up your current wallet; nothing was changed.";
|
fs::copy_file(legacy, backup, fs::copy_options::none, ec); // never overwrites an existing .bak
|
||||||
else movedLegacy = true;
|
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()) {
|
if (err.empty()) {
|
||||||
fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec);
|
fs::rename(staged, legacy, ec);
|
||||||
if (ec) {
|
if (ec) err = "Could not install the new wallet; your original wallet was left unchanged.";
|
||||||
if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); }
|
else swapDone = true;
|
||||||
err = "Could not install the new wallet; your original wallet was restored.";
|
|
||||||
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7209,6 +7811,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);
|
||||||
@@ -7217,12 +7820,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/
|
||||||
@@ -7245,7 +7849,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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+171
-16
@@ -5,6 +5,10 @@
|
|||||||
#include "chat_protocol.h" // kHushChat* constants
|
#include "chat_protocol.h" // kHushChat* constants
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
namespace dragonx::chat {
|
namespace dragonx::chat {
|
||||||
|
|
||||||
@@ -17,31 +21,61 @@ 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,
|
||||||
|
const std::string& messageId = "")
|
||||||
{
|
{
|
||||||
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 (!messageId.empty()) header["mid"] = messageId; // tier 3 only: ties this tx's slice to its siblings
|
||||||
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(); }
|
||||||
|
|
||||||
|
// A random 8-byte message id as 16 hex chars — ties a tier-3 message's sibling transactions together.
|
||||||
|
std::string newMessageId()
|
||||||
|
{
|
||||||
|
unsigned char buf[8];
|
||||||
|
randombytes_buf(buf, sizeof buf);
|
||||||
|
static const char* kHex = "0123456789abcdef";
|
||||||
|
std::string s;
|
||||||
|
s.reserve(sizeof(buf) * 2);
|
||||||
|
for (unsigned char b : buf) { s += kHex[b >> 4]; s += kHex[b & 0xF]; }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
} // 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,19 +101,111 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatComposeStatus splitOutgoingMessage(const ChatKeyPair& mine,
|
||||||
|
const std::string& myPublicKeyHex,
|
||||||
|
const std::string& myReplyZaddr,
|
||||||
|
const std::string& peerPublicKeyHex,
|
||||||
|
const std::string& peerZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& plaintext,
|
||||||
|
std::vector<OutgoingChatMemos>& out)
|
||||||
|
{
|
||||||
|
if (plaintext.empty()) return ChatComposeStatus::EmptyBody;
|
||||||
|
if (!present(myPublicKeyHex) || !present(myReplyZaddr) || !present(peerZaddr) ||
|
||||||
|
!present(conversationId)) {
|
||||||
|
return ChatComposeStatus::MissingField;
|
||||||
|
}
|
||||||
|
if (peerPublicKeyHex.size() != kHushChatPublicKeyHexLength) return ChatComposeStatus::BadPeerKey;
|
||||||
|
|
||||||
|
// Encrypt the whole message ONCE (one secretstream session). Chunking is transport-only, so every
|
||||||
|
// transaction of a tier-3 message carries the same secretstream header "e" and its slice of the one
|
||||||
|
// ciphertext — the crypto is byte-identical to the single-tx path.
|
||||||
|
std::string streamHeaderHex;
|
||||||
|
std::string ciphertextHex;
|
||||||
|
if (encryptOutgoing(mine, peerPublicKeyHex, plaintext, streamHeaderHex, ciphertextHex)
|
||||||
|
!= ChatCryptoStatus::Ok) {
|
||||||
|
return ChatComposeStatus::EncryptFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::int64_t now = static_cast<std::int64_t>(std::time(nullptr));
|
||||||
|
|
||||||
|
// 1 chunk if the ciphertext fits one memo, else ceil(hex / chunkHex) chunks (chunkHex = the 512-byte
|
||||||
|
// memo limit minus the 2-hex global index prefix, rounded to an even hex length).
|
||||||
|
const std::size_t chunkHex = (kHushChatMemoByteLimit - kHushChatPartIndexHexLen) & ~std::size_t(1);
|
||||||
|
const std::size_t nChunks = (ciphertextHex.size() <= kHushChatMemoByteLimit)
|
||||||
|
? 1
|
||||||
|
: (ciphertextHex.size() + chunkHex - 1) / chunkHex;
|
||||||
|
if (static_cast<int>(nChunks) > kHushChatMaxMessageChunks) return ChatComposeStatus::TooLong;
|
||||||
|
|
||||||
|
std::vector<OutgoingChatMemos> txs;
|
||||||
|
|
||||||
|
if (nChunks <= 1) {
|
||||||
|
// Tier 1: one classic payload — no index prefix, no "n", no "mid" (byte-identical on the wire).
|
||||||
|
OutgoingChatMemos m;
|
||||||
|
m.recipientZaddr = peerZaddr;
|
||||||
|
m.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex,
|
||||||
|
myPublicKeyHex, now, "", 1, "");
|
||||||
|
m.payloadMemos = { ciphertextHex };
|
||||||
|
txs.push_back(std::move(m));
|
||||||
|
} else {
|
||||||
|
// Tier 2/3: index each chunk with its GLOBAL index and pack up to kHushChatMaxMessageParts chunks
|
||||||
|
// per transaction. One tx (≤ the per-tx cap) is tier 2 (no "mid"); several is tier 3 (shared "mid",
|
||||||
|
// same "n" = total chunk count in every sibling header).
|
||||||
|
const bool multiTx = static_cast<int>(nChunks) > kHushChatMaxMessageParts;
|
||||||
|
const std::string mid = multiTx ? newMessageId() : std::string();
|
||||||
|
const std::size_t perTx = static_cast<std::size_t>(kHushChatMaxMessageParts);
|
||||||
|
for (std::size_t base = 0; base < nChunks; base += perTx) {
|
||||||
|
OutgoingChatMemos m;
|
||||||
|
m.recipientZaddr = peerZaddr;
|
||||||
|
m.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex,
|
||||||
|
myPublicKeyHex, now, "", static_cast<int>(nChunks), mid);
|
||||||
|
const std::size_t end = std::min(nChunks, base + perTx);
|
||||||
|
m.payloadMemos.reserve(end - base);
|
||||||
|
for (std::size_t i = base; i < end; ++i)
|
||||||
|
m.payloadMemos.push_back(partIndexPrefix(i) + ciphertextHex.substr(i * chunkHex, chunkHex));
|
||||||
|
txs.push_back(std::move(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& m : txs) {
|
||||||
|
if (m.headerMemo.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
|
||||||
|
for (const auto& p : m.payloadMemos)
|
||||||
|
if (p.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
|
||||||
|
}
|
||||||
|
out = std::move(txs);
|
||||||
|
return ChatComposeStatus::Ok;
|
||||||
|
}
|
||||||
|
|
||||||
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
||||||
const std::string& myReplyZaddr,
|
const std::string& myReplyZaddr,
|
||||||
const std::string& peerZaddr,
|
const std::string& peerZaddr,
|
||||||
@@ -100,9 +226,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,
|
||||||
@@ -54,6 +58,21 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
|||||||
const std::string& plaintext,
|
const std::string& plaintext,
|
||||||
OutgoingChatMemos& out);
|
OutgoingChatMemos& out);
|
||||||
|
|
||||||
|
// Build an ENCRYPTED message, splitting it across as many transactions as its length needs (larger-
|
||||||
|
// messages tier 3). Returns one OutgoingChatMemos per transaction to broadcast (in order): a short
|
||||||
|
// message yields a single element identical to buildOutgoingMessage (tier 1/2); a long one yields
|
||||||
|
// several, each a self-contained chat send carrying a slice of the one ciphertext under a shared
|
||||||
|
// message id. The receiver reassembles across the siblings. Refused as TooLong beyond
|
||||||
|
// kHushChatMaxMessageChunks total chunks. The caller broadcasts each element as its own chat tx.
|
||||||
|
ChatComposeStatus splitOutgoingMessage(const ChatKeyPair& mine,
|
||||||
|
const std::string& myPublicKeyHex,
|
||||||
|
const std::string& myReplyZaddr,
|
||||||
|
const std::string& peerPublicKeyHex,
|
||||||
|
const std::string& peerZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& plaintext,
|
||||||
|
std::vector<OutgoingChatMemos>& out);
|
||||||
|
|
||||||
// Build a plaintext contact request — no peer public key needed yet; this is how the peer first
|
// Build a plaintext contact request — no peer public key needed yet; this is how the peer first
|
||||||
// learns your public key + reply address. The payload is the (plaintext) request text.
|
// learns your public key + reply address. The payload is the (plaintext) request text.
|
||||||
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
||||||
@@ -63,4 +82,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,22 @@ 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.
|
||||||
|
// A single-tx (tier 2) message chunks at most kHushChatMaxMessageParts; a multi-tx (tier 3)
|
||||||
|
// message carries the larger TOTAL chunk count here alongside a "mid", capped at the chunk ceiling.
|
||||||
|
if (auto it = object.find("n"); it != object.end() && it->is_number_integer()) {
|
||||||
|
const int n = it->get<int>();
|
||||||
|
if (n < 1 || n > kHushChatMaxMessageChunks) return fail("message part count out of range");
|
||||||
|
header.parts = n;
|
||||||
|
}
|
||||||
|
// Optional tier-3 message id ("mid"): present only when the message is split across several
|
||||||
|
// transactions, tying this tx's chunks to its siblings. Short hex (the sender stamps 8 bytes).
|
||||||
|
if (auto it = object.find("mid"); it != object.end() && it->is_string()) {
|
||||||
|
header.message_id = it->get<std::string>();
|
||||||
|
if (header.message_id.empty() || header.message_id.size() > 64 || !isHexString(header.message_id))
|
||||||
|
return fail("message id must be short hex");
|
||||||
|
}
|
||||||
} 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 +205,106 @@ 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;
|
||||||
|
};
|
||||||
|
// --- Tier 3: this transaction carries only a SLICE of a larger message (message_id set) ---
|
||||||
|
// Surface its chunks with their GLOBAL indices as a partial; the chat service accumulates
|
||||||
|
// slices across the sibling txs that share this message_id and reassembles once all `parts`
|
||||||
|
// chunks are present. One tx holds at most kHushChatMaxMessageParts of them.
|
||||||
|
if (!multiHeader->message_id.empty()) {
|
||||||
|
HushChatMemoPair pair;
|
||||||
|
pair.header_position = multiHeaderPos;
|
||||||
|
pair.message_id = multiHeader->message_id;
|
||||||
|
pair.total_parts = want;
|
||||||
|
bool okp = true;
|
||||||
|
std::size_t lowPos = 0; bool haveLow = false;
|
||||||
|
for (const auto* p : payloads) {
|
||||||
|
const std::string& m = p->memo;
|
||||||
|
if (m.size() < kHushChatPartIndexHexLen || (m.size() % 2) != 0 || !isHexString(m)) { okp = false; break; }
|
||||||
|
const int idx = hexVal(m[0]) * 16 + hexVal(m[1]);
|
||||||
|
if (idx < 0 || idx >= want) { okp = false; break; }
|
||||||
|
pair.partial_chunks.emplace_back(idx, m.substr(kHushChatPartIndexHexLen));
|
||||||
|
if (!haveLow || p->position < lowPos) { lowPos = p->position; haveLow = true; }
|
||||||
|
}
|
||||||
|
if (okp && !pair.partial_chunks.empty()) {
|
||||||
|
pair.header = std::move(*multiHeader);
|
||||||
|
pair.payload_position = haveLow ? lowPos : 0;
|
||||||
|
result.pairs.push_back(std::move(pair));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.issues.push_back(HushChatMemoGroupingIssueInfo{
|
||||||
|
HushChatMemoGroupingIssue::MissingPayload, multiHeaderPos,
|
||||||
|
"tier-3 message slice did not parse"});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
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 +425,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;
|
||||||
@@ -308,6 +435,9 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
|
|||||||
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
|
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
|
||||||
metadata.payload_memo = pair.payload_memo;
|
metadata.payload_memo = pair.payload_memo;
|
||||||
metadata.sent_at = pair.header.sent_at; // carry the sender's compose time (0 if absent)
|
metadata.sent_at = pair.header.sent_at; // carry the sender's compose time (0 if absent)
|
||||||
|
metadata.message_id = pair.message_id; // tier 3: set when this tx is a slice of a bigger message
|
||||||
|
metadata.total_parts = pair.total_parts; // (payload_memo empty; chunks below)
|
||||||
|
metadata.partial_chunks = pair.partial_chunks;
|
||||||
result.metadata.push_back(std::move(metadata));
|
result.metadata.push_back(std::move(metadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#ifndef DRAGONX_ENABLE_CHAT
|
#ifndef DRAGONX_ENABLE_CHAT
|
||||||
@@ -12,7 +14,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 +24,18 @@ 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. For tier 2 all N chunks ride ONE tx; for
|
||||||
|
// tier 3 (message_id set) "n" is the TOTAL chunk count spread across several sibling transactions.
|
||||||
|
int parts = 1;
|
||||||
|
// Message only, larger-messages tier 3: when a message needs more chunks than one tx can carry, it
|
||||||
|
// is split across several transactions that all share this random id (header "mid"); each tx's
|
||||||
|
// payloads carry their GLOBAL chunk index (0..parts-1). Empty = a single-tx message (tier 1/2).
|
||||||
|
std::string message_id;
|
||||||
// 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;
|
||||||
@@ -44,6 +57,12 @@ struct HushChatMemoPair {
|
|||||||
std::size_t header_position = 0;
|
std::size_t header_position = 0;
|
||||||
std::size_t payload_position = 0;
|
std::size_t payload_position = 0;
|
||||||
std::string payload_memo;
|
std::string payload_memo;
|
||||||
|
// Larger-messages tier 3: a single transaction that carries only PART of a bigger message (the rest
|
||||||
|
// ride sibling txs sharing message_id). payload_memo is then empty; partial_chunks holds this tx's
|
||||||
|
// (global index, ciphertext-hex chunk) pairs and total_parts is the whole message's chunk count.
|
||||||
|
std::string message_id;
|
||||||
|
int total_parts = 0;
|
||||||
|
std::vector<std::pair<int, std::string>> partial_chunks;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class HushChatMemoGroupingIssue {
|
enum class HushChatMemoGroupingIssue {
|
||||||
@@ -73,6 +92,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;
|
||||||
@@ -84,6 +104,13 @@ struct HushChatTransactionMetadata {
|
|||||||
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
|
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
|
||||||
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
|
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
|
||||||
std::int64_t sent_at = 0; // header "ts": sender compose time (Unix s); 0 = absent → use tx time
|
std::int64_t sent_at = 0; // header "ts": sender compose time (Unix s); 0 = absent → use tx time
|
||||||
|
// Larger-messages tier 3 (a multi-transaction Message): message_id ties this tx's slice to its
|
||||||
|
// siblings, total_parts is the whole message's chunk count, and partial_chunks are this tx's
|
||||||
|
// (global index, ciphertext-hex) chunks. Empty message_id = an ordinary single-tx message (payload
|
||||||
|
// in payload_memo). The chat service accumulates partials by message_id and reassembles when whole.
|
||||||
|
std::string message_id;
|
||||||
|
int total_parts = 0;
|
||||||
|
std::vector<std::pair<int, std::string>> partial_chunks;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct HushChatTransactionExtractionResult {
|
struct HushChatTransactionExtractionResult {
|
||||||
@@ -95,6 +122,18 @@ 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 transaction may carry (one tx). Keeps the
|
||||||
|
// chat tx well under the shielded max_tx_size after Sietch decoy padding + proving cost. A message
|
||||||
|
// needing more chunks than this spills into sibling transactions (tier 3), up to kHushChatMaxMessageChunks.
|
||||||
|
constexpr int kHushChatMaxMessageParts = 16;
|
||||||
|
// Larger-messages tier 3: the most chunks a single message may span IN TOTAL, across all its sibling
|
||||||
|
// transactions (ceil(chunks / kHushChatMaxMessageParts) txs, each a separate 0-value chat send + fee).
|
||||||
|
// Bounds the on-chain cost / broadcast time of one message; beyond this compose returns TooLong. The
|
||||||
|
// 2-hex global part index (below) tops out at 256, so this must stay ≤ 256.
|
||||||
|
constexpr int kHushChatMaxMessageChunks = 64;
|
||||||
|
// 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
|
||||||
+177
-12
@@ -39,15 +39,14 @@ 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) {
|
|
||||||
|
// Build, decrypt (Message) and store one piece of message metadata — a single-transaction message,
|
||||||
|
// or a tier-3 message reassembled from its slices below. Shared so both apply the same guards exactly.
|
||||||
|
auto processMessage = [&](const HushChatTransactionMetadata& meta) {
|
||||||
// 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. For a reassembled tier-3 message this keys off its stable
|
||||||
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
// canonical txid, deduping it across scans.
|
||||||
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
|
if (store_.contains(meta.txid, meta.payload_position)) return;
|
||||||
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
|
|
||||||
// "from peer" message. (This also collapses same-seed self-chat, where the "peer" wallet shares
|
|
||||||
// our identity, so every message would otherwise loop back.)
|
|
||||||
if (!myPubKey.empty() && meta.sender_public_key_hex == myPubKey) continue;
|
|
||||||
|
|
||||||
ChatMessage message;
|
ChatMessage message;
|
||||||
message.direction = ChatDirection::Incoming;
|
message.direction = ChatDirection::Incoming;
|
||||||
@@ -76,8 +75,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
// is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
|
// is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
|
||||||
// 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)) return;
|
||||||
if (db_ && db_->isTombstoned(message)) continue;
|
if (conversationHiddenOnChain(message.conversation_id)) return; // 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())) return;
|
||||||
|
if (db_ && db_->isTombstoned(message)) return;
|
||||||
|
|
||||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||||
message.kind = ChatMessageKind::ContactRequest;
|
message.kind = ChatMessageKind::ContactRequest;
|
||||||
@@ -88,7 +92,7 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
// different header with this payload gets its own try.
|
// different header with this payload gets its own try.
|
||||||
const std::string failKey = ChatStore::messageKey(meta.txid, meta.payload_position) + "|" +
|
const std::string failKey = ChatStore::messageKey(meta.txid, meta.payload_position) + "|" +
|
||||||
meta.sender_public_key_hex + "|" + meta.secretstream_header_hex;
|
meta.sender_public_key_hex + "|" + meta.secretstream_header_hex;
|
||||||
if (undecryptable_.count(failKey)) continue;
|
if (undecryptable_.count(failKey)) return;
|
||||||
++decrypt_attempts_;
|
++decrypt_attempts_;
|
||||||
std::string plaintext;
|
std::string plaintext;
|
||||||
const ChatCryptoStatus status = decryptIncoming(
|
const ChatCryptoStatus status = decryptIncoming(
|
||||||
@@ -99,7 +103,7 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
if (undecryptable_.size() >= 4096) undecryptable_.clear();
|
if (undecryptable_.size() >= 4096) undecryptable_.clear();
|
||||||
undecryptable_.insert(failKey);
|
undecryptable_.insert(failKey);
|
||||||
}
|
}
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
message.body = std::move(plaintext);
|
message.body = std::move(plaintext);
|
||||||
}
|
}
|
||||||
@@ -114,8 +118,92 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
|
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
|
||||||
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
|
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
|
||||||
|
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
|
||||||
|
// "from peer" message (this also collapses same-seed self-chat). Skips our own tier-3 slices too.
|
||||||
|
if (!myPubKey.empty() && meta.sender_public_key_hex == myPubKey) continue;
|
||||||
|
|
||||||
|
// Larger-messages tier 3: a slice of a multi-transaction message. Accumulate it by message id;
|
||||||
|
// the whole message is reassembled below once every chunk has arrived (across txs and scans).
|
||||||
|
if (!meta.message_id.empty()) {
|
||||||
|
if (meta.total_parts <= 0 || meta.total_parts > kHushChatMaxMessageChunks) continue; // sanity
|
||||||
|
// Bound the buffer against a flood of never-completing partials (re-fed from chain, so a clear
|
||||||
|
// only delays, never loses, a genuine message).
|
||||||
|
if (tier3_pending_.size() > 256 && !tier3_pending_.count(meta.message_id)) tier3_pending_.clear();
|
||||||
|
Tier3Pending& g = tier3_pending_[meta.message_id];
|
||||||
|
if (g.chunks.empty()) { // first slice seen: capture the shared header fields
|
||||||
|
g.total_parts = meta.total_parts;
|
||||||
|
g.secretstream_header_hex = meta.secretstream_header_hex;
|
||||||
|
g.sender_public_key_hex = meta.sender_public_key_hex;
|
||||||
|
g.conversation_id = meta.conversation_id;
|
||||||
|
g.reply_zaddr = meta.reply_zaddr;
|
||||||
|
g.sent_at = meta.sent_at;
|
||||||
|
g.canonical_txid = meta.txid;
|
||||||
|
}
|
||||||
|
if (meta.txid < g.canonical_txid) g.canonical_txid = meta.txid; // deterministic stable key
|
||||||
|
for (const auto& c : meta.partial_chunks)
|
||||||
|
if (c.first >= 0 && c.first < g.total_parts) g.chunks[c.first] = c.second;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
processMessage(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize every tier-3 message whose chunks have all arrived: reassemble the one ciphertext and run
|
||||||
|
// it through the shared message path (decrypt + the block/hide/sensitive guards). Retire the group
|
||||||
|
// afterwards unless it was withheld only because the sensitive vault is still locked.
|
||||||
|
{
|
||||||
|
std::vector<std::string> retire;
|
||||||
|
for (auto& entry : tier3_pending_) {
|
||||||
|
Tier3Pending& g = entry.second;
|
||||||
|
if (g.total_parts <= 0 || static_cast<int>(g.chunks.size()) != g.total_parts) continue;
|
||||||
|
bool complete = true;
|
||||||
|
for (int i = 0; i < g.total_parts; ++i) if (!g.chunks.count(i)) { complete = false; break; }
|
||||||
|
if (!complete) continue;
|
||||||
|
if (store_.contains(g.canonical_txid, 0)) { retire.push_back(entry.first); continue; } // already have it
|
||||||
|
|
||||||
|
HushChatTransactionMetadata synth;
|
||||||
|
synth.type = HushChatHeaderType::Message;
|
||||||
|
synth.txid = g.canonical_txid;
|
||||||
|
synth.payload_position = 0;
|
||||||
|
synth.conversation_id = g.conversation_id;
|
||||||
|
synth.reply_zaddr = g.reply_zaddr;
|
||||||
|
synth.sender_public_key_hex = g.sender_public_key_hex;
|
||||||
|
synth.secretstream_header_hex = g.secretstream_header_hex;
|
||||||
|
synth.sent_at = g.sent_at;
|
||||||
|
std::string full;
|
||||||
|
for (int i = 0; i < g.total_parts; ++i) full += g.chunks[i];
|
||||||
|
synth.payload_memo = std::move(full);
|
||||||
|
processMessage(synth);
|
||||||
|
|
||||||
|
const bool withheldSensitive =
|
||||||
|
isConversationSensitive(g.conversation_id) && !(db_ && db_->hasSensitiveKey());
|
||||||
|
if (!withheldSensitive) retire.push_back(entry.first);
|
||||||
|
}
|
||||||
|
for (const auto& mid : retire) tier3_pending_.erase(mid);
|
||||||
|
}
|
||||||
|
|
||||||
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 +234,72 @@ 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);
|
||||||
|
}
|
||||||
|
std::vector<ChatService::Tier3Progress> ChatService::pendingLargeMessages() const {
|
||||||
|
std::vector<Tier3Progress> out;
|
||||||
|
for (const auto& entry : tier3_pending_) {
|
||||||
|
const Tier3Pending& g = entry.second;
|
||||||
|
const int received = static_cast<int>(g.chunks.size());
|
||||||
|
// Report only the genuinely in-progress ones. A complete group waiting on the sensitive vault key
|
||||||
|
// (received == total but withheld) is done receiving, so it isn't shown as "incoming".
|
||||||
|
if (g.total_parts > 0 && received < g.total_parts)
|
||||||
|
out.push_back(Tier3Progress{ g.conversation_id, received, g.total_parts });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
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_);
|
||||||
@@ -162,6 +316,17 @@ ChatComposeStatus ChatService::composeMessage(const std::string& myReplyZaddr,
|
|||||||
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
|
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatComposeStatus ChatService::composeMessageParts(const std::string& myReplyZaddr,
|
||||||
|
const std::string& peerPublicKeyHex,
|
||||||
|
const std::string& peerZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& plaintext,
|
||||||
|
std::vector<OutgoingChatMemos>& out) const {
|
||||||
|
if (!has_identity_) return ChatComposeStatus::MissingField;
|
||||||
|
return splitOutgoingMessage(identity_, chatIdentityPublicKeyHex(identity_), myReplyZaddr,
|
||||||
|
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
|
||||||
|
}
|
||||||
|
|
||||||
ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr,
|
ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr,
|
||||||
const std::string& peerZaddr,
|
const std::string& peerZaddr,
|
||||||
const std::string& conversationId,
|
const std::string& conversationId,
|
||||||
|
|||||||
+81
-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,44 @@ 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;
|
||||||
|
|
||||||
|
// Larger-messages tier 3: a message still being received across transactions (some chunks arrived,
|
||||||
|
// not yet all). Lets the UI show an "incoming large message — N of M parts" hint until it completes
|
||||||
|
// and surfaces as an ordinary message. One entry per in-progress message; empty when none.
|
||||||
|
struct Tier3Progress {
|
||||||
|
std::string conversation_id;
|
||||||
|
int received = 0; // chunks gathered so far
|
||||||
|
int total = 0; // chunks the whole message needs
|
||||||
|
};
|
||||||
|
std::vector<Tier3Progress> pendingLargeMessages() 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;
|
||||||
@@ -80,6 +120,15 @@ public:
|
|||||||
const std::string& conversationId,
|
const std::string& conversationId,
|
||||||
const std::string& plaintext,
|
const std::string& plaintext,
|
||||||
OutgoingChatMemos& out) const;
|
OutgoingChatMemos& out) const;
|
||||||
|
// Construct the outgoing memos for an ENCRYPTED message, splitting it across as many transactions as
|
||||||
|
// its length needs (larger-messages tier 3). Returns one OutgoingChatMemos per transaction to send in
|
||||||
|
// order; a short message yields a single element (identical to composeMessage).
|
||||||
|
ChatComposeStatus composeMessageParts(const std::string& myReplyZaddr,
|
||||||
|
const std::string& peerPublicKeyHex,
|
||||||
|
const std::string& peerZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& plaintext,
|
||||||
|
std::vector<OutgoingChatMemos>& out) const;
|
||||||
// Construct the outgoing memos for a plaintext contact request (no peer key needed yet).
|
// Construct the outgoing memos for a plaintext contact request (no peer key needed yet).
|
||||||
ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr,
|
ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr,
|
||||||
const std::string& peerZaddr,
|
const std::string& peerZaddr,
|
||||||
@@ -112,6 +161,37 @@ 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
|
||||||
|
|
||||||
|
// Larger-messages tier 3: a big message split across several transactions arrives one slice at a
|
||||||
|
// time (siblings share a message id). We accumulate the slices here until every chunk is present,
|
||||||
|
// then reassemble + decrypt + emit the whole message. Not persisted — a rescan re-sends every slice
|
||||||
|
// so the buffer re-derives from chain (like the control reducer); a completed group is retired.
|
||||||
|
struct Tier3Pending {
|
||||||
|
int total_parts = 0;
|
||||||
|
std::string secretstream_header_hex; // "e", shared by every slice
|
||||||
|
std::string sender_public_key_hex; // "p", shared
|
||||||
|
std::string conversation_id;
|
||||||
|
std::string reply_zaddr;
|
||||||
|
std::int64_t sent_at = 0;
|
||||||
|
std::string canonical_txid; // smallest sibling txid — the reassembled message's stable key
|
||||||
|
std::unordered_map<int, std::string> chunks; // global index -> ciphertext-hex chunk
|
||||||
|
};
|
||||||
|
std::unordered_map<std::string, Tier3Pending> tier3_pending_; // message id -> accumulating slices
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ bool Settings::load(const std::string& path)
|
|||||||
loadScalar(j, "theme", theme_);
|
loadScalar(j, "theme", theme_);
|
||||||
loadScalar(j, "save_ztxs", save_ztxs_);
|
loadScalar(j, "save_ztxs", save_ztxs_);
|
||||||
loadScalar(j, "auto_shield", auto_shield_);
|
loadScalar(j, "auto_shield", auto_shield_);
|
||||||
|
loadScalar(j, "auto_consolidate", auto_consolidate_);
|
||||||
loadScalar(j, "use_tor", use_tor_);
|
loadScalar(j, "use_tor", use_tor_);
|
||||||
loadScalar(j, "allow_custom_fees", allow_custom_fees_);
|
loadScalar(j, "allow_custom_fees", allow_custom_fees_);
|
||||||
loadScalar(j, "default_fee", default_fee_);
|
loadScalar(j, "default_fee", default_fee_);
|
||||||
@@ -180,6 +181,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_);
|
||||||
@@ -522,6 +531,7 @@ bool Settings::save(const std::string& path)
|
|||||||
j["theme"] = theme_;
|
j["theme"] = theme_;
|
||||||
j["save_ztxs"] = save_ztxs_;
|
j["save_ztxs"] = save_ztxs_;
|
||||||
j["auto_shield"] = auto_shield_;
|
j["auto_shield"] = auto_shield_;
|
||||||
|
j["auto_consolidate"] = auto_consolidate_;
|
||||||
j["use_tor"] = use_tor_;
|
j["use_tor"] = use_tor_;
|
||||||
j["allow_custom_fees"] = allow_custom_fees_;
|
j["allow_custom_fees"] = allow_custom_fees_;
|
||||||
j["default_fee"] = default_fee_;
|
j["default_fee"] = default_fee_;
|
||||||
@@ -544,6 +554,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; }
|
||||||
@@ -209,6 +229,12 @@ public:
|
|||||||
|
|
||||||
bool getAutoShield() const { return auto_shield_; }
|
bool getAutoShield() const { return auto_shield_; }
|
||||||
void setAutoShield(bool shield) { auto_shield_ = shield; }
|
void setAutoShield(bool shield) { auto_shield_ = shield; }
|
||||||
|
// Automatic background note consolidation (full-node): when on, the wallet quietly merges small notes
|
||||||
|
// in tiny batches while idle (see App::pumpAutoConsolidation). Each address's notes are merged back into
|
||||||
|
// THAT SAME address (z_mergetoaddress [addr] -> addr), so funds never move between addresses. On by
|
||||||
|
// default — a missing key in an older config therefore enables it on upgrade, which is intended.
|
||||||
|
bool getAutoConsolidate() const { return auto_consolidate_; }
|
||||||
|
void setAutoConsolidate(bool v) { auto_consolidate_ = v; }
|
||||||
|
|
||||||
bool getUseTor() const { return use_tor_; }
|
bool getUseTor() const { return use_tor_; }
|
||||||
void setUseTor(bool tor) { use_tor_ = tor; }
|
void setUseTor(bool tor) { use_tor_ = tor; }
|
||||||
@@ -694,6 +720,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)
|
||||||
@@ -706,6 +733,7 @@ private:
|
|||||||
int time_format_ = 0; // global clock: 0 = 24h, 1 = 12h
|
int time_format_ = 0; // global clock: 0 = 24h, 1 = 12h
|
||||||
bool save_ztxs_ = true;
|
bool save_ztxs_ = true;
|
||||||
bool auto_shield_ = true;
|
bool auto_shield_ = true;
|
||||||
|
bool auto_consolidate_ = true;
|
||||||
bool use_tor_ = false;
|
bool use_tor_ = false;
|
||||||
bool allow_custom_fees_ = false;
|
bool allow_custom_fees_ = false;
|
||||||
double default_fee_ = 0.0001;
|
double default_fee_ = 0.0001;
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ bool worthConsolidating(const NoteGroup& group, double fee)
|
|||||||
return group.notes >= 2 && group.amount > 2.0 * fee;
|
return group.notes >= 2 && group.amount > 2.0 * fee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<NoteGroup> autoConsolidateCandidates(const std::vector<NoteGroup>& groups, int minNotes, double fee)
|
||||||
|
{
|
||||||
|
std::vector<NoteGroup> out;
|
||||||
|
for (const auto& g : groups)
|
||||||
|
if (g.notes > minNotes && worthConsolidating(g, fee)) out.push_back(g);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
int estimateBatches(int notes, int limit)
|
int estimateBatches(int notes, int limit)
|
||||||
{
|
{
|
||||||
if (notes < 2) return 0;
|
if (notes < 2) return 0;
|
||||||
@@ -184,6 +192,22 @@ MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& m
|
|||||||
return MergeFailure::Fatal;
|
return MergeFailure::Fatal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isDeferrableMergeOpError(const std::string& message)
|
||||||
|
{
|
||||||
|
// A batch rejected from the mempool because it clashed with an earlier, still-unconfirmed batch (its
|
||||||
|
// Sapling nullifier/anchor, or an input, already in flight). Re-trying after those confirm succeeds.
|
||||||
|
// Substring match — the daemon prefixes a reject code (e.g. "18: bad-txns-shielded-requirements-not-met").
|
||||||
|
static const char* const kNeedles[] = {
|
||||||
|
"shielded-requirements-not-met", // Sapling nullifier/anchor clash (REJECT_DUPLICATE)
|
||||||
|
"joinsplit-requirements-not-met", // the Sprout analogue
|
||||||
|
"txn-mempool-conflict", // spends an output another mempool tx already spends
|
||||||
|
"bad-txns-inputs-spent", // an input already spent by an unconfirmed sibling
|
||||||
|
};
|
||||||
|
for (const char* n : kNeedles)
|
||||||
|
if (message.find(n) != std::string::npos) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e)
|
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e)
|
||||||
{
|
{
|
||||||
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
|
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
|
||||||
@@ -290,7 +314,7 @@ void ConsolidationRun::endAddress(RunAddress& a, AddrOutcome o, const std::strin
|
|||||||
if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg);
|
if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep)
|
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep, double now)
|
||||||
{
|
{
|
||||||
b.state = st;
|
b.state = st;
|
||||||
if (b.addrIdx < 0 || b.addrIdx >= static_cast<int>(addrs_.size())) return;
|
if (b.addrIdx < 0 || b.addrIdx >= static_cast<int>(addrs_.size())) return;
|
||||||
@@ -299,10 +323,20 @@ void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep
|
|||||||
case OpState::Success:
|
case OpState::Success:
|
||||||
if (rep) b.txid = rep->txid;
|
if (rep) b.txid = rep->txid;
|
||||||
++a.opsDone;
|
++a.opsDone;
|
||||||
|
a.defers = 0; // progress made: this address's consecutive-defer budget resets
|
||||||
opFailStreak_ = 0;
|
opFailStreak_ = 0;
|
||||||
break;
|
break;
|
||||||
case OpState::Failed:
|
case OpState::Failed:
|
||||||
if (rep) b.error = rep->error;
|
if (rep) b.error = rep->error;
|
||||||
|
// A transient mempool conflict (clashed with an earlier, still-unconfirmed batch): don't fail the
|
||||||
|
// address — hold it and retry once the earlier batches confirm. Bounded, and it doesn't feed the
|
||||||
|
// halt breaker. The batch itself stays terminal (Failed) so it isn't re-polled; the address stays
|
||||||
|
// not-done and gets a fresh batch after the hold (see next()).
|
||||||
|
if (isDeferrableMergeOpError(b.error) && a.defers < kRunMaxDeferRetries) {
|
||||||
|
++a.defers;
|
||||||
|
a.deferUntil = now + kRunMempoolClearSec;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (a.failure.empty()) a.failure = b.error;
|
if (a.failure.empty()) a.failure = b.error;
|
||||||
a.done = true;
|
a.done = true;
|
||||||
worsen(a, AddrOutcome::Failed);
|
worsen(a, AddrOutcome::Failed);
|
||||||
@@ -367,6 +401,7 @@ RunStep ConsolidationRun::next(const RunEnv& env, double now)
|
|||||||
if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked);
|
if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked);
|
||||||
if (env.userSendBusy) return wait(RunWait::UserSend);
|
if (env.userSendBusy) return wait(RunWait::UserSend);
|
||||||
if (now < nextCallAt_) return wait(RunWait::Batch);
|
if (now < nextCallAt_) return wait(RunWait::Batch);
|
||||||
|
if (now < addrs_[cur_].deferUntil) return wait(RunWait::Batch); // held after a mempool conflict; retry once it clears
|
||||||
if (!batches_.empty() && (batches_.back().state == OpState::Unknown || batches_.back().state == OpState::Queued))
|
if (!batches_.empty() && (batches_.back().state == OpState::Unknown || batches_.back().state == OpState::Queued))
|
||||||
return wait(RunWait::Batch); // at most 2 unfinished ops, at most 2 x limit notes locked
|
return wait(RunWait::Batch); // at most 2 unfinished ops, at most 2 x limit notes locked
|
||||||
if (env.scanBusy) return wait(RunWait::Scan); // another GUI wallet scan is in the daemon
|
if (env.scanBusy) return wait(RunWait::Scan); // another GUI wallet scan is in the daemon
|
||||||
@@ -476,7 +511,7 @@ void ConsolidationRun::onPollResult(std::uint64_t gen, bool ok, const std::vecto
|
|||||||
}
|
}
|
||||||
b.misses = 0;
|
b.misses = 0;
|
||||||
const OpState st = opStateFromStatus(it->second->status);
|
const OpState st = opStateFromStatus(it->second->status);
|
||||||
if (st != OpState::Unknown) applyOpState(b, st, it->second);
|
if (st != OpState::Unknown) applyOpState(b, st, it->second, now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ bool worthConsolidating(const NoteGroup& group, double fee);
|
|||||||
// note isn't a call (the daemon refuses to merge one note into its own address).
|
// note isn't a call (the daemon refuses to merge one note into its own address).
|
||||||
int estimateBatches(int notes, int limit);
|
int estimateBatches(int notes, int limit);
|
||||||
|
|
||||||
|
// ── Automatic (background) consolidation ──
|
||||||
|
// Gentle defaults for App::pumpAutoConsolidation: a small batch and only a couple of batches per address
|
||||||
|
// per cycle, spaced well apart, so each cycle is a few small merges that barely touch the node. The manual
|
||||||
|
// dialog's 50-input / 20-batch run stays for when the user wants speed.
|
||||||
|
inline constexpr int kAutoConsolidateLimit = 15; // inputs per merge (vs 50 in a manual run)
|
||||||
|
inline constexpr int kAutoConsolidateMaxBatches = 3; // batches per address per cycle
|
||||||
|
inline constexpr int kAutoConsolidateMinNotes = 25; // only act on addresses above this; dormant below
|
||||||
|
inline constexpr double kAutoConsolidateCooldownSec = 600; // between cycles that did work (~10 min)
|
||||||
|
inline constexpr double kAutoConsolidateIdleRescanSec = 3600; // between scans while nothing needs merging (~1 h)
|
||||||
|
inline constexpr double kAutoConsolidateRetrySec = 1800; // back off longer after a failed/halted cycle
|
||||||
|
|
||||||
|
// Addresses worth consolidating automatically: worthConsolidating(group, fee) AND more than `minNotes`
|
||||||
|
// notes, so a near-tidy address is left alone (and the driver can go dormant). Pure; preserves input order
|
||||||
|
// (groupSpendableNotes already returns most-notes-first).
|
||||||
|
std::vector<NoteGroup> autoConsolidateCandidates(const std::vector<NoteGroup>& groups, int minNotes, double fee);
|
||||||
|
|
||||||
// What one z_mergetoaddress call reported.
|
// What one z_mergetoaddress call reported.
|
||||||
struct MergeCallResult {
|
struct MergeCallResult {
|
||||||
std::string opid;
|
std::string opid;
|
||||||
@@ -97,6 +113,10 @@ enum class MergeFailure : std::uint8_t {
|
|||||||
Fatal, // anything else: this address is done
|
Fatal, // anything else: this address is done
|
||||||
};
|
};
|
||||||
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message);
|
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message);
|
||||||
|
// True when an async op FAILURE message (not an RPC-call failure) is a transient mempool conflict — a batch
|
||||||
|
// that clashed with an earlier, still-unconfirmed batch (bad-txns-shielded-requirements-not-met and kin).
|
||||||
|
// The run holds the address and retries once the mempool clears instead of failing it. Pure; substring match.
|
||||||
|
bool isDeferrableMergeOpError(const std::string& message);
|
||||||
// The failure kind of a z_mergetoaddress call that threw. Sent, then cut off before any HTTP status
|
// The failure kind of a z_mergetoaddress call that threw. Sent, then cut off before any HTTP status
|
||||||
// (connection reset, an empty or partial reply): the daemon may have queued the merge, exactly as after a
|
// (connection reset, an empty or partial reply): the daemon may have queued the merge, exactly as after a
|
||||||
// reply timeout, so it reads as one. Any other failure keeps its kind.
|
// reply timeout, so it reads as one. Any other failure keeps its kind.
|
||||||
@@ -122,6 +142,10 @@ inline constexpr int kRunMaxTransientRetries = 5;
|
|||||||
inline constexpr int kRunMaxUnlockRefusals = 2;
|
inline constexpr int kRunMaxUnlockRefusals = 2;
|
||||||
inline constexpr int kRunMaxFailStreak = 3;
|
inline constexpr int kRunMaxFailStreak = 3;
|
||||||
inline constexpr int kRunMaxPollMisses = 2;
|
inline constexpr int kRunMaxPollMisses = 2;
|
||||||
|
// An op that failed with a transient mempool conflict (it clashed with an earlier, still-unconfirmed batch)
|
||||||
|
// holds its address for this long so the earlier batches can confirm, then retries — up to this many times.
|
||||||
|
inline constexpr double kRunMempoolClearSec = 120.0;
|
||||||
|
inline constexpr int kRunMaxDeferRetries = 5;
|
||||||
|
|
||||||
struct RunConfig {
|
struct RunConfig {
|
||||||
double fee = 0.0001;
|
double fee = 0.0001;
|
||||||
@@ -153,6 +177,8 @@ struct RunAddress {
|
|||||||
int opsDone = 0;
|
int opsDone = 0;
|
||||||
int transient = 0;
|
int transient = 0;
|
||||||
int unlockRefusals = 0;
|
int unlockRefusals = 0;
|
||||||
|
double deferUntil = 0.0; // held until this time after a mempool-conflict op failure, then retried
|
||||||
|
int defers = 0; // consecutive mempool-conflict defers (reset on a success; capped at kRunMaxDeferRetries)
|
||||||
std::string failure;
|
std::string failure;
|
||||||
};
|
};
|
||||||
struct RunBatch {
|
struct RunBatch {
|
||||||
@@ -210,7 +236,7 @@ private:
|
|||||||
enum class Phase : std::uint8_t { Idle, Running, Finished };
|
enum class Phase : std::uint8_t { Idle, Running, Finished };
|
||||||
static void worsen(RunAddress& a, AddrOutcome o) { if (o > a.outcome) a.outcome = o; }
|
static void worsen(RunAddress& a, AddrOutcome o) { if (o > a.outcome) a.outcome = o; }
|
||||||
void endAddress(RunAddress& a, AddrOutcome o, const std::string& msg);
|
void endAddress(RunAddress& a, AddrOutcome o, const std::string& msg);
|
||||||
void applyOpState(RunBatch& b, OpState st, const OpReport* rep);
|
void applyOpState(RunBatch& b, OpState st, const OpReport* rep, double now = 0.0);
|
||||||
void halt(const std::string& msg) { halted_ = true; if (haltError_.empty()) haltError_ = msg; }
|
void halt(const std::string& msg) { halted_ = true; if (haltError_.empty()) haltError_ = msg; }
|
||||||
|
|
||||||
Phase phase_ = Phase::Idle;
|
Phase phase_ = Phase::Idle;
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ ScanTicket WalletScanGovernor::tryAdmit(ScanStream stream, bool visible, const S
|
|||||||
if (st.waitingSince < 0.0 || now - st.lastAskAt > cfg_.waitLapseSec) st.waitingSince = now; // a new wait
|
if (st.waitingSince < 0.0 || now - st.lastAskAt > cfg_.waitLapseSec) st.waitingSince = now; // a new wait
|
||||||
st.lastAskAt = now;
|
st.lastAskAt = now;
|
||||||
st.askVisible = visible;
|
st.askVisible = visible;
|
||||||
|
// Paused (e.g. a user-started consolidation owns the cs_main budget): keep the stream waiting, admit
|
||||||
|
// nothing. It resumes immediately once unpaused; explicit needs still go through beginExplicit.
|
||||||
|
if (suppressed_[static_cast<std::size_t>(stream)]) return {};
|
||||||
pruneLeases(now);
|
pruneLeases(now);
|
||||||
if (!live_.empty()) return {}; // one wallet scan in the daemon at a time
|
if (!live_.empty()) return {}; // one wallet scan in the daemon at a time
|
||||||
if (bestWaiter(now) != stream) return {};
|
if (bestWaiter(now) != stream) return {};
|
||||||
|
|||||||
@@ -159,6 +159,21 @@ public:
|
|||||||
void invalidate(ScanStream stream); // forget its basis: the next routine scan is needed
|
void invalidate(ScanStream stream); // forget its basis: the next routine scan is needed
|
||||||
void reset(); // a new connection: forget everything; old tickets complete as no-ops
|
void reset(); // a new connection: forget everything; old tickets complete as no-ops
|
||||||
|
|
||||||
|
// Pause/resume a stream's ROUTINE scans (tryAdmit returns empty while paused; the stream keeps waiting
|
||||||
|
// and resumes the moment it is unpaused). Explicit needs (beginExplicit) are NEVER paused.
|
||||||
|
void suppressStream(ScanStream stream, bool paused) { suppressed_[static_cast<std::size_t>(stream)] = paused; }
|
||||||
|
bool streamSuppressed(ScanStream stream) const { return suppressed_[static_cast<std::size_t>(stream)]; }
|
||||||
|
// Convenience: pause/resume the heavy wallet-display scans (balance, addresses, history, recent-tx) as a
|
||||||
|
// group — used to hand a user-started consolidation more of the cs_main budget. Chat scans and Adhoc
|
||||||
|
// (the consolidation's own merge calls) are left running.
|
||||||
|
void suppressRoutineWalletScans(bool paused)
|
||||||
|
{
|
||||||
|
suppressStream(ScanStream::Balance, paused);
|
||||||
|
suppressStream(ScanStream::Addresses, paused);
|
||||||
|
suppressStream(ScanStream::History, paused);
|
||||||
|
suppressStream(ScanStream::RecentTx, paused);
|
||||||
|
}
|
||||||
|
|
||||||
bool inFlight(ScanStream stream) const;
|
bool inFlight(ScanStream stream) const;
|
||||||
bool anyInFlight() const { return !live_.empty(); }
|
bool anyInFlight() const { return !live_.empty(); }
|
||||||
double quietUntil() const { return quietUntil_; }
|
double quietUntil() const { return quietUntil_; }
|
||||||
@@ -208,6 +223,7 @@ private:
|
|||||||
|
|
||||||
Config cfg_;
|
Config cfg_;
|
||||||
std::array<Stream, static_cast<std::size_t>(ScanStream::Count)> streams_{};
|
std::array<Stream, static_cast<std::size_t>(ScanStream::Count)> streams_{};
|
||||||
|
std::array<bool, static_cast<std::size_t>(ScanStream::Count)> suppressed_{}; // routine scans paused per stream
|
||||||
std::vector<Live> live_;
|
std::vector<Live> live_;
|
||||||
double quietUntil_ = 0.0;
|
double quietUntil_ = 0.0;
|
||||||
int tipLag_ = 0;
|
int tipLag_ = 0;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -36,27 +36,52 @@ inline void PopTooltipStyle()
|
|||||||
ImGui::PopStyleVar();
|
ImGui::PopStyleVar();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop-in replacement for ImGui::SetTooltip (same printf-style signature). (No IM_FMTARGS: GCC
|
// Comfortable maximum tooltip text width (logical px, scaled by DPI). Long tooltip text wraps at this
|
||||||
// rejects the format attribute on an inline definition; call sites already passed SetTooltip's check.)
|
// instead of stretching across the whole screen; short text is unaffected (it's narrower than the cap).
|
||||||
|
inline constexpr float kTooltipWrapWidth = 420.0f;
|
||||||
|
|
||||||
|
// The wrap position (window-local x) to clamp a just-begun tooltip's text to. Called right after
|
||||||
|
// ImGui::BeginTooltip, when the cursor sits at the tooltip's content origin.
|
||||||
|
inline float tooltipWrapPosX()
|
||||||
|
{
|
||||||
|
return ImGui::GetCursorPosX() + kTooltipWrapWidth * Layout::dpiScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop-in replacement for ImGui::SetTooltip (same printf-style signature), with the text clamped to a
|
||||||
|
// comfortable width. (No IM_FMTARGS: GCC rejects the format attribute on an inline definition; call sites
|
||||||
|
// already passed SetTooltip's check.)
|
||||||
inline void Tooltip(const char* fmt, ...)
|
inline void Tooltip(const char* fmt, ...)
|
||||||
{
|
{
|
||||||
PushTooltipStyle();
|
PushTooltipStyle();
|
||||||
|
if (ImGui::BeginTooltip()) {
|
||||||
|
ImGui::PushTextWrapPos(tooltipWrapPosX());
|
||||||
va_list args;
|
va_list args;
|
||||||
va_start(args, fmt);
|
va_start(args, fmt);
|
||||||
ImGui::SetTooltipV(fmt, args);
|
ImGui::TextV(fmt, args);
|
||||||
va_end(args);
|
va_end(args);
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::EndTooltip();
|
||||||
|
}
|
||||||
PopTooltipStyle();
|
PopTooltipStyle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop-in replacements for ImGui::BeginTooltip / ImGui::EndTooltip (must be paired, as before).
|
// Drop-in replacements for ImGui::BeginTooltip / ImGui::EndTooltip (must be paired, as before). The begin
|
||||||
|
// clamps text to a comfortable width by default; a caller that pushes its own wrap pos nests cleanly (it
|
||||||
|
// pops back to this one). Text-only callers need do nothing; non-text content ignores the wrap pos.
|
||||||
inline bool BeginTooltip()
|
inline bool BeginTooltip()
|
||||||
{
|
{
|
||||||
PushTooltipStyle();
|
PushTooltipStyle();
|
||||||
return ImGui::BeginTooltip();
|
if (ImGui::BeginTooltip()) {
|
||||||
|
ImGui::PushTextWrapPos(tooltipWrapPosX());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
PopTooltipStyle(); // not shown: EndTooltip won't run, so undo the style push here
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline void EndTooltip()
|
inline void EndTooltip()
|
||||||
{
|
{
|
||||||
|
ImGui::PopTextWrapPos(); // pair the wrap pos pushed in BeginTooltip
|
||||||
ImGui::EndTooltip();
|
ImGui::EndTooltip();
|
||||||
PopTooltipStyle();
|
PopTooltipStyle();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include "schema/ui_schema.h"
|
#include "schema/ui_schema.h"
|
||||||
#include "material/type.h"
|
#include "material/type.h"
|
||||||
#include "material/draw_helpers.h"
|
#include "material/draw_helpers.h"
|
||||||
|
#include "material/tooltip_style.h" // material::Tooltip (width-clamped)
|
||||||
#include "../embedded/IconsMaterialDesign.h"
|
#include "../embedded/IconsMaterialDesign.h"
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
|
|
||||||
@@ -210,7 +211,7 @@ void Notifications::render()
|
|||||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory();
|
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory();
|
||||||
}
|
}
|
||||||
if (toast_hovered_ && clipped)
|
if (toast_hovered_ && clipped)
|
||||||
ImGui::SetTooltip("%s", notif.message.c_str());
|
material::Tooltip("%s", notif.message.c_str()); // wrapped: a long alert message mustn't span the screen
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ struct SettingsPageState {
|
|||||||
bool save_ztxs = true;
|
bool save_ztxs = true;
|
||||||
bool allow_custom_fees = false;
|
bool allow_custom_fees = false;
|
||||||
bool auto_shield = false;
|
bool auto_shield = false;
|
||||||
|
bool auto_consolidate = true; // on by default (loaded from settings on open; see getAutoConsolidate)
|
||||||
bool fetch_prices = true;
|
bool fetch_prices = true;
|
||||||
bool use_tor = false;
|
bool use_tor = false;
|
||||||
char rpc_host[128] = DRAGONX_DEFAULT_RPC_HOST;
|
char rpc_host[128] = DRAGONX_DEFAULT_RPC_HOST;
|
||||||
@@ -185,6 +186,7 @@ struct SettingsPageState {
|
|||||||
bool rescan_probe_failed = false; // the probe could not tell (node busy): warn before the -rescan restart
|
bool rescan_probe_failed = false; // the probe could not tell (node busy): warn before the -rescan restart
|
||||||
int rescan_probe_seq = 0; // the probe is async: a late result for an earlier opening is dropped
|
int rescan_probe_seq = 0; // the probe is async: a late result for an earlier opening is dropped
|
||||||
bool confirm_repair_wallet = false;
|
bool confirm_repair_wallet = false;
|
||||||
|
bool confirm_compact_wallet = false;
|
||||||
bool confirm_reinstall_daemon = false;
|
bool confirm_reinstall_daemon = false;
|
||||||
// Cached daemon-binary status for the "daemon binary" panel (loaded once / on Refresh,
|
// Cached daemon-binary status for the "daemon binary" panel (loaded once / on Refresh,
|
||||||
// since reading the installed binary to scan its version is a one-off disk read).
|
// since reading the installed binary to scan its version is a one-off disk read).
|
||||||
@@ -423,6 +425,7 @@ static void loadSettingsPageState(App* app) {
|
|||||||
s_settingsState.save_ztxs = settings->getSaveZtxs();
|
s_settingsState.save_ztxs = settings->getSaveZtxs();
|
||||||
s_settingsState.allow_custom_fees = settings->getAllowCustomFees();
|
s_settingsState.allow_custom_fees = settings->getAllowCustomFees();
|
||||||
s_settingsState.auto_shield = settings->getAutoShield();
|
s_settingsState.auto_shield = settings->getAutoShield();
|
||||||
|
s_settingsState.auto_consolidate = settings->getAutoConsolidate();
|
||||||
s_settingsState.fetch_prices = settings->getFetchPrices();
|
s_settingsState.fetch_prices = settings->getFetchPrices();
|
||||||
s_settingsState.use_tor = settings->getUseTor();
|
s_settingsState.use_tor = settings->getUseTor();
|
||||||
|
|
||||||
@@ -512,6 +515,7 @@ static void saveSettingsPageState(config::Settings* settings) {
|
|||||||
settings->setSaveZtxs(s_settingsState.save_ztxs);
|
settings->setSaveZtxs(s_settingsState.save_ztxs);
|
||||||
settings->setAllowCustomFees(s_settingsState.allow_custom_fees);
|
settings->setAllowCustomFees(s_settingsState.allow_custom_fees);
|
||||||
settings->setAutoShield(s_settingsState.auto_shield);
|
settings->setAutoShield(s_settingsState.auto_shield);
|
||||||
|
settings->setAutoConsolidate(s_settingsState.auto_consolidate);
|
||||||
settings->setFetchPrices(s_settingsState.fetch_prices);
|
settings->setFetchPrices(s_settingsState.fetch_prices);
|
||||||
settings->setUseTor(s_settingsState.use_tor);
|
settings->setUseTor(s_settingsState.use_tor);
|
||||||
settings->setTxExplorerUrl(s_settingsState.tx_explorer);
|
settings->setTxExplorerUrl(s_settingsState.tx_explorer);
|
||||||
@@ -1330,6 +1334,9 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (CB(TrId("stop_external", "stop_ext"), &s_settingsState.stop_external_daemon))
|
if (CB(TrId("stop_external", "stop_ext"), &s_settingsState.stop_external_daemon))
|
||||||
saveSettingsPageState(app->settings());
|
saveSettingsPageState(app->settings());
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_stop_external"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_stop_external"));
|
||||||
|
if (CB(TrId("auto_consolidate", "auto_consol"), &s_settingsState.auto_consolidate))
|
||||||
|
saveSettingsPageState(app->settings());
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_consolidate"));
|
||||||
// (Stratum pool hosting lives in the Node & Security tab — it's a node feature.)
|
// (Stratum pool hosting lives in the Node & Security tab — it's a node feature.)
|
||||||
}
|
}
|
||||||
if (CB(TrId("verbose_logging", "verbose"), &s_settingsState.verbose_logging)) {
|
if (CB(TrId("verbose_logging", "verbose"), &s_settingsState.verbose_logging)) {
|
||||||
@@ -1490,6 +1497,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 +1718,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 +2186,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 {
|
||||||
@@ -2369,6 +2384,20 @@ void RenderSettingsPage(App* app) {
|
|||||||
ICON_MD_CALL_MERGE, material::ActionTier::Secondary))
|
ICON_MD_CALL_MERGE, material::ActionTier::Secondary))
|
||||||
ShieldDialog::showConsolidate();
|
ShieldDialog::showConsolidate();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge"));
|
||||||
|
ImGui::SameLine();
|
||||||
|
// Compact reclaims the file space consolidation frees (BDB won't shrink in place).
|
||||||
|
// It rewrites wallet.dat by restarting the node with -salvagewallet, so it only works
|
||||||
|
// with the embedded node — disable it for an external dragonxd and explain on hover.
|
||||||
|
// Enabled whenever this build can run the built-in node. If connected to an external
|
||||||
|
// node, the confirmation offers to switch to the built-in one first (compactViaEmbeddedSwitch).
|
||||||
|
const bool compactPossible = app->supportsEmbeddedDaemon();
|
||||||
|
ImGui::BeginDisabled(!compactPossible);
|
||||||
|
if (material::ActionButton("##walletcompact", TR("wallet_size_compact"),
|
||||||
|
ICON_MD_COMPRESS, material::ActionTier::Secondary))
|
||||||
|
s_settingsState.confirm_compact_wallet = true;
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||||
|
material::Tooltip("%s", TR(compactPossible ? "tt_wallet_size_compact" : "compact_needs_embedded"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row 3: folder buttons (their own row so the path gets the full width).
|
// Row 3: folder buttons (their own row so the path gets the full width).
|
||||||
@@ -3452,6 +3481,39 @@ void RenderSettingsPage(App* app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Confirm: compact wallet.dat to reclaim the space consolidation freed (rewrite via -salvagewallet,
|
||||||
|
// which restarts the node and re-scans the chain). A backup of the current wallet is kept.
|
||||||
|
if (s_settingsState.confirm_compact_wallet) {
|
||||||
|
// Direct compact when the wallet already runs the built-in node; otherwise the dialog explains it
|
||||||
|
// will stop the external node and switch to the built-in one first.
|
||||||
|
const bool embeddedNow = app->canCompactWallet();
|
||||||
|
if (BeginOverlayDialog(TR("confirm_compact_wallet_title"), &s_settingsState.confirm_compact_wallet, 500.0f, 0.94f)) {
|
||||||
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::TextWrapped("%s", TR(embeddedNow ? "confirm_compact_wallet_msg" : "confirm_compact_switch_msg"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_compact_wallet_safe"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::Separator();
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
bool doCancel = false, doConfirm = false;
|
||||||
|
material::DialogConfirmFooter(TrId("cancel", "compact_wallet_cancel").c_str(),
|
||||||
|
TrId("compact_wallet_confirm", "compact_wallet_confirm").c_str(),
|
||||||
|
false, doCancel, doConfirm);
|
||||||
|
if (doCancel) {
|
||||||
|
s_settingsState.confirm_compact_wallet = false;
|
||||||
|
}
|
||||||
|
if (doConfirm) {
|
||||||
|
if (embeddedNow) app->compactWallet();
|
||||||
|
else app->compactViaEmbeddedSwitch();
|
||||||
|
s_settingsState.confirm_compact_wallet = false;
|
||||||
|
}
|
||||||
|
EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
|
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
|
||||||
if (s_settingsState.confirm_reinstall_daemon) {
|
if (s_settingsState.confirm_reinstall_daemon) {
|
||||||
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
|
||||||
|
|||||||
+409
-8
@@ -63,15 +63,19 @@ 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[16400] = ""; // 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 split the ciphertext across many memo outputs: tier 2
|
||||||
// The composer hard-caps input to this; the emoji picker respects it too.
|
// packs up to kHushChatMaxMessageParts (16) into ONE transaction (~4 KB), and tier 3 spills further across
|
||||||
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
|
// several transactions up to kHushChatMaxMessageChunks (64) chunks total (protocol max ~16303: 64 × 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 = 16000;
|
||||||
|
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 +108,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 +974,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 +1210,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 +1353,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.
|
||||||
{
|
{
|
||||||
@@ -1415,6 +1487,13 @@ void RenderChatTab(App* app)
|
|||||||
const float origSpacingX = ImGui::GetStyle().ItemSpacing.x;
|
const float origSpacingX = ImGui::GetStyle().ItemSpacing.x;
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f * dp, 8.0f * dp));
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f * dp, 8.0f * dp));
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(origSpacingX, 0.0f));
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(origSpacingX, 0.0f));
|
||||||
|
// Thicker, clearly-visible scrollbar for the message thread: the global one is a thin 6px with a
|
||||||
|
// ~6% grab that's hard to see and grab. Pushed for this child only (popped after EndChild below).
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 12.0f * dp);
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 6.0f * dp);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, material::WithAlpha(material::OnSurface(), 55));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, material::WithAlpha(material::OnSurface(), 95));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, material::WithAlpha(material::OnSurface(), 135));
|
||||||
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y),
|
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y),
|
||||||
ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollWithMouse);
|
ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollWithMouse);
|
||||||
material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below
|
material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below
|
||||||
@@ -1879,7 +1958,29 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
|
ImGui::PopStyleColor(3); // ScrollbarGrab + Hovered + Active
|
||||||
|
ImGui::PopStyleVar(4); // WindowPadding + ItemSpacing + ScrollbarSize + ScrollbarRounding
|
||||||
|
// Larger-messages tier 3: while a big incoming message is still arriving across transactions,
|
||||||
|
// show a subtle centred progress line (it surfaces as an ordinary bubble once every part lands).
|
||||||
|
{
|
||||||
|
int rcv = 0, tot = 0;
|
||||||
|
for (const auto& pr : app->chatService().pendingLargeMessages())
|
||||||
|
if (pr.conversation_id == sel->cid) { rcv = pr.received; tot = pr.total; break; }
|
||||||
|
if (tot > 0) {
|
||||||
|
const float dp2 = Layout::dpiScale();
|
||||||
|
ImGui::Dummy(ImVec2(0.0f, 2.0f * dp2));
|
||||||
|
char buf[96];
|
||||||
|
std::snprintf(buf, sizeof buf, "%s %d / %d %s",
|
||||||
|
TR("chat_receiving_large"), rcv, tot, TR("chat_parts_label"));
|
||||||
|
ImFont* f = ImGui::GetFont();
|
||||||
|
const ImVec2 tsz = f->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, buf);
|
||||||
|
const float availX = ImGui::GetContentRegionAvail().x;
|
||||||
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (availX - tsz.x) * 0.5f));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(buf);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
|
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
|
||||||
} else if (convs.empty() && app->settings() && !app->settings()->getChatIntroDismissed()) {
|
} else if (convs.empty() && app->settings() && !app->settings()->getChatIntroDismissed()) {
|
||||||
// First-run intro: explain how chat works + let the user copy their address, instead of a bare
|
// First-run intro: explain how chat works + let the user copy their address, instead of a bare
|
||||||
@@ -1948,6 +2049,17 @@ void RenderChatTab(App* app)
|
|||||||
// Fraction of the on-chain cap used, for the radial gauge (drawn inside the input, below).
|
// Fraction of the on-chain cap used, for the radial gauge (drawn inside the input, below).
|
||||||
const float frac = kBodyMaxBytes > 0 ? static_cast<float>(used) / kBodyMaxBytes : 0.0f;
|
const float frac = kBodyMaxBytes > 0 ? static_cast<float>(used) / kBodyMaxBytes : 0.0f;
|
||||||
|
|
||||||
|
// How this message chunks on-chain (mirrors buildOutgoingMessage): the hex ciphertext is
|
||||||
|
// (plaintext + the 17-byte secretstream tag) * 2; ≤512 hex fits one memo output, otherwise it
|
||||||
|
// splits into 510-hex chunks (512 minus a 2-char part index). Up to kHushChatMaxMessageParts (16)
|
||||||
|
// chunks ride ONE transaction (tier 2); beyond that the message spills across several (tier 3).
|
||||||
|
const int chatMsgParts = [&]() -> int {
|
||||||
|
const std::size_t hexLen = (static_cast<std::size_t>(used) + 17) * 2;
|
||||||
|
return hexLen <= 512 ? 1 : static_cast<int>((hexLen + 509) / 510); // ceil(hexLen/510)
|
||||||
|
}();
|
||||||
|
// How many transactions those chunks need (tier 3 is >1): the real cost the user asked to see.
|
||||||
|
const int chatMsgTxns = (chatMsgParts + chat::kHushChatMaxMessageParts - 1) / chat::kHushChatMaxMessageParts;
|
||||||
|
|
||||||
// Input row: [emoji] [input] [Send]. The input's TOP is pinned 10px below the message box and
|
// Input row: [emoji] [input] [Send]. The input's TOP is pinned 10px below the message box and
|
||||||
// it grows DOWNWARD as it gets taller (the thread above shrinks), so the row bottom stays put.
|
// it grows DOWNWARD as it gets taller (the thread above shrinks), so the row bottom stays put.
|
||||||
const float rowY = boxMax.y + composerAreaH - composerBoxH;
|
const float rowY = boxMax.y + composerAreaH - composerBoxH;
|
||||||
@@ -2058,14 +2170,43 @@ void RenderChatTab(App* app)
|
|||||||
gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
|
gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
|
||||||
thick * 0.6f, fillCol, 10);
|
thick * 0.6f, fillCol, 10);
|
||||||
}
|
}
|
||||||
|
// Always-visible indicator beside the gauge once the message spans >1 memo output: the part
|
||||||
|
// count while it still fits one transaction (tier 2), or the TRANSACTION count once it spills
|
||||||
|
// across several (tier 3 — the real cost the user cares about). Full explanation on hover.
|
||||||
|
if (chatMsgParts > 1 && !overCap) {
|
||||||
|
ImFont* cf = ImGui::GetFont();
|
||||||
|
const float cfsz = std::max(10.0f, lineH * 0.42f);
|
||||||
|
char buf[32];
|
||||||
|
if (chatMsgTxns > 1)
|
||||||
|
std::snprintf(buf, sizeof buf, "%d %s", chatMsgTxns, TR("chat_txns_label"));
|
||||||
|
else
|
||||||
|
std::snprintf(buf, sizeof buf, "%d %s", chatMsgParts, TR("chat_parts_label"));
|
||||||
|
const ImVec2 tsz = cf->CalcTextSizeA(cfsz, FLT_MAX, 0.0f, buf);
|
||||||
|
gdl->AddText(cf, cfsz, ImVec2(ringC.x - ringR - 6.0f * tdp - tsz.x, ringC.y - tsz.y * 0.5f),
|
||||||
|
material::WithAlpha(material::OnSurface(), chatMsgTxns > 1 ? 205 : 150), buf);
|
||||||
|
}
|
||||||
// Exact count on hover (progressive disclosure; over-cap prefixes a label).
|
// Exact count on hover (progressive disclosure; over-cap prefixes a label).
|
||||||
const float hitR = ringR + thick;
|
const float hitR = ringR + thick;
|
||||||
if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
|
if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
|
||||||
ImVec2(ringC.x + hitR, ringC.y + hitR))) {
|
ImVec2(ringC.x + hitR, ringC.y + hitR))) {
|
||||||
std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
|
std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
|
||||||
if (overCap) tip = std::string(TR("chat_len_over")) + " " + tip;
|
if (overCap) {
|
||||||
|
tip = std::string(TR("chat_len_over")) + " " + tip;
|
||||||
|
} else if (chatMsgTxns > 1) {
|
||||||
|
tip += " \xC2\xB7 " + std::to_string(chatMsgParts) + " " + TR("chat_parts_label") +
|
||||||
|
" \xC2\xB7 " + std::to_string(chatMsgTxns) + " " + TR("chat_txns_label");
|
||||||
|
} else if (chatMsgParts > 1) {
|
||||||
|
tip += " \xC2\xB7 " + std::to_string(chatMsgParts) + " " + TR("chat_parts_label");
|
||||||
|
}
|
||||||
ImGui::BeginTooltip();
|
ImGui::BeginTooltip();
|
||||||
ImGui::TextUnformatted(tip.c_str());
|
ImGui::TextUnformatted(tip.c_str());
|
||||||
|
if (chatMsgParts > 1 && !overCap) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 24.0f);
|
||||||
|
ImGui::TextUnformatted(TR(chatMsgTxns > 1 ? "chat_parts_explain_multi" : "chat_parts_explain"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
ImGui::EndTooltip();
|
ImGui::EndTooltip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2083,7 +2224,7 @@ void RenderChatTab(App* app)
|
|||||||
// When Send is disabled *because the message is over the cap*, explain why on hover — the
|
// When Send is disabled *because the message is over the cap*, explain why on hover — the
|
||||||
// gauge went red but the numeric reason is no longer always-visible.
|
// gauge went red but the numeric reason is no longer always-visible.
|
||||||
if (overCap && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
if (overCap && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||||
ImGui::SetTooltip("%s %d / %d", TR("chat_len_over"), used, kBodyMaxBytes);
|
material::Tooltip("%s %d / %d", TR("chat_len_over"), used, kBodyMaxBytes);
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
|
|
||||||
if (submit && s_compose[0] != '\0' && !overCap) {
|
if (submit && s_compose[0] != '\0' && !overCap) {
|
||||||
@@ -2342,6 +2483,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 +2605,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.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#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 "../../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"
|
||||||
@@ -916,7 +917,8 @@ 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 ||
|
||||||
|
!liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 ||
|
||||||
!util::canAfford(s_amount, s_fee, liveAvail)) {
|
!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;
|
||||||
@@ -955,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.
|
||||||
@@ -1035,6 +1049,7 @@ 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' &&
|
||||||
@@ -1059,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()));
|
||||||
}
|
}
|
||||||
@@ -1088,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)
|
||||||
|
|||||||
@@ -553,9 +553,12 @@ void ShieldDialog::render(App* app)
|
|||||||
if (s_scan.phase == services::ScopePhase::Loaded && listed == 0) {
|
if (s_scan.phase == services::ScopePhase::Loaded && listed == 0) {
|
||||||
wrappedCaption(material::Warning(), TR("merge_keep_each_none"));
|
wrappedCaption(material::Warning(), TR("merge_keep_each_none"));
|
||||||
} else if (listed > 0) {
|
} else if (listed > 0) {
|
||||||
|
const float eachPad = 10.0f * dp; // inner padding (the floating overlay leaves ambient 0)
|
||||||
const float listH = std::min(static_cast<float>(listed), static_cast<float>(maxRows)) * rowH +
|
const float listH = std::min(static_cast<float>(listed), static_cast<float>(maxRows)) * rowH +
|
||||||
ImGui::GetStyle().WindowPadding.y * 2.0f;
|
eachPad * 2.0f;
|
||||||
if (ImGui::BeginChild("##ConsolidateEach", ImVec2(-1, listH), ImGuiChildFlags_Borders)) {
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(eachPad, eachPad));
|
||||||
|
if (ImGui::BeginChild("##ConsolidateEach", ImVec2(-1, listH),
|
||||||
|
ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding)) {
|
||||||
for (const auto& g : s_groups) {
|
for (const auto& g : s_groups) {
|
||||||
if (g.notes < 2) continue;
|
if (g.notes < 2) continue;
|
||||||
const bool worth = services::worthConsolidating(g, s_fee);
|
const bool worth = services::worthConsolidating(g, s_fee);
|
||||||
@@ -579,6 +582,7 @@ void ShieldDialog::render(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
|
ImGui::PopStyleVar(); // ##ConsolidateEach inner padding
|
||||||
}
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
@@ -720,16 +724,29 @@ void ShieldDialog::render(App* app)
|
|||||||
std::max(1, p.batchesEstimated), p.notesMerged);
|
std::max(1, p.batchesEstimated), p.notesMerged);
|
||||||
wrappedCaption(material::OnSurfaceMedium(), cap);
|
wrappedCaption(material::OnSurfaceMedium(), cap);
|
||||||
if (!p.stopping) {
|
if (!p.stopping) {
|
||||||
if (const char* key = services::runWaitKey(run.lastWait())) wrappedCaption(material::OnSurfaceMedium(), TR(key));
|
// The pause notice (waiting for a batch gap / sync / a send) toggles on and off every batch.
|
||||||
|
// Reserve a fixed two-line slot for it so its coming and going doesn't change the content
|
||||||
|
// height and make the auto-height, vertically-centred modal jump around during the run.
|
||||||
|
const float waitTop = ImGui::GetCursorPosY();
|
||||||
|
if (const char* key = services::runWaitKey(run.lastWait()))
|
||||||
|
wrappedCaption(material::OnSurfaceMedium(), TR(key));
|
||||||
|
const float waitSlotH = ImGui::GetTextLineHeightWithSpacing() * 2.0f;
|
||||||
|
if (ImGui::GetCursorPosY() < waitTop + waitSlotH) ImGui::SetCursorPosY(waitTop + waitSlotH);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGui::ProgressBar(static_cast<float>(p.batchesDone) / static_cast<float>(std::max(1, p.batchesEstimated)),
|
ImGui::ProgressBar(static_cast<float>(p.batchesDone) / static_cast<float>(std::max(1, p.batchesEstimated)),
|
||||||
ImVec2(-1, 0));
|
ImVec2(-1, 0));
|
||||||
const auto& addrs = run.addresses();
|
const auto& addrs = run.addresses();
|
||||||
|
// Inner padding for the bordered list: this floating overlay pops its content padding, so the
|
||||||
|
// ambient WindowPadding is 0 and a bordered child's text would otherwise touch the border. Push a
|
||||||
|
// small padding (and reserve it in the height) so the rows sit inset.
|
||||||
|
const float listPad = 10.0f * dp;
|
||||||
const float runListH = std::min(static_cast<float>(addrs.size()), static_cast<float>(maxRows)) * rowH +
|
const float runListH = std::min(static_cast<float>(addrs.size()), static_cast<float>(maxRows)) * rowH +
|
||||||
ImGui::GetStyle().WindowPadding.y * 2.0f;
|
listPad * 2.0f;
|
||||||
bool leftovers = false;
|
bool leftovers = false;
|
||||||
if (ImGui::BeginChild("##ConsolidateRuns", ImVec2(-1, runListH), ImGuiChildFlags_Borders)) {
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(listPad, listPad));
|
||||||
|
if (ImGui::BeginChild("##ConsolidateRuns", ImVec2(-1, runListH),
|
||||||
|
ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding)) {
|
||||||
for (const auto& a : addrs) {
|
for (const auto& a : addrs) {
|
||||||
const std::string label = app->getAddressLabel(a.group.address);
|
const std::string label = app->getAddressLabel(a.group.address);
|
||||||
const std::string name = label.empty() ? shortAddr(a.group.address) : label;
|
const std::string name = label.empty() ? shortAddr(a.group.address) : label;
|
||||||
@@ -765,10 +782,22 @@ void ShieldDialog::render(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
|
ImGui::PopStyleVar(); // ##ConsolidateRuns inner padding
|
||||||
|
{
|
||||||
|
// The "run again" hint appears partway through a run (once an address has leftover notes). While
|
||||||
|
// the run is live, reserve its measured height up front so it appearing doesn't shift the modal.
|
||||||
|
const float loTop = ImGui::GetCursorPosY();
|
||||||
if (leftovers) {
|
if (leftovers) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_run_again"));
|
wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_run_again"));
|
||||||
}
|
}
|
||||||
|
if (live) {
|
||||||
|
const float loH = ImGui::GetStyle().ItemSpacing.y +
|
||||||
|
ImGui::CalcTextSize(TR("merge_keep_each_run_again"), nullptr, false,
|
||||||
|
ImGui::GetContentRegionAvail().x).y;
|
||||||
|
if (ImGui::GetCursorPosY() < loTop + loH) ImGui::SetCursorPosY(loTop + loH);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!live) { // below the rows: merge_keep_each_some_failed says "see the list above"
|
if (!live) { // below the rows: merge_keep_each_some_failed says "see the list above"
|
||||||
std::string endLine;
|
std::string endLine;
|
||||||
ImU32 endCol = material::OnSurfaceMedium();
|
ImU32 endCol = material::OnSurfaceMedium();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
#include "../layout.h"
|
#include "../layout.h"
|
||||||
#include "../material/colors.h"
|
#include "../material/colors.h"
|
||||||
#include "../material/draw_helpers.h"
|
#include "../material/draw_helpers.h"
|
||||||
|
#include "../material/tooltip_style.h" // material::Tooltip (width-clamped)
|
||||||
#include "../material/type.h"
|
#include "../material/type.h"
|
||||||
#include "folder_picker.h"
|
#include "folder_picker.h"
|
||||||
|
|
||||||
@@ -557,7 +558,7 @@ public:
|
|||||||
ImGui::AlignTextToFramePadding();
|
ImGui::AlignTextToFramePadding();
|
||||||
const float textW = listW - bh2 - 2.0f * Layout::spacingSm();
|
const float textW = listW - bh2 - 2.0f * Layout::spacingSm();
|
||||||
Type().textColored(TypeStyle::Caption, OnSurface(), elideFront(folder, textW).c_str());
|
Type().textColored(TypeStyle::Caption, OnSurface(), elideFront(folder, textW).c_str());
|
||||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", folder.c_str());
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", folder.c_str()); // wrapped: long paths
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
if (!toRemove.empty()) {
|
if (!toRemove.empty()) {
|
||||||
|
|||||||
+104
-3
@@ -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).";
|
||||||
@@ -287,6 +293,11 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_export_confirm_path"] = "Will be saved to:";
|
strings_["chat_export_confirm_path"] = "Will be saved to:";
|
||||||
strings_["chat_export_confirm_btn"] = "Export";
|
strings_["chat_export_confirm_btn"] = "Export";
|
||||||
strings_["chat_len_over"] = "Message too long";
|
strings_["chat_len_over"] = "Message too long";
|
||||||
|
strings_["chat_parts_label"] = "parts";
|
||||||
|
strings_["chat_txns_label"] = "txns";
|
||||||
|
strings_["chat_parts_explain"] = "This long message is sent as one transaction split into these memo parts — it arrives as quickly as a normal message.";
|
||||||
|
strings_["chat_parts_explain_multi"] = "This message is too big for one transaction, so it is split across several. The recipient sees it once every part has arrived, so it takes longer to deliver and costs a network fee per transaction.";
|
||||||
|
strings_["chat_receiving_large"] = "Receiving a large message…";
|
||||||
strings_["chat_mute"] = "Mute";
|
strings_["chat_mute"] = "Mute";
|
||||||
strings_["chat_unmute"] = "Unmute";
|
strings_["chat_unmute"] = "Unmute";
|
||||||
strings_["chat_hide"] = "Hide";
|
strings_["chat_hide"] = "Hide";
|
||||||
@@ -307,11 +318,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 +561,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 +577,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.";
|
||||||
@@ -585,6 +643,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
// Wallet settings
|
// Wallet settings
|
||||||
strings_["save_z_transactions"] = "Save shielded tx history";
|
strings_["save_z_transactions"] = "Save shielded tx history";
|
||||||
strings_["auto_shield"] = "Auto-shield";
|
strings_["auto_shield"] = "Auto-shield";
|
||||||
|
strings_["auto_consolidate"] = "Auto-consolidate notes";
|
||||||
strings_["use_tor"] = "Use Tor";
|
strings_["use_tor"] = "Use Tor";
|
||||||
strings_["keep_daemon"] = "Keep daemon running";
|
strings_["keep_daemon"] = "Keep daemon running";
|
||||||
strings_["stop_external"] = "Stop external daemon";
|
strings_["stop_external"] = "Stop external daemon";
|
||||||
@@ -621,6 +680,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…";
|
||||||
@@ -806,9 +874,22 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["wiz_pin_mismatch"] = "PINs do not match";
|
strings_["wiz_pin_mismatch"] = "PINs do not match";
|
||||||
strings_["settings_data_dir"] = "Data Dir";
|
strings_["settings_data_dir"] = "Data Dir";
|
||||||
strings_["settings_wallet_size_label"] = "Wallet Size";
|
strings_["settings_wallet_size_label"] = "Wallet Size";
|
||||||
strings_["wallet_size_warn"] = "This wallet file is large. Consolidating your notes can curb further growth.";
|
strings_["wallet_size_warn"] = "Your wallet has built up a lot of notes. Each payment you receive is stored as a separate \"note\"; as they pile up they make the wallet slower to sync and keep it growing. Consolidating merges them into fewer notes so it scans faster and stops growing \xE2\x80\x94 your balance stays exactly the same.";
|
||||||
strings_["tt_wallet_size_warn"] = "Shielded wallets grow with each note's witness data — merging many notes into one address reduces it. Back up first.";
|
strings_["wallet_size_auto_info"] = "Your wallet has built up a lot of notes \xE2\x80\x94 separate pieces of received funds that slow syncing and keep the file growing. Auto-consolidation is quietly merging them in the background to keep the wallet fast and stop it growing, so there's nothing you need to do.";
|
||||||
|
strings_["tt_wallet_size_warn"] = "Each payment arrives as a separate shielded note, and every note carries witness data that slows scans and keeps the wallet file growing. Merging many notes into one per address keeps it fast and stops the growth \xE2\x80\x94 your funds and balance are unchanged. The file reclaims its freed space only when you Compact it (Berkeley DB can't shrink in place). Back up your seed first. You can also turn on Auto-consolidate in Settings.";
|
||||||
strings_["wallet_size_consolidate"] = "Consolidate notes\xE2\x80\xA6";
|
strings_["wallet_size_consolidate"] = "Consolidate notes\xE2\x80\xA6";
|
||||||
|
strings_["wallet_size_compact"] = "Compact wallet\xE2\x80\xA6";
|
||||||
|
strings_["tt_wallet_size_compact"] = "Reclaim the disk space freed by consolidating. Berkeley DB can't shrink the file in place, so this rewrites wallet.dat into a smaller file and restarts the node with a full rescan (can take a while). A backup of the current wallet is kept.";
|
||||||
|
strings_["compact_started"] = "Compacting wallet \xE2\x80\x94 the node is restarting and will re-scan the chain.";
|
||||||
|
strings_["compact_done"] = "Wallet compacted \xE2\x80\x94 the file was rewritten and the freed space reclaimed. The node is re-scanning to rebuild your transaction view.";
|
||||||
|
strings_["confirm_compact_wallet_title"] = "Compact wallet";
|
||||||
|
strings_["confirm_compact_wallet_msg"] = "Consolidating merged your notes, but the wallet file can't shrink on its own \xE2\x80\x94 the node has to rewrite it to reclaim the freed space. This restarts the node and re-scans the blockchain, which can take a while on a large wallet.";
|
||||||
|
strings_["confirm_compact_wallet_safe"] = "Your funds and balance are safe \xE2\x80\x94 a timestamped backup of the current wallet is kept.";
|
||||||
|
strings_["compact_wallet_confirm"] = "Compact";
|
||||||
|
strings_["compact_needs_embedded"] = "Compacting rewrites wallet.dat, which the wallet can only do with its built-in node. You're connected to a dragonxd you started yourself \xE2\x80\x94 turn on \"Use embedded dragonxd\" in Settings, or restart your dragonxd with -salvagewallet to reclaim the space.";
|
||||||
|
strings_["confirm_compact_switch_msg"] = "You're connected to a dragonxd you started yourself. To compact, the wallet will stop that node, switch to its own built-in node, and rewrite wallet.dat on it \xE2\x80\x94 restarting and re-scanning the blockchain, which can take a while. Note: the built-in node is the dragonxd in the wallet's own folder, so this changes which node you run.";
|
||||||
|
strings_["compact_switching"] = "Stopping your node and switching to the built-in one\xE2\x80\xA6";
|
||||||
|
strings_["compact_switch_stop_failed"] = "Couldn't stop your external node \xE2\x80\x94 it's still running, so nothing was changed.";
|
||||||
strings_["settings_debug_changed"] = "Debug categories changed \xe2\x80\x94 restart daemon to apply";
|
strings_["settings_debug_changed"] = "Debug categories changed \xe2\x80\x94 restart daemon to apply";
|
||||||
strings_["settings_auto_detected"] = "Auto-detected from DRAGONX.conf";
|
strings_["settings_auto_detected"] = "Auto-detected from DRAGONX.conf";
|
||||||
strings_["settings_visual_effects"] = "Visual Effects";
|
strings_["settings_visual_effects"] = "Visual Effects";
|
||||||
@@ -867,6 +948,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["tt_clear_ztx"] = "Delete locally cached z-transaction history";
|
strings_["tt_clear_ztx"] = "Delete locally cached z-transaction history";
|
||||||
strings_["tt_save_ztx"] = "Store z-address transaction history locally for faster loading";
|
strings_["tt_save_ztx"] = "Store z-address transaction history locally for faster loading";
|
||||||
strings_["tt_auto_shield"] = "Automatically move transparent balance to shielded addresses for privacy";
|
strings_["tt_auto_shield"] = "Automatically move transparent balance to shielded addresses for privacy";
|
||||||
|
strings_["tt_auto_consolidate"] = "Quietly merge small notes in the background while the wallet is idle, in tiny batches. Slower than a manual consolidation but barely noticeable, and each address's funds stay separate. Good for mining/pool wallets that accumulate thousands of notes.";
|
||||||
strings_["tt_tor"] = "Route daemon connections through the Tor network for anonymity";
|
strings_["tt_tor"] = "Route daemon connections through the Tor network for anonymity";
|
||||||
strings_["tt_tor_unavailable"] = "Not available yet: this wallet does not route any traffic through Tor. For the node only, you can add proxy=127.0.0.1:9050 to DRAGONX.conf and restart the node. Tor must be running, or the node finds no peers. Price, update, bootstrap and pool requests still connect directly; use a system-wide VPN/Tor for those.";
|
strings_["tt_tor_unavailable"] = "Not available yet: this wallet does not route any traffic through Tor. For the node only, you can add proxy=127.0.0.1:9050 to DRAGONX.conf and restart the node. Tor must be running, or the node finds no peers. Price, update, bootstrap and pool requests still connect directly; use a system-wide VPN/Tor for those.";
|
||||||
strings_["tt_tor_unavailable_lite"] = "Not available yet: this wallet does not route any traffic through Tor. Use a system-wide VPN/Tor.";
|
strings_["tt_tor_unavailable_lite"] = "Not available yet: this wallet does not route any traffic through Tor. Use a system-wide VPN/Tor.";
|
||||||
@@ -1090,6 +1172,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 +1435,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 +1587,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 +1681,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 +1715,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 +2784,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!";
|
||||||
@@ -2743,7 +2835,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["merge_title"] = "Merge to Address";
|
strings_["merge_title"] = "Merge to Address";
|
||||||
// Consolidate-funds flow (rich merge modal + wallet-bloat preset).
|
// Consolidate-funds flow (rich merge modal + wallet-bloat preset).
|
||||||
strings_["consolidate_title"] = "Consolidate funds";
|
strings_["consolidate_title"] = "Consolidate funds";
|
||||||
strings_["consolidate_desc"] = "Combine many small inputs into a single shielded note. Fewer notes means a smaller wallet file and better privacy.";
|
strings_["consolidate_desc"] = "Each payment you receive is stored as a separate \"note\" \xE2\x80\x94 like an individual bill in a wallet. They pile up over time, and every note adds witness data that makes the wallet slower to sync and keeps it growing. Consolidating combines many small notes into one per address: your balance doesn't change, but the wallet scans faster, stops growing, and reveals less about your funds.";
|
||||||
strings_["consolidate_funds_btn"] = "Consolidate";
|
strings_["consolidate_funds_btn"] = "Consolidate";
|
||||||
strings_["merge_scope_loading"] = "Checking your inputs\xE2\x80\xA6";
|
strings_["merge_scope_loading"] = "Checking your inputs\xE2\x80\xA6";
|
||||||
strings_["merge_scope_fmt"] = "%d transparent + %d shielded inputs \xC2\xB7 ~%s DRGX spendable";
|
strings_["merge_scope_fmt"] = "%d transparent + %d shielded inputs \xC2\xB7 ~%s DRGX spendable";
|
||||||
@@ -3111,6 +3203,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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -39,6 +41,15 @@ public:
|
|||||||
*/
|
*/
|
||||||
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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -187,11 +187,15 @@ bool Platform::openFolder(const std::string& path, bool createIfMissing)
|
|||||||
|
|
||||||
uint64_t Platform::getFileSize(const std::string& path)
|
uint64_t Platform::getFileSize(const std::string& path)
|
||||||
{
|
{
|
||||||
struct stat st;
|
// Use std::filesystem (64-bit uintmax_t) rather than stat(): on mingw/Windows struct stat's
|
||||||
if (stat(path.c_str(), &st) == 0) {
|
// st_size is a 32-bit signed _off_t, so a wallet.dat over 2 GiB overflows negative and casts to a
|
||||||
return static_cast<uint64_t>(st.st_size);
|
// garbage ~2^64 value (the "17179869182 GB" bug). The error_code overload returns on failure
|
||||||
}
|
// (missing / unreadable / Windows sharing violation while the daemon holds the file) instead of
|
||||||
return 0;
|
// throwing, and we report 0 so callers fall back to "unknown".
|
||||||
|
std::error_code ec;
|
||||||
|
const auto sz = std::filesystem::file_size(path, ec);
|
||||||
|
if (ec) return 0;
|
||||||
|
return static_cast<uint64_t>(sz);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Platform::formatFileSize(uint64_t bytes)
|
std::string Platform::formatFileSize(uint64_t bytes)
|
||||||
|
|||||||
@@ -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,7 +378,16 @@ 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"
|
||||||
|
// chip and the fast-poll hint read it that way). Lite: the not-yet-spendable shielded part (unverified /
|
||||||
|
// unconfirmed notes) is that portion; the backend's own `unconfirmed` is mempool-incoming. Take the larger so
|
||||||
|
// neither is hidden and a note counted in both is not double-reported. Display-only; never feeds a spend gate.
|
||||||
|
{
|
||||||
|
const std::uint64_t notYetSpendable = model.balance.shieldedZatoshis -
|
||||||
|
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
|
// 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
|
// 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
|
// spendable_zbalance field is required by the parser, so it's always present when hasBalance is
|
||||||
@@ -366,8 +395,21 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
const double spendableShielded = static_cast<double>(
|
const double spendableShielded = static_cast<double>(
|
||||||
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
|
||||||
state.spendablePrivateBalance = spendableShielded;
|
state.spendablePrivateBalance = spendableShielded;
|
||||||
state.spendableTransparentBalance = state.transparentBalance;
|
// Backend tbalance sums every utxo with spent==None (lightwallet.rs get_utxos), so a t-UTXO already spent by
|
||||||
state.spendableTotalBalance = spendableShielded + state.transparentBalance;
|
// 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) {
|
||||||
@@ -376,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,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();
|
||||||
@@ -407,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 {
|
||||||
@@ -435,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;
|
||||||
@@ -915,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?)");
|
||||||
@@ -1047,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);
|
||||||
@@ -1071,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1562,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;
|
||||||
@@ -1610,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);
|
||||||
@@ -1621,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;
|
||||||
@@ -1638,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 {
|
||||||
@@ -1717,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;
|
||||||
@@ -1747,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\"}");
|
||||||
}
|
}
|
||||||
|
|||||||
+1156
-9
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();
|
||||||
|
|||||||
Executable
+157
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Isolated regtest chat round-trip: starts a throwaway regtest dragonxd (no network, own datadir),
|
||||||
|
# funds a shielded note, then uses build/linux/bin/chat_regtest_smoke to compose a chunked (multi-
|
||||||
|
# output) message + a Control memo, broadcasts them via z_sendmany, mines, harvests via
|
||||||
|
# z_listreceivedbyaddress, and feeds the memos back to the tool to verify they reassemble/decrypt.
|
||||||
|
# Nothing touches ~/.hush — a dedicated datadir, -regtest, and no-network flags keep it sandboxed.
|
||||||
|
import json, os, shutil, signal, subprocess, sys, time
|
||||||
|
|
||||||
|
BIN = os.environ.get("DRAGONX_REGTEST_DAEMON_DIR", "/home/d/dragonx/release/dragonx-1.3.0-linux-amd64")
|
||||||
|
DAEMON, CLI = f"{BIN}/dragonxd", f"{BIN}/dragonx-cli"
|
||||||
|
TOOL = os.environ.get("DRAGONX_CHAT_SMOKE", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "build/linux/bin/chat_regtest_smoke"))
|
||||||
|
RUN = os.environ.get("DRAGONX_REGTEST_RUN", "/tmp/dragonx-chat-regtest")
|
||||||
|
DD = f"{RUN}/dd"
|
||||||
|
PORT = 29201
|
||||||
|
USER, PW = "u", "p"
|
||||||
|
proc = None
|
||||||
|
|
||||||
|
def log(m): print(f"[regtest] {m}", flush=True)
|
||||||
|
|
||||||
|
def cli(method, *args, check=True):
|
||||||
|
cmd = [CLI, "-regtest", f"-datadir={DD}", f"-conf={DD}/DRAGONX.conf", method, *[str(a) for a in args]]
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
if check: raise RuntimeError(f"cli {method} failed: {r.stderr.strip()[:300]}")
|
||||||
|
return None
|
||||||
|
out = r.stdout.strip()
|
||||||
|
try: return json.loads(out)
|
||||||
|
except Exception: return out
|
||||||
|
|
||||||
|
def wait_rpc(timeout=60):
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
if cli("getinfo", check=False) is not None: return True
|
||||||
|
if proc and proc.poll() is not None:
|
||||||
|
raise RuntimeError(f"daemon exited early (rc={proc.returncode}); see {DD}/regtest/debug.log")
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError("daemon RPC never came up")
|
||||||
|
|
||||||
|
def wait_op(opid, timeout=180):
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
res = cli("z_getoperationresult", json.dumps([opid]), check=False)
|
||||||
|
if isinstance(res, list) and res:
|
||||||
|
st = res[0].get("status")
|
||||||
|
if st == "success": return res[0]
|
||||||
|
if st == "failed": raise RuntimeError(f"op {opid} failed: {res[0].get('error')}")
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError(f"op {opid} timed out")
|
||||||
|
|
||||||
|
def mine(n):
|
||||||
|
cli("generate", n)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def tool(mode, *args, stdin=None):
|
||||||
|
r = subprocess.run([TOOL, mode, *args], input=stdin, capture_output=True, text=True)
|
||||||
|
sys.stderr.write(r.stderr)
|
||||||
|
return r.stdout, r.returncode
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global proc
|
||||||
|
if os.path.isdir(RUN): shutil.rmtree(RUN)
|
||||||
|
os.makedirs(DD)
|
||||||
|
with open(f"{DD}/DRAGONX.conf", "w") as f:
|
||||||
|
f.write(f"rpcuser={USER}\nrpcpassword={PW}\nrpcport={PORT}\nusemnemonic=1\n")
|
||||||
|
# Keep the real HOME so the daemon finds ~/.zcash-params (read-only); isolation is via the explicit
|
||||||
|
# -datadir + -regtest + no-network flags, so no mainnet data is touched.
|
||||||
|
log(f"starting regtest dragonxd (datadir {DD}, no network)")
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[DAEMON, "-regtest", f"-datadir={DD}", f"-conf={DD}/DRAGONX.conf", f"-rpcport={PORT}",
|
||||||
|
f"-port={PORT+100}", "-listen=0", "-dnsseed=0", "-discover=0", "-maxconnections=0",
|
||||||
|
"-usemnemonic=1", f"-asmap={BIN}/asmap.dat"],
|
||||||
|
env=os.environ, stdout=open(f"{RUN}/daemon.out", "w"), stderr=subprocess.STDOUT)
|
||||||
|
wait_rpc()
|
||||||
|
log("daemon up; mining to maturity")
|
||||||
|
mine(10)
|
||||||
|
|
||||||
|
# Fund a shielded note: shield coinbase into a fresh z-addr (if auto-shield already did it, we just
|
||||||
|
# find a funded note below).
|
||||||
|
zfrom = cli("z_getnewaddress")
|
||||||
|
op = cli("z_shieldcoinbase", "*", zfrom, check=False)
|
||||||
|
if isinstance(op, dict) and op.get("opid"):
|
||||||
|
try: wait_op(op["opid"])
|
||||||
|
except Exception as e: log(f"shieldcoinbase: {e}")
|
||||||
|
mine(3)
|
||||||
|
# Find a funded z-note to send FROM.
|
||||||
|
uns = cli("z_listunspent", 1) or []
|
||||||
|
funded = [u for u in uns if float(u.get("amount", 0)) > 0.001]
|
||||||
|
if not funded:
|
||||||
|
log(f"no funded z-note yet; z_gettotalbalance={cli('z_gettotalbalance')}")
|
||||||
|
raise RuntimeError("could not fund a shielded note")
|
||||||
|
zfrom = funded[0]["address"]
|
||||||
|
log(f"funded z-note: {zfrom} = {funded[0]['amount']}")
|
||||||
|
|
||||||
|
z = cli("z_getnewaddress") # chat recipient (self for this round-trip)
|
||||||
|
mnem = cli("z_exportmnemonic")
|
||||||
|
if isinstance(mnem, dict): mnem = mnem.get("mnemonic") or mnem.get("seed_phrase") or ""
|
||||||
|
assert mnem, "no mnemonic"
|
||||||
|
pub, rc = tool("pubkey", mnem); pub = pub.strip()
|
||||||
|
log(f"chat pubkey {pub[:16]}…, recipient {z[:16]}…")
|
||||||
|
|
||||||
|
big = ("The quick brown fox jumps over the lazy dog. " * 34).strip() # ~1560 chars -> multi-part
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
def roundtrip(label, compose_args, expect_kind):
|
||||||
|
out, rc = tool(*compose_args, )
|
||||||
|
if rc != 0: raise RuntimeError(f"{label}: compose rc={rc}")
|
||||||
|
recip, memos = None, []
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith("RECIP "): recip = line[6:]
|
||||||
|
elif line.startswith("HEADER "): memos.append(line[7:])
|
||||||
|
elif line.startswith("PAYLOAD "): memos.append(line[8:])
|
||||||
|
recipients = [{"address": recip, "amount": "0.00000000", "memo": "utf8:" + m} for m in memos]
|
||||||
|
log(f"{label}: {len(memos)} memo outputs -> z_sendmany from {zfrom[:12]}…")
|
||||||
|
op = cli("z_sendmany", zfrom, json.dumps(recipients), 1, "0.0001")
|
||||||
|
opres = wait_op(op["opid"] if isinstance(op, dict) else op)
|
||||||
|
sent_txid = (opres.get("result") or {}).get("txid")
|
||||||
|
mine(2)
|
||||||
|
recv = cli("z_listreceivedbyaddress", recip, 0) or []
|
||||||
|
# Harvest is per-transaction in production; filter to THIS tx's outputs so each extract gets
|
||||||
|
# exactly one message/control's memos (the recipient z-addr is reused across roundtrips).
|
||||||
|
memostrs = [e.get("memoStr", "") for e in recv if e.get("memoStr") and e.get("txid") == sent_txid]
|
||||||
|
log(f"{label}: tx {str(sent_txid)[:12]}… -> {len(memostrs)} of {len(recv)} received memos are this tx")
|
||||||
|
ext, rc = tool("extract", mnem, stdin="\n".join(memostrs) + "\n")
|
||||||
|
results[label] = (ext.strip(), rc)
|
||||||
|
return ext.strip(), rc
|
||||||
|
|
||||||
|
# 1) multi-part message round-trip
|
||||||
|
msg_out, msg_rc = roundtrip("MESSAGE", ("compose", mnem, pub, z, z, "cid-1", big), "msg")
|
||||||
|
rec = ""
|
||||||
|
for line in msg_out.splitlines():
|
||||||
|
if line.startswith("PLAINTEXT "): rec = line[10:]
|
||||||
|
ok_msg = (rec == big)
|
||||||
|
log(f"MESSAGE round-trip: {'OK' if ok_msg else 'FAIL'} (recovered {len(rec)}/{len(big)} bytes)")
|
||||||
|
|
||||||
|
# 2) Control(del) memo round-trip
|
||||||
|
ctl_out, ctl_rc = roundtrip("CONTROL", ("control", mnem, z, "cid-7", "del", "peerlabel"), "ctl")
|
||||||
|
ok_ctl = "CONTROL op=del" in ctl_out and "cid=cid-7" in ctl_out
|
||||||
|
log(f"CONTROL round-trip: {'OK' if ok_ctl else 'FAIL'} ({ctl_out[:80]})")
|
||||||
|
|
||||||
|
print("\n==== RESULT ====")
|
||||||
|
print(f"multi-part MESSAGE through live daemon: {'PASS' if ok_msg else 'FAIL'}")
|
||||||
|
print(f"Control(del) memo through live daemon: {'PASS' if ok_ctl else 'FAIL'}")
|
||||||
|
return 0 if (ok_msg and ok_ctl) else 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
rc = 1
|
||||||
|
try:
|
||||||
|
rc = main()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"ERROR: {e}")
|
||||||
|
finally:
|
||||||
|
if proc and proc.poll() is None:
|
||||||
|
log("stopping daemon")
|
||||||
|
cli("stop", check=False); time.sleep(3)
|
||||||
|
if proc.poll() is None: proc.terminate(); time.sleep(2)
|
||||||
|
if proc.poll() is None: proc.kill()
|
||||||
|
sys.exit(rc)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// DragonX Wallet — regtest chat smoke driver (dev tool, EXCLUDE_FROM_ALL; never shipped).
|
||||||
|
//
|
||||||
|
// Does the CHAT-side work only — identity derivation, outgoing-memo compose, and
|
||||||
|
// harvest extract + decrypt — linking the real chat library. The daemon transport
|
||||||
|
// (z_sendmany / generate / z_listreceivedbyaddress) is driven by the surrounding
|
||||||
|
// regtest runner script, which pipes memos in and out of this tool. Together they
|
||||||
|
// verify the one thing unit tests cannot: that the real dragonxd accepts a multi-
|
||||||
|
// output 0-value chat tx and round-trips its memos verbatim so a chunked (tier-2)
|
||||||
|
// message and a self-addressed Control memo reassemble end to end.
|
||||||
|
//
|
||||||
|
// Modes (memos for `extract` arrive on stdin, one per line):
|
||||||
|
// pubkey <mnemonic>
|
||||||
|
// compose <mnemonic> <peerPubKeyHex> <peerZaddr> <myReplyZaddr> <cid> <plaintext>
|
||||||
|
// control <mnemonic> <selfZaddr> <cid> <op> <label>
|
||||||
|
// extract <mnemonic>
|
||||||
|
//
|
||||||
|
// SECRET-SAFE: never prints the mnemonic or any key; only public pubkeys, memos, and
|
||||||
|
// recovered plaintext (which the runner feeds back the same message it sent).
|
||||||
|
|
||||||
|
#include "chat/chat_crypto.h"
|
||||||
|
#include "chat/chat_identity.h"
|
||||||
|
#include "chat/chat_outgoing.h"
|
||||||
|
#include "chat/chat_protocol.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
using namespace dragonx::chat;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool derive(const char* mnemonic, ChatKeyPair& keys, std::string& pubHex)
|
||||||
|
{
|
||||||
|
auto r = deriveChatIdentityFromSecret(mnemonic, keys, /*featureEnabled=*/true);
|
||||||
|
if (r.status != ChatIdentityStatus::Ready) {
|
||||||
|
std::fprintf(stderr, "[chat-smoke] identity derive failed: %s\n", r.error_name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pubHex = r.public_key_hex;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the fixed-512 memo's trailing NUL padding (and any stray whitespace) that the
|
||||||
|
// daemon's memoStr carries back, so the header JSON / chunk hex parses cleanly.
|
||||||
|
std::string trimMemo(std::string s)
|
||||||
|
{
|
||||||
|
while (!s.empty() && (s.back() == '\0' || s.back() == '\n' || s.back() == '\r' || s.back() == ' '))
|
||||||
|
s.pop_back();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void emit(const OutgoingChatMemos& memos)
|
||||||
|
{
|
||||||
|
std::printf("RECIP %s\n", memos.recipientZaddr.c_str());
|
||||||
|
std::printf("HEADER %s\n", memos.headerMemo.c_str());
|
||||||
|
for (const auto& p : memos.payloadMemos) std::printf("PAYLOAD %s\n", p.c_str());
|
||||||
|
std::fprintf(stderr, "[chat-smoke] composed %zu payload output(s)\n", memos.payloadMemos.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
if (argc < 2) { std::fprintf(stderr, "usage: chat_regtest_smoke <pubkey|compose|control|extract> ...\n"); return 2; }
|
||||||
|
const std::string mode = argv[1];
|
||||||
|
|
||||||
|
if (mode == "pubkey") {
|
||||||
|
if (argc < 3) return 2;
|
||||||
|
ChatKeyPair keys; std::string pub;
|
||||||
|
if (!derive(argv[2], keys, pub)) return 1;
|
||||||
|
std::printf("%s\n", pub.c_str());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == "compose") {
|
||||||
|
if (argc < 8) { std::fprintf(stderr, "compose needs 6 args\n"); return 2; }
|
||||||
|
ChatKeyPair keys; std::string pub;
|
||||||
|
if (!derive(argv[2], keys, pub)) return 1;
|
||||||
|
OutgoingChatMemos memos;
|
||||||
|
const auto st = buildOutgoingMessage(keys, pub, /*myReply=*/argv[5], /*peerPub=*/argv[3],
|
||||||
|
/*peerZ=*/argv[4], /*cid=*/argv[6], /*plain=*/argv[7], memos);
|
||||||
|
if (st != ChatComposeStatus::Ok) { std::fprintf(stderr, "[chat-smoke] compose status=%d\n", (int)st); return 1; }
|
||||||
|
emit(memos);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == "control") {
|
||||||
|
if (argc < 7) { std::fprintf(stderr, "control needs 5 args\n"); return 2; }
|
||||||
|
ChatKeyPair keys; std::string pub;
|
||||||
|
if (!derive(argv[2], keys, pub)) return 1;
|
||||||
|
OutgoingChatMemos memos;
|
||||||
|
const auto st = buildOutgoingControl(pub, /*selfZ=*/argv[3], /*cid=*/argv[4],
|
||||||
|
/*op=*/argv[5], /*label=*/argv[6], memos);
|
||||||
|
if (st != ChatComposeStatus::Ok) { std::fprintf(stderr, "[chat-smoke] control status=%d\n", (int)st); return 1; }
|
||||||
|
emit(memos);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == "extract") {
|
||||||
|
if (argc < 3) return 2;
|
||||||
|
ChatKeyPair keys; std::string pub;
|
||||||
|
if (!derive(argv[2], keys, pub)) return 1;
|
||||||
|
HushChatTransactionInput tx; tx.txid = "regtest-tx";
|
||||||
|
std::string line; std::size_t pos = 0;
|
||||||
|
while (std::getline(std::cin, line)) {
|
||||||
|
const std::string m = trimMemo(line);
|
||||||
|
if (!m.empty()) tx.outputs.push_back({pos++, m});
|
||||||
|
}
|
||||||
|
auto ext = extractHushChatTransactionMetadata(tx, /*featureEnabled=*/true);
|
||||||
|
std::fprintf(stderr, "[chat-smoke] %zu memo output(s) in -> %zu metadata, %zu issue(s)\n",
|
||||||
|
tx.outputs.size(), ext.metadata.size(), ext.issues.size());
|
||||||
|
int recovered = 0;
|
||||||
|
for (const auto& md : ext.metadata) {
|
||||||
|
if (md.type == HushChatHeaderType::Message) {
|
||||||
|
std::string plain;
|
||||||
|
const auto cs = decryptIncoming(keys, md.sender_public_key_hex,
|
||||||
|
md.secretstream_header_hex, md.payload_memo, plain);
|
||||||
|
if (cs == ChatCryptoStatus::Ok) { std::printf("PLAINTEXT %s\n", plain.c_str()); ++recovered; }
|
||||||
|
else std::fprintf(stderr, "[chat-smoke] decrypt failed status=%d\n", (int)cs);
|
||||||
|
} else if (md.type == HushChatHeaderType::Control) {
|
||||||
|
std::printf("CONTROL op=%s cid=%s label=%s\n",
|
||||||
|
md.op.c_str(), md.conversation_id.c_str(), md.payload_memo.c_str());
|
||||||
|
++recovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recovered > 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fprintf(stderr, "unknown mode: %s\n", mode.c_str());
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user