From 64fee8522104f9991e3c28d3a5e8ce9ee0817d67 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Sun, 10 Dec 2023 21:48:04 +0100 Subject: [PATCH] Implement seed recovery: commit 9163bd479ad4e9bbec9c91826f0456759aa3e3a5 Author: minibits-cash Date: Fri Dec 8 22:04:52 2023 +0100 Improve lnurl address handling commit a8a7840f70c1983b66d66841daf2844c6fc5ac24 Author: minibits-cash Date: Fri Dec 8 22:00:11 2023 +0100 Improve lnurl address handling commit 7f32a15e579183b4a5ff8dc9123f841e2dcabc19 Author: minibits-cash Date: Thu Dec 7 21:48:22 2023 +0100 Beta.4 release for alpha testers commit 1c44c15ccd1715d6bec44a43f8078fabacf0edf5 Author: minibits-cash Date: Thu Dec 7 19:37:54 2023 +0100 Upgrade deps commit 2c9ab1b71c4d6ed1a042cb10088dcb05931930db Author: minibits-cash Date: Thu Dec 7 11:13:43 2023 +0100 Reset cached mints on seed creation commit 5fd9e95d2a6752be348c42b2f411595257045ca9 Author: minibits-cash Date: Wed Dec 6 00:59:09 2023 +0100 Patch cashu-ts error handling commit 436c670e7e48d9085afbd5bb5d082d5f4d89d141 Author: minibits-cash Date: Wed Dec 6 00:52:09 2023 +0100 Clean unused deps commit f6174bc607993838346e5607fc42faa302711de7 Author: minibits-cash Date: Wed Dec 6 00:51:45 2023 +0100 Remove debug commit 868506a14c226cdb7665ee5a25b79e456ec37826 Author: minibits-cash Date: Wed Dec 6 00:50:33 2023 +0100 Improve receive error handling commit e1bf0875651606fc8990b49b52f53b1dd07efcd0 Author: minibits-cash Date: Tue Dec 5 23:06:36 2023 +0100 Final small UX tuning commit 875218d6481a1d5ce40cd366414c1a5873b4bcc3 Author: minibits-cash Date: Tue Dec 5 16:14:34 2023 +0100 Fix QR code error message commit c37b3e5ffec47bb7f646e475b56df7d02fb95fb3 Author: minibits-cash Date: Tue Dec 5 12:53:23 2023 +0100 New beta release v0.1.5 - Off-device seed backup commit 95811817facec71659f266b35ef05a0445f3b108 Author: minibits-cash Date: Tue Dec 5 09:35:21 2023 +0100 Align cashu-ts patch commit f61ad99e641660cc4c76ba353b3abcebe099c08f Author: minibits-cash Date: Mon Dec 4 18:46:41 2023 +0100 Recovery - final tweaks commit 6fa384c8ef6274eff2738bede407ec28a6c61229 Author: minibits-cash Date: Mon Dec 4 00:10:32 2023 +0100 Fix counters in restore. commit 50e9887405a65cfa209b20d33d79dfddbb16efb3 Author: minibits-cash Date: Sat Dec 2 23:53:37 2023 +0100 Seed recovery for testing commit 00605c49af5921ab1a9fe5473b49c4bf0ac44945 Author: minibits-cash Date: Tue Nov 28 00:57:08 2023 +0100 SeedRecovery before first tests commit aaefbe0e6305ef49e3c9f2daf1c715a7f6ab9af3 Author: minibits-cash Date: Thu Nov 23 11:46:08 2023 +0100 Recovery screen - wip commit c019f63f48490c8429649615fef7b40429ea6d23 Author: minibits-cash Date: Mon Nov 20 00:44:35 2023 +0100 Remote backup screen with seed phrase commit ada2f36070eb2e7fdf6240eaf7e1c89cb0ab6a74 Author: minibits-cash Date: Sun Nov 19 22:03:06 2023 +0100 Generate seed --- .github/workflows/jsCodePush-PROD.yaml | 3 +- .github/workflows/jsCodePush-TEST.yaml | 3 +- android/app/build.gradle | 4 +- babel.config.js | 2 +- package.json | 8 +- patches/@cashu+cashu-ts+0.8.2-rc.7.patch | 172 +++- ...atch => react-native-keychain+8.1.2.patch} | 0 src/components/HeaderBg.ts | 124 +++ src/components/Icon.tsx | 4 +- src/components/InfoModal.tsx | 2 +- src/components/Loading.tsx | 6 +- src/components/index.ts | 1 + src/i18n/en.ts | 4 +- src/models/Mint.ts | 51 +- src/models/MintsStore.ts | 34 +- src/models/ProofsStore.ts | 24 +- src/navigation/AppNavigator.tsx | 6 + src/navigation/TabsNavigator.tsx | 5 +- src/screens/BackupScreen.tsx | 125 +-- src/screens/ContactsScreen.tsx | 5 +- src/screens/DeveloperScreen.tsx | 38 +- src/screens/MintsScreen.tsx | 43 +- src/screens/ReceiveOptionsScreen.tsx | 8 +- src/screens/ReceiveScreen.tsx | 17 +- src/screens/RemoteBackupScreen.tsx | 201 +++++ src/screens/RemoteRecoveryScreen.tsx | 804 ++++++++++++++++++ src/screens/ScanScreen.tsx | 1 + src/screens/SendOptionsScreen.tsx | 13 +- src/screens/SendScreen.tsx | 110 ++- src/screens/TranDetailScreen.tsx | 2 +- src/screens/TransferScreen.tsx | 43 +- src/screens/WalletScreen.tsx | 35 +- src/screens/WelcomeScreen.tsx | 71 +- src/screens/index.ts | 2 + src/services/cashuMintClient.ts | 247 +++++- src/services/incomingParser.ts | 6 +- src/services/index.ts | 1 + src/services/keyChain.ts | 403 ++++++--- src/services/lnurlService.ts | 6 +- src/services/walletService.ts | 118 +-- src/utils/envtypes/env.d.tsx | 1 + src/utils/utils.ts | 17 +- yarn.lock | 33 +- 43 files changed, 2327 insertions(+), 476 deletions(-) rename patches/{react-native-keychain+8.1.1.patch => react-native-keychain+8.1.2.patch} (100%) create mode 100644 src/components/HeaderBg.ts create mode 100644 src/screens/RemoteBackupScreen.tsx create mode 100644 src/screens/RemoteRecoveryScreen.tsx diff --git a/.github/workflows/jsCodePush-PROD.yaml b/.github/workflows/jsCodePush-PROD.yaml index 0af3d84c..14e97cb0 100644 --- a/.github/workflows/jsCodePush-PROD.yaml +++ b/.github/workflows/jsCodePush-PROD.yaml @@ -89,7 +89,8 @@ jobs: echo MINIBITS_SERVER_API_KEY='${{ secrets.MINIBITS_SERVER_API_KEY }}' >> .env echo MINIBITS_SERVER_API_HOST='https://api.minibits.cash' >> .env echo MINIBITS_NIP05_DOMAIN='@minibits.cash' >> .env - echo MINIBITS_RELAY_URL='wss://relay.minibits.cash' >> .env + echo MINIBITS_RELAY_URL='wss://relay.minibits.cash' >> .env + echo MINIBITS_MINT_URL='wss://mint.minibits.cash/Bitcoin' >> .env echo COMMIT='${{ env.COMMIT_HASH }}' >> .env # Install dependecies from package.json diff --git a/.github/workflows/jsCodePush-TEST.yaml b/.github/workflows/jsCodePush-TEST.yaml index f30e8a4c..48a0bd04 100644 --- a/.github/workflows/jsCodePush-TEST.yaml +++ b/.github/workflows/jsCodePush-TEST.yaml @@ -89,7 +89,8 @@ jobs: echo MINIBITS_SERVER_API_KEY='${{ secrets.MINIBITS_SERVER_API_KEY }}' >> .env echo MINIBITS_SERVER_API_HOST='https://api.minibits.cash' >> .env echo MINIBITS_NIP05_DOMAIN='@minibits.cash' >> .env - echo MINIBITS_RELAY_URL='wss://relay.minibits.cash/test' >> .env + echo MINIBITS_RELAY_URL='wss://relay.minibits.cash/test' >> .env + echo MINIBITS_MINT_URL='wss://mint.minibits.cash/Bitcoin' >> .env echo COMMIT='${{ env.COMMIT_HASH }}' >> .env # Install dependecies from package.json diff --git a/android/app/build.gradle b/android/app/build.gradle index 42b7b8cb..cf77baf0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -102,8 +102,8 @@ android { applicationId "com.minibits_wallet" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 23 - versionName "0.1.4" + versionCode 28 + versionName "0.1.5" ndk { abiFilters 'arm64-v8a', 'x86_64', 'x86', 'armeabi-v7a' } // react-native-tor build diff --git a/babel.config.js b/babel.config.js index f9b56f53..e20b34ff 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,6 @@ module.exports = { presets: ['module:metro-react-native-babel-preset'], - plugins: [ + plugins: [ [ 'module-resolver', { diff --git a/package.json b/package.json index 53cfa169..78ace76f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minibits_wallet", - "version": "0.1.4-beta.13", + "version": "0.1.5-beta.5", "private": true, "scripts": { "android:clean": "cd android && ./gradlew clean", @@ -50,13 +50,13 @@ "react-native-flash-message": "^0.4.2", "react-native-gesture-handler": "^2.9.0", "react-native-json-tree": "^1.3.0", - "react-native-keychain": "^8.1.1", + "react-native-keychain": "^8.1.2", "react-native-logs": "^5.0.1", - "react-native-mmkv": "^2.8.0", + "react-native-mmkv": "^2.11.0", "react-native-modal": "^13.0.1", "react-native-pager-view": "^6.2.0", "react-native-qrcode-svg": "^6.2.0", - "react-native-quick-base64": "^2.0.6", + "react-native-quick-base64": "^2.0.7", "react-native-quick-crypto": "^0.6.1", "react-native-quick-sqlite": "^8.0.2", "react-native-reanimated": "3.3.0", diff --git a/patches/@cashu+cashu-ts+0.8.2-rc.7.patch b/patches/@cashu+cashu-ts+0.8.2-rc.7.patch index 189e4213..62413611 100644 --- a/patches/@cashu+cashu-ts+0.8.2-rc.7.patch +++ b/patches/@cashu+cashu-ts+0.8.2-rc.7.patch @@ -13,7 +13,7 @@ index af7649e..91a54c4 100644 "type": "git", "url": "git+https://github.com/cashubtc/cashu-ts" diff --git a/node_modules/@cashu/cashu-ts/src/CashuMint.ts b/node_modules/@cashu/cashu-ts/src/CashuMint.ts -index f6bc3b1..0bfe1dc 100644 +index f6bc3b1..e260dcf 100644 --- a/node_modules/@cashu/cashu-ts/src/CashuMint.ts +++ b/node_modules/@cashu/cashu-ts/src/CashuMint.ts @@ -6,14 +6,14 @@ import { @@ -189,7 +189,15 @@ index f6bc3b1..0bfe1dc 100644 endpoint: joinUrls(mintUrl, 'split'), method: 'POST', requestBody: splitPayload -@@ -158,7 +188,7 @@ class CashuMint { +@@ -149,7 +179,6 @@ class CashuMint { + if (!isObj(data) || !Array.isArray(data?.promises)) { + throw new Error('bad response'); + } +- + return data; + } + /** +@@ -158,7 +187,7 @@ class CashuMint { * @returns split tokens */ async split(splitPayload: SplitPayload): Promise { @@ -198,7 +206,7 @@ index f6bc3b1..0bfe1dc 100644 } /** * Ask mint to perform a melt operation. This pays a lightning invoice and destroys tokens matching its amount + fees -@@ -166,8 +196,13 @@ class CashuMint { +@@ -166,8 +195,13 @@ class CashuMint { * @param meltPayload * @returns */ @@ -214,7 +222,7 @@ index f6bc3b1..0bfe1dc 100644 endpoint: joinUrls(mintUrl, 'melt'), method: 'POST', requestBody: meltPayload -@@ -189,7 +224,7 @@ class CashuMint { +@@ -189,7 +223,7 @@ class CashuMint { * @returns */ async melt(meltPayload: MeltPayload): Promise { @@ -223,7 +231,7 @@ index f6bc3b1..0bfe1dc 100644 } /** * Estimate fees for a given LN invoice -@@ -199,9 +234,11 @@ class CashuMint { +@@ -199,9 +233,11 @@ class CashuMint { */ public static async checkFees( mintUrl: string, @@ -237,7 +245,7 @@ index f6bc3b1..0bfe1dc 100644 endpoint: joinUrls(mintUrl, 'checkfees'), method: 'POST', requestBody: checkfeesPayload -@@ -220,7 +257,7 @@ class CashuMint { +@@ -220,7 +256,7 @@ class CashuMint { * @returns estimated Fee */ async checkFees(checkfeesPayload: { pr: string }): Promise<{ fee: number }> { @@ -246,7 +254,7 @@ index f6bc3b1..0bfe1dc 100644 } /** * Checks if specific proofs have already been redeemed -@@ -230,9 +267,11 @@ class CashuMint { +@@ -230,9 +266,11 @@ class CashuMint { */ public static async check( mintUrl: string, @@ -260,7 +268,7 @@ index f6bc3b1..0bfe1dc 100644 endpoint: joinUrls(mintUrl, 'check'), method: 'POST', requestBody: checkPayload -@@ -250,14 +289,16 @@ class CashuMint { +@@ -250,14 +288,16 @@ class CashuMint { * @returns redeemed and unredeemed ordered list of booleans */ async check(checkPayload: CheckSpendablePayload): Promise { @@ -281,7 +289,7 @@ index f6bc3b1..0bfe1dc 100644 method: 'POST', requestBody: restorePayload diff --git a/node_modules/@cashu/cashu-ts/src/CashuWallet.ts b/node_modules/@cashu/cashu-ts/src/CashuWallet.ts -index b7cf354..d0a9af2 100644 +index b7cf354..38dd081 100644 --- a/node_modules/@cashu/cashu-ts/src/CashuWallet.ts +++ b/node_modules/@cashu/cashu-ts/src/CashuWallet.ts @@ -1,7 +1,7 @@ @@ -304,7 +312,7 @@ index b7cf354..d0a9af2 100644 import { bytesToNumber, cleanToken, -@@ -25,8 +25,8 @@ import { +@@ -25,8 +25,10 @@ import { getDecodedToken, getDefaultAmountPreference, splitAmount @@ -312,10 +320,39 @@ index b7cf354..d0a9af2 100644 -import { deriveBlindingFactor, deriveSecret, deriveSeedFromMnemonic } from './secrets.js'; +} from './utils'; +import { deriveBlindingFactor, deriveSecret, deriveSeedFromMnemonic } from './secrets'; ++import { validateMnemonic } from '@scure/bip39'; ++import { wordlist } from '@scure/bip39/wordlists/english.js'; /** * Class that represents a Cashu wallet. -@@ -69,13 +69,16 @@ class CashuWallet { +@@ -43,15 +45,23 @@ class CashuWallet { + * @param mint Cashu mint instance is used to make api calls + * @param mnemonic mnemonic seed phrase to initial derivation key for this wallets deterministic secrets + */ +- constructor(mint: CashuMint, keys?: MintKeys, mnemonic?: string) { ++ constructor(mint: CashuMint, keys?: MintKeys, mnemonicOrSeed?: string | Uint8Array) { + this._keys = keys || {}; + this.mint = mint; + if (keys) { + this._keysetId = deriveKeysetId(this._keys); + } +- if (mnemonic) { +- this._seed = deriveSeedFromMnemonic(mnemonic); ++ if (!mnemonicOrSeed) { ++ return + } ++ if (mnemonicOrSeed instanceof Uint8Array) { ++ this._seed = mnemonicOrSeed ++ return ++ } ++ if (!validateMnemonic(mnemonicOrSeed, wordlist)) { ++ throw new Error('Tried to instantiate with mnemonic, but mnemonic was invalid'); ++ } ++ this._seed = deriveSeedFromMnemonic(mnemonicOrSeed); + } + + get keys(): MintKeys { +@@ -69,13 +79,16 @@ class CashuWallet { * @param proofs (only the 'secret' field is required) * @returns */ @@ -335,7 +372,84 @@ index b7cf354..d0a9af2 100644 } /** * Starts a minting process by requesting an invoice from the mint -@@ -512,8 +515,8 @@ class CashuWallet { +@@ -166,6 +179,7 @@ class CashuWallet { + const { token } = cleanToken(getDecodedToken(encodedToken)); + const tokenEntries: Array = []; + const tokenEntriesWithError: Array = []; ++ let errors: Error[] = [] + let newKeys: MintKeys | undefined; + for (const tokenEntry of token) { + if (!tokenEntry?.proofs?.length) { +@@ -175,8 +189,12 @@ class CashuWallet { + const { + proofsWithError, + proofs, +- newKeys: newKeysFromReceive ++ newKeys: newKeysFromReceive, ++ error + } = await this.receiveTokenEntry(tokenEntry, preference, count); ++ ++ if(error) {errors.push(error)} ++ + if (proofsWithError?.length) { + tokenEntriesWithError.push(tokenEntry); + continue; +@@ -185,15 +203,16 @@ class CashuWallet { + if (!newKeys) { + newKeys = newKeysFromReceive; + } +- } catch (error) { +- console.error(error); ++ } catch (e: any) { ++ errors.push(e) + tokenEntriesWithError.push(tokenEntry); + } + } + return { + token: { token: tokenEntries }, + tokensWithErrors: tokenEntriesWithError.length ? { token: tokenEntriesWithError } : undefined, +- newKeys ++ newKeys, ++ errors: errors.length ? errors : undefined + }; + } + +@@ -212,6 +231,7 @@ class CashuWallet { + const proofsWithError: Array = []; + const proofs: Array = []; + let newKeys: MintKeys | undefined; ++ let err: Error | undefined = undefined + try { + const amount = tokenEntry.proofs.reduce((total, curr) => total + curr.amount, 0); + if (!preference) { +@@ -224,6 +244,7 @@ class CashuWallet { + count + ); + const { promises, error } = await CashuMint.split(tokenEntry.mint, payload); ++ if(error) {err = {name: 'Error', message: error}} + const newProofs = dhke.constructProofs( + promises, + blindedMessages.rs, +@@ -235,14 +256,15 @@ class CashuWallet { + tokenEntry.mint === this.mint.mintUrl + ? await this.changedKeys([...(promises || [])]) + : undefined; +- } catch (error) { +- console.error(error); ++ } catch (e: any) { ++ err = e + proofsWithError.push(...tokenEntry.proofs); + } + return { + proofs, + proofsWithError: proofsWithError.length ? proofsWithError : undefined, +- newKeys ++ newKeys, ++ error: err + }; + } + +@@ -512,8 +534,8 @@ class CashuWallet { const secrets: Array = []; const rs: Array = []; for (let i = 0; i < amounts.length; i++) { @@ -363,7 +477,7 @@ index ca37559..02843b5 100644 import { bytesToHex } from '@noble/curves/abstract/utils'; diff --git a/node_modules/@cashu/cashu-ts/src/index.ts b/node_modules/@cashu/cashu-ts/src/index.ts -index b1f2c00..138c251 100644 +index b1f2c00..0c0f85c 100644 --- a/node_modules/@cashu/cashu-ts/src/index.ts +++ b/node_modules/@cashu/cashu-ts/src/index.ts @@ -1,10 +1,10 @@ @@ -378,16 +492,42 @@ index b1f2c00..138c251 100644 import { decode as getDecodedLnInvoice } from '@gandlaf21/bolt11-decode'; -import { generateNewMnemonic } from './secrets.js'; -export * from './model/types/index.js'; -+import { generateNewMnemonic } from './secrets'; ++import { generateNewMnemonic, deriveSeedFromMnemonic } from './secrets'; +export * from './model/types/index'; export { CashuMint, +@@ -14,5 +14,6 @@ export { + deriveKeysetId, + getDecodedLnInvoice, + setGlobalRequestOptions, +- generateNewMnemonic ++ generateNewMnemonic, ++ deriveSeedFromMnemonic + }; diff --git a/node_modules/@cashu/cashu-ts/src/model/types/index.ts b/node_modules/@cashu/cashu-ts/src/model/types/index.ts -index 4855c33..4546acc 100644 +index 4855c33..91e45bb 100644 --- a/node_modules/@cashu/cashu-ts/src/model/types/index.ts +++ b/node_modules/@cashu/cashu-ts/src/model/types/index.ts -@@ -215,6 +215,7 @@ export type CheckSpendableResponse = { +@@ -41,6 +41,8 @@ export type ReceiveTokenEntryResponse = { + * If the mint has rotated keys, this field will be populated with the new keys. + */ + newKeys?: MintKeys; ++ ++ error?: Error + }; + + /** +@@ -76,6 +78,8 @@ export type ReceiveResponse = { + * If the mint has rotated keys, this field will be populated with the new keys. + */ + newKeys?: MintKeys; ++ ++ errors?: Error[] | undefined; + }; + + /** +@@ -215,6 +219,7 @@ export type CheckSpendableResponse = { * Ordered list for checked proofs. True if the secret has not been redeemed at the mint before */ spendable: Array; diff --git a/patches/react-native-keychain+8.1.1.patch b/patches/react-native-keychain+8.1.2.patch similarity index 100% rename from patches/react-native-keychain+8.1.1.patch rename to patches/react-native-keychain+8.1.2.patch diff --git a/src/components/HeaderBg.ts b/src/components/HeaderBg.ts new file mode 100644 index 00000000..a4412cc4 --- /dev/null +++ b/src/components/HeaderBg.ts @@ -0,0 +1,124 @@ +export const HeaderBg = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + + +export const HeaderBg2 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` \ No newline at end of file diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 7b04f559..1b6772cd 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -67,6 +67,7 @@ import { faKey } from '@fortawesome/free-solid-svg-icons/faKey' import { faCircleNodes } from '@fortawesome/free-solid-svg-icons/faCircleNodes' import { faBullseye } from '@fortawesome/free-solid-svg-icons/faBullseye' import { faEyeSlash } from '@fortawesome/free-solid-svg-icons/faEyeSlash' +import { faUpRightFromSquare } from '@fortawesome/free-solid-svg-icons/faUpRightFromSquare' export type IconTypes = keyof typeof iconRegistry @@ -123,7 +124,8 @@ export const iconRegistry = { faKey: faKey, faCircleNodes: faCircleNodes, faBullseye: faBullseye, - faEyeSlash: faEyeSlash, + faEyeSlash: faEyeSlash, + faUpRightFromSquare: faUpRightFromSquare, } diff --git a/src/components/InfoModal.tsx b/src/components/InfoModal.tsx index 9b129c74..9f5a80d7 100644 --- a/src/components/InfoModal.tsx +++ b/src/components/InfoModal.tsx @@ -31,7 +31,7 @@ export const InfoModal: FC = function ({ message }) { onBackdropPress={() => onClose()} onBackButtonPress={() => onClose()} ContentComponent={ - + {message} diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index d7ee9274..f9bb8d57 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -1,8 +1,9 @@ import * as React from 'react' import { StyleSheet, View, ActivityIndicator, ViewProps } from 'react-native' import { useThemeColor } from '../theme' +import { Text } from './Text' -export function Loading(props: ViewProps) { +export function Loading(props: ViewProps & {statusMessage?: string}) { return ( + }, StyleSheet.absoluteFillObject, props.style]}> + {props.statusMessage && ()} ) } \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index 11dd5771..a2e40426 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,3 +14,4 @@ export * from "./InfoModal" export * from "./BottomModal" export * from "./Loading" export * from "./ScanIcon" +export * from "./HeaderBg" diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a8f135ef..5595781e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -59,7 +59,7 @@ const en = { other: "{{count}} mints have been added to your wallet", }, toReceive: "Amount to receive", - received: "Amount received:", + received: "Amount received", sharePaymentRequest: "Send payment request", sharePaymentRequestDescription: "Send payment request to one of your contacts, so that the payer can pay you exact amount.", scanToReceive: "Scan or paste to receive", @@ -156,6 +156,8 @@ const en = { backupScreen: { localBackup: "Local backup", localBackupDescription: "Local backup stores a copy of all your ecash in the local database. Ecash is never deleted unless you switch off the backup. Please note that this backup is not encrypted.", + remoteBackup: "Off-device backup", + remoteBackupDescription: "Seed phrase allows you to recover your ecash balance in case of device loss.", recoveryTool: "Recovery tool", recoveryToolDescription: "Show ecash backed up in the local database and attempt to recover unspent ones into the wallet in case the wallet storage gets corrupted or a transaction fails due to an unexpected error.", removeSpentCoins: "Remove spent ecash", diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 3228dc88..e69a1b91 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -1,7 +1,9 @@ -import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree' +import {cast, Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree' import {withSetPropAction} from './helpers/withSetPropAction' import type {MintKeys} from '@cashu/cashu-ts/dist/lib/es5/model/types' import {colors, getRandomIconColor} from '../theme' +import { log } from '../services' +import { deriveKeysetId } from '@cashu/cashu-ts' // used as a helper type across app export type MintBalance = { @@ -14,6 +16,11 @@ export enum MintStatus { OFFLINE = 'OFFLINE' } +export type MintProofsCounter = { + keyset: string + counter: number +} + /** * This represents a Cashu mint */ @@ -24,11 +31,23 @@ export const MintModel = types shortname: types.maybe(types.string), keys: types.frozen(), keysets: types.frozen(), + proofsCounters: types.array( + types.model('MintProofsCounter', { + keyset: types.string, + counter: types.number, + }) + ), color: types.optional(types.string, colors.palette.iconBlue200), status: types.optional(types.frozen(), MintStatus.ONLINE), createdAt: types.optional(types.Date, new Date()), }) .actions(withSetPropAction) + .views(self => ({ + get currentProofsCounter() { + const currentKeyset = deriveKeysetId(self.keys) + return self.proofsCounters.find(c => c.keyset === currentKeyset) + }, + })) .actions(self => ({ validateURL(url: string) { try { @@ -61,12 +80,36 @@ export const MintModel = types setStatus(status: MintStatus) { self.status = status }, - updateKeys(keyset: string, keys: MintKeys) { - // not sure why to keep keysetIDs history and even keys, cashu-ts seems to (on first look) get current keys before each mint interaction + updateKeys(keyset: string, keys: MintKeys) { self.keysets.push(keyset) self.keys = keys + self.keysets = cast(self.keysets) }, - })) + increaseProofsCounter(numberOfProofs: number) { + const currentCounter = self.currentProofsCounter + + if (currentCounter) { + log.trace('[increaseProofsCounter]', 'Before update', {currentCounter}) + currentCounter.counter += numberOfProofs + log.trace('[increaseProofsCounter]', 'Updated proofsCounter', {numberOfProofs, currentCounter}) + } else { + // If the counter doesn't exist, create a new one + const currentKeyset = deriveKeysetId(self.keys) + + const newCounter = { + keyset: currentKeyset, + counter: numberOfProofs, + } + + self.proofsCounters.push(newCounter) + + log.trace('[increaseProofsCounter]', 'Adding new proofsCounter', {newCounter}) + } + // Make sure to cast the frozen array back to a mutable array + self.proofsCounters = cast(self.proofsCounters) + }, + })) + export type Mint = { mintUrl: string diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index 2c57ddd8..660b368a 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -77,6 +77,18 @@ export const MintsStoreModel = types self.blockedMintUrls.remove(blockedMint.mintUrl) log.debug('[unblockMint]', 'Mint unblocked in MintsStore') }, + increaseProofsCounter(mintUrl: string, numberOfProofs: number) { + const mintInstance = self.findByUrl(mintUrl) + + if(mintInstance) { + mintInstance.increaseProofsCounter(numberOfProofs) + return mintInstance.currentProofsCounter + } + + log.warn('[increaseProofsCounter]', 'Could not find mint', {mintUrl}) + return 0 + + }, })) .views(self => ({ get mintCount() { @@ -91,14 +103,14 @@ export const MintsStoreModel = types self.mints.forEach((mint: Mint) => { const {hostname} = mint - if (!grouped[hostname]) { - grouped[hostname] = { - hostname, - mints: [], - } + if (!grouped[hostname as string]) { + grouped[hostname as string] = { + hostname, + mints: [], + } } - grouped[hostname].mints.push(mint) + grouped[hostname as string].mints.push(mint) }) return Object.values(grouped) as MintsByHostname[] @@ -123,6 +135,16 @@ export const MintsStoreModel = types } return missingMints }, + currentProofsCounterValue(mintUrl: string) { + const mintInstance = self.findByUrl(mintUrl) + + if (mintInstance) { + return mintInstance.currentProofsCounter?.counter || 0 + } + + log.warn('[currentProofsCounter]', 'Could not find mint', {mintUrl}) + return 0 + }, })) export interface MintsStore extends Instance {} diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index ceb7c0bf..c4be9217 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -62,10 +62,11 @@ export const ProofsStoreModel = types }, })) .actions(self => ({ - addProofs(newProofs: Proof[], isPending: boolean = false): number { + addProofs(newProofs: Proof[], isPending: boolean = false): {addedAmount: number, addedProofs: Proof[]} { try { const proofs = isPending ? self.pendingProofs : self.proofs let addedAmount: number = 0 + let addedProofs: Proof[] = [] for (const proof of newProofs) { if(self.alreadyExists(proof)) { @@ -81,18 +82,23 @@ export const ProofsStoreModel = types } addedAmount += proof.amount + addedProofs.push(proof) } - log.debug('[addProofs]', `Added new ${newProofs.length}${isPending ? ' pending' : ''} proofs to the ProofsStore`,) + // Handle counter increment + const mintsStore = getRootStore(self).mintsStore + mintsStore.increaseProofsCounter(newProofs[0].mintUrl as string, addedProofs.length) + + log.debug('[addProofs]', `Added new ${addedProofs.length}${isPending ? ' pending' : ''} proofs to the ProofsStore`,) const rootStore = getRootStore(self) const {userSettingsStore} = rootStore - if (userSettingsStore.isLocalBackupOn === true) { - Database.addOrUpdateProofs(newProofs, isPending) // isSpent = false + if (userSettingsStore.isLocalBackupOn === true && addedProofs.length > 0) { + Database.addOrUpdateProofs(addedProofs, isPending) // isSpent = false } - return addedAmount + return { addedAmount, addedProofs } } catch (e: any) { throw new AppError(Err.STORAGE_ERROR, e.message) } @@ -204,6 +210,14 @@ export const ProofsStoreModel = types }, })) .views(self => ({ + getMintBalance: (mintUrl: string) => { + const balances = self.getBalances().mintBalances + + const mintBalance = balances + .find((balance: MintBalance) => balance.mint === mintUrl) + + return mintBalance + }, getMintBalancesWithEnoughBalance: (amount: number) => { const balances = self.getBalances().mintBalances diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 2e05ea23..9ef0ce4e 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -15,6 +15,8 @@ import React from "react" import Config from "../config" import { WelcomeScreen, + RemoteRecoveryScreen, + MintsScreen } from "../screens" import { useStores } from "../models" import { TabsNavigator, TabsParamList } from "./TabsNavigator" @@ -35,6 +37,8 @@ import { navigationRef, useBackButtonHandler } from "./navigationUtilities" */ export type AppStackParamList = { Welcome: undefined + RemoteRecovery: undefined + Mints: undefined Tabs: NavigatorScreenParams } @@ -68,6 +72,8 @@ const AppStack = observer(function AppStack() { ) : ( <> + + )} diff --git a/src/navigation/TabsNavigator.tsx b/src/navigation/TabsNavigator.tsx index b754e752..ddaec61e 100644 --- a/src/navigation/TabsNavigator.tsx +++ b/src/navigation/TabsNavigator.tsx @@ -26,6 +26,7 @@ import { PrivacyScreen, UpdateScreen, BackupScreen, + RemoteBackupScreen, LocalRecoveryScreen, RelaysScreen, TranDetailScreen, @@ -124,7 +125,7 @@ export type WalletStackParamList = { TranDetail: {id: number} TranHistory: undefined PaymentRequests: undefined - Transfer: {encodedInvoice?: string, paymentRequest?: PaymentRequest, lnurlParams?: LNURLPayParams, paymentOption?: SendOption,} + Transfer: {encodedInvoice?: string, paymentRequest?: PaymentRequest, lnurlParams?: LNURLPayParams & {address?: string}, paymentOption?: SendOption,} Topup: {contact?: Contact, relays?: string[], paymentOption?: ReceiveOption, lnurlParams?: LNURLWithdrawParams} ContactsNavigator: {screen: string, params: any} SettingsNavigator: {screen: string, params: any} @@ -212,6 +213,7 @@ export type SettingsStackParamList = { updateSize: string, } Backup: undefined + RemoteBackup: undefined LocalRecovery: undefined Developer: undefined Relays: undefined @@ -240,6 +242,7 @@ const SettingsNavigator = function SettingsNavigator() { + diff --git a/src/screens/BackupScreen.tsx b/src/screens/BackupScreen.tsx index ef258915..ff3b38f1 100644 --- a/src/screens/BackupScreen.tsx +++ b/src/screens/BackupScreen.tsx @@ -75,6 +75,10 @@ export const BackupScreen: FC> = observer(fun navigation.navigate('LocalRecovery') } + const gotoRemoteBackup = function () { + navigation.navigate('RemoteBackup') + } + const checkSpent = async function () { setIsLoading(true) const result = (await Wallet.checkSpent()) as { @@ -106,60 +110,77 @@ export const BackupScreen: FC> = observer(fun - - - - - } - style={$item} - /> - {isLocalBackupOn && ( - + - )} - - } - /> - - - - } - /> + onPress={gotoRemoteBackup} + /> + + } + /> + + + + + } + style={$item} + /> + {isLocalBackupOn && ( + + )} + + } + /> + + + + } + /> {isLoading && } = observer(function Contact const renderScene = ({route}: {route: Route}) => { switch (route.key) { case 'first': - return + return case 'second': return default: diff --git a/src/screens/DeveloperScreen.tsx b/src/screens/DeveloperScreen.tsx index fa0ac3f4..80a9972d 100644 --- a/src/screens/DeveloperScreen.tsx +++ b/src/screens/DeveloperScreen.tsx @@ -117,23 +117,27 @@ export const DeveloperScreen: FC> = observ { text: 'Confirm', onPress: async () => { - setIsLoading(true) - try { - // Delete database - Database.cleanAll() - // Delete Nostr keys - await KeyChain.removeNostrKeypair() - // Delete Encryption key - await KeyChain.removeMmkvEncryptionKey() - // Clean mobx storage - MMKVStorage.clearAll() - // recreate db schema - Database.getInstance() - setIsLoading(false) - setInfo('Factory reset completed, please restart immediately.') - } catch (e: any) { - handleError(e) - } + setIsLoading(true) + try { + // Delete database + Database.cleanAll() + // Delete Nostr keys + await KeyChain.removeNostrKeypair() + // Delete Encryption key + await KeyChain.removeMmkvEncryptionKey() + // Delete mnemonic + await KeyChain.removeMnemonic() + // Delete seed + await KeyChain.removeSeed() + // Clean mobx storage + MMKVStorage.clearAll() + // recreate db schema + Database.getInstance() + setIsLoading(false) + setInfo('Factory reset completed, please restart immediately.') + } catch (e: any) { + handleError(e) + } }, }, ], diff --git a/src/screens/MintsScreen.tsx b/src/screens/MintsScreen.tsx index 9009e4cb..d53d14e8 100644 --- a/src/screens/MintsScreen.tsx +++ b/src/screens/MintsScreen.tsx @@ -2,8 +2,11 @@ import {observer} from 'mobx-react-lite' import React, {FC, useCallback, useRef, useState} from 'react' import {Alert, TextInput, TextStyle, View, ViewStyle} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' +import { + MINIBITS_MINT_URL +} from '@env' import {spacing, typography, useThemeColor, colors} from '../theme' -import {SettingsStackScreenProps} from '../navigation' +import {AppStackScreenProps, SettingsStackScreenProps} from '../navigation' import { Button, Icon, @@ -19,12 +22,12 @@ import { import {Mint} from '../models/Mint' import {useStores} from '../models' import {useHeader} from '../utils/useHeader' -import {MintKeys, MintKeySets, MintClient} from '../services' +import {MintKeys, MintClient} from '../services' import {log} from '../services/logService' import AppError from '../utils/AppError' import {translate} from '../i18n' import {MintListItem} from './Mints/MintListItem' -import { infoMessage } from '../utils/utils' + export const MintsScreen: FC> = observer(function MintsScreen({route, navigation}) { @@ -37,6 +40,7 @@ export const MintsScreen: FC> = observer(funct const mintInputRef = useRef(null) const [mintUrl, setMintUrl] = useState('') + const [defaultMintUrl, setDefaultMintUrl] = useState(MINIBITS_MINT_URL) const [selectedMint, setSelectedMint] = useState() const [info, setInfo] = useState('') const [error, setError] = useState() @@ -117,6 +121,12 @@ export const MintsScreen: FC> = observer(funct } + const addDefaultMint = async function () { + setMintUrl(defaultMintUrl) + toggleAddMintModal() + } + + const removeMint = async function () { if (!selectedMint) {return} @@ -129,8 +139,6 @@ export const MintsScreen: FC> = observer(funct message += 'Do you really want to remove this mint from the wallet?' - - Alert.alert( 'Warning', message, @@ -220,6 +228,7 @@ export const MintsScreen: FC> = observer(funct > = observer(funct style={$actionItem} onPress={toggleAddMintModal} /> + {!mintsStore.alreadyExists(defaultMintUrl) && ( + + } + style={$actionItem} + onPress={addDefaultMint} + topSeparator={true} + /> + )} + } /> {mintsStore.mintCount > 0 && ( @@ -313,6 +338,7 @@ export const MintsScreen: FC> = observer(funct bottomSeparator={true} style={{paddingHorizontal: spacing.medium}} /> + } onBackButtonPress={onMintUnselect} @@ -404,8 +430,9 @@ const $contentContainer: TextStyle = { const $actionCard: ViewStyle = { marginBottom: spacing.small, - marginTop: -spacing.extraLarge * 1.5, + marginTop: -spacing.extraLarge * 2, minHeight: 70, + paddingVertical: 0, } const $actionItem: ViewStyle = { @@ -414,8 +441,8 @@ const $actionItem: ViewStyle = { } const $card: ViewStyle = { - marginBottom: spacing.small, - paddingTop: 0, + marginBottom: spacing.small, + // paddingTop: 0, } const $cardHeading: TextStyle = { diff --git a/src/screens/ReceiveOptionsScreen.tsx b/src/screens/ReceiveOptionsScreen.tsx index 490ab1cc..60d7f8ab 100644 --- a/src/screens/ReceiveOptionsScreen.tsx +++ b/src/screens/ReceiveOptionsScreen.tsx @@ -1,5 +1,5 @@ import {observer} from 'mobx-react-lite' -import React, {FC, useState, useCallback} from 'react' +import React, {FC, useState, useCallback, useEffect} from 'react' import {useFocusEffect} from '@react-navigation/native' import {Alert, TextStyle, View, ViewStyle} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' @@ -19,8 +19,6 @@ import {useHeader} from '../utils/useHeader' import {log} from '../services/logService' import AppError from '../utils/AppError' import useIsInternetReachable from '../utils/useIsInternetReachable' -import { infoMessage } from '../utils/utils' -import { IncomingDataType, IncomingParser } from '../services/incomingParser' export enum ReceiveOption { SEND_PAYMENT_REQUEST = 'SEND_PAYMENT_REQUEST', @@ -36,12 +34,12 @@ export const ReceiveOptionsScreen: FC> onLeftPress: () => navigation.goBack(), }) - const isInternetReachable = useIsInternetReachable() + + const isInternetReachable = useIsInternetReachable() const [info, setInfo] = useState('') const [error, setError] = useState() - const gotoContacts = function () { navigation.navigate('ContactsNavigator', { screen: 'Contacts', diff --git a/src/screens/ReceiveScreen.tsx b/src/screens/ReceiveScreen.tsx index d0f233eb..cdda0781 100644 --- a/src/screens/ReceiveScreen.tsx +++ b/src/screens/ReceiveScreen.tsx @@ -132,7 +132,8 @@ export const ReceiveScreen: FC> = observer( if (error) { setResultModalInfo({ status, - message: error.message, + title: error.message || 'Receive failed', + message: JSON.parse(error.params).message || error.message, }) } else { setResultModalInfo({ @@ -290,12 +291,14 @@ export const ReceiveScreen: FC> = observer( } /> {transactionStatus === TransactionStatus.COMPLETED ? ( - -