Add transaction item on completed import, fix bad imports across the repo

This commit is contained in:
minibits-cash
2026-09-07 17:31:16 +02:00
parent 32b0b029fb
commit 2bd9f05cb6
24 changed files with 580 additions and 71 deletions
+26
View File
@@ -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}),
}
+92 -1
View File
@@ -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<str
if (resolvedUrl && resolvedUrl !== proof.mintUrl) proof.mintUrl = resolvedUrl
}
root.proofsStore.importProofs(backup.proofsStore.proofs)
const importedProofs = root.proofsStore.importProofs(backup.proofsStore.proofs)
root.proofsStore.importPendingByMintSecrets(backup.proofsStore.pendingByMintSecrets)
root.contactsStore.mergeFromBackup(backup.contactsStore)
@@ -139,6 +140,9 @@ const importBackup = (root: Instance, encoded: string, keysByMintUrl: Record<str
if (root.proofsStore.pendingProofsCount > 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([])
})
})
+13 -4
View File
@@ -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$': '<rootDir>/__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(/.*)?$': '<rootDir>/__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$': '<rootDir>/__mocks__/react-native-localize.js',
},
}
+38
View File
@@ -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).",
+38
View File
@@ -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).",
+38
View File
@@ -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).",
+38
View File
@@ -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á)",
+1 -1
View File
@@ -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 = {
+24 -3
View File
@@ -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
},
// ─────────────────────────────────────────────────────────────
+12
View File
@@ -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,
]
+8 -11
View File
@@ -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 && (
<ListItem
tx="exportBackupEcashProofs"
subText={`Number of proofs: ${proofsStore.proofsCount}`}
subText={translate('exportBackupProofsCount', {count: proofsStore.proofsCount})}
RightComponent={
<View style={$rightContainer}>
{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"
/>
)}
<Switch
@@ -358,7 +355,7 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
{mintsStore.mintCount > 0 && (
<ListItem
tx="exportBackupMints"
subText={`Number of mints: ${mintsStore.mintCount}`}
subText={translate('exportBackupMintsCount', {count: mintsStore.mintCount})}
RightComponent={
<View style={$rightContainer}>
<Switch
@@ -373,7 +370,7 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
{contactsStore.count > 0 && (
<ListItem
tx="contacts"
subText={`Number of contacts: ${contactsStore.count}`}
subText={translate('exportBackupContactsCount', {count: contactsStore.count})}
RightComponent={
<View style={$rightContainer}>
<Switch
@@ -404,7 +401,7 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
style={{color: hint}}
size='xs'
preset='formHelper'
text='This backup is encrypted with your seed phrase. You will need those words to restore it.'
tx="exportBackupSeedHint"
/>
</View>
<View style={$buttonContainer}>
+143 -15
View File
@@ -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 {
@@ -17,6 +17,7 @@ import {
InfoModal,
Button,
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<undefined>
@@ -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<AppError | undefined>()
const [statusMessage, setStatusMessage] = useState<string>()
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 }
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text
preset="heading"
text={"Import backup"}
tx="importBackupTitle"
style={{color: headerTitle, textAlign: 'center'}}
/>
</View>
@@ -393,7 +494,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
ContentComponent={
<ListItem
tx="importBackupInsertWalletBackup"
subText={'Paste the backup exported from previous wallet.'}
subTx="importBackupPasteHint"
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text='2'/></View>}
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={
<ListItem
text='Wallet backup'
tx="importBackupWalletBackup"
subText={`${backup.slice(0, 50)}...`}
subTextStyle={{fontFamily: typography.code?.normal}}
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text='2'/></View>}
@@ -450,8 +551,8 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
style={$card}
ContentComponent={
<ListItem
text={'Wallet profile recovery'}
subText="While importing the wallet we'll try to recover wallet address and avatar linked to the provided seed."
tx="importBackupProfileRecoveryTitle"
subTx="importBackupProfileRecoveryDesc"
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text={'3'}/></View>}
style={$item}
/>
@@ -465,20 +566,47 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
<>
{isValidBackup && (
<Button
text={`Import wallet`}
tx="importBackupImportWallet"
LeftAccessory={() => (
<Icon
icon='faDownload'
size={spacing.medium}
/>
)}
onPress={importWallet}
onPress={toggleConfirmModal}
/>
)}
</>
</View>
</View>
)}
<BottomModal
isVisible={isConfirmModalVisible}
ContentComponent={
<>
<ResultModalInfo
icon='faTriangleExclamation'
iconColor={colors.palette.accent300}
title={translate('importBackupConfirmTitle')}
message={confirmMessage()}
/>
<View style={$buttonContainer}>
<Button
tx="importBackupImportWallet"
onPress={onConfirmImport}
style={{marginRight: spacing.small}}
/>
<Button
preset='secondary'
tx='commonCancel'
onPress={toggleConfirmModal}
/>
</View>
</>
}
onBackButtonPress={toggleConfirmModal}
onBackdropPress={toggleConfirmModal}
/>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
{isLoading && <Loading statusMessage={statusMessage} textStyle={{color: 'white'}} style={{backgroundColor: headerBg, opacity: 1}}/>}
+1 -1
View File
@@ -2,7 +2,7 @@ import {observer} from 'mobx-react-lite'
import React, {FC, useEffect, useState} from 'react'
import {FlatList, TextStyle, View, ViewStyle} from 'react-native'
import {validateMnemonic} from '@scure/bip39'
import { wordlist } from '@scure/bip39/wordlists/english'
import { wordlist } from '@scure/bip39/wordlists/english.js'
import {colors, spacing, useThemeColor} from '../theme'
import {
+1 -1
View File
@@ -2,7 +2,7 @@ import {observer} from 'mobx-react-lite'
import React, {FC, useRef, useState} from 'react'
import {Image, LayoutAnimation, Platform, ScrollView, TextInput, TextStyle, UIManager, View, ViewStyle} from 'react-native'
import {getPublicKey} from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils.js'
import {colors, spacing, typography, useThemeColor} from '../theme'
import {Icon, ListItem, Screen, Text, Card, BottomModal, Button, InfoModal, ErrorModal, Loading} from '../components'
import {useHeader} from '../utils/useHeader'
+1 -1
View File
@@ -3,7 +3,7 @@ import React, { 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, useThemeColor } from '../theme'
import { Icon, ListItem, Screen, Text, Card, Loading, ErrorModal, Button } from '../components'
+1 -1
View File
@@ -3,7 +3,7 @@ import React, {FC, useEffect, useRef, useState} from 'react'
import {FlatList, LayoutAnimation, Platform, Pressable, ScrollView, Switch, 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 {colors, spacing, useThemeColor} from '../theme'
import {
Icon,
+7 -1
View File
@@ -229,7 +229,13 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
if (!transaction) { return '' }
switch (transaction?.type) {
case TransactionType.RECEIVE || TransactionType.RECEIVE_OFFLINE || TransactionType.RECEIVE_BY_PAYMENT_REQUEST:
// Stacked labels, not `A || B || C` — that expression evaluates to A
// alone, so offline and payment-request receives fell through to the
// default and showed their amount with no sign at all.
case TransactionType.RECEIVE:
case TransactionType.RECEIVE_OFFLINE:
case TransactionType.RECEIVE_BY_PAYMENT_REQUEST:
case TransactionType.RECEIVE_IMPORT:
return `+${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.SEND:
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
@@ -100,6 +100,9 @@ export const TransactionListItem = observer(function (props: TransactionListProp
: translate('transactionCommon_youPaid'))
// Onchain has no contact/profile to name — the counterparty is an address,
// and for a topup it may not even be known (anyone can pay it).
// No counterparty to name: the ecash came from the user's own backup.
case TransactionType.RECEIVE_IMPORT:
return tx.memo ? tx.memo : translate('transactionType_receiveImport')
case TransactionType.TOPUP_ONCHAIN:
return tx.memo ? tx.memo : translate('transactionType_topupOnchain')
case TransactionType.TRANSFER_ONCHAIN:
@@ -183,6 +186,10 @@ export const TransactionListItem = observer(function (props: TransactionListProp
return (<Icon containerStyle={$txIconContainer} icon="faArrowTurnDown" size={spacing.medium} color={txReceiveColor}/>)
}
if([TransactionType.RECEIVE_IMPORT].includes(tx.type)) {
return (<Icon containerStyle={$txIconContainer} icon="faDownload" size={spacing.medium} color={txReceiveColor}/>)
}
if([TransactionType.RECEIVE_OFFLINE].includes(tx.type)) {
return (<Icon containerStyle={$txIconContainer} icon="faArrowTurnDown" size={spacing.medium} color={txPendingColor}/>)
}
@@ -251,7 +258,7 @@ export const TransactionListItem = observer(function (props: TransactionListProp
LeftComponent={getLeftIcon(tx)}
RightComponent={
<View style={$txContainer}>
{([TransactionType.RECEIVE, TransactionType.RECEIVE_OFFLINE, TransactionType.RECEIVE_BY_PAYMENT_REQUEST].includes(tx.type)) && (
{([TransactionType.RECEIVE, TransactionType.RECEIVE_OFFLINE, TransactionType.RECEIVE_BY_PAYMENT_REQUEST, TransactionType.RECEIVE_IMPORT].includes(tx.type)) && (
<>
{[TransactionStatus.COMPLETED, TransactionStatus.RECOVERED].includes(tx.status) && (
<CurrencyAmount
+19 -22
View File
@@ -31,6 +31,7 @@
import QuickCrypto from 'react-native-quick-crypto'
import AppError, {Err} from '../../utils/AppError'
import {log} from '../logService'
import {translate} from '../../i18n'
const PREFIX = 'minibits'
/** Plaintext JSON. Decoded, never produced. */
@@ -52,10 +53,10 @@ const HKDF_INFO = 'minibits/backup/v1'
const CIPHER = 'aes-256-gcm'
/**
* Every failure in here is shown to the user as-is, so each message is a whole
* sentence naming what went wrong and, where there is one, what to do about it.
* AppError also carries them to Sentry — a wallet that cannot restore its backup
* is exactly what we want to hear about.
* Every failure in here is shown to the user as-is, so each message is a whole,
* translated sentence naming what went wrong and, where there is one, what to do
* about it. AppError also carries it to Sentry — a wallet that cannot restore its
* backup is exactly what we want to hear about.
*/
const backupError = (message: string, params?: Record<string, any>) =>
new AppError(Err.VALIDATION_ERROR, message, params)
@@ -100,7 +101,7 @@ const deriveBackupKey = function (seed: Uint8Array, salt: Uint8Array) {
*/
export const encodeBackup = function (payload: unknown, seed: Uint8Array): string {
if (!seed || seed.length === 0) {
throw backupError('Missing the wallet seed to encrypt the backup with.', {
throw backupError(translate('backupCodec_missingSeedToEncrypt'), {
caller: 'encodeBackup',
})
}
@@ -118,7 +119,7 @@ export const encodeBackup = function (payload: unknown, seed: Uint8Array): strin
encoded = PREFIX + VERSION_ENCRYPTED + Buffer.concat([salt, iv, tag, ciphertext]).toString('base64')
} catch (e: any) {
throw backupError(`Could not encrypt the wallet backup: ${e.message}`, {
throw backupError(translate('backupCodec_encryptFailed', {error: e.message}), {
caller: 'encodeBackup',
})
}
@@ -128,14 +129,14 @@ export const encodeBackup = function (payload: unknown, seed: Uint8Array): strin
try {
verified = decodeBackup(encoded, seed)
} catch (e: any) {
throw backupError('The backup was encrypted but could not be read back, so it was not exported.', {
throw backupError(translate('backupCodec_verifyFailed'), {
caller: 'encodeBackup',
error: e.message,
})
}
if (!Buffer.from(JSON.stringify(verified), 'utf8').equals(plaintext)) {
throw backupError('The encrypted backup did not decrypt back to the wallet state, so it was not exported.', {
throw backupError(translate('backupCodec_verifyMismatch'), {
caller: 'encodeBackup',
})
}
@@ -155,7 +156,7 @@ const parsePayload = function (json: string): unknown {
try {
return JSON.parse(json)
} catch (e: any) {
throw backupError('The backup could not be read. It may have been cut short when it was copied.', {
throw backupError(translate('backupCodec_unreadable'), {
caller: 'decodeBackup',
error: e.message,
})
@@ -167,7 +168,9 @@ const parseEnvelope = function (backup: string): {version: string; body: string}
const trimmed = (backup ?? '').trim()
if (!trimmed.startsWith(PREFIX) || trimmed.length <= PREFIX.length) {
throw backupError(`A Minibits backup starts with '${PREFIX}'.`, {caller: 'decodeBackup'})
throw backupError(translate('backupCodec_notABackup', {prefix: PREFIX}), {
caller: 'decodeBackup',
})
}
return {
@@ -195,14 +198,13 @@ export const decodeBackup = function (backup: string, seed: Uint8Array): unknown
}
if (version !== VERSION_ENCRYPTED) {
throw backupError(
`This backup was made by a newer version of Minibits (format ${version}). Update the app to restore it.`,
{caller: 'decodeBackup'},
)
throw backupError(translate('backupCodec_unsupportedVersion', {version}), {
caller: 'decodeBackup',
})
}
if (!seed || seed.length === 0) {
throw backupError('Missing the seed phrase this backup was encrypted with.', {
throw backupError(translate('backupCodec_missingSeedToDecrypt'), {
caller: 'decodeBackup',
})
}
@@ -210,9 +212,7 @@ export const decodeBackup = function (backup: string, seed: Uint8Array): unknown
const envelope = Buffer.from(body, 'base64')
if (envelope.length <= SALT_BYTES + IV_BYTES + TAG_BYTES) {
throw backupError('The backup is incomplete — it may have been cut short when it was copied.', {
caller: 'decodeBackup',
})
throw backupError(translate('backupCodec_incomplete'), {caller: 'decodeBackup'})
}
const salt = envelope.subarray(0, SALT_BYTES)
@@ -229,10 +229,7 @@ export const decodeBackup = function (backup: string, seed: Uint8Array): unknown
} catch (e: any) {
// GCM cannot tell a wrong key from altered bytes — both fail the same tag
// check — so the message names both, in the order the user should check.
throw backupError(
'Could not decrypt the backup. It belongs to a different seed phrase, or it was damaged in transit.',
{caller: 'decodeBackup'},
)
throw backupError(translate('backupCodec_decryptFailed'), {caller: 'decodeBackup'})
}
return parsePayload(plaintext.toString('utf8'))
+62
View File
@@ -0,0 +1,62 @@
import {MintUnit} from '../wallet/currency'
/**
* The shape this needs from a proof. Structural rather than the Proof model, so
* the helper stays a pure function over plain data and can be tested without a
* store — it is called with live MST instances, which satisfy it.
*/
export type SummarizableProof = {
mintUrl: string
unit: MintUnit
amount: number
state?: string
}
export type ImportedProofsGroup<T extends SummarizableProof = SummarizableProof> = {
mintUrl: string
unit: MintUnit
/** Spendable amount restored — what the RECEIVE_IMPORT transaction records. */
amount: number
proofs: T[]
}
/**
* Group freshly imported proofs into the transactions that should record them.
*
* BY MINT AND UNIT, not by unit alone. A transaction names the mint it happened
* at (`Transaction.mint` is required, and `mintId` is resolved from it), and the
* balance the history has to explain is itself per mint per unit — so a
* unit-only grouping would have to attribute the ecash of several mints to one
* arbitrary url, which is precisely the mis-attribution `mintId` exists to stop.
*
* Only UNSPENT proofs are counted. The transaction's job is to account for the
* balance that appeared, and PENDING proofs — locked in an operation the other
* device had in flight — are not part of it. They still arrive and are still
* written; they are simply not what this transaction claims. A proof with no
* state predates the state column and is spendable (see ProofModel's default).
*
* Groups come back ordered by mint then unit, so the transactions a single
* import writes land in a stable order rather than whatever the proofs happened
* to be in.
*/
export const groupImportedProofs = function <T extends SummarizableProof>(
proofs: T[],
): ImportedProofsGroup<T>[] {
const groups = new Map<string, ImportedProofsGroup<T>>()
for (const proof of proofs ?? []) {
if (!proof?.mintUrl) continue
if (proof.state && proof.state !== 'UNSPENT') continue
const key = `${proof.mintUrl}|${proof.unit}`
const group = groups.get(key) ?? {mintUrl: proof.mintUrl, unit: proof.unit, amount: 0, proofs: []}
group.amount += Number(proof.amount)
group.proofs.push(proof)
groups.set(key, group)
}
return Array.from(groups.values())
.filter(group => group.amount > 0)
.sort((a, b) => a.mintUrl.localeCompare(b.mintUrl) || String(a.unit).localeCompare(String(b.unit)))
}
+1 -1
View File
@@ -19,7 +19,7 @@ import type {
SerializedBlindedSignature,
HasKeysetKeys,
} from '@cashu/cashu-ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import AppError, {Err} from '../../utils/AppError'
import { getTokenMetadata } from '@cashu/cashu-ts'
import {Proof} from '../../models/Proof'
+4 -3
View File
@@ -29,9 +29,10 @@
* only supply the key.
*/
import {HDKey} from '@scure/bip32'
// `.js` suffix: the bare '@noble/hashes/utils' specifier used elsewhere in this
// codebase does not resolve under tsc (the package's exports map only names the
// suffixed path). Both forms work at runtime; this one also typechecks.
// `.js` suffix, as everywhere this package is imported: its exports map names
// only the suffixed path, and moduleResolution "bundler" honours that map — the
// bare specifier does not typecheck. Both forms resolve at runtime today, but
// only this one survives a bundler that enforces exports.
import {bytesToHex} from '@noble/hashes/utils.js'
import {allocateNextCounter, NUT20_COUNTER} from '../db/walletCountersRepo'
import AppError, {Err} from '../../utils/AppError'
+2 -2
View File
@@ -1,11 +1,11 @@
import * as _Keychain from 'react-native-keychain'
import AppError, {Err} from '../utils/AppError'
import QuickCrypto from 'react-native-quick-crypto'
import { wordlist } from "@scure/bip39/wordlists/english"
import { wordlist } from "@scure/bip39/wordlists/english.js"
import * as bip39 from '@scure/bip39'
import { bytesToHex } from '@noble/hashes/utils'
import { bytesToHex } from '@noble/hashes/utils.js'
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { accountFromSeedWords } from 'nostr-tools/nip06'
import {log} from './logService'
+1 -1
View File
@@ -10,7 +10,7 @@ import { encrypt, decrypt } from 'nostr-tools/nip04'
import { wrapEvent, unwrapEvent } from 'nostr-tools/nip59'
import { neventEncode as nostrNeventEncode, npubEncode, decode as nip19Decode, nprofileEncode } from 'nostr-tools/nip19'
import {SimplePool} from 'nostr-tools/pool'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { PrivateDirectMessage, Metadata } from 'nostr-tools/kinds'
/*import {
MINIBITS_RELAY_URL,