From 2bd9f05cb6673e88d3f5944c52726a4ffa8c772a Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Mon, 7 Sep 2026 17:31:13 +0200 Subject: [PATCH] Add transaction item on completed import, fix bad imports across the repo --- __mocks__/react-native-localize.js | 26 +++ __tests__/backupRoundtrip.test.ts | 93 +++++++++- jest.config.js | 17 +- src/i18n_messages/en.json | 38 +++++ src/i18n_messages/es.json | 38 +++++ src/i18n_messages/pt.json | 38 +++++ src/i18n_messages/sk.json | 38 +++++ src/models/AuthStore.ts | 2 +- src/models/ProofsStore.ts | 27 ++- src/models/Transaction.ts | 12 ++ src/screens/ExportBackupScreen.tsx | 19 +-- src/screens/ImportBackupScreen.tsx | 160 ++++++++++++++++-- src/screens/MnemonicScreen.tsx | 2 +- src/screens/OwnKeysScreen.tsx | 2 +- src/screens/RecoverWalletAddressScreen.tsx | 2 +- src/screens/SeedRecoveryScreen.tsx | 2 +- src/screens/TranDetailScreen.tsx | 8 +- .../Transactions/TransactionListItem.tsx | 9 +- src/services/backup/backupCodec.ts | 41 +++-- src/services/backup/importSummary.ts | 62 +++++++ src/services/cashu/cashuUtils.ts | 2 +- src/services/cashu/nut20.ts | 7 +- src/services/keyChain.ts | 4 +- src/services/nostrService.ts | 2 +- 24 files changed, 580 insertions(+), 71 deletions(-) create mode 100644 __mocks__/react-native-localize.js create mode 100644 src/services/backup/importSummary.ts diff --git a/__mocks__/react-native-localize.js b/__mocks__/react-native-localize.js new file mode 100644 index 00000000..42f95b64 --- /dev/null +++ b/__mocks__/react-native-localize.js @@ -0,0 +1,26 @@ +/** + * Jest manual mock for react-native-localize. + * + * The real module reaches for a native TurboModule at import time, so anything + * importing `src/i18n` — which calls getLocales() at module scope to pick the + * device locale — was unloadable under jest. That kept `translate` out of every + * testable module, which is why user-facing strings in services used to be + * hardcoded English. + * + * Reports en-US, so tests read the `en` messages: assertions can match on the + * English text without pinning a device locale. + */ +module.exports = { + getLocales: () => [ + {countryCode: 'US', languageTag: 'en-US', languageCode: 'en', isRTL: false}, + ], + getNumberFormatSettings: () => ({decimalSeparator: '.', groupingSeparator: ','}), + getCountry: () => 'US', + getCurrencies: () => ['USD'], + getTimeZone: () => 'UTC', + uses24HourClock: () => true, + usesMetricSystem: () => true, + usesAutoDateAndTime: () => true, + usesAutoTimeZone: () => true, + findBestLanguageTag: () => ({languageTag: 'en-US', isRTL: false}), +} diff --git a/__tests__/backupRoundtrip.test.ts b/__tests__/backupRoundtrip.test.ts index 7432e6de..fc6bbbea 100644 --- a/__tests__/backupRoundtrip.test.ts +++ b/__tests__/backupRoundtrip.test.ts @@ -23,6 +23,7 @@ jest.mock('../src/services/logService', () => ({ import {mnemonicToSeedSync} from '@scure/bip39' import {types, getSnapshot} from 'mobx-state-tree' import {encodeBackup, decodeBackup} from '../src/services/backup/backupCodec' +import {groupImportedProofs} from '../src/services/backup/importSummary' import {MintsStoreModel, MintsStoreSnapshot} from '../src/models/MintsStore' import {ProofsStoreModel} from '../src/models/ProofsStore' import {ContactsStoreModel} from '../src/models/ContactsStore' @@ -116,7 +117,7 @@ const importBackup = (root: Instance, encoded: string, keysByMintUrl: Record 0) { Database.addOrUpdateProofs(root.proofsStore.allPendingProofs, 'PENDING') } + + // What ImportBackupScreen turns into RECEIVE_IMPORT transactions. + return importedProofs } // The store instances are structurally typed here; the models' own Instance types @@ -360,3 +364,90 @@ describe('importing over an existing wallet', () => { expect(Database.getCounters().find(c => c.keysetId === KEYSET_1)?.counter).toBe(99) }) }) + +/** + * The history side of an import: one RECEIVE_IMPORT transaction per (mint, unit) + * restored. Without it the balance rises with nothing in the history to account + * for it, and every total after the import is unexplainable. + * + * The screen writes the transactions; what it writes them FROM is here — the + * proofs importProofs reports as added, grouped by groupImportedProofs. + */ +describe('recording the imported ecash', () => { + test('one group per mint and unit, holding what that mint restored', () => { + const source = TestRoot.create({ + mintsStore: { + mints: [ + mintSnapshot({id: 'backup01'}), + mintSnapshot({ + id: 'backup02', + mintUrl: OTHER_MINT_URL, + hostname: 'other.test', + keysets: [{id: KEYSET_2, unit: 'sat', active: true, input_fee_ppk: 0}], + keys: [{id: KEYSET_2, unit: 'sat', keys: {'1': '02cc'}}], + proofsCounters: [{keyset: KEYSET_2, unit: 'sat'}], + }), + ], + }, + proofsStore: { + proofs: { + a1: proofSnapshot('a1'), + a2: proofSnapshot('a2'), + b1: {...proofSnapshot('b1'), id: KEYSET_2, mintUrl: OTHER_MINT_URL, amount: 5}, + }, + } as any, + }) + + const imported = importBackup(TestRoot.create({}), exportBackup(source)) + + expect(groupImportedProofs(imported)).toEqual([ + expect.objectContaining({mintUrl: MINT_URL, unit: 'sat', amount: 4}), + expect.objectContaining({mintUrl: OTHER_MINT_URL, unit: 'sat', amount: 5}), + ]) + }) + + // The transaction must claim what the import BROUGHT, not what the wallet ends + // up holding — otherwise re-importing the same backup would keep announcing + // ecash that was already here. + test('ecash the wallet already had is not counted again', () => { + const source = TestRoot.create({ + mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}, + proofsStore: {proofs: {a1: proofSnapshot('a1'), a2: proofSnapshot('a2')}} as any, + }) + const backup = exportBackup(source) + + const target = TestRoot.create({}) + expect(groupImportedProofs(importBackup(target, backup))[0].amount).toBe(4) + + // The very same backup, imported a second time. + expect(groupImportedProofs(importBackup(target, backup))).toEqual([]) + expect(target.proofsStore.proofs.size).toBe(2) + }) + + // A PENDING proof is locked in an operation the other device had in flight. It + // arrives, but it is not part of the balance this transaction accounts for. + test('pending proofs arrive but are left out of the amount', () => { + const source = TestRoot.create({ + mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}, + proofsStore: { + proofs: {a1: proofSnapshot('a1'), a2: proofSnapshot('a2', 'PENDING')}, + } as any, + }) + + const target = TestRoot.create({}) + const imported = importBackup(target, exportBackup(source)) + + expect(imported).toHaveLength(2) + expect(groupImportedProofs(imported)).toEqual([ + expect.objectContaining({mintUrl: MINT_URL, amount: 2}), + ]) + }) + + test('an import that restored no ecash writes no transaction', () => { + const source = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}}) + + const imported = importBackup(TestRoot.create({}), exportBackup(source)) + + expect(groupImportedProofs(imported)).toEqual([]) + }) +}) diff --git a/jest.config.js b/jest.config.js index 11509e66..7e64eaef 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,10 +12,15 @@ module.exports = { // mmkvStorage / keyChain / db). That is what made MST stores impossible to // instantiate in a test at all; these mappings are what make store tests possible. moduleNameMapper: { - '^@noble/hashes/utils$': '@noble/hashes/utils.js', - // Same shape as the @noble mapping: the package only exports the explicit - // `.js` subpath, which jest's resolver will not infer. - '^@scure/bip39/wordlists/(.*)$': '@scure/bip39/wordlists/$1.js', + // Same shape as the @scure/bip39 mapping below: the package's exports map + // names only the suffixed path, so app code imports it that way. This accepts + // both spellings, for transitive dependencies still using the bare one. + '^@noble/hashes/utils(?:\\.js)?$': '@noble/hashes/utils.js', + // The package's exports map lists ONLY the explicit `.js` subpath, so app code + // imports it that way (moduleResolution is "bundler", which honours exports). + // This accepts both spellings, since a transitive dependency may still use the + // extensionless one, which no resolver can infer from that map. + '^@scure/bip39/wordlists/([^/.]+)(?:\\.js)?$': '@scure/bip39/wordlists/$1.js', // quick-crypto's native module is unavailable under jest; route to a // Node `crypto` shim so deps that import it at load time (e.g. bip32) work. '^react-native-quick-crypto$': '/__mocks__/react-native-quick-crypto.js', @@ -34,5 +39,9 @@ module.exports = { // cross the nested copies onto the top-level @noble versions — on crypto code. // Mock the surface instead; nothing in the model layer needs real nostr. '^nostr-tools(/.*)?$': '/__mocks__/nostr-tools.js', + // react-native-localize is a native TurboModule, and src/i18n calls getLocales() + // at module scope — so without this, importing `translate` anywhere makes the + // module unloadable under jest. + '^react-native-localize$': '/__mocks__/react-native-localize.js', }, } diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index ca7530e0..f8722248 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -9,6 +9,16 @@ "amountTopup": "Topup amount", "amountWithdraw": "Withdraw amount", "attention": "Attention!", + "backupCodec_decryptFailed": "Could not decrypt the backup. It belongs to a different seed phrase, or it was damaged in transit.", + "backupCodec_encryptFailed": "Could not encrypt the wallet backup: %{error}", + "backupCodec_incomplete": "The backup is incomplete — it may have been cut short when it was copied.", + "backupCodec_missingSeedToDecrypt": "Missing the seed phrase this backup was encrypted with.", + "backupCodec_missingSeedToEncrypt": "Missing the wallet seed to encrypt the backup with.", + "backupCodec_notABackup": "A Minibits backup starts with '%{prefix}'.", + "backupCodec_unreadable": "The backup could not be read. It may have been cut short when it was copied.", + "backupCodec_unsupportedVersion": "This backup was made by a newer version of Minibits (format %{version}). Update the app to restore it.", + "backupCodec_verifyFailed": "The backup was encrypted but could not be read back, so it was not exported.", + "backupCodec_verifyMismatch": "The encrypted backup did not decrypt back to the wallet state, so it was not exported.", "backupDeletedSuccess": "Your backup of minibits tokens has been deleted. New tokens will NOT be backed up.", "backupInvalidMnemonicError": "Corrupted or invalid seed phrase. Please move funds and reinstall this wallet.", "backupLocalBackup": "Local backup", @@ -166,14 +176,41 @@ "errorScreen_reset": "RESET APP", "errorScreen_title": "Something went wrong!", "exportBackup": "Export backup", + "exportBackupContactsCount": "Number of contacts: %{count}", "exportBackupCopyBackup": "Export backup", "exportBackupCopyOrphanedProofs": "Copy orphaned proofs", "exportBackupEcashProofs": "Ecash proofs", + "exportBackupFailed": "Could not export the wallet backup: %{error}", "exportBackupMints": "Mints", + "exportBackupMintsCount": "Number of mints: %{count}", + "exportBackupMissingMnemonic": "This wallet has no seed phrase to encrypt the backup with.", + "exportBackupOptimize": "Optimize", + "exportBackupOrphanedProofsFound": { + "one": "Found %{count} orphaned proof that belongs to no active mint. It won't be included in the backup, but you can copy it separately.", + "other": "Found %{count} orphaned proofs that belong to no active mint. They won't be included in the backup, but you can copy them separately." + }, + "exportBackupProofsCount": "Number of proofs: %{count}", + "exportBackupSeedHint": "This backup is encrypted with your seed phrase. You will need those words to restore it.", "exportBackupWalletTitle": "Wallet backup", "factoryResetSuccess": "Factory reset completed, please restart immediately.", "factoryResetUserConfirmDesc": "All application data will be deleted. Are you sure you want to perform a factory reset?", + "importBackupConfirmEcash": "Ecash already in this wallet stays spendable, but it will no longer be recoverable from a seed phrase. Write your current seed phrase down before you continue.", + "importBackupConfirmMerge": "Mints and contacts from the backup are added to this wallet. Nothing you already have here is removed.", + "importBackupConfirmSeed": "This wallet will start using the seed phrase you entered, and your Minibits address will be replaced by the one linked to it.", + "importBackupConfirmTitle": "Before you import", + "importBackupImportWallet": "Import wallet", + "importBackupInserting": "Inserting backup...", "importBackupInsertWalletBackup": "Insert wallet backup", + "importBackupMissingBackupOrSeed": "Missing backup or seed.", + "importBackupMissingClipboard": "Copy and paste the wallet backup.", + "importBackupMissingSnapshot": "Missing the wallet state decoded from the backup.", + "importBackupNotAWalletBackup": "This does not look like a Minibits wallet backup.", + "importBackupPasteHint": "Paste the backup exported from your previous wallet.", + "importBackupPastePlaceholder": "Paste your backup", + "importBackupProfileRecoveryDesc": "While importing the wallet we'll try to recover the wallet address and avatar linked to the provided seed.", + "importBackupProfileRecoveryTitle": "Wallet profile recovery", + "importBackupTitle": "Import backup", + "importBackupWalletBackup": "Wallet backup", "importBackupWalletProfileNotFound": "Wallet profile linked to the provided seed not found, new one will be created for you.", "importBackupWalletRecovery": "Wallet recovery", "increaseRecoveryIndexes": "Increase recovery indexes", @@ -772,6 +809,7 @@ "transactionResult_onchainPaymentBroadcast": "Bitcoin payment has been broadcast. It will complete once confirmed on the blockchain.", "transactionResult_onchainPaymentConfirmed": "Bitcoin payment confirmed on the blockchain.", "transactionResult_onchainPaymentFailed": "Bitcoin payment failed. Reserved ecash has been returned to spendable balance.", + "transactionType_receiveImport": "Imported from backup", "transferScreen_donationSuccessMessage": "Donation for %{donationForName} has been successfully paid and your wallet address has been updated. Thank you!", "transferScreen_insufficientFunds": "There is not enough balance in %{currency} to pay the invoice amount and expected fees: %{amount} %{currency}", "transferScreen_LUD18unsupported": "Minibits does not yet support entering of payer identity data (LUD18).", diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json index 7d346ea3..0e795c04 100644 --- a/src/i18n_messages/es.json +++ b/src/i18n_messages/es.json @@ -9,6 +9,16 @@ "amountTopup": "Cantidad de recarga", "amountWithdraw": "Cantidad a retirar", "attention": "¡Atención!", + "backupCodec_decryptFailed": "No se pudo descifrar la copia de seguridad. Pertenece a otra frase semilla o se dañó por el camino.", + "backupCodec_encryptFailed": "No se pudo cifrar la copia de seguridad de la billetera: %{error}", + "backupCodec_incomplete": "La copia de seguridad está incompleta: puede que se cortara al copiarla.", + "backupCodec_missingSeedToDecrypt": "Falta la frase semilla con la que se cifró esta copia de seguridad.", + "backupCodec_missingSeedToEncrypt": "Falta la semilla de la billetera para cifrar la copia de seguridad.", + "backupCodec_notABackup": "Una copia de seguridad de Minibits empieza por '%{prefix}'.", + "backupCodec_unreadable": "No se pudo leer la copia de seguridad. Puede que se cortara al copiarla.", + "backupCodec_unsupportedVersion": "Esta copia de seguridad se creó con una versión más reciente de Minibits (formato %{version}). Actualiza la app para restaurarla.", + "backupCodec_verifyFailed": "La copia de seguridad se cifró pero no se pudo volver a leer, así que no se exportó.", + "backupCodec_verifyMismatch": "La copia de seguridad cifrada no se descifró de vuelta al estado de la billetera, así que no se exportó.", "backupDeletedSuccess": "Tu copia de seguridad de tokens minibits ha sido eliminada. Los nuevos tokens NO se respaldarán.", "backupInvalidMnemonicError": "Frase semilla corrupta o inválida. Por favor, mueve los fondos y reinstala esta billetera.", "backupLocalBackup": "Copia de seguridad local", @@ -166,14 +176,41 @@ "errorScreen_reset": "REINICIAR APLICACIÓN", "errorScreen_title": "¡Algo salió mal!", "exportBackup": "Exportar copia de seguridad", + "exportBackupContactsCount": "Número de contactos: %{count}", "exportBackupCopyBackup": "Exportar copia de seguridad", "exportBackupCopyOrphanedProofs": "Copiar pruebas huérfanas", "exportBackupEcashProofs": "Comprobantes de efectivo electrónico", + "exportBackupFailed": "No se pudo exportar la copia de seguridad de la billetera: %{error}", "exportBackupMints": "mints", + "exportBackupMintsCount": "Número de mints: %{count}", + "exportBackupMissingMnemonic": "Esta billetera no tiene frase semilla con la que cifrar la copia de seguridad.", + "exportBackupOptimize": "Optimizar", + "exportBackupOrphanedProofsFound": { + "one": "Se encontró %{count} prueba huérfana que no pertenece a ningún mint activo. No se incluirá en la copia de seguridad, pero puedes copiarla aparte.", + "other": "Se encontraron %{count} pruebas huérfanas que no pertenecen a ningún mint activo. No se incluirán en la copia de seguridad, pero puedes copiarlas aparte." + }, + "exportBackupProofsCount": "Número de pruebas: %{count}", + "exportBackupSeedHint": "Esta copia de seguridad está cifrada con tu frase semilla. Necesitarás esas palabras para restaurarla.", "exportBackupWalletTitle": "Copia de seguridad de la billetera", "factoryResetSuccess": "Restablecimiento de fábrica completado, reinicie inmediatamente.", "factoryResetUserConfirmDesc": "Se eliminarán todos los datos de la aplicación. ¿Seguro que quieres restablecer la configuración de fábrica?", + "importBackupConfirmEcash": "El ecash que ya está en esta billetera seguirá siendo gastable, pero dejará de poder recuperarse con una frase semilla. Anota tu frase semilla actual antes de continuar.", + "importBackupConfirmMerge": "Los mints y contactos de la copia de seguridad se añaden a esta billetera. No se elimina nada de lo que ya tienes aquí.", + "importBackupConfirmSeed": "Esta billetera pasará a usar la frase semilla que has introducido, y tu dirección Minibits será reemplazada por la vinculada a ella.", + "importBackupConfirmTitle": "Antes de importar", + "importBackupImportWallet": "Importar billetera", + "importBackupInserting": "Insertando la copia de seguridad...", "importBackupInsertWalletBackup": "Insertar copia de seguridad de la billetera", + "importBackupMissingBackupOrSeed": "Falta la copia de seguridad o la semilla.", + "importBackupMissingClipboard": "Copia y pega la copia de seguridad de la billetera.", + "importBackupMissingSnapshot": "Falta el estado de la billetera leído de la copia de seguridad.", + "importBackupNotAWalletBackup": "Esto no parece una copia de seguridad de una billetera Minibits.", + "importBackupPasteHint": "Pega la copia de seguridad exportada de tu billetera anterior.", + "importBackupPastePlaceholder": "Pega tu copia de seguridad", + "importBackupProfileRecoveryDesc": "Durante la importación intentaremos recuperar la dirección y el avatar de la billetera vinculados a la semilla proporcionada.", + "importBackupProfileRecoveryTitle": "Recuperación del perfil de la billetera", + "importBackupTitle": "Importar copia de seguridad", + "importBackupWalletBackup": "Copia de seguridad de la billetera", "importBackupWalletProfileNotFound": "No se encontró el perfil de billetera vinculado a la semilla proporcionada; se creará uno nuevo para usted.", "importBackupWalletRecovery": "Recuperación de billetera", "increaseRecoveryIndexes": "Aumentar los índices de recuperación", @@ -785,6 +822,7 @@ "transactionResult_onchainPaymentBroadcast": "El pago de Bitcoin ha sido transmitido. Se completará una vez confirmado en la blockchain.", "transactionResult_onchainPaymentConfirmed": "Pago de Bitcoin confirmado en la blockchain.", "transactionResult_onchainPaymentFailed": "El pago de Bitcoin falló. El ecash reservado ha sido devuelto al saldo disponible.", + "transactionType_receiveImport": "Importado de la copia de seguridad", "transferScreen_donationSuccessMessage": "La donación para %{donationForName} se ha realizado correctamente y la dirección de tu billetera se ha actualizado. ¡Gracias!", "transferScreen_insufficientFunds": "No hay suficiente saldo en %{currency} para pagar el importe de la factura y las tarifas previstas: %{amount} %{currency}", "transferScreen_LUD18unsupported": "Minibits aún no admite la introducción de datos de identidad del pagador (LUD18).", diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json index 136d138b..8ae0ffed 100644 --- a/src/i18n_messages/pt.json +++ b/src/i18n_messages/pt.json @@ -9,6 +9,16 @@ "amountTopup": "Valor da recarga", "amountWithdraw": "Valor do saque", "attention": "Atenção!", + "backupCodec_decryptFailed": "Não foi possível descriptografar o backup. Ele pertence a outra frase seed ou foi danificado no caminho.", + "backupCodec_encryptFailed": "Não foi possível criptografar o backup da carteira: %{error}", + "backupCodec_incomplete": "O backup está incompleto — pode ter sido cortado ao ser copiado.", + "backupCodec_missingSeedToDecrypt": "Falta a frase seed com a qual este backup foi criptografado.", + "backupCodec_missingSeedToEncrypt": "Falta a seed da carteira para criptografar o backup.", + "backupCodec_notABackup": "Um backup do Minibits começa com '%{prefix}'.", + "backupCodec_unreadable": "Não foi possível ler o backup. Pode ter sido cortado ao ser copiado.", + "backupCodec_unsupportedVersion": "Este backup foi feito por uma versão mais nova do Minibits (formato %{version}). Atualize o app para restaurá-lo.", + "backupCodec_verifyFailed": "O backup foi criptografado mas não pôde ser lido de volta, então não foi exportado.", + "backupCodec_verifyMismatch": "O backup criptografado não voltou ao estado da carteira ao ser descriptografado, então não foi exportado.", "backupDeletedSuccess": "Seu backup de tokens minibits foi excluído. Novos tokens NÃO serão salvos em backup.", "backupInvalidMnemonicError": "Frase seed corrompida ou inválida. Transfira os fundos e reinstale esta carteira.", "backupLocalBackup": "Backup local", @@ -166,14 +176,41 @@ "errorScreen_reset": "REINICIAR APLICATIVO", "errorScreen_title": "Algo deu errado!", "exportBackup": "Exportar backup", + "exportBackupContactsCount": "Número de contatos: %{count}", "exportBackupCopyBackup": "Exportar backup", "exportBackupCopyOrphanedProofs": "Copiar proofs órfãos", "exportBackupEcashProofs": "Proofs de ecash", + "exportBackupFailed": "Não foi possível exportar o backup da carteira: %{error}", "exportBackupMints": "Mints", + "exportBackupMintsCount": "Número de mints: %{count}", + "exportBackupMissingMnemonic": "Esta carteira não tem frase seed para criptografar o backup.", + "exportBackupOptimize": "Otimizar", + "exportBackupOrphanedProofsFound": { + "one": "Encontrado %{count} proof órfão que não pertence a nenhum mint ativo. Ele não entra no backup, mas você pode copiá-lo separadamente.", + "other": "Encontrados %{count} proofs órfãos que não pertencem a nenhum mint ativo. Eles não entram no backup, mas você pode copiá-los separadamente." + }, + "exportBackupProofsCount": "Número de proofs: %{count}", + "exportBackupSeedHint": "Este backup é criptografado com sua frase seed. Você precisará dessas palavras para restaurá-lo.", "exportBackupWalletTitle": "Backup de carteira", "factoryResetSuccess": "Redefinição de fábrica concluída, reinicie imediatamente.", "factoryResetUserConfirmDesc": "Todos os dados serão excluídos. Deseja redefinir para configurações de fábrica?", + "importBackupConfirmEcash": "O ecash que já está nesta carteira continua gastável, mas deixa de ser recuperável pela frase seed. Anote sua frase seed atual antes de continuar.", + "importBackupConfirmMerge": "Os mints e contatos do backup são adicionados a esta carteira. Nada do que você já tem aqui é removido.", + "importBackupConfirmSeed": "Esta carteira passará a usar a frase seed que você digitou, e seu endereço Minibits será substituído pelo vinculado a ela.", + "importBackupConfirmTitle": "Antes de importar", + "importBackupImportWallet": "Importar carteira", + "importBackupInserting": "Inserindo backup...", "importBackupInsertWalletBackup": "Inserir backup da carteira", + "importBackupMissingBackupOrSeed": "Falta o backup ou a seed.", + "importBackupMissingClipboard": "Copie e cole o backup da carteira.", + "importBackupMissingSnapshot": "Falta o estado da carteira lido do backup.", + "importBackupNotAWalletBackup": "Isto não parece um backup de carteira Minibits.", + "importBackupPasteHint": "Cole o backup exportado da sua carteira anterior.", + "importBackupPastePlaceholder": "Cole seu backup", + "importBackupProfileRecoveryDesc": "Durante a importação tentaremos recuperar o endereço e o avatar da carteira vinculados à seed fornecida.", + "importBackupProfileRecoveryTitle": "Recuperação do perfil da carteira", + "importBackupTitle": "Importar backup", + "importBackupWalletBackup": "Backup da carteira", "importBackupWalletProfileNotFound": "Perfil vinculado à seed fornecida não encontrado, novo será criado.", "importBackupWalletRecovery": "Recuperação de carteira", "increaseRecoveryIndexes": "Aumentar índices de recuperação", @@ -785,6 +822,7 @@ "transactionResult_onchainPaymentBroadcast": "O pagamento Bitcoin foi transmitido. Será concluído assim que confirmado na blockchain.", "transactionResult_onchainPaymentConfirmed": "Pagamento Bitcoin confirmado na blockchain.", "transactionResult_onchainPaymentFailed": "O pagamento Bitcoin falhou. O ecash reservado foi devolvido ao saldo disponível.", + "transactionType_receiveImport": "Importado do backup", "transferScreen_donationSuccessMessage": "Doação para %{donationForName} paga com sucesso e endereço atualizado. Obrigado!", "transferScreen_insufficientFunds": "Saldo insuficiente em %{currency} para pagar invoice e taxas: %{amount} %{currency}", "transferScreen_LUD18unsupported": "Minibits ainda não suporta dados de identidade do pagador (LUD18).", diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json index 4fdc625d..eb3ac2d8 100644 --- a/src/i18n_messages/sk.json +++ b/src/i18n_messages/sk.json @@ -9,6 +9,16 @@ "amountTopup": "Pridať sumu", "amountWithdraw": "Vybrať sumu", "attention": "Pozor!", + "backupCodec_decryptFailed": "Zálohu sa nepodarilo dešifrovať. Patrí k inej mnemonickej vete alebo sa cestou poškodila.", + "backupCodec_encryptFailed": "Zálohu peňaženky sa nepodarilo zašifrovať: %{error}", + "backupCodec_incomplete": "Záloha je neúplná — pri kopírovaní mohla byť skrátená.", + "backupCodec_missingSeedToDecrypt": "Chýba mnemonická veta, ktorou je táto záloha zašifrovaná.", + "backupCodec_missingSeedToEncrypt": "Chýba seed kľúč peňaženky na zašifrovanie zálohy.", + "backupCodec_notABackup": "Záloha Minibits začína reťazcom '%{prefix}'.", + "backupCodec_unreadable": "Zálohu sa nepodarilo prečítať. Pri kopírovaní mohla byť skrátená.", + "backupCodec_unsupportedVersion": "Táto záloha bola vytvorená novšou verziou Minibits (formát %{version}). Na jej obnovenie aktualizuj aplikáciu.", + "backupCodec_verifyFailed": "Záloha sa zašifrovala, ale nedala sa spätne prečítať, preto nebola exportovaná.", + "backupCodec_verifyMismatch": "Zašifrovaná záloha sa nedešifrovala späť na stav peňaženky, preto nebola exportovaná.", "backupDeletedSuccess": "Záloha bola vymazaná.", "backupInvalidMnemonicError": "Poškodená alebo neplatná mnemonická veta. Reinštaluj aplikáciu.", "backupLocalBackup": "Lokálna záloha", @@ -166,14 +176,41 @@ "errorScreen_reset": "RESET APP", "errorScreen_title": "Niečo sa pokazilo!", "exportBackup": "Exportovať zálohu", + "exportBackupContactsCount": "Počet kontaktov: %{count}", "exportBackupCopyBackup": "Exportovať zálohu", "exportBackupCopyOrphanedProofs": "Kopírovať osirelé dôkazy", "exportBackupEcashProofs": "Ecash dôkazy", + "exportBackupFailed": "Zálohu peňaženky sa nepodarilo exportovať: %{error}", "exportBackupMints": "Minty", + "exportBackupMintsCount": "Počet mintov: %{count}", + "exportBackupMissingMnemonic": "Táto peňaženka nemá mnemonickú vetu, ktorou by sa dala záloha zašifrovať.", + "exportBackupOptimize": "Optimalizovať", + "exportBackupOrphanedProofsFound": { + "one": "Našiel sa %{count} osirelý dôkaz, ktorý nepatrí žiadnemu aktívnemu mintu. Do zálohy sa nedostane, ale môžeš ho skopírovať samostatne.", + "other": "Našli sa osirelé dôkazy (%{count}), ktoré nepatria žiadnemu aktívnemu mintu. Do zálohy sa nedostanú, ale môžeš ich skopírovať samostatne." + }, + "exportBackupProofsCount": "Počet dôkazov: %{count}", + "exportBackupSeedHint": "Táto záloha je zašifrovaná tvojou mnemonickou vetou. Na obnovenie budeš potrebovať tie slová.", "exportBackupWalletTitle": "Záloha peňaženky", "factoryResetSuccess": "Factory reset ukončený, ihneď reštartuj aplikáciu.", "factoryResetUserConfirmDesc": "Všetky dáta aplikácie budú vymazané. Si si istý, že chceš pokračovať?", + "importBackupConfirmEcash": "Ecash, ktorý už v tejto peňaženke máš, zostane použiteľný, ale nebude sa dať obnoviť z mnemonickej vety. Než budeš pokračovať, zapíš si súčasnú mnemonickú vetu.", + "importBackupConfirmMerge": "Minty a kontakty zo zálohy sa do tejto peňaženky pridajú. Nič, čo tu už máš, sa neodstráni.", + "importBackupConfirmSeed": "Peňaženka začne používať zadanú mnemonickú vetu a tvoju Minibits adresu nahradí adresa priradená k tejto vete.", + "importBackupConfirmTitle": "Než začneš s importom", + "importBackupImportWallet": "Importovať peňaženku", + "importBackupInserting": "Vkladá sa záloha...", "importBackupInsertWalletBackup": "Vložiť zálohu peňaženky", + "importBackupMissingBackupOrSeed": "Chýba záloha alebo seed kľúč.", + "importBackupMissingClipboard": "Skopíruj a vlož zálohu peňaženky.", + "importBackupMissingSnapshot": "Chýba stav peňaženky načítaný zo zálohy.", + "importBackupNotAWalletBackup": "Toto nevyzerá ako záloha peňaženky Minibits.", + "importBackupPasteHint": "Vlož zálohu exportovanú z predchádzajúcej peňaženky.", + "importBackupPastePlaceholder": "Vlož svoju zálohu", + "importBackupProfileRecoveryDesc": "Počas importu peňaženky sa pokúsime obnoviť adresu a avatara priradené k zadanému seed kľúču.", + "importBackupProfileRecoveryTitle": "Obnova profilu peňaženky", + "importBackupTitle": "Import zálohy", + "importBackupWalletBackup": "Záloha peňaženky", "importBackupWalletProfileNotFound": "Profil peňaženky pripojený k poskytnutému seedu nebol nájdený, nový bude vytvorený pre vás.", "importBackupWalletRecovery": "Obnova peňaženky", "increaseRecoveryIndexes": "Navýšiť indexy pre obnovenie", @@ -785,6 +822,7 @@ "transactionResult_onchainPaymentBroadcast": "Bitcoinová platba bola odoslaná do siete. Dokončí sa po potvrdení v blockchaine.", "transactionResult_onchainPaymentConfirmed": "Bitcoinová platba bola potvrdená v blockchaine.", "transactionResult_onchainPaymentFailed": "Bitcoinová platba zlyhala. Rezervovaný ecash bol vrátený do disponibilného zostatku.", + "transactionType_receiveImport": "Importované zo zálohy", "transferScreen_donationSuccessMessage": "Dar pre %{donationForName} bol úspešne zaplatený a vaša adresa peňaženky bola aktualizovaná. Ďakujeme!", "transferScreen_insufficientFunds": "Nie je dostatočný zostatok %{currency} na zaplatenie sumy invoice a poplatku: %{amount} %{currency}", "transferScreen_LUD18unsupported": "Minibits ešte nepodporuje zadanie údajov platiteľa (LUD1á)", diff --git a/src/models/AuthStore.ts b/src/models/AuthStore.ts index 467da494..4979ee65 100644 --- a/src/models/AuthStore.ts +++ b/src/models/AuthStore.ts @@ -2,7 +2,7 @@ import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree' import {JwtTokens, KeyChain, log, MinibitsClient, NostrEvent, NostrKeyPair, NostrUnsignedEvent} from '../services' import AppError, { Err } from '../utils/AppError' import { finalizeEvent, getEventHash, verifyEvent } from 'nostr-tools/pure' -import { hexToBytes } from '@noble/hashes/utils' +import { hexToBytes } from '@noble/hashes/utils.js' import { decodeJwtExpiry } from '../utils/authUtils' export type AuthState = { diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 8cabed7b..b982ba67 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -323,11 +323,32 @@ import { }, // Import proofs from backup without validation or side effects - importProofs(proofs: Proof[]) { - for (const proof of proofs) { + /** + * Add a backup's proofs to the wallet's own. + * + * A secret already here is SKIPPED, not overwritten: the local copy carries + * this device's state and transaction id, and a backup — which may be + * months old — must not reset a proof to how it looked then. + * + * @returns the proofs actually added, which is what the import writes its + * RECEIVE_IMPORT transactions from. Counting the whole input instead would + * claim ecash the wallet already had. + */ + importProofs(proofs: Proof[]): Proof[] { + const added: Proof[] = [] + + for (const proof of proofs ?? []) { + if (!proof?.secret || self.proofs.has(proof.secret)) continue + self.proofs.put(ProofModel.create(proof)) + + const instance = self.proofs.get(proof.secret) + if (instance) added.push(instance) } - log.trace('[importProofs]', `Imported ${proofs.length} proofs from backup`) + + log.trace('[importProofs]', `Imported ${added.length} of ${proofs?.length ?? 0} proofs from backup`) + + return added }, // ───────────────────────────────────────────────────────────── diff --git a/src/models/Transaction.ts b/src/models/Transaction.ts index bcd3b98d..c2d16e7e 100644 --- a/src/models/Transaction.ts +++ b/src/models/Transaction.ts @@ -31,6 +31,17 @@ export enum TransactionType { * whatever was actually minted. See onchainQuotesRepo. */ TOPUP_ONCHAIN = 'TOPUP_ONCHAIN', + /** + * Ecash restored from a wallet backup. + * + * Not a payment: nothing moved at the mint, the proofs were already signed and + * simply arrived on this device. It exists so the history still explains the + * balance — without it, importing a backup raises the balance out of nowhere + * and every later total is unaccountable. One per (mint, unit) actually + * imported, always COMPLETED: proofs that are already in hand cannot fail, and + * a failed import writes no transaction at all rather than an ERROR one. + */ + RECEIVE_IMPORT = 'RECEIVE_IMPORT', /** * Melt to a Bitcoin onchain address (NUT-30). * @@ -57,6 +68,7 @@ export const INCOMING_TRANSACTION_TYPES: TransactionType[] = [ TransactionType.RECEIVE_OFFLINE, TransactionType.RECEIVE_NOSTR, TransactionType.RECEIVE_BY_PAYMENT_REQUEST, + TransactionType.RECEIVE_IMPORT, TransactionType.TOPUP, TransactionType.TOPUP_ONCHAIN, ] diff --git a/src/screens/ExportBackupScreen.tsx b/src/screens/ExportBackupScreen.tsx index 2cd1529a..dac91804 100644 --- a/src/screens/ExportBackupScreen.tsx +++ b/src/screens/ExportBackupScreen.tsx @@ -80,7 +80,7 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) { if (orphaned.length > 0) { setOrphanedProofs(orphaned) - setInfo(`Found ${orphaned.length} orphaned proofs not belonging to any active mint. Those won't be included to the backup, but you can copy them separately.`) + setInfo(translate('exportBackupOrphanedProofsFound', {count: orphaned.length})) } // log.trace('[loadProofs]', {refreshedProofs: proofsStore.proofs}) @@ -182,10 +182,7 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) { const mnemonic: string = await walletStore.getCachedMnenomic() if(!mnemonic) { - throw new AppError( - Err.VALIDATION_ERROR, - 'This wallet has no seed phrase to encrypt the backup with.', - ) + throw new AppError(Err.VALIDATION_ERROR, translate('exportBackupMissingMnemonic')) } const encodedBackup = encodeBackup(exportedSnapshot, mnemonicToSeedSync(mnemonic)) @@ -199,7 +196,7 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) { } catch (e: any) { // Everything the codec raises is already written for the user; only an // unexpected failure (the share sheet, the keychain) needs framing. - setInfo(e instanceof AppError ? e.message : `Could not export the wallet backup: ${e.message}`) + setInfo(e instanceof AppError ? e.message : translate('exportBackupFailed', {error: e.message})) setIsLoading(false) } } @@ -335,7 +332,7 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) { {totalProofsCount > 0 && ( {proofsStore.proofsCount > OPTIMIZE_FROM_PROOFS_COUNT && ( @@ -344,7 +341,7 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) { onPress={() => navigation.navigate('OptimizeEcash')} textStyle={{lineHeight: verticalScale(16), fontSize: verticalScale(14)}} style={{minHeight: verticalScale(40), paddingVertical: verticalScale(spacing.tiny), marginRight: spacing.tiny, marginTop: -spacing.tiny}} - text={'Optimize'} + tx="exportBackupOptimize" /> )} 0 && ( 0 && ( diff --git a/src/screens/ImportBackupScreen.tsx b/src/screens/ImportBackupScreen.tsx index d551dabb..9eac1056 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -3,7 +3,7 @@ import React, {FC, useEffect, useRef, useState} from 'react' import {LayoutAnimation, Platform, ScrollView, TextInput, TextStyle, UIManager, View, ViewStyle} from 'react-native' import {validateMnemonic} from '@scure/bip39' import QuickCrypto from 'react-native-quick-crypto' -import { wordlist } from '@scure/bip39/wordlists/english' +import { wordlist } from '@scure/bip39/wordlists/english.js' import { mnemonicToSeedSync } from '@scure/bip39' import {colors, spacing, typography, useThemeColor} from '../theme' import { @@ -16,7 +16,8 @@ import { ErrorModal, InfoModal, Button, - Header, + Header, + BottomModal, } from '../components' import AppError, { Err } from '../utils/AppError' import { Database, KeyChain, log } from '../services' @@ -34,6 +35,9 @@ import { Mint as CashuMint, GetKeysResponse } from '@cashu/cashu-ts' import { StaticScreenProps, useNavigation } from '@react-navigation/native' import { Proof } from '../models/Proof' import { decodeBackup } from '../services/backup/backupCodec' +import { groupImportedProofs } from '../services/backup/importSummary' +import { TransactionStatus, TransactionType } from '../models/Transaction' +import { ResultModalInfo } from './Wallet/ResultModalInfo' type Props = StaticScreenProps @@ -45,6 +49,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } contactsStore, walletProfileStore, walletStore, + transactionsStore, authStore } = useStores() @@ -66,6 +71,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState() const [statusMessage, setStatusMessage] = useState() + const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false) const onBack = () => { @@ -104,12 +110,12 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } const onPasteBackup = async function () { try { - setStatusMessage('Inserting backup...') + setStatusMessage(translate('importBackupInserting')) setIsLoading(true) const maybeBackup = await Clipboard.getString() if(!maybeBackup) { - throw new AppError(Err.VALIDATION_ERROR, 'Copy and paste the wallet backup.') + throw new AppError(Err.VALIDATION_ERROR, translate('importBackupMissingClipboard')) } const cleaned = maybeBackup.trim() @@ -141,7 +147,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } } if(!snapshot?.proofsStore || !snapshot?.mintsStore || !snapshot?.contactsStore) { - throw new AppError(Err.VALIDATION_ERROR, 'This does not look like a Minibits wallet backup.') + throw new AppError(Err.VALIDATION_ERROR, translate('importBackupNotAWalletBackup')) } return snapshot @@ -153,7 +159,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } // seedRef, not just seedHashRef: an encrypted backup cannot be opened // without the seed itself. if(!backup || !seedRef.current) { - throw new AppError(Err.VALIDATION_ERROR, 'Missing backup or seed.') + throw new AppError(Err.VALIDATION_ERROR, translate('importBackupMissingBackupOrSeed')) } LayoutAnimation.easeInEaseOut() @@ -168,10 +174,99 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } } + const toggleConfirmModal = () => + setIsConfirmModalVisible(previousState => !previousState) + + + const onConfirmImport = function () { + setIsConfirmModalVisible(false) + importWallet() + } + + + /** + * What the import is about to do that cannot be undone. + * + * Mints and contacts are merged, so nothing already in this wallet is lost — + * the seed is the exception, and it is the whole reason this modal exists. The + * ecash clause is conditional because it is noise on the flow that brings most + * people here: a fresh install with nothing in it yet. + */ + const confirmMessage = function () { + const holdsEcash = proofsStore.proofsCount > 0 || proofsStore.pendingProofsCount > 0 + + return [ + translate('importBackupConfirmSeed'), + holdsEcash ? translate('importBackupConfirmEcash') : undefined, + translate('importBackupConfirmMerge'), + ] + .filter(Boolean) + .join('\n\n') + } + + + /** + * Write one COMPLETED RECEIVE_IMPORT transaction per (mint, unit) restored. + * + * Without it the balance jumps and the history cannot explain it — every total + * after an import would be unaccountable. Only what was actually ADDED counts: + * proofs the wallet already held are not ecash the import brought. + * + * There is deliberately NO failure path. The status is always COMPLETED, + * because the proofs are already in hand and nothing at the mint can refuse + * them; and if writing the history fails, that is logged and the import + * carries on — the ecash is restored either way, and an ERROR transaction + * would claim a loss that did not happen. + */ + const addImportTransactions = async function (importedProofs: Proof[]) { + try { + for (const group of groupImportedProofs(importedProofs)) { + const transactionData = [{ + status: TransactionStatus.COMPLETED, + importedAmount: group.amount, + importedProofsCount: group.proofs.length, + mintUrl: group.mintUrl, + unit: group.unit, + createdAt: new Date(), + }] + + const transaction = await transactionsStore.addTransaction({ + type: TransactionType.RECEIVE_IMPORT, + amount: group.amount, + fee: 0, + unit: group.unit, + data: JSON.stringify(transactionData), + mint: group.mintUrl, + status: TransactionStatus.COMPLETED, + // The unit total once everything the import brought is in — the same + // reading every other flow records here. + balanceAfter: proofsStore.getUnitBalance(group.unit)?.unitBalance, + }) + + for (const proof of group.proofs) { + proof.setTransactionId(transaction.id) + } + + log.info('[addImportTransactions]', 'Recorded imported ecash', { + transactionId: transaction.id, + mintUrl: group.mintUrl, + unit: group.unit, + amount: group.amount, + }) + } + } catch (e: any) { + // Never fatal: the ecash is restored whether or not its history is. + log.error('[addImportTransactions]', 'Could not record the imported ecash in the history', { + error: e?.message, + }) + } + } + + const importWallet = async function () { try { if(!walletSnapshot) { - throw new AppError(Err.VALIDATION_ERROR, 'Missing wallet spnapshot decoded from backup.') + throw new AppError(Err.VALIDATION_ERROR, translate('importBackupMissingSnapshot')) } setStatusMessage(translate("recovery_starting")) @@ -236,7 +331,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } } } - proofsStore.importProofs(walletSnapshot.proofsStore.proofs) + const importedProofs = proofsStore.importProofs(walletSnapshot.proofsStore.proofs) proofsStore.importPendingByMintSecrets(walletSnapshot.proofsStore.pendingByMintSecrets) // Merged, not applied: an applySnapshot here used to discard every contact // added on this device, and a contact list has no second copy anywhere. @@ -266,6 +361,12 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } log.trace('After import and mint keys hydration', {mintsStore}) + // Record the restored ecash in the history, BEFORE the proofs are written: + // each one is then stamped with the id of the transaction that accounts for + // it, so an imported proof references a transaction that exists in THIS + // wallet rather than one left behind on the other device. + await addImportTransactions(importedProofs) + // import proofs into the db if(proofsStore.proofsCount > 0) { Database.addOrUpdateProofs(proofsStore.allProofs, 'UNSPENT') @@ -374,7 +475,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } @@ -393,7 +494,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } ContentComponent={ } style={$item} /> @@ -408,7 +509,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } multiline={true} autoCapitalize='none' keyboardType='default' - placeholder={'Paste your backup'} + placeholder={translate('importBackupPastePlaceholder')} placeholderTextColor={placeholderTextColor} selectTextOnFocus={true} style={[$backupInput, {backgroundColor: inputBg, flexWrap: 'wrap', color: inputText}]} @@ -436,7 +537,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } style={$card} ContentComponent={ } @@ -450,8 +551,8 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } style={$card} ContentComponent={ } style={$item} /> @@ -465,20 +566,47 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } <> {isValidBackup && (