feat(wallet): consolidate notes per address, keeping each address's funds in place

Users who keep funds apart under different labelled addresses can now
shed notes (smaller wallet, faster scans/witness rebuilds) without the
funds being merged into a single address.

- Merge/Consolidate dialog: "Where should the funds go?" - Into one
  address (unchanged) or Keep in each address. The latter lists shielded
  addresses with 2+ spendable notes (label, note count, amount;
  too-small ones disabled) and merges each address's notes back into
  that address with z_mergetoaddress [addr] addr. Addresses with more
  notes than the batch size get several batches (the daemon locks a
  batch's notes before returning, so calls take disjoint notes); the
  dialog suggests a second run after those confirm. Per-address
  progress; ops keep running if the dialog is closed. The Consolidate
  (wallet-size) preset opens in this mode; Merge keeps "Into one".
  Shielded only: a transparent address can't take a merge's output.
- services/note_consolidation: groupSpendableNotes, worthConsolidating,
  estimateBatches, consolidateInPlace (pure; driven by a merge-call
  function), with unit tests against a stand-in following dragonxd's
  rules.
- fake_dragonxd: optional note wallet (FAKE_WALLET_NOTES,
  FAKE_MERGE_SECS) with z_mergetoaddress and its operations.
- i18n: 17 new keys in all 8 languages; CJK subset rebuilt.
- UI sweep: modal-consolidate target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 22:15:56 -05:00
co-authored by Claude Opus 5.5
parent e367eb47ff
commit 71ed92b9f1
19 changed files with 1016 additions and 6 deletions
+2
View File
@@ -467,6 +467,7 @@ set(APP_SOURCES
src/app_sweep.cpp src/app_sweep.cpp
src/app_wizard.cpp src/app_wizard.cpp
src/services/network_refresh_service.cpp src/services/network_refresh_service.cpp
src/services/note_consolidation.cpp
src/services/refresh_scheduler.cpp src/services/refresh_scheduler.cpp
src/services/wallet_security_controller.cpp src/services/wallet_security_controller.cpp
src/services/wallet_security_workflow.cpp src/services/wallet_security_workflow.cpp
@@ -1146,6 +1147,7 @@ if(BUILD_TESTING)
add_executable(ObsidianDragonTests add_executable(ObsidianDragonTests
tests/test_phase4.cpp tests/test_phase4.cpp
src/services/network_refresh_service.cpp src/services/network_refresh_service.cpp
src/services/note_consolidation.cpp
src/services/refresh_scheduler.cpp src/services/refresh_scheduler.cpp
src/services/wallet_security_controller.cpp src/services/wallet_security_controller.cpp
src/services/wallet_security_workflow.cpp src/services/wallet_security_workflow.cpp
Binary file not shown.
+17
View File
@@ -1163,6 +1163,22 @@
"merge_description": "Mehrere UTXOs zu einer einzelnen abgeschirmten Adresse zusammenführen. Dies kann die Wallet-Größe reduzieren und die Privatsphäre verbessern.", "merge_description": "Mehrere UTXOs zu einer einzelnen abgeschirmten Adresse zusammenführen. Dies kann die Wallet-Größe reduzieren und die Privatsphäre verbessern.",
"merge_fee_hint": "Netzwerkgebühr für diese Transaktion.", "merge_fee_hint": "Netzwerkgebühr für diese Transaktion.",
"merge_funds": "Gelder zusammenführen", "merge_funds": "Gelder zusammenführen",
"merge_into_one": "In eine Adresse",
"merge_keep_each": "In jeder Adresse belassen",
"merge_keep_each_batches_fmt": "Adressen mit mehr als %d Notes werden in mehreren Durchgängen zusammengeführt; führen Sie es nach deren Bestätigung erneut aus, um den Rest zusammenzuführen.",
"merge_keep_each_confirm_fmt": "%d Notes in %d Adressen konsolidieren? Jede Adresse behält ihre eigenen Mittel. Gebühren: etwa %s DRGX (eine pro Durchgang).",
"merge_keep_each_desc": "Die Notes jeder Adresse werden in genau diese Adresse zurückgeführt, sodass Mittel, die Sie unter verschiedenen Bezeichnungen getrennt halten, getrennt bleiben. Nur abgeschirmte Adressen – transparente Mittel können nur abgeschirmt werden (In eine Adresse).",
"merge_keep_each_none": "Jede abgeschirmte Adresse hat bereits nur eine Note – nichts zu konsolidieren.",
"merge_keep_each_nothing_submitted": "Nichts wurde konsolidiert – der Node hat jede Zusammenführung abgelehnt.",
"merge_keep_each_row_fmt": "%s · %d Notes · %s DRGX",
"merge_keep_each_run_again": "Adressen, die in mehreren Durchgängen zusammengeführt wurden, haben jetzt eine Note pro Durchgang. Führen Sie dies nach deren Bestätigung erneut aus, um sie zu einer zusammenzuführen.",
"merge_keep_each_select": "Wählen Sie mindestens eine Adresse zum Konsolidieren aus.",
"merge_keep_each_some_failed": "Einige Zusammenführungen sind fehlgeschlagen – siehe Liste oben.",
"merge_keep_each_status_done": "%s – fertig: %d Notes in %d Durchgang/Durchgängen zusammengeführt",
"merge_keep_each_status_failed": "%s – fehlgeschlagen: %s",
"merge_keep_each_status_not_started": "%s – nicht gestartet: %s",
"merge_keep_each_status_running": "%s – %d Notes werden zusammengeführt (%d von %d Durchgängen fertig)…",
"merge_keep_each_too_small": "Zu klein zum Konsolidieren: Die Gebühr würde diese Mittel aufbrauchen.",
"merge_max_inputs": "Max. Inputs pro Batch", "merge_max_inputs": "Max. Inputs pro Batch",
"merge_no_spendable": "Noch keine verfügbaren Inputs zum Konsolidieren.", "merge_no_spendable": "Noch keine verfügbaren Inputs zum Konsolidieren.",
"merge_progress": "Konsolidierung läuft… dies kann einige Minuten dauern. Sie können dieses Fenster schließen.", "merge_progress": "Konsolidierung läuft… dies kann einige Minuten dauern. Sie können dieses Fenster schließen.",
@@ -1175,6 +1191,7 @@
"merge_src_transparent": "Transparent", "merge_src_transparent": "Transparent",
"merge_started": "Zusammenführung gestartet", "merge_started": "Zusammenführung gestartet",
"merge_title": "An Adresse zusammenführen", "merge_title": "An Adresse zusammenführen",
"merge_where": "Wohin sollen die Mittel gehen?",
"mig_adopt": "Dies zu meiner Wallet machen", "mig_adopt": "Dies zu meiner Wallet machen",
"mig_adopt_now": "Seed-Wallet übernehmen", "mig_adopt_now": "Seed-Wallet übernehmen",
"mig_adopting": "Ihre neue Wallet wird installiert und neu gescannt…", "mig_adopting": "Ihre neue Wallet wird installiert und neu gescannt…",
+17
View File
@@ -1163,6 +1163,22 @@
"merge_description": "Fusiona múltiples UTXOs en una sola dirección protegida. Esto puede ayudar a reducir el tamaño de la cartera y mejorar la privacidad.", "merge_description": "Fusiona múltiples UTXOs en una sola dirección protegida. Esto puede ayudar a reducir el tamaño de la cartera y mejorar la privacidad.",
"merge_fee_hint": "Comisión de red para esta transacción.", "merge_fee_hint": "Comisión de red para esta transacción.",
"merge_funds": "Fusionar Fondos", "merge_funds": "Fusionar Fondos",
"merge_into_one": "A una sola dirección",
"merge_keep_each": "Mantener en cada dirección",
"merge_keep_each_batches_fmt": "Las direcciones con más de %d notas se fusionan en varios lotes; vuelve a ejecutarlo cuando se confirmen para fusionar el resto.",
"merge_keep_each_confirm_fmt": "¿Consolidar %d notas en %d direcciones? Cada dirección conserva sus propios fondos. Comisiones: unos %s DRGX (una por lote).",
"merge_keep_each_desc": "Las notas de cada dirección se fusionan de nuevo en esa misma dirección, así que los fondos que mantienes separados con distintas etiquetas siguen separados. Solo direcciones blindadas: los fondos transparentes solo pueden blindarse (A una sola dirección).",
"merge_keep_each_none": "Cada dirección blindada ya tiene una sola nota: no hay nada que consolidar.",
"merge_keep_each_nothing_submitted": "No se consolidó nada: el nodo rechazó todas las fusiones.",
"merge_keep_each_row_fmt": "%s · %d notas · %s DRGX",
"merge_keep_each_run_again": "Las direcciones fusionadas en varios lotes tienen ahora una nota por lote. Vuelve a ejecutarlo cuando se confirmen para fusionarlas en una.",
"merge_keep_each_select": "Marca al menos una dirección para consolidar.",
"merge_keep_each_some_failed": "Algunas fusiones fallaron; consulta la lista de arriba.",
"merge_keep_each_status_done": "%s: listo: %d notas fusionadas en %d lote(s)",
"merge_keep_each_status_failed": "%s: falló: %s",
"merge_keep_each_status_not_started": "%s: no iniciado: %s",
"merge_keep_each_status_running": "%s: fusionando %d notas (%d de %d lotes listos)…",
"merge_keep_each_too_small": "Demasiado poco para consolidar: la comisión se llevaría estos fondos.",
"merge_max_inputs": "Máximo de entradas por lote", "merge_max_inputs": "Máximo de entradas por lote",
"merge_no_spendable": "Aún no hay entradas disponibles para consolidar.", "merge_no_spendable": "Aún no hay entradas disponibles para consolidar.",
"merge_progress": "Consolidando… esto puede tardar varios minutos. Puedes cerrar esta ventana.", "merge_progress": "Consolidando… esto puede tardar varios minutos. Puedes cerrar esta ventana.",
@@ -1175,6 +1191,7 @@
"merge_src_transparent": "Transparentes", "merge_src_transparent": "Transparentes",
"merge_started": "Operación de fusión iniciada", "merge_started": "Operación de fusión iniciada",
"merge_title": "Fusionar a Dirección", "merge_title": "Fusionar a Dirección",
"merge_where": "¿Adónde deben ir los fondos?",
"mig_adopt": "Hacer que esta sea mi cartera", "mig_adopt": "Hacer que esta sea mi cartera",
"mig_adopt_now": "Adoptar cartera con frase", "mig_adopt_now": "Adoptar cartera con frase",
"mig_adopting": "Instalando tu nueva cartera y reescaneando…", "mig_adopting": "Instalando tu nueva cartera y reescaneando…",
+17
View File
@@ -1163,6 +1163,22 @@
"merge_description": "Fusionnez plusieurs UTXOs en une seule adresse blindée. Cela peut réduire la taille du portefeuille et améliorer la confidentialité.", "merge_description": "Fusionnez plusieurs UTXOs en une seule adresse blindée. Cela peut réduire la taille du portefeuille et améliorer la confidentialité.",
"merge_fee_hint": "Frais réseau pour cette transaction.", "merge_fee_hint": "Frais réseau pour cette transaction.",
"merge_funds": "Fusionner les fonds", "merge_funds": "Fusionner les fonds",
"merge_into_one": "Vers une seule adresse",
"merge_keep_each": "Garder dans chaque adresse",
"merge_keep_each_batches_fmt": "Les adresses de plus de %d notes sont fusionnées en plusieurs lots ; relancez l'opération une fois ceux-ci confirmés pour fusionner le reste.",
"merge_keep_each_confirm_fmt": "Consolider %d notes dans %d adresses ? Chaque adresse garde ses propres fonds. Frais : environ %s DRGX (un par lot).",
"merge_keep_each_desc": "Les notes de chaque adresse sont refusionnées dans cette même adresse : les fonds que vous gardez séparés sous des libellés différents restent séparés. Adresses blindées uniquement — les fonds transparents ne peuvent qu'être blindés (Vers une seule adresse).",
"merge_keep_each_none": "Chaque adresse blindée ne contient déjà qu'une seule note — rien à consolider.",
"merge_keep_each_nothing_submitted": "Rien n'a été consolidé — le nœud a refusé chaque fusion.",
"merge_keep_each_row_fmt": "%s · %d notes · %s DRGX",
"merge_keep_each_run_again": "Les adresses fusionnées en plusieurs lots contiennent maintenant une note par lot. Relancez l'opération une fois ceux-ci confirmés pour les fusionner en une seule.",
"merge_keep_each_select": "Cochez au moins une adresse à consolider.",
"merge_keep_each_some_failed": "Certaines fusions ont échoué — voir la liste ci-dessus.",
"merge_keep_each_status_done": "%s — terminé : %d notes fusionnées en %d lot(s)",
"merge_keep_each_status_failed": "%s — échec : %s",
"merge_keep_each_status_not_started": "%s — non démarré : %s",
"merge_keep_each_status_running": "%s — fusion de %d notes (%d lots sur %d terminés)…",
"merge_keep_each_too_small": "Trop faible pour être consolidé : les frais absorberaient ces fonds.",
"merge_max_inputs": "Nombre max d'entrées par lot", "merge_max_inputs": "Nombre max d'entrées par lot",
"merge_no_spendable": "Aucune entrée dépensable à consolider pour l'instant.", "merge_no_spendable": "Aucune entrée dépensable à consolider pour l'instant.",
"merge_progress": "Consolidation en cours… cela peut prendre quelques minutes. Vous pouvez fermer cette fenêtre.", "merge_progress": "Consolidation en cours… cela peut prendre quelques minutes. Vous pouvez fermer cette fenêtre.",
@@ -1175,6 +1191,7 @@
"merge_src_transparent": "Transparentes", "merge_src_transparent": "Transparentes",
"merge_started": "Opération de fusion démarrée", "merge_started": "Opération de fusion démarrée",
"merge_title": "Fusionner vers une adresse", "merge_title": "Fusionner vers une adresse",
"merge_where": "Où doivent aller les fonds ?",
"mig_adopt": "Faire de ceci mon portefeuille", "mig_adopt": "Faire de ceci mon portefeuille",
"mig_adopt_now": "Adopter le portefeuille à phrase", "mig_adopt_now": "Adopter le portefeuille à phrase",
"mig_adopting": "Installation de votre nouveau portefeuille et rescan en cours…", "mig_adopting": "Installation de votre nouveau portefeuille et rescan en cours…",
+17
View File
@@ -1163,6 +1163,22 @@
"merge_description": "複数のUTXOを単一のシールドアドレスに統合します。ウォレットサイズの縮小とプライバシーの向上に役立ちます。", "merge_description": "複数のUTXOを単一のシールドアドレスに統合します。ウォレットサイズの縮小とプライバシーの向上に役立ちます。",
"merge_fee_hint": "この取引にかかるネットワーク手数料です。", "merge_fee_hint": "この取引にかかるネットワーク手数料です。",
"merge_funds": "資金を統合", "merge_funds": "資金を統合",
"merge_into_one": "1 つのアドレスへ",
"merge_keep_each": "各アドレスに残す",
"merge_keep_each_batches_fmt": "ノートが %d 件を超えるアドレスは複数回に分けて統合されます。承認後にもう一度実行すると残りが統合されます。",
"merge_keep_each_confirm_fmt": "%d 件のノートを %d 個のアドレスで統合しますか?各アドレスの資金はそのまま残ります。手数料:約 %s DRGX(1 回ごとに 1 件)。",
"merge_keep_each_desc": "各アドレスのノートはそのアドレス自身に統合されるため、ラベルごとに分けている資金は分かれたままです。シールドアドレスのみ対象です。透明アドレスの資金はシールドすることしかできません(1 つのアドレスへ)。",
"merge_keep_each_none": "すべてのシールドアドレスのノートはすでに 1 つです。統合するものはありません。",
"merge_keep_each_nothing_submitted": "何も統合されませんでした。ノードがすべての統合を拒否しました。",
"merge_keep_each_row_fmt": "%s · ノート %d 件 · %s DRGX",
"merge_keep_each_run_again": "複数回に分けて統合したアドレスには、回数分のノートが残っています。承認後にもう一度実行すると 1 つにまとめられます。",
"merge_keep_each_select": "統合するアドレスを 1 つ以上選んでください。",
"merge_keep_each_some_failed": "一部の統合に失敗しました。上の一覧を確認してください。",
"merge_keep_each_status_done": "%s — 完了:%d 件のノートを %d 回で統合",
"merge_keep_each_status_failed": "%s — 失敗:%s",
"merge_keep_each_status_not_started": "%s — 開始されませんでした:%s",
"merge_keep_each_status_running": "%s — %d 件のノートを統合中(%d / %d 回完了)…",
"merge_keep_each_too_small": "少なすぎて統合できません。手数料でこの資金がなくなります。",
"merge_max_inputs": "バッチあたりの最大入力数", "merge_max_inputs": "バッチあたりの最大入力数",
"merge_no_spendable": "統合できる使用可能な入力がまだありません。", "merge_no_spendable": "統合できる使用可能な入力がまだありません。",
"merge_progress": "統合中… 数分かかることがあります。このウィンドウは閉じても構いません。", "merge_progress": "統合中… 数分かかることがあります。このウィンドウは閉じても構いません。",
@@ -1175,6 +1191,7 @@
"merge_src_transparent": "透明", "merge_src_transparent": "透明",
"merge_started": "統合操作を開始しました", "merge_started": "統合操作を開始しました",
"merge_title": "アドレスに統合", "merge_title": "アドレスに統合",
"merge_where": "資金の移動先",
"mig_adopt": "これを自分のウォレットにする", "mig_adopt": "これを自分のウォレットにする",
"mig_adopt_now": "シードウォレットを採用", "mig_adopt_now": "シードウォレットを採用",
"mig_adopting": "新しいウォレットをインストールして再スキャン中…", "mig_adopting": "新しいウォレットをインストールして再スキャン中…",
+17
View File
@@ -1163,6 +1163,22 @@
"merge_description": "여러 UTXO를 단일 차폐 주소로 통합합니다. 지갑 크기를 줄이고 프라이버시를 향상시킵니다.", "merge_description": "여러 UTXO를 단일 차폐 주소로 통합합니다. 지갑 크기를 줄이고 프라이버시를 향상시킵니다.",
"merge_fee_hint": "이 거래에 대한 네트워크 수수료입니다.", "merge_fee_hint": "이 거래에 대한 네트워크 수수료입니다.",
"merge_funds": "자금 통합", "merge_funds": "자금 통합",
"merge_into_one": "한 주소로",
"merge_keep_each": "각 주소에 유지",
"merge_keep_each_batches_fmt": "노트가 %d개를 넘는 주소는 여러 번에 나눠 병합됩니다. 확인된 후 다시 실행하면 나머지가 병합됩니다.",
"merge_keep_each_confirm_fmt": "노트 %d개를 주소 %d개에서 통합할까요? 각 주소는 자기 자금을 그대로 유지합니다. 수수료: 약 %s DRGX(배치마다 1회).",
"merge_keep_each_desc": "각 주소의 노트는 같은 주소로 다시 병합되므로, 다른 라벨로 나눠 둔 자금은 계속 나뉜 상태로 유지됩니다. 차폐 주소만 해당되며, 투명 자금은 차폐만 할 수 있습니다(한 주소로).",
"merge_keep_each_none": "모든 차폐 주소에 노트가 이미 하나뿐입니다. 통합할 것이 없습니다.",
"merge_keep_each_nothing_submitted": "아무것도 통합되지 않았습니다. 노드가 모든 병합을 거부했습니다.",
"merge_keep_each_row_fmt": "%s · 노트 %d개 · %s DRGX",
"merge_keep_each_run_again": "여러 번에 나눠 병합된 주소에는 배치마다 노트가 하나씩 남아 있습니다. 확인된 후 다시 실행하면 하나로 병합됩니다.",
"merge_keep_each_select": "통합할 주소를 하나 이상 선택하세요.",
"merge_keep_each_some_failed": "일부 병합에 실패했습니다. 위 목록을 확인하세요.",
"merge_keep_each_status_done": "%s — 완료: 노트 %d개를 %d번에 걸쳐 병합",
"merge_keep_each_status_failed": "%s — 실패: %s",
"merge_keep_each_status_not_started": "%s — 시작되지 않음: %s",
"merge_keep_each_status_running": "%s — 노트 %d개 병합 중(%d/%d 배치 완료)…",
"merge_keep_each_too_small": "너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
"merge_max_inputs": "배치당 최대 입력 수", "merge_max_inputs": "배치당 최대 입력 수",
"merge_no_spendable": "아직 통합할 사용 가능한 입력이 없습니다.", "merge_no_spendable": "아직 통합할 사용 가능한 입력이 없습니다.",
"merge_progress": "통합 중… 몇 분 정도 걸릴 수 있습니다. 이 창을 닫아도 됩니다.", "merge_progress": "통합 중… 몇 분 정도 걸릴 수 있습니다. 이 창을 닫아도 됩니다.",
@@ -1175,6 +1191,7 @@
"merge_src_transparent": "투명", "merge_src_transparent": "투명",
"merge_started": "통합 작업이 시작되었습니다", "merge_started": "통합 작업이 시작되었습니다",
"merge_title": "주소로 통합", "merge_title": "주소로 통합",
"merge_where": "자금을 어디로 보낼까요?",
"mig_adopt": "이 지갑을 내 지갑으로 사용", "mig_adopt": "이 지갑을 내 지갑으로 사용",
"mig_adopt_now": "시드 지갑 사용", "mig_adopt_now": "시드 지갑 사용",
"mig_adopting": "새 지갑을 설치하고 재스캔하는 중…", "mig_adopting": "새 지갑을 설치하고 재스캔하는 중…",
+17
View File
@@ -1163,6 +1163,22 @@
"merge_description": "Fundir múltiplos UTXOs em um único endereço blindado. Isso pode ajudar a reduzir o tamanho da carteira e melhorar a privacidade.", "merge_description": "Fundir múltiplos UTXOs em um único endereço blindado. Isso pode ajudar a reduzir o tamanho da carteira e melhorar a privacidade.",
"merge_fee_hint": "Taxa de rede para esta transação.", "merge_fee_hint": "Taxa de rede para esta transação.",
"merge_funds": "Fundir Fundos", "merge_funds": "Fundir Fundos",
"merge_into_one": "Para um só endereço",
"merge_keep_each": "Manter em cada endereço",
"merge_keep_each_batches_fmt": "Endereços com mais de %d notas são mesclados em vários lotes; execute de novo depois que forem confirmados para mesclar o restante.",
"merge_keep_each_confirm_fmt": "Consolidar %d notas em %d endereços? Cada endereço mantém seus próprios fundos. Taxas: cerca de %s DRGX (uma por lote).",
"merge_keep_each_desc": "As notas de cada endereço são mescladas de volta nesse mesmo endereço, então os fundos que você mantém separados com rótulos diferentes continuam separados. Só endereços blindados — fundos transparentes só podem ser blindados (Para um só endereço).",
"merge_keep_each_none": "Cada endereço blindado já tem uma única nota — nada a consolidar.",
"merge_keep_each_nothing_submitted": "Nada foi consolidado — o nó recusou todas as mesclagens.",
"merge_keep_each_row_fmt": "%s · %d notas · %s DRGX",
"merge_keep_each_run_again": "Endereços mesclados em vários lotes agora têm uma nota por lote. Execute de novo depois que forem confirmados para mesclá-las em uma.",
"merge_keep_each_select": "Marque pelo menos um endereço para consolidar.",
"merge_keep_each_some_failed": "Algumas mesclagens falharam — veja a lista acima.",
"merge_keep_each_status_done": "%s — concluído: %d notas mescladas em %d lote(s)",
"merge_keep_each_status_failed": "%s — falhou: %s",
"merge_keep_each_status_not_started": "%s — não iniciado: %s",
"merge_keep_each_status_running": "%s — mesclando %d notas (%d de %d lotes concluídos)…",
"merge_keep_each_too_small": "Pequeno demais para consolidar: a taxa consumiria esses fundos.",
"merge_max_inputs": "Máx. de entradas por lote", "merge_max_inputs": "Máx. de entradas por lote",
"merge_no_spendable": "Ainda não há entradas disponíveis para consolidar.", "merge_no_spendable": "Ainda não há entradas disponíveis para consolidar.",
"merge_progress": "Consolidando… isso pode levar alguns minutos. Você pode fechar esta janela.", "merge_progress": "Consolidando… isso pode levar alguns minutos. Você pode fechar esta janela.",
@@ -1175,6 +1191,7 @@
"merge_src_transparent": "Transparente", "merge_src_transparent": "Transparente",
"merge_started": "Operação de fusão iniciada", "merge_started": "Operação de fusão iniciada",
"merge_title": "Fundir para Endereço", "merge_title": "Fundir para Endereço",
"merge_where": "Para onde devem ir os fundos?",
"mig_adopt": "Tornar esta minha carteira", "mig_adopt": "Tornar esta minha carteira",
"mig_adopt_now": "Adotar carteira semente", "mig_adopt_now": "Adotar carteira semente",
"mig_adopting": "Instalando sua nova carteira e reescaneando…", "mig_adopting": "Instalando sua nova carteira e reescaneando…",
+17
View File
@@ -1163,6 +1163,22 @@
"merge_description": "Объедините несколько UTXO в один экранированный адрес. Это может уменьшить размер кошелька и улучшить конфиденциальность.", "merge_description": "Объедините несколько UTXO в один экранированный адрес. Это может уменьшить размер кошелька и улучшить конфиденциальность.",
"merge_fee_hint": "Сетевая комиссия за эту транзакцию.", "merge_fee_hint": "Сетевая комиссия за эту транзакцию.",
"merge_funds": "Объединить средства", "merge_funds": "Объединить средства",
"merge_into_one": "На один адрес",
"merge_keep_each": "Оставить в каждом адресе",
"merge_keep_each_batches_fmt": "Адреса с более чем %d заметками объединяются в несколько партий; запустите снова после их подтверждения, чтобы объединить остальное.",
"merge_keep_each_confirm_fmt": "Объединить %d заметок на %d адресах? Каждый адрес сохраняет свои средства. Комиссии: около %s DRGX (по одной на партию).",
"merge_keep_each_desc": "Заметки каждого адреса объединяются обратно в тот же адрес, поэтому средства, которые вы держите раздельно под разными метками, остаются раздельными. Только экранированные адреса — прозрачные средства можно только экранировать («На один адрес»).",
"merge_keep_each_none": "На каждом экранированном адресе уже одна заметка — объединять нечего.",
"merge_keep_each_nothing_submitted": "Ничего не объединено — узел отклонил все объединения.",
"merge_keep_each_row_fmt": "%s · заметок: %d · %s DRGX",
"merge_keep_each_run_again": "На адресах, объединённых в несколько партий, теперь по одной заметке на партию. Запустите снова после их подтверждения, чтобы объединить их в одну.",
"merge_keep_each_select": "Отметьте хотя бы один адрес для объединения.",
"merge_keep_each_some_failed": "Некоторые объединения не удались — см. список выше.",
"merge_keep_each_status_done": "%s — готово: объединено заметок: %d, партий: %d",
"merge_keep_each_status_failed": "%s — ошибка: %s",
"merge_keep_each_status_not_started": "%s — не запущено: %s",
"merge_keep_each_status_running": "%s — объединение %d заметок (готово партий: %d из %d)…",
"merge_keep_each_too_small": "Слишком мало для объединения: комиссия съест эти средства.",
"merge_max_inputs": "Макс. входов за партию", "merge_max_inputs": "Макс. входов за партию",
"merge_no_spendable": "Пока нет доступных для расходования входов для объединения.", "merge_no_spendable": "Пока нет доступных для расходования входов для объединения.",
"merge_progress": "Объединение… это может занять несколько минут. Вы можете закрыть это окно.", "merge_progress": "Объединение… это может занять несколько минут. Вы можете закрыть это окно.",
@@ -1175,6 +1191,7 @@
"merge_src_transparent": "Прозрачные", "merge_src_transparent": "Прозрачные",
"merge_started": "Операция объединения начата", "merge_started": "Операция объединения начата",
"merge_title": "Объединить на адрес", "merge_title": "Объединить на адрес",
"merge_where": "Куда направить средства?",
"mig_adopt": "Сделать это моим кошельком", "mig_adopt": "Сделать это моим кошельком",
"mig_adopt_now": "Принять сид-кошелёк", "mig_adopt_now": "Принять сид-кошелёк",
"mig_adopting": "Установка вашего нового кошелька и пересканирование…", "mig_adopting": "Установка вашего нового кошелька и пересканирование…",
+17
View File
@@ -1163,6 +1163,22 @@
"merge_description": "将多个 UTXO 合并到一个屏蔽地址。这可以帮助减小钱包大小并提高隐私性。", "merge_description": "将多个 UTXO 合并到一个屏蔽地址。这可以帮助减小钱包大小并提高隐私性。",
"merge_fee_hint": "此交易的网络手续费。", "merge_fee_hint": "此交易的网络手续费。",
"merge_funds": "合并资金", "merge_funds": "合并资金",
"merge_into_one": "合并到一个地址",
"merge_keep_each": "保留在各自地址",
"merge_keep_each_batches_fmt": "超过 %d 张票据的地址会分多批合并;确认后再运行一次即可合并其余部分。",
"merge_keep_each_confirm_fmt": "整合 %d 张票据,涉及 %d 个地址?每个地址保留各自的资金。手续费:约 %s DRGX(每批一次)。",
"merge_keep_each_desc": "每个地址的票据会合并回该地址本身,因此您用不同标签分开存放的资金仍保持分开。仅限屏蔽地址——透明资金只能被屏蔽(合并到一个地址)。",
"merge_keep_each_none": "每个屏蔽地址都只有一张票据——无需整合。",
"merge_keep_each_nothing_submitted": "没有任何整合——节点拒绝了所有合并。",
"merge_keep_each_row_fmt": "%s · %d 张票据 · %s DRGX",
"merge_keep_each_run_again": "分多批合并的地址现在每批各有一张票据。确认后再运行一次,即可把它们合并为一张。",
"merge_keep_each_select": "请至少勾选一个要整合的地址。",
"merge_keep_each_some_failed": "部分合并失败——请查看上方列表。",
"merge_keep_each_status_done": "%s——完成:已合并 %d 张票据,共 %d 批",
"merge_keep_each_status_failed": "%s——失败:%s",
"merge_keep_each_status_not_started": "%s——未开始:%s",
"merge_keep_each_status_running": "%s——正在合并 %d 张票据(已完成 %d/%d 批)…",
"merge_keep_each_too_small": "金额太小,无法整合:手续费会耗尽这些资金。",
"merge_max_inputs": "每批最大输入数", "merge_max_inputs": "每批最大输入数",
"merge_no_spendable": "暂无可合并的可用输入。", "merge_no_spendable": "暂无可合并的可用输入。",
"merge_progress": "正在合并…这可能需要几分钟。您可以关闭此窗口。", "merge_progress": "正在合并…这可能需要几分钟。您可以关闭此窗口。",
@@ -1175,6 +1191,7 @@
"merge_src_transparent": "透明", "merge_src_transparent": "透明",
"merge_started": "合并操作已开始", "merge_started": "合并操作已开始",
"merge_title": "合并到地址", "merge_title": "合并到地址",
"merge_where": "资金应转到哪里?",
"mig_adopt": "将其设为我的钱包", "mig_adopt": "将其设为我的钱包",
"mig_adopt_now": "采用助记词钱包", "mig_adopt_now": "采用助记词钱包",
"mig_adopting": "正在安装您的新钱包并重新扫描…", "mig_adopting": "正在安装您的新钱包并重新扫描…",
+170
View File
@@ -25,6 +25,176 @@ def load_english_keys():
# All translations for the 158 missing keys # All translations for the 158 missing keys
TRANSLATIONS = { TRANSLATIONS = {
"merge_where": {
"de": "Wohin sollen die Mittel gehen?",
"es": "¿Adónde deben ir los fondos?",
"fr": "Où doivent aller les fonds ?",
"pt": "Para onde devem ir os fundos?",
"ru": "Куда направить средства?",
"zh": "资金应转到哪里?",
"ja": "資金の移動先",
"ko": "자금을 어디로 보낼까요?",
},
"merge_into_one": {
"de": "In eine Adresse",
"es": "A una sola dirección",
"fr": "Vers une seule adresse",
"pt": "Para um só endereço",
"ru": "На один адрес",
"zh": "合并到一个地址",
"ja": "1 つのアドレスへ",
"ko": "한 주소로",
},
"merge_keep_each": {
"de": "In jeder Adresse belassen",
"es": "Mantener en cada dirección",
"fr": "Garder dans chaque adresse",
"pt": "Manter em cada endereço",
"ru": "Оставить в каждом адресе",
"zh": "保留在各自地址",
"ja": "各アドレスに残す",
"ko": "각 주소에 유지",
},
"merge_keep_each_desc": {
"de": "Die Notes jeder Adresse werden in genau diese Adresse zurückgeführt, sodass Mittel, die Sie unter verschiedenen Bezeichnungen getrennt halten, getrennt bleiben. Nur abgeschirmte Adressen – transparente Mittel können nur abgeschirmt werden (In eine Adresse).",
"es": "Las notas de cada dirección se fusionan de nuevo en esa misma dirección, así que los fondos que mantienes separados con distintas etiquetas siguen separados. Solo direcciones blindadas: los fondos transparentes solo pueden blindarse (A una sola dirección).",
"fr": "Les notes de chaque adresse sont refusionnées dans cette même adresse : les fonds que vous gardez séparés sous des libellés différents restent séparés. Adresses blindées uniquement — les fonds transparents ne peuvent qu'être blindés (Vers une seule adresse).",
"pt": "As notas de cada endereço são mescladas de volta nesse mesmo endereço, então os fundos que você mantém separados com rótulos diferentes continuam separados. Só endereços blindados — fundos transparentes só podem ser blindados (Para um só endereço).",
"ru": "Заметки каждого адреса объединяются обратно в тот же адрес, поэтому средства, которые вы держите раздельно под разными метками, остаются раздельными. Только экранированные адреса — прозрачные средства можно только экранировать («На один адрес»).",
"zh": "每个地址的票据会合并回该地址本身,因此您用不同标签分开存放的资金仍保持分开。仅限屏蔽地址——透明资金只能被屏蔽(合并到一个地址)。",
"ja": "各アドレスのノートはそのアドレス自身に統合されるため、ラベルごとに分けている資金は分かれたままです。シールドアドレスのみ対象です。透明アドレスの資金はシールドすることしかできません(1 つのアドレスへ)。",
"ko": "각 주소의 노트는 같은 주소로 다시 병합되므로, 다른 라벨로 나눠 둔 자금은 계속 나뉜 상태로 유지됩니다. 차폐 주소만 해당되며, 투명 자금은 차폐만 할 수 있습니다(한 주소로).",
},
"merge_keep_each_none": {
"de": "Jede abgeschirmte Adresse hat bereits nur eine Note – nichts zu konsolidieren.",
"es": "Cada dirección blindada ya tiene una sola nota: no hay nada que consolidar.",
"fr": "Chaque adresse blindée ne contient déjà qu'une seule note — rien à consolider.",
"pt": "Cada endereço blindado já tem uma única nota — nada a consolidar.",
"ru": "На каждом экранированном адресе уже одна заметка — объединять нечего.",
"zh": "每个屏蔽地址都只有一张票据——无需整合。",
"ja": "すべてのシールドアドレスのノートはすでに 1 つです。統合するものはありません。",
"ko": "모든 차폐 주소에 노트가 이미 하나뿐입니다. 통합할 것이 없습니다.",
},
"merge_keep_each_row_fmt": {
"de": "%s · %d Notes · %s DRGX",
"es": "%s · %d notas · %s DRGX",
"fr": "%s · %d notes · %s DRGX",
"pt": "%s · %d notas · %s DRGX",
"ru": "%s · заметок: %d · %s DRGX",
"zh": "%s · %d 张票据 · %s DRGX",
"ja": "%s · ノート %d 件 · %s DRGX",
"ko": "%s · 노트 %d개 · %s DRGX",
},
"merge_keep_each_too_small": {
"de": "Zu klein zum Konsolidieren: Die Gebühr würde diese Mittel aufbrauchen.",
"es": "Demasiado poco para consolidar: la comisión se llevaría estos fondos.",
"fr": "Trop faible pour être consolidé : les frais absorberaient ces fonds.",
"pt": "Pequeno demais para consolidar: a taxa consumiria esses fundos.",
"ru": "Слишком мало для объединения: комиссия съест эти средства.",
"zh": "金额太小,无法整合:手续费会耗尽这些资金。",
"ja": "少なすぎて統合できません。手数料でこの資金がなくなります。",
"ko": "너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
},
"merge_keep_each_batches_fmt": {
"de": "Adressen mit mehr als %d Notes werden in mehreren Durchgängen zusammengeführt; führen Sie es nach deren Bestätigung erneut aus, um den Rest zusammenzuführen.",
"es": "Las direcciones con más de %d notas se fusionan en varios lotes; vuelve a ejecutarlo cuando se confirmen para fusionar el resto.",
"fr": "Les adresses de plus de %d notes sont fusionnées en plusieurs lots ; relancez l'opération une fois ceux-ci confirmés pour fusionner le reste.",
"pt": "Endereços com mais de %d notas são mesclados em vários lotes; execute de novo depois que forem confirmados para mesclar o restante.",
"ru": "Адреса с более чем %d заметками объединяются в несколько партий; запустите снова после их подтверждения, чтобы объединить остальное.",
"zh": "超过 %d 张票据的地址会分多批合并;确认后再运行一次即可合并其余部分。",
"ja": "ノートが %d 件を超えるアドレスは複数回に分けて統合されます。承認後にもう一度実行すると残りが統合されます。",
"ko": "노트가 %d개를 넘는 주소는 여러 번에 나눠 병합됩니다. 확인된 후 다시 실행하면 나머지가 병합됩니다.",
},
"merge_keep_each_confirm_fmt": {
"de": "%d Notes in %d Adressen konsolidieren? Jede Adresse behält ihre eigenen Mittel. Gebühren: etwa %s DRGX (eine pro Durchgang).",
"es": "¿Consolidar %d notas en %d direcciones? Cada dirección conserva sus propios fondos. Comisiones: unos %s DRGX (una por lote).",
"fr": "Consolider %d notes dans %d adresses ? Chaque adresse garde ses propres fonds. Frais : environ %s DRGX (un par lot).",
"pt": "Consolidar %d notas em %d endereços? Cada endereço mantém seus próprios fundos. Taxas: cerca de %s DRGX (uma por lote).",
"ru": "Объединить %d заметок на %d адресах? Каждый адрес сохраняет свои средства. Комиссии: около %s DRGX (по одной на партию).",
"zh": "整合 %d 张票据,涉及 %d 个地址?每个地址保留各自的资金。手续费:约 %s DRGX(每批一次)。",
"ja": "%d 件のノートを %d 個のアドレスで統合しますか?各アドレスの資金はそのまま残ります。手数料:約 %s DRGX(1 回ごとに 1 件)。",
"ko": "노트 %d개를 주소 %d개에서 통합할까요? 각 주소는 자기 자금을 그대로 유지합니다. 수수료: 약 %s DRGX(배치마다 1회).",
},
"merge_keep_each_select": {
"de": "Wählen Sie mindestens eine Adresse zum Konsolidieren aus.",
"es": "Marca al menos una dirección para consolidar.",
"fr": "Cochez au moins une adresse à consolider.",
"pt": "Marque pelo menos um endereço para consolidar.",
"ru": "Отметьте хотя бы один адрес для объединения.",
"zh": "请至少勾选一个要整合的地址。",
"ja": "統合するアドレスを 1 つ以上選んでください。",
"ko": "통합할 주소를 하나 이상 선택하세요.",
},
"merge_keep_each_nothing_submitted": {
"de": "Nichts wurde konsolidiert – der Node hat jede Zusammenführung abgelehnt.",
"es": "No se consolidó nada: el nodo rechazó todas las fusiones.",
"fr": "Rien n'a été consolidé — le nœud a refusé chaque fusion.",
"pt": "Nada foi consolidado — o nó recusou todas as mesclagens.",
"ru": "Ничего не объединено — узел отклонил все объединения.",
"zh": "没有任何整合——节点拒绝了所有合并。",
"ja": "何も統合されませんでした。ノードがすべての統合を拒否しました。",
"ko": "아무것도 통합되지 않았습니다. 노드가 모든 병합을 거부했습니다.",
},
"merge_keep_each_some_failed": {
"de": "Einige Zusammenführungen sind fehlgeschlagen – siehe Liste oben.",
"es": "Algunas fusiones fallaron; consulta la lista de arriba.",
"fr": "Certaines fusions ont échoué — voir la liste ci-dessus.",
"pt": "Algumas mesclagens falharam — veja a lista acima.",
"ru": "Некоторые объединения не удались — см. список выше.",
"zh": "部分合并失败——请查看上方列表。",
"ja": "一部の統合に失敗しました。上の一覧を確認してください。",
"ko": "일부 병합에 실패했습니다. 위 목록을 확인하세요.",
},
"merge_keep_each_status_not_started": {
"de": "%s – nicht gestartet: %s",
"es": "%s: no iniciado: %s",
"fr": "%s — non démarré : %s",
"pt": "%s — não iniciado: %s",
"ru": "%s — не запущено: %s",
"zh": "%s——未开始:%s",
"ja": "%s — 開始されませんでした:%s",
"ko": "%s — 시작되지 않음: %s",
},
"merge_keep_each_status_failed": {
"de": "%s – fehlgeschlagen: %s",
"es": "%s: falló: %s",
"fr": "%s — échec : %s",
"pt": "%s — falhou: %s",
"ru": "%s — ошибка: %s",
"zh": "%s——失败:%s",
"ja": "%s — 失敗:%s",
"ko": "%s — 실패: %s",
},
"merge_keep_each_status_done": {
"de": "%s – fertig: %d Notes in %d Durchgang/Durchgängen zusammengeführt",
"es": "%s: listo: %d notas fusionadas en %d lote(s)",
"fr": "%s — terminé : %d notes fusionnées en %d lot(s)",
"pt": "%s — concluído: %d notas mescladas em %d lote(s)",
"ru": "%s — готово: объединено заметок: %d, партий: %d",
"zh": "%s——完成:已合并 %d 张票据,共 %d 批",
"ja": "%s — 完了:%d 件のノートを %d 回で統合",
"ko": "%s — 완료: 노트 %d개를 %d번에 걸쳐 병합",
},
"merge_keep_each_status_running": {
"de": "%s – %d Notes werden zusammengeführt (%d von %d Durchgängen fertig)…",
"es": "%s: fusionando %d notas (%d de %d lotes listos)…",
"fr": "%s — fusion de %d notes (%d lots sur %d terminés)…",
"pt": "%s — mesclando %d notas (%d de %d lotes concluídos)…",
"ru": "%s — объединение %d заметок (готово партий: %d из %d)…",
"zh": "%s——正在合并 %d 张票据(已完成 %d/%d 批)…",
"ja": "%s — %d 件のノートを統合中(%d / %d 回完了)…",
"ko": "%s — 노트 %d개 병합 중(%d/%d 배치 완료)…",
},
"merge_keep_each_run_again": {
"de": "Adressen, die in mehreren Durchgängen zusammengeführt wurden, haben jetzt eine Note pro Durchgang. Führen Sie dies nach deren Bestätigung erneut aus, um sie zu einer zusammenzuführen.",
"es": "Las direcciones fusionadas en varios lotes tienen ahora una nota por lote. Vuelve a ejecutarlo cuando se confirmen para fusionarlas en una.",
"fr": "Les adresses fusionnées en plusieurs lots contiennent maintenant une note par lot. Relancez l'opération une fois ceux-ci confirmés pour les fusionner en une seule.",
"pt": "Endereços mesclados em vários lotes agora têm uma nota por lote. Execute de novo depois que forem confirmados para mesclá-las em uma.",
"ru": "На адресах, объединённых в несколько партий, теперь по одной заметке на партию. Запустите снова после их подтверждения, чтобы объединить их в одну.",
"zh": "分多批合并的地址现在每批各有一张票据。确认后再运行一次,即可把它们合并为一张。",
"ja": "複数回に分けて統合したアドレスには、回数分のノートが残っています。承認後にもう一度実行すると 1 つにまとめられます。",
"ko": "여러 번에 나눠 병합된 주소에는 배치마다 노트가 하나씩 남아 있습니다. 확인된 후 다시 실행하면 하나로 병합됩니다.",
},
"mining_addr_explainer": { "mining_addr_explainer": {
"de": "Empfängt nur Solo-Mining-Belohnungen – das Netzwerk lehnt Zahlungen an diese Adresse ab. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.", "de": "Empfängt nur Solo-Mining-Belohnungen – das Netzwerk lehnt Zahlungen an diese Adresse ab. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
"es": "Solo recibe recompensas de minería en solitario; la red rechaza los pagos a esta dirección. Las recompensas se blindan automáticamente cuando maduran.", "es": "Solo recibe recompensas de minería en solitario; la red rechaza los pagos a esta dirección. Las recompensas se blindan automáticamente cuando maduran.",
+4
View File
@@ -401,6 +401,10 @@ void App::buildSweepCatalog()
add("modal-merge", ui::NavPage::Send, add("modal-merge", ui::NavPage::Send,
[](App&) { ui::ShieldDialog::showMerge(); }, [](App&) { ui::ShieldDialog::showMerge(); },
[](App&) { ui::ShieldDialog::hide(); }); [](App&) { ui::ShieldDialog::hide(); });
// Consolidate (wallet-size preset): keeps each address's funds in that address by default.
add("modal-consolidate", ui::NavPage::Send,
[](App&) { ui::ShieldDialog::showConsolidate(); },
[](App&) { ui::ShieldDialog::hide(); });
// z->t transfer so the (converted) deshielding DialogWarningHeader renders. // z->t transfer so the (converted) deshielding DialogWarningHeader renders.
add("modal-transfer", ui::NavPage::Overview, add("modal-transfer", ui::NavPage::Overview,
[](App& a) { [](App& a) {
+71
View File
@@ -0,0 +1,71 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "note_consolidation.h"
#include <algorithm>
#include <map>
namespace dragonx {
namespace services {
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
{
std::map<std::string, NoteGroup> byAddress;
if (zListUnspent.is_array()) {
for (const auto& n : zListUnspent) {
if (!n.is_object()) continue;
const std::string address = n.value("address", std::string());
if (address.empty()) continue;
if (n.value("confirmations", 0) < 1) continue; // z_mergetoaddress spends confirmed notes only
if (!n.value("spendable", true)) continue; // watch-only: no key to spend it
NoteGroup& g = byAddress[address];
g.address = address;
++g.notes;
g.amount += n.value("amount", 0.0);
}
}
std::vector<NoteGroup> out;
out.reserve(byAddress.size());
for (auto& kv : byAddress) out.push_back(std::move(kv.second));
std::sort(out.begin(), out.end(), [](const NoteGroup& a, const NoteGroup& b) {
if (a.notes != b.notes) return a.notes > b.notes;
return a.address < b.address;
});
return out;
}
bool worthConsolidating(const NoteGroup& group, double fee)
{
// The daemon rejects a merge whose fee is more than what it nets (merged value < 2 x fee).
return group.notes >= 2 && group.amount > 2.0 * fee;
}
int estimateBatches(int notes, int limit)
{
if (notes < 2) return 0;
if (limit < 2) limit = 2;
return notes / limit + ((notes % limit) >= 2 ? 1 : 0);
}
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches)
{
AddressRun run;
run.address = address;
for (int b = 0; b < maxBatches; ++b) {
MergeCallResult r = call(address);
if (!r.error.empty()) {
run.error = r.error; // earlier batches (if any) are already submitted and stand
break;
}
run.opids.push_back(r.opid);
run.notesMerged += r.merging;
run.remaining = r.remaining;
if (r.remaining < 2) break; // one leftover note can't be merged into its own address
}
return run;
}
} // namespace services
} // namespace dragonx
+63
View File
@@ -0,0 +1,63 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Consolidating notes WITHOUT moving funds between addresses: each address's notes are merged back into
// that same address (z_mergetoaddress [addr] addr), so funds kept apart under different labels stay apart
// while the wallet sheds notes (fewer notes = smaller wallet, faster witness rebuilds and scans).
// Shielded only: a transparent address can't receive a non-coinbase output on DragonX (private chain).
// Pure apart from the merge call it is handed, so the test target can drive it.
#include <nlohmann/json.hpp>
#include <functional>
#include <string>
#include <vector>
namespace dragonx {
namespace services {
// One shielded address's spendable notes (confirmed, spendable), from z_listunspent.
struct NoteGroup {
std::string address;
int notes = 0;
double amount = 0.0;
};
// Spendable shielded notes grouped by address, most notes first. Transparent UTXOs and unconfirmed or
// watch-only notes are left out (z_mergetoaddress only spends confirmed notes it has keys for).
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent);
// Worth consolidating in place: at least two notes, and more than one batch's fee to work with.
bool worthConsolidating(const NoteGroup& group, double fee);
// Merge calls needed to bring `notes` down when each call takes at most `limit` of them. A lone leftover
// note isn't a call (the daemon refuses to merge one note into its own address).
int estimateBatches(int notes, int limit);
// What one z_mergetoaddress call reported.
struct MergeCallResult {
std::string opid;
int merging = 0; // notes this call is merging
int remaining = 0; // notes of the address it left for a later call
std::string error; // non-empty: the call failed (nothing was submitted)
};
using MergeCall = std::function<MergeCallResult(const std::string& address)>;
// The outcome for one address.
struct AddressRun {
std::string address;
std::vector<std::string> opids; // one per submitted batch
int notesMerged = 0;
int remaining = 0; // notes still left after the last batch
std::string error; // the call that stopped it, if any (earlier batches still went out)
};
// Submit merges of `address` into itself until fewer than two of its notes are left unmerged. The daemon
// locks a batch's notes before the call returns, so each call takes the next batch. At most maxBatches.
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches = 20);
} // namespace services
} // namespace dragonx
+269 -6
View File
@@ -8,12 +8,15 @@
#include "../../data/wallet_state.h" #include "../../data/wallet_state.h"
#include "../../rpc/rpc_client.h" #include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h" #include "../../rpc/rpc_worker.h"
#include "../../services/note_consolidation.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../notifications.h" #include "../notifications.h"
#include "../schema/ui_schema.h" #include "../schema/ui_schema.h"
#include "../material/draw_helpers.h" #include "../material/draw_helpers.h"
#include "imgui.h" #include "imgui.h"
#include <algorithm>
#include <set>
#include <vector> #include <vector>
#include <string> #include <string>
#include <cstdio> #include <cstdio>
@@ -44,6 +47,18 @@ static bool s_scope_loaded = false;
static int s_t_count = 0, s_z_count = 0; static int s_t_count = 0, s_z_count = 0;
static double s_t_amount = 0.0, s_z_amount = 0.0; static double s_t_amount = 0.0, s_z_amount = 0.0;
static bool s_creating_addr = false; // z_getnewaddress in flight (empty state) static bool s_creating_addr = false; // z_getnewaddress in flight (empty state)
// "Keep each address's funds in that address": every ticked address's notes merge back into it, so
// separately labelled funds stay apart. Shielded only (a transparent address can't take change).
static bool s_per_address = false;
static std::vector<services::NoteGroup> s_groups; // spendable shielded notes by address (scope load)
static std::set<std::string> s_selected; // addresses ticked for per-address consolidation
struct PerAddressRun {
services::AddressRun run;
std::vector<int> state; // per opid: 0 = running, 1 = done, 2 = failed
std::string failure; // first failed op's message
};
static std::vector<PerAddressRun> s_runs; // after submit
static bool s_runs_submitting = false;
static void resetTransient() static void resetTransient()
{ {
@@ -53,6 +68,8 @@ static void resetTransient()
s_status_message.clear(); s_status_message.clear();
s_operation_id.clear(); s_operation_id.clear();
s_creating_addr = false; s_creating_addr = false;
s_runs.clear();
s_runs_submitting = false;
} }
// Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a // Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a
@@ -63,6 +80,7 @@ static void loadScope(App* app)
s_scope_loading = true; s_scope_loaded = false; s_scope_loading = true; s_scope_loaded = false;
app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb { app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error; int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error;
std::vector<services::NoteGroup> groups;
try { try {
rpc::RPCClient::TraceScope trace("Shield dialog / Scope count"); rpc::RPCClient::TraceScope trace("Shield dialog / Scope count");
nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0})); nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0}));
@@ -73,10 +91,15 @@ static void loadScope(App* app)
if (zs.is_array()) for (const auto& z : zs) { if (zs.is_array()) for (const auto& z : zs) {
if (z.value("confirmations", 0) >= 1) { ++zC; zA += z.value("amount", 0.0); } if (z.value("confirmations", 0) >= 1) { ++zC; zA += z.value("amount", 0.0); }
} }
groups = services::groupSpendableNotes(zs);
} catch (const std::exception& e) { error = e.what(); } } catch (const std::exception& e) { error = e.what(); }
return [tC, zC, tA, zA, error]() { return [tC, zC, tA, zA, error, groups]() {
s_scope_loading = false; s_scope_loaded = error.empty(); s_scope_loading = false; s_scope_loaded = error.empty();
s_t_count = tC; s_z_count = zC; s_t_amount = tA; s_z_amount = zA; s_t_count = tC; s_z_count = zC; s_t_amount = tA; s_z_amount = zA;
s_groups = groups;
s_selected.clear(); // every address worth it, ticked by default
for (const auto& g : s_groups)
if (services::worthConsolidating(g, s_fee)) s_selected.insert(g.address);
// Clamp the source to what actually has inputs (unless the user is mid-op). // Clamp the source to what actually has inputs (unless the user is mid-op).
const bool tOk = tC > 0, zOk = zC > 0; const bool tOk = tC > 0, zOk = zC > 0;
if (!s_operation_pending) { if (!s_operation_pending) {
@@ -108,6 +131,9 @@ void ShieldDialog::show(Mode mode)
s_scope_loaded = false; s_scope_loading = false; s_scope_loaded = false; s_scope_loading = false;
s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0; s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0;
s_last_poll = 0.0; s_last_poll = 0.0;
s_per_address = false;
s_groups.clear();
s_selected.clear();
} }
void ShieldDialog::showShieldCoinbase(const std::string& fromAddress) void ShieldDialog::showShieldCoinbase(const std::string& fromAddress)
@@ -128,6 +154,7 @@ void ShieldDialog::showConsolidate()
show(Mode::MergeToAddress); show(Mode::MergeToAddress);
s_consolidate = true; s_consolidate = true;
s_src = 1; // wallet-bloat consolidation targets shielded notes (witness bloat) s_src = 1; // wallet-bloat consolidation targets shielded notes (witness bloat)
s_per_address = true; // shed notes without moving funds between (labelled) addresses
} }
void ShieldDialog::hide() void ShieldDialog::hide()
@@ -142,6 +169,14 @@ static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_am
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; } static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
// A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap).
static void wrappedCaption(ImU32 color, const char* text)
{
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color));
material::Type().textWrapped(material::TypeStyle::Caption, text);
ImGui::PopStyleColor();
}
static std::string shortAddr(const std::string& a) static std::string shortAddr(const std::string& a)
{ {
if (a.size() <= 20) return a; if (a.size() <= 20) return a;
@@ -226,6 +261,121 @@ static void submitOperation(App* app)
}); });
} }
// The ticked addresses that are worth consolidating, in list order.
static std::vector<services::NoteGroup> selectedGroups()
{
std::vector<services::NoteGroup> out;
for (const auto& g : s_groups)
if (s_selected.count(g.address) && services::worthConsolidating(g, s_fee)) out.push_back(g);
return out;
}
// Per-address consolidation. One background job submits, address by address, merges of each address's
// notes back into that address — several batches when it holds more notes than the batch size.
// z_mergetoaddress locks a batch's notes before it returns, so back-to-back calls take disjoint notes.
static void submitPerAddress(App* app)
{
const auto groups = selectedGroups();
if (groups.empty() || !app->worker()) return;
s_runs_submitting = true;
s_operation_pending = true;
s_op_terminal = false;
s_status_message = TR("shield_submitting");
s_last_poll = ImGui::GetTime();
const double fee = s_fee;
const int limit = s_utxo_limit;
app->worker()->post([app, rpc = app->rpc(), groups, fee, limit]() -> rpc::RPCWorker::MainCb {
std::vector<services::AddressRun> runs;
for (const auto& g : groups) {
runs.push_back(services::consolidateInPlace(g.address, [&](const std::string& addr) {
services::MergeCallResult r;
try {
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place");
// fromaddrs = [addr], toaddr = addr: the address's notes merge back into it.
const nlohmann::json res = rpc->call("z_mergetoaddress",
{nlohmann::json::array({addr}), addr, fee, limit, limit});
r.opid = res.value("opid", std::string());
r.merging = res.value("mergingNotes", 0);
r.remaining = res.value("remainingNotes", 0);
if (r.opid.empty()) r.error = "no operation id";
} catch (const std::exception& e) { r.error = e.what(); }
return r;
}));
}
return [app, runs]() {
s_runs_submitting = false;
s_runs.clear();
int submitted = 0;
for (const auto& r : runs) {
PerAddressRun pr;
pr.run = r;
pr.state.assign(r.opids.size(), 0);
for (const auto& id : r.opids) { app->trackOperation(id); ++submitted; }
s_runs.push_back(std::move(pr));
}
if (submitted > 0) {
s_status_message = TR("merge_progress");
Notifications::instance().success(TR("merge_started"));
} else {
s_operation_pending = false; s_op_terminal = true;
s_status_message = TR("merge_keep_each_nothing_submitted");
Notifications::instance().error(TR("merge_keep_each_nothing_submitted"));
}
};
});
}
// Progress of a per-address run: poll every ~2s until each batch's operation is done or failed. Unfiltered,
// like the shared poller (some daemons reject the per-opid form); only our operations are read.
static void pollPerAddress(App* app)
{
if (s_runs.empty() || s_op_terminal || !app->worker()) return;
const double now = ImGui::GetTime();
if (now - s_last_poll < 2.0) return;
s_last_poll = now;
app->worker()->post([rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
nlohmann::json result; std::string error;
try {
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place status");
result = rpc->call("z_getoperationstatus", nlohmann::json::array());
} catch (const std::exception& e) { error = e.what(); }
return [result, error]() {
if (!error.empty() || !result.is_array()) return; // transient — retry next tick
for (const auto& op : result) {
if (!op.is_object() || !op.contains("id") || !op["id"].is_string()) continue;
const std::string id = op["id"].get<std::string>();
const std::string status = (op.contains("status") && op["status"].is_string())
? op["status"].get<std::string>() : std::string();
for (auto& r : s_runs) {
for (size_t i = 0; i < r.run.opids.size(); ++i) {
if (r.run.opids[i] != id || r.state[i] != 0) continue;
if (status == "success") r.state[i] = 1;
else if (status == "failed" || status == "cancelled") {
r.state[i] = 2;
if (r.failure.empty() && op.contains("error") && op["error"].is_object())
r.failure = op["error"].value("message", std::string());
}
}
}
}
bool allDone = true;
int failed = 0;
for (const auto& r : s_runs)
for (int st : r.state) { if (st == 0) allDone = false; if (st == 2) ++failed; }
if (!allDone) return;
s_operation_pending = false;
s_op_terminal = true;
if (failed == 0) {
s_status_message = TR("shield_completed");
Notifications::instance().success(TR("shield_merge_done"));
} else {
s_status_message = TR("merge_keep_each_some_failed");
Notifications::instance().error(TR("merge_keep_each_some_failed"));
}
};
});
}
// Live-progress self-poll: while an op is in flight, poll z_getoperationstatus every ~2s so the modal // Live-progress self-poll: while an op is in flight, poll z_getoperationstatus every ~2s so the modal
// shows "Consolidating… → Done/Failed" without a manual button. (The shared poller also tracks it for // shows "Consolidating… → Done/Failed" without a manual button. (The shared poller also tracks it for
// balance refresh; this drives only the inline display.) // balance refresh; this drives only the inline display.)
@@ -290,6 +440,7 @@ void ShieldDialog::render(App* app)
const auto& state = app->getWalletState(); const auto& state = app->getWalletState();
autoSelectDestination(state); autoSelectDestination(state);
pollOperation(app); pollOperation(app);
pollPerAddress(app);
if (isMerge && !s_scope_loaded && !s_scope_loading && s_operation_id.empty()) loadScope(app); if (isMerge && !s_scope_loaded && !s_scope_loading && s_operation_id.empty()) loadScope(app);
// ── Description ────────────────────────────────────────────────────────────────────────────── // ── Description ──────────────────────────────────────────────────────────────────────────────
@@ -298,7 +449,9 @@ void ShieldDialog::render(App* app)
: TR("shield_description")); : TR("shield_description"));
ImGui::Spacing(); ImGui::Spacing();
const bool opInFlight = !s_operation_id.empty(); // submitted — inputs frozen, showing progress // Submitted — inputs frozen, showing progress.
const bool opInFlight = !s_operation_id.empty() || s_runs_submitting || !s_runs.empty();
const bool perAddress = isMerge && s_per_address;
// ── Merge: scope + source selector ────────────────────────────────────────────────────────── // ── Merge: scope + source selector ──────────────────────────────────────────────────────────
if (isMerge && !opInFlight) { if (isMerge && !opInFlight) {
@@ -313,9 +466,17 @@ void ShieldDialog::render(App* app)
} }
ImGui::Spacing(); ImGui::Spacing();
// Where the funds go: all into one address, or each address's notes back into that address.
ImGui::TextUnformatted(TR("merge_where")); // own line: the choices are long in some languages
int where = s_per_address ? 1 : 0;
ImGui::RadioButton(TR("merge_into_one"), &where, 0); ImGui::SameLine();
ImGui::RadioButton(TR("merge_keep_each"), &where, 1);
s_per_address = (where == 1);
ImGui::Spacing();
// Source selector — only offer the types that actually have inputs. // Source selector — only offer the types that actually have inputs.
const bool tOk = s_t_count > 0, zOk = s_z_count > 0; const bool tOk = s_t_count > 0, zOk = s_z_count > 0;
if (tOk && zOk) { if (tOk && zOk && !s_per_address) {
ImGui::AlignTextToFramePadding(); ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("merge_source")); ImGui::TextUnformatted(TR("merge_source"));
ImGui::SameLine(0, Layout::spacingLg()); ImGui::SameLine(0, Layout::spacingLg());
@@ -324,6 +485,46 @@ void ShieldDialog::render(App* app)
ImGui::RadioButton(TR("merge_src_both"), &s_src, 2); ImGui::RadioButton(TR("merge_src_both"), &s_src, 2);
ImGui::Spacing(); ImGui::Spacing();
} }
// Keep each address's funds in place: pick the addresses. Shielded only — a transparent address
// can't take a merge's output, its funds can only be shielded ("Into one address").
if (s_per_address) {
wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_desc"));
ImGui::Spacing();
int listed = 0;
for (const auto& g : s_groups) if (g.notes >= 2) ++listed;
if (s_scope_loaded && listed == 0) {
wrappedCaption(material::Warning(), TR("merge_keep_each_none"));
} else if (listed > 0) {
const float rowH = ImGui::GetFrameHeightWithSpacing();
const float listH = std::min(static_cast<float>(listed), 7.0f) * rowH + ImGui::GetStyle().WindowPadding.y * 2.0f;
if (ImGui::BeginChild("##ConsolidateEach", ImVec2(-1, listH), ImGuiChildFlags_Borders)) {
for (const auto& g : s_groups) {
if (g.notes < 2) continue;
const bool worth = services::worthConsolidating(g, s_fee);
bool ticked = worth && s_selected.count(g.address) > 0;
const std::string label = app->getAddressLabel(g.address);
char row[256];
std::snprintf(row, sizeof(row), TR("merge_keep_each_row_fmt"),
label.empty() ? shortAddr(g.address).c_str() : label.c_str(),
g.notes, fmtAmt(g.amount).c_str());
ImGui::PushID(g.address.c_str());
ImGui::BeginDisabled(!worth);
if (ImGui::Checkbox(row, &ticked)) {
if (ticked) s_selected.insert(g.address);
else s_selected.erase(g.address);
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s%s%s", g.address.c_str(), worth ? "" : "\n\n",
worth ? "" : TR("merge_keep_each_too_small"));
ImGui::PopID();
}
}
ImGui::EndChild();
}
ImGui::Spacing();
}
} }
// ── Shield coinbase: from address ─────────────────────────────────────────────────────────── // ── Shield coinbase: from address ───────────────────────────────────────────────────────────
@@ -335,6 +536,7 @@ void ShieldDialog::render(App* app)
// ── Destination (z-address) ───────────────────────────────────────────────────────────────── // ── Destination (z-address) ─────────────────────────────────────────────────────────────────
if (!opInFlight) { if (!opInFlight) {
if (!perAddress) {
ImGui::TextUnformatted(TR("shield_to_address")); ImGui::TextUnformatted(TR("shield_to_address"));
if (state.z_addresses.empty()) { if (state.z_addresses.empty()) {
material::Type().textColored(material::TypeStyle::Caption, material::Warning(), TR("shield_no_zaddr_hint")); material::Type().textColored(material::TypeStyle::Caption, material::Warning(), TR("shield_no_zaddr_hint"));
@@ -381,6 +583,7 @@ void ShieldDialog::render(App* app)
} }
} }
ImGui::Spacing(); ImGui::Spacing();
} // !perAddress
// ── Advanced (fee + batch size) ───────────────────────────────────────────────────────── // ── Advanced (fee + batch size) ─────────────────────────────────────────────────────────
ImDrawList* dl = ImGui::GetWindowDrawList(); ImDrawList* dl = ImGui::GetWindowDrawList();
@@ -406,7 +609,16 @@ void ShieldDialog::render(App* app)
} }
// Batch hint: one run only merges up to the limit; large sets need repeats. // Batch hint: one run only merges up to the limit; large sets need repeats.
if (isMerge && s_scope_loaded && srcCount() > s_utxo_limit) { if (perAddress) {
bool multi = false;
for (const auto& g : selectedGroups()) if (services::estimateBatches(g.notes, s_utxo_limit) > 1) multi = true;
if (multi) {
char hb[200];
std::snprintf(hb, sizeof(hb), TR("merge_keep_each_batches_fmt"), s_utxo_limit);
ImGui::Spacing();
wrappedCaption(material::Warning(), hb);
}
} else if (isMerge && s_scope_loaded && srcCount() > s_utxo_limit) {
char hb[160]; char hb[160];
std::snprintf(hb, sizeof(hb), TR("merge_batch_fmt"), s_utxo_limit); std::snprintf(hb, sizeof(hb), TR("merge_batch_fmt"), s_utxo_limit);
ImGui::Spacing(); ImGui::Spacing();
@@ -415,6 +627,46 @@ void ShieldDialog::render(App* app)
ImGui::Spacing(); ImGui::Spacing();
} }
// ── Per-address progress ────────────────────────────────────────────────────────────────────
if (!s_runs.empty()) {
bool leftovers = false;
for (const auto& r : s_runs) {
const std::string label = app->getAddressLabel(r.run.address);
const char* name = label.empty() ? nullptr : label.c_str();
const std::string shortA = shortAddr(r.run.address);
int done = 0, failed = 0;
for (int st : r.state) { if (st == 1) ++done; if (st == 2) ++failed; }
const int batches = static_cast<int>(r.run.opids.size());
char line[320];
ImU32 col = material::OnSurfaceMedium();
if (batches == 0) {
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_not_started"), name ? name : shortA.c_str(),
r.run.error.c_str());
col = material::Error();
} else if (failed > 0) {
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_failed"), name ? name : shortA.c_str(),
r.failure.c_str());
col = material::Error();
} else if (done == batches) {
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_done"), name ? name : shortA.c_str(),
r.run.notesMerged, batches);
col = material::Success();
} else {
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_running"), name ? name : shortA.c_str(),
r.run.notesMerged, done, batches);
}
wrappedCaption(col, line);
if (batches > 1 || r.run.remaining >= 2) leftovers = true;
}
if (leftovers) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
ImGui::TextWrapped("%s", TR("merge_keep_each_run_again"));
ImGui::PopStyleColor();
}
ImGui::Spacing();
}
// ── Live progress / status ────────────────────────────────────────────────────────────────── // ── Live progress / status ──────────────────────────────────────────────────────────────────
if (!s_status_message.empty()) { if (!s_status_message.empty()) {
if (s_operation_pending && !s_op_terminal) if (s_operation_pending && !s_op_terminal)
@@ -447,7 +699,16 @@ void ShieldDialog::render(App* app)
// Confirm summary (inline, before the fund-moving call). Merge/consolidate shows amount + input // Confirm summary (inline, before the fund-moving call). Merge/consolidate shows amount + input
// count; shield-coinbase just gets the button relabel (its inputs aren't enumerated here). // count; shield-coinbase just gets the button relabel (its inputs aren't enumerated here).
if (s_confirm && isMerge) { if (s_confirm && perAddress) {
int notes = 0, batches = 0;
const auto sel = selectedGroups();
for (const auto& g : sel) { notes += g.notes; batches += services::estimateBatches(g.notes, s_utxo_limit); }
char cb[320];
std::snprintf(cb, sizeof(cb), TR("merge_keep_each_confirm_fmt"), notes, static_cast<int>(sel.size()),
fmtAmt(s_fee * batches).c_str());
ImGui::TextWrapped("%s", cb);
ImGui::Spacing();
} else if (s_confirm && isMerge) {
char cb[200]; char cb[200];
std::snprintf(cb, sizeof(cb), TR("merge_confirm_fmt"), std::snprintf(cb, sizeof(cb), TR("merge_confirm_fmt"),
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str()); fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
@@ -457,19 +718,21 @@ void ShieldDialog::render(App* app)
bool can_submit = haveDest && connected && !syncing; bool can_submit = haveDest && connected && !syncing;
if (isMerge && s_scope_loaded && srcCount() == 0) can_submit = false; if (isMerge && s_scope_loaded && srcCount() == 0) can_submit = false;
if (perAddress) can_submit = connected && !syncing && s_scope_loaded && !selectedGroups().empty();
float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x; float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x;
material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false); material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false);
if (!can_submit) ImGui::BeginDisabled(); if (!can_submit) ImGui::BeginDisabled();
if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) { if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
if (s_confirm) { submitOperation(app); } if (s_confirm) { if (perAddress) submitPerAddress(app); else submitOperation(app); }
else { s_confirm = true; } // first click → show the confirm summary else { s_confirm = true; } // first click → show the confirm summary
} }
if (!can_submit) ImGui::EndDisabled(); if (!can_submit) ImGui::EndDisabled();
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
if (!connected) material::Tooltip("%s", TR("send_tooltip_not_connected")); if (!connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
else if (syncing) material::Tooltip("%s", TR("send_tooltip_syncing")); else if (syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
else if (perAddress) material::Tooltip("%s", TR("merge_keep_each_select"));
else if (!haveDest) material::Tooltip("%s", TR("shield_select_z")); else if (!haveDest) material::Tooltip("%s", TR("shield_select_z"));
else if (isMerge && srcCount() == 0) material::Tooltip("%s", TR("merge_no_spendable")); else if (isMerge && srcCount() == 0) material::Tooltip("%s", TR("merge_no_spendable"));
} }
+17
View File
@@ -2519,6 +2519,23 @@ void I18n::loadBuiltinEnglish()
strings_["merge_back"] = "Back"; strings_["merge_back"] = "Back";
strings_["merge_progress"] = "Consolidating\xE2\x80\xA6 this can take a few minutes. You can close this window."; strings_["merge_progress"] = "Consolidating\xE2\x80\xA6 this can take a few minutes. You can close this window.";
strings_["merge_no_spendable"] = "No spendable inputs to consolidate yet."; strings_["merge_no_spendable"] = "No spendable inputs to consolidate yet.";
strings_["merge_where"] = "Where should the funds go?";
strings_["merge_into_one"] = "Into one address";
strings_["merge_keep_each"] = "Keep in each address";
strings_["merge_keep_each_desc"] = "Each address's notes are merged back into that same address, so funds you keep apart under different labels stay apart. Shielded addresses only — transparent funds can only be shielded (Into one address).";
strings_["merge_keep_each_none"] = "Every shielded address already holds a single note — nothing to consolidate.";
strings_["merge_keep_each_row_fmt"] = "%s · %d notes · %s DRGX";
strings_["merge_keep_each_too_small"] = "Too small to consolidate: the fee would use up these funds.";
strings_["merge_keep_each_batches_fmt"] = "Addresses with more than %d notes are merged in several batches; run it again after they confirm to merge the rest.";
strings_["merge_keep_each_confirm_fmt"] = "Consolidate %d notes in %d addresses? Each address keeps its own funds. Fees: about %s DRGX (one per batch).";
strings_["merge_keep_each_select"] = "Tick at least one address to consolidate.";
strings_["merge_keep_each_nothing_submitted"] = "Nothing was consolidated — the node refused every merge.";
strings_["merge_keep_each_some_failed"] = "Some merges failed — see the list above.";
strings_["merge_keep_each_status_not_started"] = "%s — not started: %s";
strings_["merge_keep_each_status_failed"] = "%s — failed: %s";
strings_["merge_keep_each_status_done"] = "%s — done: %d notes merged in %d batch(es)";
strings_["merge_keep_each_status_running"] = "%s — merging %d notes (%d of %d batches done)…";
strings_["merge_keep_each_run_again"] = "Addresses merged in several batches now hold one note per batch. Run this again after those confirm to merge them into one.";
strings_["done"] = "Done"; strings_["done"] = "Done";
// --- Transaction Details Dialog --- // --- Transaction Details Dialog ---
+93
View File
@@ -28,6 +28,7 @@
#include "resources/embedded_resources.h" #include "resources/embedded_resources.h"
#include "services/network_refresh_service.h" #include "services/network_refresh_service.h"
#include "services/refresh_scheduler.h" #include "services/refresh_scheduler.h"
#include "services/note_consolidation.h"
#include "services/wallet_security_controller.h" #include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h" #include "services/wallet_security_workflow.h"
#include "services/wallet_security_workflow_executor.h" #include "services/wallet_security_workflow_executor.h"
@@ -4456,6 +4457,18 @@ void testDaemonStopStringsTranslated()
}); });
} }
// Per-address consolidation strings.
void testConsolidationStringsTranslated()
{
expectKeysTranslated({
"merge_where", "merge_into_one", "merge_keep_each", "merge_keep_each_desc", "merge_keep_each_none",
"merge_keep_each_row_fmt", "merge_keep_each_too_small", "merge_keep_each_batches_fmt",
"merge_keep_each_confirm_fmt", "merge_keep_each_select", "merge_keep_each_nothing_submitted",
"merge_keep_each_some_failed", "merge_keep_each_status_not_started", "merge_keep_each_status_failed",
"merge_keep_each_status_done", "merge_keep_each_status_running", "merge_keep_each_run_again",
});
}
// Mining addresses: the strings that explain transparent addresses only receive mining rewards. // Mining addresses: the strings that explain transparent addresses only receive mining rewards.
void testMiningAddressStringsTranslated() void testMiningAddressStringsTranslated()
{ {
@@ -4616,6 +4629,84 @@ void testDebugLogTailRules()
EXPECT_FALSE(EmbeddedDaemon::processIsDaemon(0)); EXPECT_FALSE(EmbeddedDaemon::processIsDaemon(0));
} }
// Per-address consolidation: each address's notes merge back into that same address, so separately
// labelled funds stay separate while the wallet sheds notes.
void testNoteConsolidation()
{
using namespace dragonx::services;
const nlohmann::json unspent = nlohmann::json::parse(R"([
{"address":"zsA","amount":1.0,"confirmations":5,"spendable":true},
{"address":"zsA","amount":2.0,"confirmations":5,"spendable":true},
{"address":"zsA","amount":0.5,"confirmations":1},
{"address":"zsA","amount":9.0,"confirmations":0,"spendable":true},
{"address":"zsB","amount":3.0,"confirmations":9,"spendable":true},
{"address":"zsW","amount":4.0,"confirmations":9,"spendable":false},
{"address":"zsW","amount":4.0,"confirmations":9,"spendable":false},
{"amount":1.0,"confirmations":9}
])");
const auto groups = groupSpendableNotes(unspent);
EXPECT_EQ(groups.size(), size_t(2)); // watch-only and address-less left out
EXPECT_EQ(groups[0].address, std::string("zsA")); // most notes first
EXPECT_EQ(groups[0].notes, 3); // the unconfirmed note is not spendable yet
EXPECT_NEAR(groups[0].amount, 3.5, 1e-9);
EXPECT_EQ(groups[1].notes, 1);
EXPECT_TRUE(groupSpendableNotes(nlohmann::json::object()).empty());
const double fee = 0.0001;
EXPECT_TRUE(worthConsolidating(groups[0], fee));
EXPECT_FALSE(worthConsolidating(groups[1], fee)); // a single note: nothing to merge
EXPECT_FALSE(worthConsolidating(NoteGroup{"zsD", 4, 0.00015}, fee)); // the fee would eat it
EXPECT_EQ(estimateBatches(0, 90), 0);
EXPECT_EQ(estimateBatches(1, 90), 0);
EXPECT_EQ(estimateBatches(2, 90), 1);
EXPECT_EQ(estimateBatches(90, 90), 1);
EXPECT_EQ(estimateBatches(91, 90), 1); // the lone leftover note is not a batch
EXPECT_EQ(estimateBatches(92, 90), 2);
EXPECT_EQ(estimateBatches(182, 90), 3);
EXPECT_EQ(estimateBatches(5, 1), 2); // a limit below 2 is treated as 2
// A stand-in daemon: each call merges up to `limit` of the address's unlocked notes into itself and
// refuses a lone note, like z_mergetoaddress.
auto daemonFor = [](int notes, int limit, int failOnCall) {
auto left = std::make_shared<int>(notes);
auto calls = std::make_shared<int>(0);
return [=](const std::string&) {
MergeCallResult r;
if (++*calls == failOnCall) { r.error = "Insufficient funds"; return r; }
if (*left < 2) { r.error = "Destination address is also the only source address"; return r; }
r.merging = std::min(limit, *left);
*left -= r.merging;
r.remaining = *left;
r.opid = "opid-" + std::to_string(*calls);
return r;
};
};
for (int notes : {2, 90, 91, 92, 250}) {
const AddressRun run = consolidateInPlace("zsA", daemonFor(notes, 90, -1));
EXPECT_TRUE(run.error.empty());
EXPECT_EQ(static_cast<int>(run.opids.size()), estimateBatches(notes, 90));
EXPECT_TRUE(run.remaining < 2);
EXPECT_EQ(run.notesMerged + run.remaining, notes);
}
{ // the first call fails: nothing submitted
const AddressRun run = consolidateInPlace("zsA", daemonFor(10, 90, 1));
EXPECT_TRUE(run.opids.empty());
EXPECT_FALSE(run.error.empty());
}
{ // a later call fails: the earlier batch stands, the error is reported
const AddressRun run = consolidateInPlace("zsA", daemonFor(200, 90, 2));
EXPECT_EQ(run.opids.size(), size_t(1));
EXPECT_EQ(run.notesMerged, 90);
EXPECT_FALSE(run.error.empty());
}
{ // bounded
const AddressRun run = consolidateInPlace("zsA", daemonFor(1000, 2, -1), /*maxBatches=*/5);
EXPECT_EQ(run.opids.size(), size_t(5));
EXPECT_TRUE(run.remaining >= 2);
}
}
void testDatadirLockGate() void testDatadirLockGate()
{ {
using dragonx::daemon::EmbeddedDaemon; using dragonx::daemon::EmbeddedDaemon;
@@ -9233,6 +9324,7 @@ int main()
testConfSetsKey(); testConfSetsKey();
testOwnershipRecord(); testOwnershipRecord();
testDebugLogTailRules(); testDebugLogTailRules();
testNoteConsolidation();
testStopDeliveryPolicy(); testStopDeliveryPolicy();
testProcStatParse(); testProcStatParse();
testStopProgressTracker(); testStopProgressTracker();
@@ -9245,6 +9337,7 @@ int main()
testChooseDaemonStatus(); testChooseDaemonStatus();
testDaemonStopStringsTranslated(); testDaemonStopStringsTranslated();
testMiningAddressStringsTranslated(); testMiningAddressStringsTranslated();
testConsolidationStringsTranslated();
#ifdef _WIN32 #ifdef _WIN32
testWindowsStopNeverKillsForeignDaemon(); testWindowsStopNeverKillsForeignDaemon();
#endif #endif
+2
View File
@@ -120,6 +120,8 @@ inherits the GUI's environment.
| `FAKE_MINING` | 0 | internal miner on: `stop` waits for cs_main first (`GenerateBitcoins` join) | | `FAKE_MINING` | 0 | internal miner on: `stop` waits for cs_main first (`GenerateBitcoins` join) |
| `FAKE_REBUILD_SILENT` | 0 | hold cs_main like a rebuild but log no rebuild markers (a busy node whose output says nothing) | | `FAKE_REBUILD_SILENT` | 0 | hold cs_main like a rebuild but log no rebuild markers (a busy node whose output says nothing) |
| `FAKE_STUCK_SHUTDOWN` | 0 | the Sep 29 orphan: hold the datadir lock, no listener, ignore SIGTERM, never exit | | `FAKE_STUCK_SHUTDOWN` | 0 | the Sep 29 orphan: hold the datadir lock, no listener, ignore SIGTERM, never exit |
| `FAKE_WALLET_NOTES` | (off) | a note wallet: `addr:count:amount,...` shielded notes; enables `z_mergetoaddress` + its operations (dragonxd rules: confirmed unlocked notes, a batch locked on return, merged note next block) |
| `FAKE_MERGE_SECS` | 6 | how long a `z_mergetoaddress` operation runs before it succeeds |
| `FAKE_HEIGHT`, `FAKE_REBUILD_TOTAL`, `FAKE_NOTES`, `FAKE_VERSION` | 3327145, 62583, 13632, 1030050 | cosmetic numbers | | `FAKE_HEIGHT`, `FAKE_REBUILD_TOTAL`, `FAKE_NOTES`, `FAKE_VERSION` | 3327145, 62583, 13632, 1030050 | cosmetic numbers |
## Test cases ## Test cases
+189
View File
@@ -407,6 +407,187 @@ struct Canned {
std::string errMsg; std::string errMsg;
}; };
// ---------------------------------------------------------------------------------------------
// Optional note wallet (FAKE_WALLET_NOTES="addr:count:amount,..."): shielded notes per address, plus
// z_mergetoaddress and its async operations with dragonxd's rules — confirmed, unlocked notes only;
// a batch's notes are locked when the call returns; "only source == destination with one note" is
// refused; the merged note appears one block later. Everything here runs under g_csMain (execOne).
// ---------------------------------------------------------------------------------------------
struct FakeNote { long id; std::string address; double amount; long height; bool locked; };
struct FakeOp {
std::string id;
std::vector<long> noteIds;
std::string to;
double fee = 0.0;
double finishAt = 0.0; // unixMs()/1000.0
std::string status = "executing";
long long created = 0;
};
bool g_noteWallet = false;
std::vector<FakeNote> g_notes;
std::vector<FakeOp> g_ops;
long g_noteSeq = 0;
long g_opSeq = 0;
double g_mergeSecs = 6.0;
void loadNoteWallet()
{
const char* spec = std::getenv("FAKE_WALLET_NOTES");
if (!spec || !*spec) return;
g_noteWallet = true;
g_mergeSecs = envDouble("FAKE_MERGE_SECS", 6.0);
std::string s(spec);
size_t pos = 0;
while (pos <= s.size()) {
size_t comma = s.find(',', pos);
if (comma == std::string::npos) comma = s.size();
const std::string item = s.substr(pos, comma - pos);
const size_t c1 = item.find(':'), c2 = item.find(':', c1 == std::string::npos ? 0 : c1 + 1);
if (c1 != std::string::npos && c2 != std::string::npos) {
const std::string addr = item.substr(0, c1);
const long count = std::strtol(item.c_str() + c1 + 1, nullptr, 10);
const double amount = std::strtod(item.c_str() + c2 + 1, nullptr);
for (long i = 0; i < count; ++i)
g_notes.push_back({++g_noteSeq, addr, amount, g_height.load() - 10 - i, false});
}
pos = comma + 1;
}
}
std::vector<std::string> noteAddresses()
{
std::vector<std::string> out;
for (const auto& n : g_notes)
if (std::find(out.begin(), out.end(), n.address) == out.end()) out.push_back(n.address);
return out;
}
// Operations past their run time succeed: their notes are spent, one merged note lands next block.
void settleOps()
{
const double now = unixMs() / 1000.0;
for (auto& op : g_ops) {
if (op.status != "executing" || now < op.finishAt) continue;
double sum = 0.0;
for (long id : op.noteIds) {
auto it = std::find_if(g_notes.begin(), g_notes.end(), [&](const FakeNote& n) { return n.id == id; });
if (it != g_notes.end()) { sum += it->amount; g_notes.erase(it); }
}
g_notes.push_back({++g_noteSeq, op.to, sum - op.fee, g_height.load() + 1, false});
op.status = "success";
event("merge-done", op.id + " notes=" + std::to_string(op.noteIds.size()) + " to=" + op.to);
}
}
json opStatus(const FakeOp& op)
{
json o = {{"id", op.id}, {"status", op.status}, {"creation_time", op.created}, {"method", "z_mergetoaddress"}};
if (op.status == "success") o["result"] = {{"txid", fakeHash(1000000 + std::stol(op.id.substr(5)))}};
return o;
}
// The note-wallet answer for `m`, or c.known = false when it isn't a note-wallet method.
Canned noteWalletReply(const std::string& m, const json& params)
{
Canned c;
settleOps();
const long h = g_height.load();
if (m == "z_listunspent") {
json arr = json::array();
for (const auto& n : g_notes) {
const long conf = std::max(0L, h - n.height + 1);
arr.push_back({{"txid", fakeHash(5000000 + n.id)}, {"outindex", 0}, {"confirmations", conf},
{"spendable", true}, {"address", n.address}, {"amount", n.amount}, {"memo", "f6"},
{"change", false}});
}
c.result = arr;
} else if (m == "z_listaddresses") {
json arr = json::array({kFakeZAddr});
for (const auto& a : noteAddresses()) arr.push_back(a);
c.result = arr;
} else if (m == "z_gettotalbalance" || m == "z_getbalance") {
const std::string only = (m == "z_getbalance" && !params.empty() && params[0].is_string())
? params[0].get<std::string>() : std::string();
double sum = 0.0;
for (const auto& n : g_notes)
if (only.empty() || n.address == only) sum += n.amount;
if (m == "z_getbalance") c.result = sum;
else {
char buf[64];
std::snprintf(buf, sizeof(buf), "%.8f", sum);
c.result = {{"transparent", "0.00"}, {"private", buf}, {"total", buf}};
}
} else if (m == "z_mergetoaddress") {
if (params.size() < 2 || !params[0].is_array() || !params[1].is_string()) {
c.errCode = -8; c.errMsg = "Invalid parameter";
return c;
}
std::vector<std::string> from;
bool anySapling = false;
for (const auto& a : params[0]) {
if (!a.is_string()) continue;
if (a.get<std::string>() == "ANY_SAPLING" || a.get<std::string>() == "*") anySapling = true;
else from.push_back(a.get<std::string>());
}
const std::string to = params[1].get<std::string>();
const double fee = params.size() > 2 && params[2].is_number() ? params[2].get<double>() : 0.0001;
long limit = params.size() > 4 && params[4].is_number() ? params[4].get<long>() : 90;
if (limit <= 0) limit = 1000000;
std::vector<long> picked;
double merging = 0.0, remainingValue = 0.0;
long counter = 0;
for (const auto& n : g_notes) {
if (n.locked || h - n.height + 1 < 1) continue;
if (!anySapling && std::find(from.begin(), from.end(), n.address) == from.end()) continue;
++counter;
if (static_cast<long>(picked.size()) < limit) { picked.push_back(n.id); merging += n.amount; }
else remainingValue += n.amount;
}
if (picked.empty()) { c.errCode = -6; c.errMsg = "Could not find any funds to merge."; return c; }
if (from.size() == 1 && !anySapling && from[0] == to && picked.size() == 1) {
c.errCode = -8;
c.errMsg = "Destination address is also the only source address, and all its funds are already merged.";
return c;
}
if (merging - fee < fee) {
c.errCode = -6; c.errMsg = "Insufficient funds to pay the fee"; return c;
}
for (auto& n : g_notes)
if (std::find(picked.begin(), picked.end(), n.id) != picked.end()) n.locked = true;
FakeOp op;
op.id = "opid-" + std::to_string(++g_opSeq);
op.noteIds = picked;
op.to = to;
op.fee = fee;
op.created = unixMs() / 1000;
op.finishAt = unixMs() / 1000.0 + g_mergeSecs;
g_ops.push_back(op);
event("merge", op.id + " notes=" + std::to_string(picked.size()) + " to=" + to);
c.result = {{"remainingUTXOs", 0}, {"remainingTransparentValue", 0.0},
{"remainingNotes", counter - static_cast<long>(picked.size())},
{"remainingShieldedValue", remainingValue}, {"mergingUTXOs", 0},
{"mergingTransparentValue", 0.0}, {"mergingNotes", static_cast<long>(picked.size())},
{"mergingShieldedValue", merging}, {"opid", op.id}};
} else if (m == "z_getoperationstatus" || m == "z_getoperationresult") {
std::vector<std::string> filter;
if (!params.empty() && params[0].is_array())
for (const auto& id : params[0]) if (id.is_string()) filter.push_back(id.get<std::string>());
json arr = json::array();
std::vector<FakeOp> keep;
for (const auto& op : g_ops) {
const bool match = filter.empty() || std::find(filter.begin(), filter.end(), op.id) != filter.end();
const bool finished = op.status != "executing";
if (match && (m == "z_getoperationstatus" || finished)) arr.push_back(opStatus(op));
if (!(m == "z_getoperationresult" && match && finished)) keep.push_back(op);
}
g_ops.swap(keep);
c.result = arr;
} else {
c.known = false;
}
return c;
}
Canned cannedReply(const std::string& m, const json& params) Canned cannedReply(const std::string& m, const json& params)
{ {
Canned c; Canned c;
@@ -500,6 +681,9 @@ Canned cannedReply(const std::string& m, const json& params)
c.result = json::array(); c.result = json::array();
} else if (m == "setban" || m == "clearbanned") { } else if (m == "setban" || m == "clearbanned") {
c.result = nullptr; c.result = nullptr;
} else if (m == "z_mergetoaddress") {
c.errCode = -8; // only the note wallet (FAKE_WALLET_NOTES) can merge; execOne answers it there
c.errMsg = "fake_dragonxd: set FAKE_WALLET_NOTES to merge notes";
} else if (m == "z_listaddresses") { } else if (m == "z_listaddresses") {
c.result = json::array({kFakeZAddr}); c.result = json::array({kFakeZAddr});
} else if (m == "getaddressesbyaccount" || m == "listaddresses") { } else if (m == "getaddressesbyaccount" || m == "listaddresses") {
@@ -589,6 +773,10 @@ json execOne(const json& req, int& status)
std::lock_guard<std::mutex> lk(g_csMain); std::lock_guard<std::mutex> lk(g_csMain);
c = cannedReply(m, params); c = cannedReply(m, params);
if (m == "setgenerate") g_minerOn = !params.empty() && params[0].is_boolean() && params[0].get<bool>(); if (m == "setgenerate") g_minerOn = !params.empty() && params[0].is_boolean() && params[0].get<bool>();
if (g_noteWallet) {
Canned w = noteWalletReply(m, params);
if (w.known) c = w;
}
} }
if (c.errCode != 0) return fail(c.errCode, c.errMsg); if (c.errCode != 0) return fail(c.errCode, c.errMsg);
return rpcReply(c.result, nullptr, id); return rpcReply(c.result, nullptr, id);
@@ -1240,6 +1428,7 @@ int main(int argc, char** argv)
} }
g_expectedAuth = "Basic " + base64(g_cfg.rpcUser + ":" + g_cfg.rpcPassword); g_expectedAuth = "Basic " + base64(g_cfg.rpcUser + ":" + g_cfg.rpcPassword);
g_height = g_cfg.height; g_height = g_cfg.height;
loadNoteWallet(); // FAKE_WALLET_NOTES (optional)
g_minerOn = g_cfg.mining; g_minerOn = g_cfg.mining;
const socket_t listener = bindListener(g_cfg.rpcPort); const socket_t listener = bindListener(g_cfg.rpcPort);