diff --git a/CMakeLists.txt b/CMakeLists.txt index 0623697..3bd4607 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -467,6 +467,7 @@ set(APP_SOURCES src/app_sweep.cpp src/app_wizard.cpp src/services/network_refresh_service.cpp + src/services/note_consolidation.cpp src/services/refresh_scheduler.cpp src/services/wallet_security_controller.cpp src/services/wallet_security_workflow.cpp @@ -1146,6 +1147,7 @@ if(BUILD_TESTING) add_executable(ObsidianDragonTests tests/test_phase4.cpp src/services/network_refresh_service.cpp + src/services/note_consolidation.cpp src/services/refresh_scheduler.cpp src/services/wallet_security_controller.cpp src/services/wallet_security_workflow.cpp diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index df8ff4d..e5c963b 100644 Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ diff --git a/res/lang/de.json b/res/lang/de.json index 10df873..0a4a69b 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -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_fee_hint": "Netzwerkgebühr für diese Transaktion.", "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_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.", @@ -1175,6 +1191,7 @@ "merge_src_transparent": "Transparent", "merge_started": "Zusammenführung gestartet", "merge_title": "An Adresse zusammenführen", + "merge_where": "Wohin sollen die Mittel gehen?", "mig_adopt": "Dies zu meiner Wallet machen", "mig_adopt_now": "Seed-Wallet übernehmen", "mig_adopting": "Ihre neue Wallet wird installiert und neu gescannt…", diff --git a/res/lang/es.json b/res/lang/es.json index 06533be..3857954 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -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_fee_hint": "Comisión de red para esta transacción.", "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_no_spendable": "Aún no hay entradas disponibles para consolidar.", "merge_progress": "Consolidando… esto puede tardar varios minutos. Puedes cerrar esta ventana.", @@ -1175,6 +1191,7 @@ "merge_src_transparent": "Transparentes", "merge_started": "Operación de fusión iniciada", "merge_title": "Fusionar a Dirección", + "merge_where": "¿Adónde deben ir los fondos?", "mig_adopt": "Hacer que esta sea mi cartera", "mig_adopt_now": "Adoptar cartera con frase", "mig_adopting": "Instalando tu nueva cartera y reescaneando…", diff --git a/res/lang/fr.json b/res/lang/fr.json index 4ba91c6..bb25d3a 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -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_fee_hint": "Frais réseau pour cette transaction.", "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_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.", @@ -1175,6 +1191,7 @@ "merge_src_transparent": "Transparentes", "merge_started": "Opération de fusion démarrée", "merge_title": "Fusionner vers une adresse", + "merge_where": "Où doivent aller les fonds ?", "mig_adopt": "Faire de ceci mon portefeuille", "mig_adopt_now": "Adopter le portefeuille à phrase", "mig_adopting": "Installation de votre nouveau portefeuille et rescan en cours…", diff --git a/res/lang/ja.json b/res/lang/ja.json index 3ad1ec4..2e3f362 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -1163,6 +1163,22 @@ "merge_description": "複数のUTXOを単一のシールドアドレスに統合します。ウォレットサイズの縮小とプライバシーの向上に役立ちます。", "merge_fee_hint": "この取引にかかるネットワーク手数料です。", "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_no_spendable": "統合できる使用可能な入力がまだありません。", "merge_progress": "統合中… 数分かかることがあります。このウィンドウは閉じても構いません。", @@ -1175,6 +1191,7 @@ "merge_src_transparent": "透明", "merge_started": "統合操作を開始しました", "merge_title": "アドレスに統合", + "merge_where": "資金の移動先", "mig_adopt": "これを自分のウォレットにする", "mig_adopt_now": "シードウォレットを採用", "mig_adopting": "新しいウォレットをインストールして再スキャン中…", diff --git a/res/lang/ko.json b/res/lang/ko.json index 947a9eb..50065c1 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -1163,6 +1163,22 @@ "merge_description": "여러 UTXO를 단일 차폐 주소로 통합합니다. 지갑 크기를 줄이고 프라이버시를 향상시킵니다.", "merge_fee_hint": "이 거래에 대한 네트워크 수수료입니다.", "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_no_spendable": "아직 통합할 사용 가능한 입력이 없습니다.", "merge_progress": "통합 중… 몇 분 정도 걸릴 수 있습니다. 이 창을 닫아도 됩니다.", @@ -1175,6 +1191,7 @@ "merge_src_transparent": "투명", "merge_started": "통합 작업이 시작되었습니다", "merge_title": "주소로 통합", + "merge_where": "자금을 어디로 보낼까요?", "mig_adopt": "이 지갑을 내 지갑으로 사용", "mig_adopt_now": "시드 지갑 사용", "mig_adopting": "새 지갑을 설치하고 재스캔하는 중…", diff --git a/res/lang/pt.json b/res/lang/pt.json index 0a814d5..29b16ad 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -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_fee_hint": "Taxa de rede para esta transação.", "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_no_spendable": "Ainda não há entradas disponíveis para consolidar.", "merge_progress": "Consolidando… isso pode levar alguns minutos. Você pode fechar esta janela.", @@ -1175,6 +1191,7 @@ "merge_src_transparent": "Transparente", "merge_started": "Operação de fusão iniciada", "merge_title": "Fundir para Endereço", + "merge_where": "Para onde devem ir os fundos?", "mig_adopt": "Tornar esta minha carteira", "mig_adopt_now": "Adotar carteira semente", "mig_adopting": "Instalando sua nova carteira e reescaneando…", diff --git a/res/lang/ru.json b/res/lang/ru.json index e7530c9..5b0e217 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -1163,6 +1163,22 @@ "merge_description": "Объедините несколько UTXO в один экранированный адрес. Это может уменьшить размер кошелька и улучшить конфиденциальность.", "merge_fee_hint": "Сетевая комиссия за эту транзакцию.", "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_no_spendable": "Пока нет доступных для расходования входов для объединения.", "merge_progress": "Объединение… это может занять несколько минут. Вы можете закрыть это окно.", @@ -1175,6 +1191,7 @@ "merge_src_transparent": "Прозрачные", "merge_started": "Операция объединения начата", "merge_title": "Объединить на адрес", + "merge_where": "Куда направить средства?", "mig_adopt": "Сделать это моим кошельком", "mig_adopt_now": "Принять сид-кошелёк", "mig_adopting": "Установка вашего нового кошелька и пересканирование…", diff --git a/res/lang/zh.json b/res/lang/zh.json index feacb31..c91879f 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -1163,6 +1163,22 @@ "merge_description": "将多个 UTXO 合并到一个屏蔽地址。这可以帮助减小钱包大小并提高隐私性。", "merge_fee_hint": "此交易的网络手续费。", "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_no_spendable": "暂无可合并的可用输入。", "merge_progress": "正在合并…这可能需要几分钟。您可以关闭此窗口。", @@ -1175,6 +1191,7 @@ "merge_src_transparent": "透明", "merge_started": "合并操作已开始", "merge_title": "合并到地址", + "merge_where": "资金应转到哪里?", "mig_adopt": "将其设为我的钱包", "mig_adopt_now": "采用助记词钱包", "mig_adopting": "正在安装您的新钱包并重新扫描…", diff --git a/scripts/add_missing_translations.py b/scripts/add_missing_translations.py index c8908fa..11dac15 100644 --- a/scripts/add_missing_translations.py +++ b/scripts/add_missing_translations.py @@ -25,6 +25,176 @@ def load_english_keys(): # All translations for the 158 missing keys 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": { "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.", diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp index c2a369b..279c6c1 100644 --- a/src/app_sweep.cpp +++ b/src/app_sweep.cpp @@ -401,6 +401,10 @@ void App::buildSweepCatalog() add("modal-merge", ui::NavPage::Send, [](App&) { ui::ShieldDialog::showMerge(); }, [](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. add("modal-transfer", ui::NavPage::Overview, [](App& a) { diff --git a/src/services/note_consolidation.cpp b/src/services/note_consolidation.cpp new file mode 100644 index 0000000..811f2cb --- /dev/null +++ b/src/services/note_consolidation.cpp @@ -0,0 +1,71 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "note_consolidation.h" + +#include +#include + +namespace dragonx { +namespace services { + +std::vector groupSpendableNotes(const nlohmann::json& zListUnspent) +{ + std::map 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 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 diff --git a/src/services/note_consolidation.h b/src/services/note_consolidation.h new file mode 100644 index 0000000..bae82df --- /dev/null +++ b/src/services/note_consolidation.h @@ -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 + +#include +#include +#include + +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 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; + +// The outcome for one address. +struct AddressRun { + std::string address; + std::vector 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 diff --git a/src/ui/windows/shield_dialog.cpp b/src/ui/windows/shield_dialog.cpp index f7961fc..c9f5124 100644 --- a/src/ui/windows/shield_dialog.cpp +++ b/src/ui/windows/shield_dialog.cpp @@ -8,12 +8,15 @@ #include "../../data/wallet_state.h" #include "../../rpc/rpc_client.h" #include "../../rpc/rpc_worker.h" +#include "../../services/note_consolidation.h" #include "../../util/i18n.h" #include "../notifications.h" #include "../schema/ui_schema.h" #include "../material/draw_helpers.h" #include "imgui.h" +#include +#include #include #include #include @@ -44,6 +47,18 @@ static bool s_scope_loaded = false; static int s_t_count = 0, s_z_count = 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) +// "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 s_groups; // spendable shielded notes by address (scope load) +static std::set s_selected; // addresses ticked for per-address consolidation +struct PerAddressRun { + services::AddressRun run; + std::vector state; // per opid: 0 = running, 1 = done, 2 = failed + std::string failure; // first failed op's message +}; +static std::vector s_runs; // after submit +static bool s_runs_submitting = false; static void resetTransient() { @@ -53,6 +68,8 @@ static void resetTransient() s_status_message.clear(); s_operation_id.clear(); 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 @@ -63,6 +80,7 @@ static void loadScope(App* app) s_scope_loading = true; s_scope_loaded = false; 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; + std::vector groups; try { rpc::RPCClient::TraceScope trace("Shield dialog / Scope count"); 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 (z.value("confirmations", 0) >= 1) { ++zC; zA += z.value("amount", 0.0); } } + groups = services::groupSpendableNotes(zs); } 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_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). const bool tOk = tC > 0, zOk = zC > 0; if (!s_operation_pending) { @@ -108,6 +131,9 @@ void ShieldDialog::show(Mode mode) s_scope_loaded = false; s_scope_loading = false; s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0; s_last_poll = 0.0; + s_per_address = false; + s_groups.clear(); + s_selected.clear(); } void ShieldDialog::showShieldCoinbase(const std::string& fromAddress) @@ -128,6 +154,7 @@ void ShieldDialog::showConsolidate() show(Mode::MergeToAddress); s_consolidate = true; 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() @@ -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; } +// 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) { 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 selectedGroups() +{ + std::vector 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 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(); + const std::string status = (op.contains("status") && op["status"].is_string()) + ? op["status"].get() : 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 // shows "Consolidating… → Done/Failed" without a manual button. (The shared poller also tracks it for // balance refresh; this drives only the inline display.) @@ -290,6 +440,7 @@ void ShieldDialog::render(App* app) const auto& state = app->getWalletState(); autoSelectDestination(state); pollOperation(app); + pollPerAddress(app); if (isMerge && !s_scope_loaded && !s_scope_loading && s_operation_id.empty()) loadScope(app); // ── Description ────────────────────────────────────────────────────────────────────────────── @@ -298,7 +449,9 @@ void ShieldDialog::render(App* app) : TR("shield_description")); 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 ────────────────────────────────────────────────────────── if (isMerge && !opInFlight) { @@ -313,9 +466,17 @@ void ShieldDialog::render(App* app) } 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. const bool tOk = s_t_count > 0, zOk = s_z_count > 0; - if (tOk && zOk) { + if (tOk && zOk && !s_per_address) { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(TR("merge_source")); ImGui::SameLine(0, Layout::spacingLg()); @@ -324,6 +485,46 @@ void ShieldDialog::render(App* app) ImGui::RadioButton(TR("merge_src_both"), &s_src, 2); 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(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 ─────────────────────────────────────────────────────────── @@ -335,6 +536,7 @@ void ShieldDialog::render(App* app) // ── Destination (z-address) ───────────────────────────────────────────────────────────────── if (!opInFlight) { + if (!perAddress) { ImGui::TextUnformatted(TR("shield_to_address")); if (state.z_addresses.empty()) { material::Type().textColored(material::TypeStyle::Caption, material::Warning(), TR("shield_no_zaddr_hint")); @@ -381,6 +583,7 @@ void ShieldDialog::render(App* app) } } ImGui::Spacing(); + } // !perAddress // ── Advanced (fee + batch size) ───────────────────────────────────────────────────────── 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. - 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]; std::snprintf(hb, sizeof(hb), TR("merge_batch_fmt"), s_utxo_limit); ImGui::Spacing(); @@ -415,6 +627,46 @@ void ShieldDialog::render(App* app) 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(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 ────────────────────────────────────────────────────────────────── if (!s_status_message.empty()) { 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 // 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(sel.size()), + fmtAmt(s_fee * batches).c_str()); + ImGui::TextWrapped("%s", cb); + ImGui::Spacing(); + } else if (s_confirm && isMerge) { char cb[200]; std::snprintf(cb, sizeof(cb), TR("merge_confirm_fmt"), 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; 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; material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false); if (!can_submit) ImGui::BeginDisabled(); 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 } if (!can_submit) ImGui::EndDisabled(); if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { if (!connected) material::Tooltip("%s", TR("send_tooltip_not_connected")); 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 (isMerge && srcCount() == 0) material::Tooltip("%s", TR("merge_no_spendable")); } diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 9978651..d58232b 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -2519,6 +2519,23 @@ void I18n::loadBuiltinEnglish() strings_["merge_back"] = "Back"; 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_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"; // --- Transaction Details Dialog --- diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 35f2653..e2d663b 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -28,6 +28,7 @@ #include "resources/embedded_resources.h" #include "services/network_refresh_service.h" #include "services/refresh_scheduler.h" +#include "services/note_consolidation.h" #include "services/wallet_security_controller.h" #include "services/wallet_security_workflow.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. void testMiningAddressStringsTranslated() { @@ -4616,6 +4629,84 @@ void testDebugLogTailRules() 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(notes); + auto calls = std::make_shared(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(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() { using dragonx::daemon::EmbeddedDaemon; @@ -9233,6 +9324,7 @@ int main() testConfSetsKey(); testOwnershipRecord(); testDebugLogTailRules(); + testNoteConsolidation(); testStopDeliveryPolicy(); testProcStatParse(); testStopProgressTracker(); @@ -9245,6 +9337,7 @@ int main() testChooseDaemonStatus(); testDaemonStopStringsTranslated(); testMiningAddressStringsTranslated(); + testConsolidationStringsTranslated(); #ifdef _WIN32 testWindowsStopNeverKillsForeignDaemon(); #endif diff --git a/tools/fake_dragonxd/README.md b/tools/fake_dragonxd/README.md index 7dd1688..fa96a38 100644 --- a/tools/fake_dragonxd/README.md +++ b/tools/fake_dragonxd/README.md @@ -120,6 +120,8 @@ inherits the GUI's environment. | `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_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 | ## Test cases diff --git a/tools/fake_dragonxd/fake_dragonxd.cpp b/tools/fake_dragonxd/fake_dragonxd.cpp index ce24e84..5f2c056 100644 --- a/tools/fake_dragonxd/fake_dragonxd.cpp +++ b/tools/fake_dragonxd/fake_dragonxd.cpp @@ -407,6 +407,187 @@ struct Canned { 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 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 g_notes; +std::vector 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 noteAddresses() +{ + std::vector 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(); + 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 from; + bool anySapling = false; + for (const auto& a : params[0]) { + if (!a.is_string()) continue; + if (a.get() == "ANY_SAPLING" || a.get() == "*") anySapling = true; + else from.push_back(a.get()); + } + const std::string to = params[1].get(); + const double fee = params.size() > 2 && params[2].is_number() ? params[2].get() : 0.0001; + long limit = params.size() > 4 && params[4].is_number() ? params[4].get() : 90; + if (limit <= 0) limit = 1000000; + std::vector 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(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(picked.size())}, + {"remainingShieldedValue", remainingValue}, {"mergingUTXOs", 0}, + {"mergingTransparentValue", 0.0}, {"mergingNotes", static_cast(picked.size())}, + {"mergingShieldedValue", merging}, {"opid", op.id}}; + } else if (m == "z_getoperationstatus" || m == "z_getoperationresult") { + std::vector filter; + if (!params.empty() && params[0].is_array()) + for (const auto& id : params[0]) if (id.is_string()) filter.push_back(id.get()); + json arr = json::array(); + std::vector 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 c; @@ -500,6 +681,9 @@ Canned cannedReply(const std::string& m, const json& params) c.result = json::array(); } else if (m == "setban" || m == "clearbanned") { 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") { c.result = json::array({kFakeZAddr}); } else if (m == "getaddressesbyaccount" || m == "listaddresses") { @@ -589,6 +773,10 @@ json execOne(const json& req, int& status) std::lock_guard lk(g_csMain); c = cannedReply(m, params); if (m == "setgenerate") g_minerOn = !params.empty() && params[0].is_boolean() && params[0].get(); + if (g_noteWallet) { + Canned w = noteWalletReply(m, params); + if (w.known) c = w; + } } if (c.errCode != 0) return fail(c.errCode, c.errMsg); 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_height = g_cfg.height; + loadNoteWallet(); // FAKE_WALLET_NOTES (optional) g_minerOn = g_cfg.mining; const socket_t listener = bindListener(g_cfg.rpcPort);