mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
Add transaction item on completed import, fix bad imports across the repo
This commit is contained in:
Vendored
+26
@@ -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}),
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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á)",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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<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}}/>}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user