Implement seed recovery:

commit 9163bd479a
Author: minibits-cash <support@minibits.cash>
Date:   Fri Dec 8 22:04:52 2023 +0100

    Improve lnurl address handling

commit a8a7840f70
Author: minibits-cash <support@minibits.cash>
Date:   Fri Dec 8 22:00:11 2023 +0100

    Improve lnurl address handling

commit 7f32a15e57
Author: minibits-cash <support@minibits.cash>
Date:   Thu Dec 7 21:48:22 2023 +0100

    Beta.4 release for alpha testers

commit 1c44c15ccd
Author: minibits-cash <support@minibits.cash>
Date:   Thu Dec 7 19:37:54 2023 +0100

    Upgrade deps

commit 2c9ab1b71c
Author: minibits-cash <support@minibits.cash>
Date:   Thu Dec 7 11:13:43 2023 +0100

    Reset cached mints on seed creation

commit 5fd9e95d2a
Author: minibits-cash <support@minibits.cash>
Date:   Wed Dec 6 00:59:09 2023 +0100

    Patch cashu-ts error handling

commit 436c670e7e
Author: minibits-cash <support@minibits.cash>
Date:   Wed Dec 6 00:52:09 2023 +0100

    Clean unused deps

commit f6174bc607
Author: minibits-cash <support@minibits.cash>
Date:   Wed Dec 6 00:51:45 2023 +0100

    Remove debug

commit 868506a14c
Author: minibits-cash <support@minibits.cash>
Date:   Wed Dec 6 00:50:33 2023 +0100

    Improve receive error handling

commit e1bf087565
Author: minibits-cash <support@minibits.cash>
Date:   Tue Dec 5 23:06:36 2023 +0100

    Final small UX tuning

commit 875218d648
Author: minibits-cash <support@minibits.cash>
Date:   Tue Dec 5 16:14:34 2023 +0100

    Fix QR code error message

commit c37b3e5ffe
Author: minibits-cash <support@minibits.cash>
Date:   Tue Dec 5 12:53:23 2023 +0100

    New beta release v0.1.5 - Off-device seed backup

commit 95811817fa
Author: minibits-cash <support@minibits.cash>
Date:   Tue Dec 5 09:35:21 2023 +0100

    Align cashu-ts patch

commit f61ad99e64
Author: minibits-cash <support@minibits.cash>
Date:   Mon Dec 4 18:46:41 2023 +0100

    Recovery - final tweaks

commit 6fa384c8ef
Author: minibits-cash <support@minibits.cash>
Date:   Mon Dec 4 00:10:32 2023 +0100

    Fix counters in restore.

commit 50e9887405
Author: minibits-cash <support@minibits.cash>
Date:   Sat Dec 2 23:53:37 2023 +0100

    Seed recovery for testing

commit 00605c49af
Author: minibits-cash <support@minibits.cash>
Date:   Tue Nov 28 00:57:08 2023 +0100

    SeedRecovery before first tests

commit aaefbe0e63
Author: minibits-cash <support@minibits.cash>
Date:   Thu Nov 23 11:46:08 2023 +0100

    Recovery screen - wip

commit c019f63f48
Author: minibits-cash <support@minibits.cash>
Date:   Mon Nov 20 00:44:35 2023 +0100

    Remote backup screen with seed phrase

commit ada2f36070
Author: minibits-cash <support@minibits.cash>
Date:   Sun Nov 19 22:03:06 2023 +0100

    Generate seed
This commit is contained in:
minibits-cash
2023-12-10 21:48:04 +01:00
parent 5f94612eb7
commit 64fee85221
43 changed files with 2327 additions and 476 deletions
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
plugins: [
[
'module-resolver',
{
+4 -4
View File
@@ -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",
+156 -16
View File
@@ -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<SplitResponse> {
@@ -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<MeltResponse> {
@@ -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<CheckSpendableResponse> {
@@ -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<TokenEntry> = [];
const tokenEntriesWithError: Array<TokenEntry> = [];
+ 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<Proof> = [];
const proofs: Array<Proof> = [];
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<Uint8Array> = [];
const rs: Array<bigint> = [];
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<boolean>;
+124
View File
@@ -0,0 +1,124 @@
export const HeaderBg = `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1440" height="200" preserveAspectRatio="none" viewBox="0 0 1440 200">
<g mask="url(&quot;#SvgjsMask1228&quot;)" fill="none">
<rect width="1440" height="200" x="0" y="0" fill="rgba(33, 108, 233, 1)"></rect>
<path d="M747.1057075216023 127.75562642682651L754.3970814083749 75.87480543830213 702.5162604198505 68.58343155152957 695.2248865330779 120.46425254005393z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
<path d="M327.2048426729167 35.928073236888665L288.5226793723616 68.38626219552378 320.9808683309967 107.06842549607889 359.6630316315518 74.61023653744377z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M692.4246915017623 170.19979034423497L702.9325329152182 215.7142519174232 748.4469944884064 205.20641050396728 737.9391530749505 159.69194893077903z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M1011.3353816446419 62.94159037130783L1028.581667963285 89.49854242930147 1055.1386200212787 72.25225611065815 1037.8923337026356 45.69530405266451z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M969.6723418652923 55.983154930508455L919.0428285294618 52.44279447268238 915.5024680716357 103.07230780851287 966.1319814074662 106.61266826633894z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
<path d="M490.49547511700763 73.50446578850527L517.250567607621 35.29423377783492 479.04033559695057 8.53914128722161 452.28524310633725 46.749373297891964z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M1421.5534289524064 76.40745393189856L1402.143414506712 126.97227030992588 1452.7082308847394 146.38228475562033 1472.1182453304336 95.817468377593z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M44.18649852640779 56.50055120620612L12.331357360316396 43.63023874742407-0.5389550984656495 75.48537991351547 31.316186067625747 88.35569237229751z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M567.477420162059 5.581617787479144L553.758592711916 33.709382410470965 581.8863573349078 47.42820986061403 595.6051847850508 19.300445237622203z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M1191.7568875108875 151.89571627870086L1189.2314299670188 103.70711568508801 1141.042829373406 106.23257322895671 1143.5682869172747 154.42117382256956z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M1233.0041486652651 43.54078493335189L1218.5612249444973 13.928402945024839 1188.9488429561702 28.371326665792672 1203.391766676938 57.983708654119724z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
<path d="M1093.9611587853271 54.44612369420207L1125.8106675944764 85.20283683431656 1156.567380734591 53.353328025167215 1124.7178719254416 22.596614885052723z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M1431.181750298965 152.22148179381813L1388.2730376608383 138.27959591769297 1374.3311517847133 181.18830855581956 1417.23986442284 195.13019443194472z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M689.945851702067 69.68986125874905L724.3502661613595 91.18812542474701 745.8485303273575 56.783710965454546 711.444115868065 35.28544679945657z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M592.5530077850946 131.1723799971202L580.8661884602851 167.1407114350277 616.8345198981926 178.8275307598371 628.5213392230021 142.8591993219296z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M1066.3984237341463 93.45412929443842L1015.8249975827404 76.04030217184369 998.4111704601456 126.6137283232496 1048.9845966115515 144.0275554458443z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M520.0887716328363 213.80147367701232L526.5041910090813 161.55207579613258 474.2547931282015 155.13665641988757 467.8393737519565 207.38605430076734z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
<path d="M1209.4433647244202 151.13012700405633L1208.364068011502 182.03714104259996 1239.2710820500456 183.11643775551815 1240.3503787629638 152.20942371697453z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M1412.5831611707363 137.77032976422595L1425.2780766404287 104.69894429691828 1392.206691173121 92.00402882722595 1379.5117757034286 125.07541429453362z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M200.67441576045212 107.21742873144655L188.93683921376643 134.8694262079934 216.5888366903133 146.6070027546791 228.32641323699897 118.95500527813222z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
</g>
<defs>
<mask id="SvgjsMask1228">
<rect width="1440" height="200" fill="#ffffff"></rect>
</mask>
<style>
@keyframes float1 {
0%{transform: translate(0, 0)}
50%{transform: translate(-10px, 0)}
100%{transform: translate(0, 0)}
}
.triangle-float1 {
animation: float1 5s infinite;
}
@keyframes float2 {
0%{transform: translate(0, 0)}
50%{transform: translate(-5px, -5px)}
100%{transform: translate(0, 0)}
}
.triangle-float2 {
animation: float2 4s infinite;
}
@keyframes float3 {
0%{transform: translate(0, 0)}
50%{transform: translate(0, -10px)}
100%{transform: translate(0, 0)}
}
.triangle-float3 {
animation: float3 6s infinite;
}
</style>
</defs>
</svg>`
export const HeaderBg2 = `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1440" height="200" preserveAspectRatio="none" viewBox="0 0 1440 200">
<g mask="url(&quot;#SvgjsMask1105&quot;)" fill="none">
<rect width="1440" height="200" x="0" y="0" fill="rgba(33, 108, 233, 1)"></rect>
<path d="M749.0831241863771 121.38806886260159L754.6023483557941 68.8761586102141 702.0904381034065 63.356934440797104 696.5712139339896 115.86884469318458z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M1351.3054843130171 54.0624429919247L1354.7515332124415 103.3432382069029 1404.0323284274198 99.8971893074785 1400.5862795279954 50.61639409250029z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M94.16973276472656 153.7220346920555L121.01408311325078 123.9083632425251 91.20041166372037 97.06401289400088 64.35606131519616 126.87768434353129z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M466.97021985019103 150.27038846783466L442.0906957238391 124.50688704231337 416.32719429831775 149.38641116866532 441.2067184246697 175.14991259418662z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M589.7063339756562 117.83984879717408L627.6221439178643 106.24782238548573 616.030117506176 68.33201244327765 578.1143075639678 79.924038854966z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
<path d="M918.9888996274942 100.97281796775246L943.7668434439643 71.44361440737686 914.2376398835886 46.665670590906856 889.4596960671186 76.19487415128246z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
<path d="M362.0235285710007 60.29738945472211L362.56413622240916 29.325997848238075 331.5927446159251 28.785390196829653 331.0521369645167 59.756781803313686z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M921.2410433137273 123.30434229262224L898.4513471220594 148.61486409224395 923.7618689216811 171.40456028391196 946.551565113349 146.09403848429025z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M305.1087778387738 165.28226340185944L287.71314790346094 133.8997162655259 256.3306007671274 151.29534620083876 273.72623070244026 182.6778933371723z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M1274.054443780785 60.90011888152908L1242.8063434543035 78.221223733202 1260.1274483059765 109.46932405968349 1291.375548632458 92.14821920801057z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
<path d="M768.4138023884847 183.50966038245656L791.7112207622134 215.57580582570978 823.7773662054667 192.278387451981 800.4799478317378 160.21224200872777z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M955.206998076348 162.76877783195323L999.3710378176617 140.26607563127058 976.8683356169789 96.10203588995692 932.7042958756654 118.60473809063959z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M12.809281352625778 38.32420067506579L15.333160449379674 86.4826827035264 63.49164247784029 83.9588036067725 60.967763381086385 35.800321578311895z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M730.8203243804758 169.9399636519063L759.0370257813572 126.49005377027117 715.587115899722 98.27335236938977 687.3704144988407 141.72326225102492z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
<path d="M829.0504139631389 113.90023900242355L838.0171519845464 164.75313732450644 888.8700503066293 155.78639930309896 879.9033122852218 104.93350098101605z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
<path d="M1113.9957280681033 78.02099446221344L1140.6850821326325 57.16898575084809 1119.8330734212673 30.47963168631881 1093.1437193567378 51.33164039768416z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M172.06045733524795 59.181689102757716L206.81411742545455 105.30135375782231 252.93378208051914 70.5476936676157 218.18012199031253 24.428029012551107z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M562.8672114116416 114.31024483273946L600.7862711130508 138.93517012974428 625.4111964100557 101.01611042833508 587.4921367086465 76.39118513133025z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float3"></path>
<path d="M888.0288011006094 114.15671294879479L858.1621970497167 92.4573549387241 836.462839039646 122.32395898961676 866.3294430905387 144.02331699968744z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float2"></path>
<path d="M1126.2173189168125 54.03684274720831L1105.9951016004316 26.203348441102374 1078.1616072943254 46.42556575748341 1098.3838246107066 74.25906006358935z" fill="rgba(89, 157, 82, 0.8)" class="triangle-float1"></path>
</g>
<defs>
<mask id="SvgjsMask1105">
<rect width="1440" height="200" fill="#ffffff"></rect>
</mask>
<style>
@keyframes float1 {
0%{transform: translate(0, 0)}
50%{transform: translate(-10px, 0)}
100%{transform: translate(0, 0)}
}
.triangle-float1 {
animation: float1 5s infinite;
}
@keyframes float2 {
0%{transform: translate(0, 0)}
50%{transform: translate(-5px, -5px)}
100%{transform: translate(0, 0)}
}
.triangle-float2 {
animation: float2 4s infinite;
}
@keyframes float3 {
0%{transform: translate(0, 0)}
50%{transform: translate(0, -10px)}
100%{transform: translate(0, 0)}
}
.triangle-float3 {
animation: float3 6s infinite;
}
</style>
</defs>
</svg>`
+3 -1
View File
@@ -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,
}
+1 -1
View File
@@ -31,7 +31,7 @@ export const InfoModal: FC<InfoModalProps> = function ({ message }) {
onBackdropPress={() => onClose()}
onBackButtonPress={() => onClose()}
ContentComponent={
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: spacing.medium}}>
<View style={{ padding: spacing.small, flexDirection: 'row', alignItems: 'center', marginRight: spacing.medium}}>
<Icon icon="faInfoCircle" size={spacing.large} color="white" />
<Text style={{ marginHorizontal: spacing.extraSmall}}>{message}</Text>
</View>
+4 -2
View File
@@ -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 (
<View style={[{
flex: 1,
@@ -11,8 +12,9 @@ export function Loading(props: ViewProps) {
opacity: 0.5,
justifyContent: 'center',
zIndex: 9999,
}, StyleSheet.absoluteFillObject]}>
}, StyleSheet.absoluteFillObject, props.style]}>
<ActivityIndicator color="#ccc" animating size="large" />
{props.statusMessage && (<Text style={{opacity: 1}} text={props.statusMessage}/>)}
</View>
)
}
+1
View File
@@ -14,3 +14,4 @@ export * from "./InfoModal"
export * from "./BottomModal"
export * from "./Loading"
export * from "./ScanIcon"
export * from "./HeaderBg"
+3 -1
View File
@@ -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",
+47 -4
View File
@@ -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<MintKeys>(),
keysets: types.frozen<string[]>(),
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>(), 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
+28 -6
View File
@@ -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<typeof MintsStoreModel> {}
+19 -5
View File
@@ -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
+6
View File
@@ -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<TabsParamList>
}
@@ -68,6 +72,8 @@ const AppStack = observer(function AppStack() {
) : (
<>
<Stack.Screen name="Welcome" component={WelcomeScreen} />
<Stack.Screen name="RemoteRecovery" component={RemoteRecoveryScreen} />
<Stack.Screen name="Mints" component={MintsScreen} />
<Stack.Screen name="Tabs" component={TabsNavigator}/>
</>
)}
+4 -1
View File
@@ -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() {
<SettingsStack.Screen name="Privacy" component={PrivacyScreen} />
<SettingsStack.Screen name="Update" component={UpdateScreen} />
<SettingsStack.Screen name="Backup" component={BackupScreen} />
<SettingsStack.Screen name="RemoteBackup" component={RemoteBackupScreen} />
<SettingsStack.Screen name="LocalRecovery" component={LocalRecoveryScreen} />
<SettingsStack.Screen name="Developer" component={DeveloperScreen} />
<SettingsStack.Screen name="Relays" component={RelaysScreen} />
+73 -52
View File
@@ -75,6 +75,10 @@ export const BackupScreen: FC<SettingsStackScreenProps<'Backup'>> = 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<SettingsStackScreenProps<'Backup'>> = observer(fun
<Text preset="heading" text="Backup" style={{color: 'white'}} />
</View>
<View style={$contentContainer}>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
tx="backupScreen.localBackup"
subTx="backupScreen.localBackupDescription"
leftIcon='faDownload'
leftIconColor={
isLocalBackupOn
? colors.palette.success200
: colors.palette.neutral400
}
leftIconInverse={true}
RightComponent={
<View style={$rightContainer}>
<Switch
onValueChange={toggleBackupSwitch}
value={isLocalBackupOn}
/>
</View>
}
style={$item}
/>
{isLocalBackupOn && (
<ListItem
tx="backupScreen.recoveryTool"
subTx="backupScreen.recoveryToolDescription"
leftIcon='faUpload'
leftIconColor={colors.palette.focus300}
<Card
style={$card}
HeadingComponent={
<>
<ListItem
tx="backupScreen.remoteBackup"
subTx="backupScreen.remoteBackupDescription"
leftIcon='faUpRightFromSquare'
leftIconColor={colors.palette.blue200}
leftIconInverse={true}
style={$item}
onPress={gotoLocalRecovery}
/>
)}
</>
}
/>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
tx="backupScreen.removeSpentCoins"
subTx="backupScreen.removeSpentCoinsDescription"
leftIcon='faRecycle'
leftIconColor={colors.palette.secondary300}
leftIconInverse={true}
style={$item}
onPress={checkSpent}
/>
</>
}
/>
onPress={gotoRemoteBackup}
/>
</>
}
/>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
tx="backupScreen.localBackup"
subTx="backupScreen.localBackupDescription"
leftIcon='faDownload'
leftIconColor={
isLocalBackupOn
? colors.palette.success200
: colors.palette.neutral400
}
leftIconInverse={true}
RightComponent={
<View style={$rightContainer}>
<Switch
onValueChange={toggleBackupSwitch}
value={isLocalBackupOn}
/>
</View>
}
style={$item}
/>
{isLocalBackupOn && (
<ListItem
tx="backupScreen.recoveryTool"
subTx="backupScreen.recoveryToolDescription"
leftIcon='faUpload'
leftIconColor={colors.palette.focus300}
leftIconInverse={true}
style={$item}
onPress={gotoLocalRecovery}
topSeparator={true}
/>
)}
</>
}
/>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
tx="backupScreen.removeSpentCoins"
subTx="backupScreen.removeSpentCoinsDescription"
leftIcon='faRecycle'
leftIconColor={colors.palette.secondary300}
leftIconInverse={true}
style={$item}
onPress={checkSpent}
/>
</>
}
/>
{isLoading && <Loading />}
</View>
<BottomModal
+2 -3
View File
@@ -6,12 +6,11 @@ import {colors, spacing, typography, useThemeColor} from '../theme'
import {Header, Icon, Screen} from '../components'
import {useStores} from '../models'
import {ContactsStackScreenProps} from '../navigation'
import { NostrClient, NostrUnsignedEvent } from '../services'
import { NostrClient } from '../services'
import { PrivateContacts } from './Contacts/PrivateContacts'
import { PublicContacts } from './Contacts/PublicContacts'
import { log } from '../services/logService'
import { getImageSource } from '../utils/utils'
import { MINIBITS_NIP05_DOMAIN } from '@env'
import { ReceiveOption } from './ReceiveOptionsScreen'
import { SendOption } from './SendOptionsScreen'
@@ -55,7 +54,7 @@ export const ContactsScreen: FC<ContactsScreenProps> = observer(function Contact
const renderScene = ({route}: {route: Route}) => {
switch (route.key) {
case 'first':
return <PrivateContacts navigation={navigation} paymentOption={paymentOption} />
return <PrivateContacts navigation={navigation} paymentOption={paymentOption} />
case 'second':
return <PublicContacts navigation={navigation} paymentOption={paymentOption} />
default:
+21 -17
View File
@@ -117,23 +117,27 @@ export const DeveloperScreen: FC<SettingsStackScreenProps<'Developer'>> = 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)
}
},
},
],
+35 -8
View File
@@ -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<SettingsStackScreenProps<'Mints'>> = observer(function MintsScreen({route, navigation}) {
@@ -37,6 +40,7 @@ export const MintsScreen: FC<SettingsStackScreenProps<'Mints'>> = observer(funct
const mintInputRef = useRef<TextInput>(null)
const [mintUrl, setMintUrl] = useState('')
const [defaultMintUrl, setDefaultMintUrl] = useState<string>(MINIBITS_MINT_URL)
const [selectedMint, setSelectedMint] = useState<Mint | undefined>()
const [info, setInfo] = useState('')
const [error, setError] = useState<AppError | undefined>()
@@ -117,6 +121,12 @@ export const MintsScreen: FC<SettingsStackScreenProps<'Mints'>> = observer(funct
}
const addDefaultMint = async function () {
setMintUrl(defaultMintUrl)
toggleAddMintModal()
}
const removeMint = async function () {
if (!selectedMint) {return}
@@ -129,8 +139,6 @@ export const MintsScreen: FC<SettingsStackScreenProps<'Mints'>> = 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<SettingsStackScreenProps<'Mints'>> = observer(funct
<Card
style={$actionCard}
ContentComponent={
<>
<ListItem
text={'Add mint'}
LeftComponent={<Icon
@@ -232,6 +241,22 @@ export const MintsScreen: FC<SettingsStackScreenProps<'Mints'>> = observer(funct
style={$actionItem}
onPress={toggleAddMintModal}
/>
{!mintsStore.alreadyExists(defaultMintUrl) && (
<ListItem
text={'Add Minibits mint'}
LeftComponent={<Icon
containerStyle={$iconContainer}
icon="faCoins"
size={spacing.medium}
color={iconColor}
/>
}
style={$actionItem}
onPress={addDefaultMint}
topSeparator={true}
/>
)}
</>
}
/>
{mintsStore.mintCount > 0 && (
@@ -313,6 +338,7 @@ export const MintsScreen: FC<SettingsStackScreenProps<'Mints'>> = observer(funct
bottomSeparator={true}
style={{paddingHorizontal: spacing.medium}}
/>
<Text text={`Current recovery index: ${selectedMint?.currentProofsCounter?.counter}`} size='xxs' style={{alignSelf: 'center', marginTop: spacing.small}} />
</View>
}
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 = {
+3 -5
View File
@@ -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<WalletStackScreenProps<'ReceiveOptions'>>
onLeftPress: () => navigation.goBack(),
})
const isInternetReachable = useIsInternetReachable()
const isInternetReachable = useIsInternetReachable()
const [info, setInfo] = useState('')
const [error, setError] = useState<AppError | undefined>()
const gotoContacts = function () {
navigation.navigate('ContactsNavigator', {
screen: 'Contacts',
+10 -7
View File
@@ -132,7 +132,8 @@ export const ReceiveScreen: FC<WalletStackScreenProps<'Receive'>> = 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<WalletStackScreenProps<'Receive'>> = observer(
}
/>
{transactionStatus === TransactionStatus.COMPLETED ? (
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'common.close'}
onPress={gotoWallet}
/>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'common.close'}
onPress={gotoWallet}
/>
</View>
</View>
) : (
<View style={$bottomContainer}>
+201
View File
@@ -0,0 +1,201 @@
import {observer} from 'mobx-react-lite'
import React, {FC, useEffect, useState} from 'react'
import {FlatList, Linking, Platform, Switch, TextStyle, View, ViewStyle} from 'react-native'
import {validateMnemonic} from '@scure/bip39'
import { wordlist } from '@scure/bip39/wordlists/english'
import {colors, spacing, useThemeColor} from '../theme'
import {SettingsStackScreenProps} from '../navigation' // @demo remove-current-line
import {
Icon,
ListItem,
Screen,
Text,
Card,
Loading,
ErrorModal,
InfoModal,
BottomModal,
Button,
$sizeStyles,
} from '../components'
import {useHeader} from '../utils/useHeader'
import AppError, { Err } from '../utils/AppError'
import { KeyChain, log, MintClient } from '../services'
import { scale } from '@gocodingnow/rn-size-matters'
import Clipboard from '@react-native-clipboard/clipboard'
export const RemoteBackupScreen: FC<SettingsStackScreenProps<'RemoteBackup'>> = observer(function RemoteBackupScreen(_props) {
const {navigation, route} = _props
useHeader({
leftIcon: 'faArrowLeft',
onLeftPress: () => {
navigation.goBack()
},
})
// const {userSettingsStore} = useStores()
const [info, setInfo] = useState('')
const [mnemonic, setMnemonic] = useState<string>()
const [mnemonicArray, setMnemonicArray] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isNewMnemonic, setIsNewMnemonic] = useState(false)
const [error, setError] = useState<AppError | undefined>()
useEffect(() => {
const getmnemonic = async () => {
try {
setIsLoading(true)
let mnemonic: string | undefined = undefined
mnemonic = await MintClient.getMnemonic()
if(!mnemonic) {
// wallets upgraded from 0.1.4 with no generated seed
mnemonic = await MintClient.getOrCreateMnemonic() // expensive, derives seed
MintClient.resetCachedWallets() // force all cached wallet instances to be recreated with seed
setIsNewMnemonic(true)
}
if (!validateMnemonic(mnemonic, wordlist)) {
throw new AppError(Err.VALIDATION_ERROR, 'Corrupted or invalid mnemonic. Please move funds and reinstall this wallet.')
}
setMnemonic(mnemonic)
setMnemonicArray(mnemonic.split(/\s+/))
setIsLoading(false)
} catch (e: any) {
handleError(e)
}
}
getmnemonic()
}, [])
const onCopy = function (): void {
try {
if(mnemonic) {
Clipboard.setString(mnemonic)
if(isNewMnemonic) {
setInfo('To apply backup to your existing funds, send your whole balance to yourself. Otherwise only funds from now on can be restored.')
}
return
}
throw new AppError(Err.VALIDATION_ERROR, 'Missing mnemonic.')
} catch (e: any) {
setInfo(`Could not copy: ${e.message}`)
}
}
const handleError = function (e: AppError): void {
setIsLoading(false)
setError(e)
}
const headerBg = useThemeColor('header')
return (
<Screen style={$screen}>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text preset="heading" text="Off-device backup" style={{color: 'white'}} />
</View>
<View style={$contentContainer}>
<Card
style={$card}
ContentComponent={
<ListItem
text='Your mnemonic phrase'
subText='These 12 words sequence allows you to recover your ecash balance in case of device loss. Keep them in safe place.'
leftIcon='faInfoCircle'
leftIconColor={colors.palette.iconYellow300}
leftIconInverse={true}
style={$item}
/>
}
/>
<Card
style={$card}
ContentComponent={
<>
{isLoading && <Loading />}
<FlatList
data={mnemonicArray}
numColumns={2}
renderItem={({ item, index }) => {
return(
<Button
key={index}
preset={'secondary'}
onPress={() => false}
text={`${index + 1}. ${item}`}
style={{minWidth: scale(150), margin: spacing.tiny, minHeight: scale(25)}}
textStyle={[$sizeStyles.xs, {padding: 0, margin: 0, lineHeight: 16}]}
/>
)
}}
keyExtractor={(item) => item}
style={{ flexGrow: 0 }}
contentContainerStyle={{alignItems: 'center'}}
/>
</>
}
FooterComponent={
<View style={$buttonContainer}>
<Button
preset="default"
style={{margin: spacing.small}}
text="Copy"
onPress={onCopy}
/>
</View>
}
/>
</View>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
</Screen>
)
})
const $screen: ViewStyle = {}
const $headerContainer: TextStyle = {
alignItems: 'center',
padding: spacing.medium,
height: spacing.screenHeight * 0.18,
}
const $contentContainer: TextStyle = {
marginTop: -spacing.extraLarge * 2,
padding: spacing.extraSmall,
}
const $card: ViewStyle = {
marginBottom: spacing.small,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
}
const $bottomModal: ViewStyle = {
flex: 1,
alignItems: 'center',
paddingVertical: spacing.large,
paddingHorizontal: spacing.small,
}
const $item: ViewStyle = {
paddingHorizontal: spacing.small,
paddingLeft: 0,
}
const $rightContainer: ViewStyle = {
// padding: spacing.extraSmall,
alignSelf: 'center',
//marginLeft: spacing.small,
}
+804
View File
@@ -0,0 +1,804 @@
import {observer} from 'mobx-react-lite'
import React, {FC, useEffect, useRef, useState} from 'react'
import {LayoutAnimation, Platform, Switch, TextInput, TextStyle, UIManager, View, ViewStyle} from 'react-native'
import {validateMnemonic} from '@scure/bip39'
import { wordlist } from '@scure/bip39/wordlists/english'
import {colors, spacing, useThemeColor} from '../theme'
import {AppStackScreenProps} from '../navigation' // @demo remove-current-line
import {
Icon,
ListItem,
Screen,
Text,
Card,
Loading,
ErrorModal,
InfoModal,
BottomModal,
Button,
$sizeStyles,
} from '../components'
import {useHeader} from '../utils/useHeader'
import AppError, { Err } from '../utils/AppError'
import { KeyChain, log, MintClient, MintKeys } from '../services'
import Clipboard from '@react-native-clipboard/clipboard'
import { useStores } from '../models'
import { MintListItem } from './Mints/MintListItem'
import { Mint } from '../models/Mint'
import { CashuMint, deriveKeysetId } from '@cashu/cashu-ts'
import { CashuUtils } from '../services/cashu/cashuUtils'
import { Proof } from '../models/Proof'
import {
type Proof as CashuProof,
} from '@cashu/cashu-ts'
import { Transaction, TransactionData, TransactionRecord, TransactionStatus, TransactionType } from '../models/Transaction'
import { ResultModalInfo } from './Wallet/ResultModalInfo'
import { deriveSeedFromMnemonic } from '@cashu/cashu-ts'
if (Platform.OS === 'android' &&
UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true)
}
const RESTORE_INDEX_INTERVAL = 50
export const RemoteRecoveryScreen: FC<AppStackScreenProps<'RemoteRecovery'>> = observer(function RemoteRecoveryScreen(_props) {
const {navigation, route} = _props
useHeader({
leftIcon: 'faArrowLeft',
onLeftPress: () => {
navigation.goBack()
},
})
const {mintsStore, proofsStore, userSettingsStore, transactionsStore} = useStores()
const mnemonicInputRef = useRef<TextInput>(null)
const [info, setInfo] = useState('')
const [mnemonic, setMnemonic] = useState<string>('')
const [mnemonicExists, setMnemonicExists] = useState(false)
const [isValidMnemonic, setIsValidMnemonic] = useState(false)
const [seed, setSeed] = useState<Uint8Array>()
const [startIndex, setStartIndex] = useState<number>(0) // start of interval of indexes of proofs to recover
const [endIndex, setEndIndex] = useState<number>(RESTORE_INDEX_INTERVAL) // end of interval
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<AppError | undefined>()
const [isErrorsModalVisible, setIsErrorsModalVisible] = useState(false)
const [resultModalInfo, setResultModalInfo] = useState<{status: TransactionStatus, message: string} | undefined>()
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
const [lastRecoveredAmount, setLastRecoveredAmount] = useState<number>(0)
const [recoveryErrors, setRecoveryErrors] = useState<AppError[]>([])
const [statusMessage, setStatusMessage] = useState<string>()
useEffect(() => {
const getMnemonic = async () => {
try {
setIsLoading(true)
const existing = await MintClient.getMnemonic()
if(existing) {
setMnemonicExists(true)
}
setIsLoading(false)
} catch (e: any) {
handleError(e)
}
}
getMnemonic()
}, [])
const toggleResultModal = () => {
if(isResultModalVisible === true) {
setResultModalInfo(undefined)
}
setIsResultModalVisible(previousState => !previousState)
}
const toggleErrorsModal = () => {
setIsErrorsModalVisible(previousState => !previousState)
}
const onPaste = async function () {
try {
const maybeMnemonic = await Clipboard.getString()
if(!maybeMnemonic) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mnemonic phrase.')
}
setMnemonic(maybeMnemonic)
} catch (e: any) {
handleError(e)
}
}
const onConfirm = async function () {
try {
setStatusMessage('Deriving seed, this takes a while...')
if(!mnemonic) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mnemonic.')
}
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsLoading(true)
if (!validateMnemonic(mnemonic, wordlist)) {
throw new AppError(Err.VALIDATION_ERROR, 'Invalid mnemonic phrase. Provide 12 words sequence separated by blank spaces.')
}
setTimeout(() => {
const binarySeed = deriveSeedFromMnemonic(mnemonic) // expensive
setSeed(binarySeed)
setIsValidMnemonic(true)
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsLoading(false)
}, 200)
} catch (e: any) {
handleError(e)
}
}
const onBack = function (): void {
return navigation.goBack()
}
const onAddMints = function (): void {
return navigation.navigate('Mints')
}
const startRecovery = async function () {
setStatusMessage('Starting recovery...')
setIsLoading(true)
setTimeout(() => doRecovery(), 200)
}
const doRecovery = async function () {
let errors: AppError[] = []
let recoveredAmount: number = 0
let alreadySpentAmount: number = 0
setStatusMessage('Loading mints...')
for (const mint of mintsStore.allMints) {
const transactionData: TransactionData[] = []
let transactionId: number = 0
const pendingTransactionData: TransactionData[] = []
let pendingTransactionId: number = 0
try {
// TODO allow input or get previous keysets from mint and try to restore from them
setStatusMessage(`Restoring from ${mint.hostname}...`)
log.info('[restore]', `Restoring from ${mint.hostname}...`)
const { proofs, newKeys } = await MintClient.restore(
mint.mintUrl,
startIndex,
endIndex,
seed as Uint8Array
)
log.debug('[restore]', `Restored proofs`, proofs.length)
setStatusMessage(`Found ${proofs.length} proofs...`)
// exit if nothing recovered
if (proofs.length === 0) {
continue
}
// need to move counter by whole interval to avoid duplicate _B!!!
mintsStore.increaseProofsCounter(mint.mintUrl, Math.abs(endIndex - startIndex))
if(newKeys) {updateMintKeys(mint.mintUrl as string, newKeys)}
const {spent, pending} = await MintClient.getSpentOrPendingProofsFromMint(
mint.mintUrl,
proofs as Proof[]
)
log.debug('[restore]', `Spent and pending proofs`, {spent: spent.length, pending: pending.length})
setStatusMessage(`${spent.length} proofs were already spent...`)
const spentAmount = CashuUtils.getProofsAmount(spent as Proof[])
alreadySpentAmount += spentAmount
const unspent = proofs.filter(proof => !spent.includes(proof))
if(unspent && unspent.length > 0) {
setStatusMessage(`Completing recovery...`)
const amount = CashuUtils.getProofsAmount(unspent as Proof[])
recoveredAmount += amount
// Let's create new draft receive transaction in database
transactionData.push({
status: TransactionStatus.PREPARED,
amount,
createdAt: new Date(),
})
const newTransaction: Transaction = {
type: TransactionType.RECEIVE,
amount,
data: JSON.stringify(transactionData),
memo: 'Wallet recovery',
mint: mint.mintUrl,
status: TransactionStatus.PREPARED,
}
const draftTransaction: TransactionRecord = await transactionsStore.addTransaction(newTransaction)
transactionId = draftTransaction.id as number
const { amountToAdd, addedAmount } = addCashuProofs(
unspent,
mint.mintUrl,
transactionId as number
)
if (amountToAdd !== addedAmount) {
await transactionsStore.updateReceivedAmount(
transactionId as number,
addedAmount,
)
recoveredAmount = recoveredAmount - amount + addedAmount
}
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
createdAt: new Date(),
})
await transactionsStore.updateStatus(
transactionId,
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
)
const balanceAfter = proofsStore.getBalances().totalBalance
await transactionsStore.updateBalanceAfter(transactionId, balanceAfter)
}
if(pending && pending.length > 0) {
// infoMessage(`Found pending ecash...`)
setStatusMessage(`Found ${pending.length} pending proofs...`)
log.debug(`Found pending ecash with ${mint.hostname}...`)
const amount = CashuUtils.getProofsAmount(pending as Proof[])
// Let's create new draft receive transaction in database
pendingTransactionData.push({
status: TransactionStatus.PREPARED,
amount,
createdAt: new Date(),
})
const newTransaction: Transaction = {
type: TransactionType.RECEIVE,
amount,
data: JSON.stringify(transactionData),
memo: 'Wallet recovery - pending',
mint: mint.mintUrl,
status: TransactionStatus.PREPARED,
}
const draftTransaction: TransactionRecord = await transactionsStore.addTransaction(newTransaction)
pendingTransactionId = draftTransaction.id as number
const { amountToAdd, addedAmount } = addCashuProofs(
pending,
mint.mintUrl,
pendingTransactionId as number,
true // isPending = true
)
if (amountToAdd !== addedAmount) {
await transactionsStore.updateReceivedAmount(
transactionId as number,
addedAmount,
)
}
// Finally, update pending transaction
pendingTransactionData.push({
status: TransactionStatus.PENDING,
createdAt: new Date(),
})
await transactionsStore.updateStatus(
pendingTransactionId,
TransactionStatus.PENDING,
JSON.stringify(pendingTransactionData),
)
}
} catch(e: any) {
if (mint) {
e.params = {mintUrl: mint.mintUrl}
}
// log.error(e, {mintUrl: mint.mintUrl})
errors.push(e)
if (transactionId > 0) {
transactionData.push({
status: TransactionStatus.ERROR,
error: formatError(e),
createdAt: new Date(),
})
await transactionsStore.updateStatus(
transactionId,
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
if (pendingTransactionId > 0) {
pendingTransactionData.push({
status: TransactionStatus.ERROR,
error: formatError(e),
createdAt: new Date(),
})
await transactionsStore.updateStatus(
pendingTransactionId,
TransactionStatus.ERROR,
JSON.stringify(pendingTransactionData),
)
}
setStatusMessage(undefined)
continue
}
}
setLastRecoveredAmount(recoveredAmount)
setStartIndex(startIndex + RESTORE_INDEX_INTERVAL)
setEndIndex(endIndex + RESTORE_INDEX_INTERVAL)
setStatusMessage(undefined)
setIsLoading(false)
if(recoveredAmount > 0) {
setResultModalInfo({
status: TransactionStatus.COMPLETED,
message: `${recoveredAmount} sats were recovered into your wallet.`
})
} else {
if(errors.length > 0) {
setResultModalInfo({
status: TransactionStatus.ERROR,
message: `Recovery ended up with errors.`
})
setRecoveryErrors(errors)
} else {
if(alreadySpentAmount > 0) {
setResultModalInfo({
status: TransactionStatus.EXPIRED,
message: `Good news is that already spent ecash has been found. Continue with next recovery interval.`
})
} else {
setResultModalInfo({
status: TransactionStatus.EXPIRED,
message: `Nothing has been found in this recovery interval.`
})
}
}
}
toggleResultModal() // open
}
// TODO: make it DRY with walletService
const updateMintKeys = function (mintUrl: string, newKeys: MintKeys) {
if(!CashuUtils.validateMintKeys(newKeys)) {
// silent
log.warn('[_updateMintKeys]', 'Invalid mint keys to update, skipping', newKeys)
return
}
const keyset = deriveKeysetId(newKeys)
const mint = mintsStore.findByUrl(mintUrl)
return mint?.updateKeys(keyset, newKeys) // TODO make only newKeys as param
}
// TODO: make it DRY with walletService
const addCashuProofs = function (
proofsToAdd: CashuProof[],
mintUrl: string,
transactionId: number,
isPending: boolean = false
): {
amountToAdd: number,
addedAmount: number,
addedProofs: Proof[]
} {
// Add internal references
for (const proof of proofsToAdd as Proof[]) {
proof.tId = transactionId
proof.mintUrl = mintUrl
}
const amountToAdd = CashuUtils.getProofsAmount(proofsToAdd as Proof[])
// Creates proper model instances and adds them to the wallet
const {addedAmount, addedProofs} = proofsStore.addProofs(proofsToAdd as Proof[], isPending)
log.trace('[addCashuProofs]', 'Added recovered proofs to the wallet with amount', { amountToAdd, addedAmount, isPending })
return {
amountToAdd,
addedAmount,
addedProofs
}
}
// TODO: make it DRY with walletService
const formatError = function (e: AppError) {
return {
name: e.name,
message: e.message.slice(0, 100),
params: e.params || {},
} as AppError
}
const onComplete = async () => {
try {
if(!seed || !mnemonic) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mnemonic or seed.')
}
await KeyChain.saveMnemonic(mnemonic)
await KeyChain.saveSeed(seed as Uint8Array)
userSettingsStore.setIsOnboarded(true)
navigation.navigate('Tabs')
} catch (e: any) {
handleError(e)
}
}
const handleError = function (e: AppError): void {
setIsLoading(false)
setError(e)
}
const headerBg = useThemeColor('header')
const numIconColor = useThemeColor('textDim')
const textHint = useThemeColor('textDim')
const inputBg = useThemeColor('background')
return (
<Screen contentContainerStyle={$screen} preset="auto">
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text preset="heading" text="Wallet recovery" style={{color: 'white', zIndex: 10}} />
</View>
<View style={$contentContainer}>
{mnemonicExists ? (
<Card
style={$card}
ContentComponent={
<ListItem
text='Mnemonic exists'
subText='Your wallet already has another mnemonic in its secure storage. Recovery process works only with freshly installed wallet to avoid loss of your funds.'
leftIcon='faTriangleExclamation'
// leftIconColor='red'
style={$item}
bottomSeparator={true}
/>
}
FooterComponent={
<View style={$buttonContainer}>
<Button
onPress={onBack}
text='Back'
preset='secondary'
/>
</View>
}
/>
) : (
<>
{isValidMnemonic ? (
<>
<Card
style={$card}
ContentComponent={
<ListItem
text='Your mnemonic phrase'
subText={mnemonic}
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text='1'/></View>}
style={$item}
/>
}
/>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
text='Recovery from mints'
subText='Identify mints to recover your ecash from and add them to the list.'
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text='2'/></View>}
RightComponent={mintsStore.mintCount > 0 ? (
<View style={$rightContainer}>
<Button
onPress={onAddMints}
text='Mints'
preset='secondary'
/>
</View>
) : (undefined)
}
style={$item}
/>
{mintsStore.mintCount === 0 && (
<View style={$buttonContainer}>
<Button
onPress={onAddMints}
text='Add mints'
/>
</View>
)}
</>
}
ContentComponent={
<>
{mintsStore.mints.map((mint: Mint, index: number) => (
<MintListItem
key={mint.mintUrl}
mint={mint}
mintBalance={proofsStore.getMintBalance(mint.mintUrl)}
// onMintSelect={() => onMintSelect(mint)}
isSelectable={false}
// isSelected={selectedMint?.mintUrl === mint.mintUrl}
// isBlocked={mintsStore.isBlocked(mint.mintUrl as string)}
separator={index === 0 ? 'both' : 'bottom'}
/>
))}
</>
}
FooterComponent={
<>
{mintsStore.mintCount > 0 && (
<>
<View style={$buttonContainer}>
{startIndex > 0 && (
<Button
onPress={onComplete}
text={'Complete'}
style={{marginRight: spacing.small}}
/>
)}
<Button
onPress={startRecovery}
text={startIndex === 0 ? 'Start recovery' : 'Next interval'}
preset={startIndex === 0 ? 'default' : 'secondary'}
/>
</View>
<Text
text={`Recovery interval ${startIndex} - ${endIndex}`}
size='xxs'
style={{color: textHint, alignSelf: 'center', marginTop: spacing.small}}
/>
</>
)}
</>
}
/>
</>
) : (
<Card
style={$card}
ContentComponent={
<ListItem
text='Insert backup mnemonic phrase'
subText='Paste or rewrite 12 words phrase to recover your ecash balance on this device. Separate words by blank spaces.'
LeftComponent={<View style={[$numIcon, {backgroundColor: numIconColor}]}><Text text='1'/></View>}
style={$item}
/>
}
FooterComponent={
<>
<TextInput
ref={mnemonicInputRef}
onChangeText={(mnemonic: string) => setMnemonic(mnemonic)}
value={mnemonic}
numberOfLines={3}
multiline={true}
autoCapitalize='none'
keyboardType='default'
maxLength={150}
placeholder='Mnemonic phrase...'
selectTextOnFocus={true}
style={[$mnemonicInput, {backgroundColor: inputBg, flexWrap: 'wrap'}]}
/>
<View style={$buttonContainer}>
{mnemonic ? (
<Button
onPress={onConfirm}
text='Confirm'
/>
) : (
<Button
onPress={onPaste}
text='Paste'
/>
)
}
</View>
</>
}
/>
)}
</>
)}
</View>
<BottomModal
isVisible={isErrorsModalVisible}
style={{alignItems: 'stretch'}}
ContentComponent={
<>
{recoveryErrors?.map((err, index) => (
<ListItem
key={index}
leftIcon='faTriangleExclamation'
leftIconColor={colors.palette.angry500}
text={err.message}
subText={err.params ? err.params.mintUrl : ''}
bottomSeparator={true}
style={{paddingHorizontal: spacing.small}}
/>
)
)}
</>
}
onBackButtonPress={toggleErrorsModal}
onBackdropPress={toggleErrorsModal}
/>
<BottomModal
isVisible={isResultModalVisible ? true : false}
ContentComponent={
<>
{resultModalInfo &&
resultModalInfo.status === TransactionStatus.COMPLETED && (
<>
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title="Recovery success!"
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'common.close'}
onPress={toggleResultModal}
/>
</View>
</>
)}
{resultModalInfo &&
resultModalInfo.status === TransactionStatus.ERROR && (
<>
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.angry500}
title="Recovery failed"
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
text={'Show errors'}
onPress={toggleErrorsModal}
/>
</View>
</>
)}
{resultModalInfo &&
resultModalInfo.status === TransactionStatus.EXPIRED && (
<>
<ResultModalInfo
icon='faInfoCircle'
iconColor={colors.palette.neutral400}
title="No ecash recovered"
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'common.close'}
onPress={toggleResultModal}
/>
</View>
</>
)}
</>
}
onBackButtonPress={toggleResultModal}
onBackdropPress={toggleResultModal}
/>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
{isLoading && <Loading statusMessage={statusMessage} style={{backgroundColor: headerBg, opacity: 1}}/>}
</Screen>
)
})
const $screen: ViewStyle = {flex: 1}
const $headerContainer: TextStyle = {
alignItems: 'center',
padding: spacing.medium,
height: spacing.screenHeight * 0.18,
}
const $contentContainer: TextStyle = {
flex: 1,
marginTop: -spacing.extraLarge * 2,
padding: spacing.extraSmall,
}
const $card: ViewStyle = {
marginBottom: spacing.small,
}
const $numIcon: ViewStyle = {
width: 30,
height: 30,
borderRadius: 15,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.medium
}
const $mnemonicInput: TextStyle = {
// flex: 1,
borderRadius: spacing.small,
fontSize: 16,
padding: spacing.small,
alignSelf: 'stretch',
textAlignVertical: 'top',
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
marginTop: spacing.small,
}
const $bottomModal: ViewStyle = {
flex: 1,
alignItems: 'center',
paddingVertical: spacing.large,
paddingHorizontal: spacing.small,
}
const $item: ViewStyle = {
paddingHorizontal: spacing.small,
paddingLeft: 0,
}
const $rightContainer: ViewStyle = {
// padding: spacing.extraSmall,
alignSelf: 'center',
//marginLeft: spacing.small,
}
+1
View File
@@ -108,6 +108,7 @@ export const ScanScreen: FC<WalletStackScreenProps<'Scan'>> = function ScanScree
return IncomingParser.navigateWithIncomingData(invoiceResult, navigation)
} catch (e: any) {
log.trace('CATCH')
const maybeLnurl = LnurlUtils.findEncodedLnurl(incoming)
if(maybeLnurl) {
+3 -10
View File
@@ -1,8 +1,6 @@
import {observer} from 'mobx-react-lite'
import React, {FC, useState, useCallback} from 'react'
import {useFocusEffect} from '@react-navigation/native'
import React, {FC, useState, useCallback, useEffect} from 'react'
import {Alert, TextStyle, View, ViewStyle} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {spacing, useThemeColor, colors} from '../theme'
import {WalletStackScreenProps} from '../navigation'
import {
@@ -20,10 +18,7 @@ import {
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'
import { LnurlUtils } from '../services/lnurl/lnurlUtils'
import { IncomingDataType } from '../services/incomingParser'
import { SvgXml } from 'react-native-svg'
export enum SendOption {
@@ -41,11 +36,9 @@ export const SendOptionsScreen: FC<WalletStackScreenProps<'SendOptions'>> = obse
leftIcon: 'faArrowLeft',
onLeftPress: () => navigation.goBack(),
})
const isInternetReachable = useIsInternetReachable()
const [error, setError] = useState<AppError | undefined>()
const gotoContacts = function () {
navigation.navigate('ContactsNavigator', {
screen: 'Contacts',
+76 -34
View File
@@ -324,21 +324,26 @@ export const SendScreen: FC<WalletStackScreenProps<'Send'>> = observer(
return
}
setIsMintSelectorVisible(false)
if (paymentOption === SendOption.SHOW_TOKEN) {
toggleQRModal()
}
if (paymentOption === SendOption.SEND_TOKEN) {
toggleNostrDMModal()
}
setIsMintSelectorVisible(false)
}
}
const onMintBalanceCancel = async function () {
setIsMintSelectorVisible(false)
amountInputRef && amountInputRef.current
? amountInputRef.current.focus()
: false
}
@@ -638,7 +643,7 @@ export const SendScreen: FC<WalletStackScreenProps<'Send'>> = observer(
gotoWallet={resetState}
/>
)}
{(transactionStatus === TransactionStatus.PENDING || transactionStatus === TransactionStatus.COMPLETED) && (
{(transactionStatus === TransactionStatus.COMPLETED) && (
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
@@ -676,7 +681,7 @@ export const SendScreen: FC<WalletStackScreenProps<'Send'>> = observer(
encodedTokenToSend={encodedTokenToSend as string}
onCopy={onCopy}
onShareToApp={onShareToApp}
handleError={handleError}
// handleError={handleError}
/>
}
onBackButtonPress={toggleQRModal}
@@ -778,7 +783,7 @@ const MintBalanceSelector = observer(function (props: {
const {mintsStore} = useStores()
return (
<>
<View style={{flex: 1}}>
<Card
style={$card}
heading={'Pay from'}
@@ -806,20 +811,29 @@ const MintBalanceSelector = observer(function (props: {
</>
}
/>
<View style={[$buttonContainer, {marginTop: spacing.large}]}>
<Button
text="Send now"
onPress={props.onMintBalanceConfirm}
style={{marginRight: spacing.medium}}
// LeftAccessory={() => <Icon icon="faCoins" color="white" size={spacing.medium} containerStyle={{marginRight: spacing.small}}/>}
/>
<Button
preset="secondary"
tx={'common.cancel'}
onPress={props.onCancel}
/>
<View style={$bottomContainer}>
<View style={[$buttonContainer, {marginTop: spacing.large}]}>
<Button
text="Send now"
onPress={props.onMintBalanceConfirm}
style={{marginRight: spacing.medium}}
LeftAccessory={() => (
<Icon
icon="faArrowUp"
color="white"
size={spacing.medium}
/>
)}
// LeftAccessory={() => <Icon icon="faCoins" color="white" size={spacing.medium} containerStyle={{marginRight: spacing.small}}/>}
/>
<Button
preset="secondary"
tx={'common.cancel'}
onPress={props.onCancel}
/>
</View>
</View>
</>
</View>
)
})
@@ -943,7 +957,7 @@ const SelectedMintBlock = observer(function (props: {
const tokenTextColor = useThemeColor('textDim')
return (
<View>
<View style={{flex: 1}}>
<Card
style={$card}
heading={'Send from'}
@@ -959,8 +973,9 @@ const SelectedMintBlock = observer(function (props: {
separator={'top'}
/>
}
/>
<View style={$buttonContainer}>
/>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
text='QR code'
preset='secondary'
@@ -978,7 +993,7 @@ const SelectedMintBlock = observer(function (props: {
text='Send to contact'
preset='secondary'
onPress={props.toggleNostrDMModal}
style={{marginLeft: spacing.medium}}
style={{marginLeft: spacing.small}}
LeftAccessory={() => (
<Icon
icon='faPaperPlane'
@@ -988,7 +1003,15 @@ const SelectedMintBlock = observer(function (props: {
)}
/>
)}
</View>
<Button
preset="secondary"
tx={'common.close'}
onPress={props.gotoWallet}
style={{marginLeft: spacing.small}}
/>
</View>
</View>
</View>
)
})
@@ -1001,18 +1024,37 @@ const SendAsQRCodeBlock = observer(function (props: {
encodedTokenToSend: string
onCopy: any
onShareToApp: any
handleError: any
// handleError: any
}) {
const [qrError, setQrError] = useState<Error | undefined>()
const handleQrError = function (error: Error) {
setQrError(error)
}
return (
<View style={[$bottomModal, {marginHorizontal: spacing.small}]}>
<Text text={'Scan to receive'} />
<View style={$qrCodeContainer}>
<QRCode
size={spacing.screenWidth - spacing.large * 2} value={props.encodedTokenToSend}
onError={props.handleError}
/>
</View>
<View style={[{marginHorizontal: spacing.small}]}>
<Text text={'Scan to receive'} style={{alignSelf: 'center'}}/>
{qrError ? (
<ListItem
text='Could not display QR code, copy token instead.'
subText={qrError ? qrError.message : ''}
leftIcon='faTriangleExclamation'
containerStyle={{marginVertical: spacing.large}}
leftIconColor={colors.palette.angry500}
/>
) : (
<View style={$qrCodeContainer}>
<QRCode
size={spacing.screenWidth - spacing.large * 2} value={props.encodedTokenToSend}
onError={(error: any) => handleQrError(error)}
/>
</View>
)}
<View style={$buttonContainer}>
<Button
text="Share"
@@ -1292,7 +1334,7 @@ const $profileIcon: ImageStyle = {
}
const $bottomContainer: ViewStyle = {
position: 'absolute',
// position: 'absolute',
bottom: 0,
left: 0,
right: 0,
+1 -1
View File
@@ -1205,7 +1205,7 @@ const $contentContainer: TextStyle = {
const $actionCard: ViewStyle = {
marginBottom: spacing.extraSmall,
marginTop: -spacing.extraLarge * 2,
paddingTop: 0,
paddingVertical: 0,
}
const $dataCard: ViewStyle = {
+18 -25
View File
@@ -70,7 +70,8 @@ export const TransferScreen: FC<WalletStackScreenProps<'Transfer'>> = observer(
const [amountToTransfer, setAmountToTransfer] = useState<string>('')
const [invoiceExpiry, setInvoiceExpiry] = useState<Date | undefined>()
const [paymentHash, setPaymentHash] = useState<string | undefined>()
const [lnurlPayParams, setLnurlPayParams] = useState<LNURLPayParams | undefined>()
const [lnurlPayParams, setLnurlPayParams] = useState<LNURLPayParams & {address?: string} | undefined>()
const [isWaitingForFees, setIsWaitingForFees] = useState<boolean>(false)
const [estimatedFee, setEstimatedFee] = useState<number>(0)
const [finalFee, setFinalFee] = useState<number>(0)
const [memo, setMemo] = useState('')
@@ -158,6 +159,7 @@ useFocusEffect(
if(metadata) {
let desc: string = ''
let address: string = ''
for (const entry of metadata) {
if (entry[0] === "text/plain") {
@@ -166,9 +168,21 @@ useFocusEffect(
}
}
for (const entry of metadata) {
if (entry[0] === "text/identifier" || entry[0] === "text/email") {
address = entry[1];
break
}
}
if(desc) {
setLnurlDescription(desc)
}
if(address) {
// overwrite sender address set by wallet with the address from the lnurl response
lnurlParams.address = address
}
}
const amountSats = roundUp(lnurlParams.minSendable / 1000, 0)
@@ -302,29 +316,8 @@ const onAmountEndEditing = async function () {
setIsLoading(true)
const encoded = await LnurlClient.getInvoice(lnurlPayParams, amount * 1000)
const invoice = LightningUtils.decodeInvoice(encoded)
const {description_hash} = LightningUtils.getInvoiceData(invoice)
if(!description_hash || description_hash.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, `Invoice from ${lnurlPayParams.domain} is invalid, missing description_hash`)
}
// check that retrieved invoice matches the previous LNURL pay link
const hashedMetadata = QuickCrypto.createHash('sha256')
.update(lnurlPayParams.metadata)
.digest('hex')
log.trace('hashedMetadata', hashedMetadata)
log.trace('description_hash', description_hash)
if(hashedMetadata !== description_hash) {
throw new AppError(
Err.VALIDATION_ERROR,
`Invoice from ${lnurlPayParams.domain} has invalid description_hash ${description_hash}, expected ${hashedMetadata}`
)
}
setIsLoading(false)
if(encoded) {
return onEncodedInvoice(encoded)
}
@@ -347,7 +340,7 @@ const onEncodedInvoice = async function (encoded: string, paymentRequestDesc: st
setEncodedInvoice(encoded)
const invoice = LightningUtils.decodeInvoice(encoded)
const {amount, expiry, description, description_hash, timestamp} = LightningUtils.getInvoiceData(invoice)
const {amount, expiry, description, timestamp} = LightningUtils.getInvoiceData(invoice)
// log.trace('Decoded invoice', invoice, 'onEncodedInvoice')
log.trace('Invoice data', {amount, expiry, description}, 'onEncodedInvoice')
@@ -522,7 +515,7 @@ const satsColor = colors.palette.primary200
style={[$card, {minHeight: 0}]}
ContentComponent={
<ListItem
text={(memo) ? memo : lnurlPayParams ? lnurlPayParams.address ? lnurlPayParams.address : lnurlPayParams.domain : ''}
text={lnurlPayParams?.address || memo || lnurlPayParams?.domain || 'Payment info'}
subText={lnurlDescription}
LeftComponent={
<Icon
+23 -12
View File
@@ -49,7 +49,8 @@ import AppError, { Err } from '../utils/AppError'
import {
APP_ENV,
CODEPUSH_STAGING_DEPLOYMENT_KEY,
CODEPUSH_PRODUCTION_DEPLOYMENT_KEY,
CODEPUSH_PRODUCTION_DEPLOYMENT_KEY,
MINIBITS_MINT_URL
} from '@env'
import { round } from '../utils/number'
import { NotificationService } from '../services/notificationService'
@@ -89,7 +90,7 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
const isInternetReachable = useIsInternetReachable()
const [info, setInfo] = useState<string>('')
const [defaultMintUrl, setDefaultMintUrl] = useState<string>('https://mint.minibits.cash/Bitcoin')
const [defaultMintUrl, setDefaultMintUrl] = useState<string>(MINIBITS_MINT_URL)
const [error, setError] = useState<AppError | undefined>()
const [isLoading, setIsLoading] = useState<boolean>(false)
@@ -107,18 +108,24 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
setUpdateDescription(update.description)
setUpdateSize(`${round(update.packageSize * 0.000001, 2)}MB`)
setIsUpdateAvailable(true)
toggleUpdateModal()
// toggleUpdateModal()
log.info('OTA Update available', update, 'checkForUpdate')
}
} catch (e: any) {
return false // silent
}
}
InteractionManager.runAfterInteractions(async () => {
setTimeout(() => {
if(!isInternetReachable) {
return
}
checkForUpdate()
})
}, 100)
}, [])
const handleBinaryVersionMismatchCallback = function(update: RemotePackage) {
log.info('Native update available, silent', update)
@@ -147,13 +154,13 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
}
InteractionManager.runAfterInteractions(async () => {
setTimeout(async () => {
if(!isInternetReachable) {
return
}
// subscribe once to receive tokens or payment requests by NOSTR DMs
Wallet.checkPendingReceived()
})
}, 200)
EventEmitter.on('receiveTokenCompleted', onReceiveTokenCompleted)
EventEmitter.on('receivePaymentRequest', onReceivePaymentRequest)
@@ -201,14 +208,14 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
useFocusEffect(
useCallback(() => {
InteractionManager.runAfterInteractions(async () => {
setTimeout(() => {
if(!isInternetReachable) {
return
}
Wallet.checkPendingSpent()
Wallet.checkPendingTopups()
})
}, 100)
}, [])
)
@@ -232,10 +239,14 @@ export const WalletScreen: FC<WalletScreenProps> = observer(
appState.current.match(/inactive|background/) &&
nextAppState === 'active') {
InteractionManager.runAfterInteractions(async () => {
setTimeout(() => {
if(!isInternetReachable) {
return
}
Wallet.checkPendingSpent()
Wallet.checkPendingTopups()
})
Wallet.checkPendingTopups()
}, 100)
}
appState.current = nextAppState
+47 -24
View File
@@ -15,16 +15,20 @@ import {
// import { isRTL } from "../i18n"
import {useStores} from '../models'
import {AppStackScreenProps} from '../navigation'
import {spacing, colors} from '../theme'
import {spacing, colors, useThemeColor} from '../theme'
import {useHeader} from '../utils/useHeader'
import {useSafeAreaInsetsStyle} from '../utils/useSafeAreaInsetsStyle'
import {
Button,
ErrorModal,
Icon,
Loading,
Screen,
Text,
} from '../components'
import {TxKeyPath} from '../i18n'
import AppError from '../utils/AppError'
import { MintClient } from '../services'
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
@@ -77,14 +81,35 @@ export const WelcomeScreen: FC<AppStackScreenProps<'Welcome'>> =
})
const {userSettingsStore} = useStores()
const [isGotoWalletVisible, setIsGotoWalletVisible] = useState<boolean>(false)
const [error, setError] = useState<AppError | undefined>()
const [isLoading, setIsLoading] = useState<boolean>(false)
const gotoWallet = function () {
userSettingsStore.setIsOnboarded(true)
navigation.navigate('Tabs')
const gotoWallet = async function () {
try {
// do not overwrite if one was set during recovery
setIsLoading(true)
const mnemonic = await MintClient.getOrCreateMnemonic()
userSettingsStore.setIsOnboarded(true)
navigation.navigate('Tabs')
setIsLoading(false)
} catch (e: any) {
handleError(e)
}
}
const gotoRecovery = async function () {
try {
navigation.navigate('RemoteRecovery')
} catch (e: any) {
handleError(e)
}
}
const handleError = function (e: AppError) {
setIsLoading(false)
setError(e)
}
@@ -121,15 +146,7 @@ export const WelcomeScreen: FC<AppStackScreenProps<'Welcome'>> =
[]
)
const onPageSelected = function(e: any) {
if(e.nativeEvent.position === PAGES.length - 1) {
setIsGotoWalletVisible(true)
} else {
setIsGotoWalletVisible(false)
}
}
const $bottomContainerInsets = useSafeAreaInsetsStyle(['bottom'])
const headerBg = useThemeColor('header')
const renderBullet = ({item}: {item: {id: string; tx: string}}) => (
<View style={$listItem}>
@@ -150,7 +167,7 @@ export const WelcomeScreen: FC<AppStackScreenProps<'Welcome'>> =
return (
<Screen contentContainerStyle={$container} preset="fixed">
<Screen contentContainerStyle={$container} preset="fixed" style={{backgroundColor: headerBg}}>
<AnimatedPagerView
testID="pager-view"
initialPage={0}
@@ -192,6 +209,13 @@ export const WelcomeScreen: FC<AppStackScreenProps<'Welcome'>> =
preset='secondary'
text='Got it, take me to the wallet'
/>
<Button
onPress={gotoRecovery}
preset='tertiary'
text='Recover lost wallet'
LeftAccessory={() => {return<Icon icon='faRotate'/>}}
style={{marginTop: spacing.medium}}
/>
</View>
)}
</View>
@@ -212,7 +236,8 @@ export const WelcomeScreen: FC<AppStackScreenProps<'Welcome'>> =
/>
</View>
</View>
{error && <ErrorModal error={error} />}
{isLoading && <Loading statusMessage={'Creating wallet seed, this takes a while...'} style={{backgroundColor: headerBg, opacity: 1}}/>}
</Screen>
)
}
@@ -230,12 +255,11 @@ const $dotContainer: ViewStyle ={
const $container: ViewStyle = {
// alignItems: 'center',
flex: 1,
padding: spacing.medium,
backgroundColor: colors.palette.primary500
padding: spacing.medium,
}
const $listContainer: ViewStyle = {
maxHeight: spacing.screenHeight * 0.35,
maxHeight: spacing.screenHeight * 0.4,
}
const $listItem: ViewStyle = {
@@ -249,8 +273,7 @@ const $itemIcon: ViewStyle = {
marginBottom: spacing.small,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
const $buttonContainer: ViewStyle = {
alignSelf: 'center',
marginTop: spacing.large,
}
@@ -267,6 +290,6 @@ const $welcomeIntro: TextStyle = {
}
const $welcomeFinal: TextStyle = {
marginTop: spacing.huge,
marginTop: spacing.large,
color: 'white',
}
+2
View File
@@ -28,6 +28,8 @@ export * from './SecurityScreen'
export * from './PrivacyScreen'
export * from './UpdateScreen'
export * from './BackupScreen'
export * from './RemoteBackupScreen'
export * from './RemoteRecoveryScreen'
export * from './LocalRecoveryScreen'
export * from './DeveloperScreen'
export * from './RelaysScreen'
+208 -39
View File
@@ -5,26 +5,86 @@ import {
PayLnInvoiceResponse,
type Proof as CashuProof,
} from '@cashu/cashu-ts'
import { TorDaemon } from '../services'
import {rootStoreInstance} from '../models'
import { KeyChain, TorDaemon } from '../services'
import {CashuUtils} from './cashu/cashuUtils'
import AppError, {Err} from '../utils/AppError'
import {log} from './logService'
import {Token} from '../models/Token'
import {Proof} from '../models/Proof'
import { deriveSeedFromMnemonic } from '@cashu/cashu-ts'
export type MintKeys = {[k: number]: string}
export type MintKeySets = {keysets: Array<string>}
let _mints: {[mintUrl: string]: CashuMint} = {}
let _wallets: {[mintUrl: string]: CashuWallet} = {}
let _wallets: {[mintUrl: string]: CashuWallet} = {} // used where seed is not required (perf)
let _seedWallets: {[mintUrl: string]: CashuWallet} = {} // wallet instances with seeds from keystore
let _mnemonic: string | undefined = undefined
let _seed: Uint8Array | undefined = undefined
const getMint = function (mintUrl: string) {
const { mintsStore } = rootStoreInstance
const getOrCreateMnemonic = async function (): Promise<string> {
let mnemonic: string | undefined = undefined
mnemonic = await getMnemonic() // returns cached or saved mnemonic
if (!mnemonic) {
mnemonic = KeyChain.generateMnemonic() as string
const seed = deriveSeedFromMnemonic(mnemonic) // expensive
await KeyChain.saveMnemonic(mnemonic)
await KeyChain.saveSeed(seed)
log.trace('[getOrCreateMnemonic]', 'Created and saved new mnemonic and seed')
}
return mnemonic
}
// caching mnemonic
const getMnemonic = async function (): Promise<string | undefined> {
if (_mnemonic) {
log.trace('[getMnemonic]', 'returning cached mnemonic')
return _mnemonic
}
const mnemonic = await KeyChain.loadMnemonic() as string
if (!mnemonic) {
return undefined
}
_mnemonic = mnemonic
return mnemonic
}
const getSeed = async function (): Promise<Uint8Array | undefined> {
if (_seed) {
log.trace('[getSeed]', 'returning cached seed')
return _seed
}
const seed = await KeyChain.loadSeed()
if (!seed) {
return undefined
}
_seed = seed
return seed
}
const getMint = function (mintUrl: string) {
if (_mints[mintUrl]) {
return _mints[mintUrl]
}
if(mintUrl.includes('.onion')) {
log.trace('Creating mint instance with .onion mintUrl', {mintUrl}, 'getMint')
log.trace('[getMint]', 'Creating mint instance with .onion mintUrl', {mintUrl})
const mint = new CashuMint(mintUrl, TorDaemon.torRequest)
_mints[mintUrl] = mint
@@ -37,16 +97,55 @@ const getMint = function (mintUrl: string) {
return mint
}
const getWallet = function (mintUrl: string) {
if (_wallets[mintUrl]) {
return _wallets[mintUrl]
}
const mint = getMint(mintUrl)
const wallet = new CashuWallet(mint)
_wallets[mintUrl] = wallet
const getWallet = async function (
mintUrl: string,
withSeed: boolean = false
) {
log.trace('[getWallet] start')
return wallet
if (withSeed && _seedWallets[mintUrl]) {
log.trace('[getWallet]', 'Returning existing cashuWallet instance with seed')
return _seedWallets[mintUrl]
}
if (!withSeed && _wallets[mintUrl]) {
log.trace('[getWallet]', 'Returning existing cashuWallet instance')
return _wallets[mintUrl]
}
const cashuMint = getMint(mintUrl)
const mint = mintsStore.findByUrl(mintUrl)
if(withSeed) {
let seed: Uint8Array | undefined = undefined
seed = await getSeed()
// Handle legacy pre-0.1.5 created wallets
if(!seed) {
const mnemonic = await getOrCreateMnemonic()
seed = await getSeed()
resetCachedWallets() // force all wallet instances to be recreated with seed
}
const seedWallet = new CashuWallet(cashuMint, mint ? mint.keys : undefined, seed)
log.trace('[getWallet]', 'Returning new cashuWallet instance with seed')
_seedWallets[mintUrl] = seedWallet
return seedWallet
}
const wallet = new CashuWallet(cashuMint, mint ? mint.keys : undefined)
_wallets[mintUrl] = wallet
log.trace('[getWallet]', 'Returning new cashuWallet instance')
return wallet
}
const resetCachedWallets = function () {
_seedWallets = {}
_wallets = {}
}
const getMintKeys = async function (mintUrl: string) {
@@ -56,13 +155,12 @@ const getMintKeys = async function (mintUrl: string) {
try {
// keysets = await mint.getKeySets()
log.trace('Sending getKeys request', {mintUrl}, 'getMintKeys')
keys = await mint.getKeys()
} catch (e: any) {
throw new AppError(
Err.CONNECTION_ERROR,
`Could not connect to the selected mint.`,
[e.message, mintUrl],
{message: e.message, mintUrl},
)
}
@@ -70,7 +168,7 @@ const getMintKeys = async function (mintUrl: string) {
throw new AppError(
Err.VALIDATION_ERROR,
'Invalid keys retrieved from the selected mint.',
[mintUrl, keys],
{mintUrl, keys},
)
}
@@ -85,47 +183,61 @@ const getMintKeys = async function (mintUrl: string) {
}
const receiveFromMint = async function (mintUrl: string, encodedToken: string) {
const receiveFromMint = async function (
mintUrl: string,
encodedToken: string,
counter: number
) {
try {
const cashuWallet = getWallet(mintUrl)
const cashuWallet = await getWallet(mintUrl, true) // with seed
log.trace('[receiveFromMint] calling cashuWallet.receive', {encodedToken, counter})
// this method returns quite a mess, we normalize naming of returned parameters
const {token, tokensWithErrors, newKeys} = await cashuWallet.receive(
const {token, tokensWithErrors, newKeys, errors} = await cashuWallet.receive(
encodedToken,
undefined,
counter
)
log.trace('[receiveFromMint] updatedToken', token)
log.trace('[receiveFromMint] tokensWithErrors', tokensWithErrors)
log.trace('[receiveFromMint] newKeys', newKeys)
// if (newKeys) { _setKeys(mintUrl, newKeys) }
log.trace('[receiveFromMint] errors', errors)
return {
updatedToken: token as Token,
errorToken: tokensWithErrors as Token,
newKeys,
errors
}
} catch (e: any) {
throw new AppError(Err.MINT_ERROR, e.message)
}
}
const sendFromMint = async function (
mintUrl: string,
amountToSend: number,
proofsToSendFrom: Proof[],
counter: number
) {
try {
const cashuWallet = getWallet(mintUrl)
const cashuWallet = await getWallet(mintUrl, true) // with seed
const {returnChange, send, newKeys} = await cashuWallet.send(
amountToSend,
proofsToSendFrom,
undefined,
counter
)
log.trace('[MintClient.sendFromMint] returnedProofs', returnChange)
log.trace('[MintClient.sendFromMint] sentProofs', send)
log.trace('[MintClient.sendFromMint] newKeys', newKeys)
log.debug('[MintClient.sendFromMint] returnedProofs', returnChange)
log.debug('[MintClient.sendFromMint] sentProofs', send)
log.debug('[MintClient.sendFromMint] newKeys', newKeys)
// do some basic validations that proof amounts from mints match
const totalAmountToSendFrom = CashuUtils.getProofsAmount(proofsToSendFrom)
@@ -157,7 +269,7 @@ const sendFromMint = async function (
} catch (e: any) {
throw new AppError(
Err.MINT_ERROR,
`The mint could not return proofs necessary for this transaction. ${e.message}`,
`The mint could not return signatures necessary for this transaction. ${e.message}`,
{
caller: 'sendFromMint',
mintUrl,
@@ -167,12 +279,14 @@ const sendFromMint = async function (
}
}
const getSpentOrPendingProofsFromMint = async function (
mintUrl: string,
proofs: Proof[],
) {
try {
const cashuWallet = getWallet(mintUrl)
const cashuWallet = await getWallet(mintUrl)
const spentPendingProofs = await cashuWallet.checkProofsSpent(proofs)
@@ -186,7 +300,7 @@ const getSpentOrPendingProofsFromMint = async function (
} catch (e: any) {
throw new AppError(
Err.MINT_ERROR,
'The mint could not check if the proofs were spent or are pending.',
'The mint could not reply if the proofs are spent or pending.',
{
caller: 'getSpentOrPendingProofsFromMint',
mintUrl,
@@ -196,6 +310,8 @@ const getSpentOrPendingProofsFromMint = async function (
}
}
const getLightningFee = async function (
mintUrl: string,
encodedInvoice: string,
@@ -218,20 +334,24 @@ const getLightningFee = async function (
}
}
const payLightningInvoice = async function (
mintUrl: string,
encodedInvoice: string,
proofsToPayFrom: CashuProof[],
estimatedFee: number,
counter: number
) {
try {
const cashuWallet = getWallet(mintUrl)
const cashuWallet = await getWallet(mintUrl, true) // with seed
const {isPaid, change, preimage, newKeys}: PayLnInvoiceResponse =
await cashuWallet.payLnInvoice(
encodedInvoice,
proofsToPayFrom,
estimatedFee,
counter
)
// if (newKeys) { _setKeys(mintUrl, newKeys) }
@@ -261,12 +381,14 @@ const payLightningInvoice = async function (
}
}
const requestLightningInvoice = async function (
mintUrl: string,
amount: number,
) {
try {
const cashuWallet = getWallet(mintUrl)
const cashuWallet = await getWallet(mintUrl)
const {pr, hash} = await cashuWallet.requestMint(amount)
log.info('[requestLightningInvoice]', {pr, hash})
@@ -280,17 +402,22 @@ const requestLightningInvoice = async function (
}
}
const requestProofs = async function (
mintUrl: string,
amount: number,
paymentHash: string,
counter: number
) {
try {
const cashuWallet = getWallet(mintUrl)
const cashuWallet = await getWallet(mintUrl, true) // with seed
/* eslint-disable @typescript-eslint/no-unused-vars */
const {proofs, newKeys} = await cashuWallet.requestTokens(
amount,
paymentHash,
undefined,
counter
)
/* eslint-enable */
@@ -312,13 +439,55 @@ const requestProofs = async function (
}
}
export const MintClient = {
getMintKeys,
receiveFromMint,
sendFromMint,
getSpentOrPendingProofsFromMint,
getLightningFee,
payLightningInvoice,
requestLightningInvoice,
requestProofs,
const restore = async function (
mintUrl: string,
indexFrom: number,
indexTo: number,
seed: Uint8Array
) {
try {
// need special wallet instance to pass seed directly
const cashuMint = getMint(mintUrl)
const mint = mintsStore.findByUrl(mintUrl)
if(!mint) {
throw new AppError(Err.MINT_ERROR, 'Could not find mint in wallet state', {caller: 'restore', mintUrl})
}
const seedWallet = new CashuWallet(cashuMint, mint.keys, seed)
const count = Math.abs(indexTo - indexFrom)
/* eslint-disable @typescript-eslint/no-unused-vars */
const {proofs, newKeys} = await seedWallet.restore(
count,
indexFrom,
)
/* eslint-enable */
log.info('[restore]', 'Number of recovered proofs', {proofs: proofs.length, newKeys})
return {
proofs: proofs || [],
newKeys
}
} catch (e: any) {
log.error(e)
throw new AppError(Err.MINT_ERROR, e.message, {mintUrl})
}
}
export const MintClient = {
getOrCreateMnemonic,
getMnemonic,
getSeed,
resetCachedWallets,
getMintKeys,
receiveFromMint,
sendFromMint,
getSpentOrPendingProofsFromMint,
getLightningFee,
payLightningInvoice,
requestLightningInvoice,
requestProofs,
restore,
}
+2 -4
View File
@@ -163,8 +163,7 @@ const navigateWithIncomingData = async function (
if(lnurlParams.tag === 'payRequest') {
return navigation.navigate('Transfer', {
lnurlParams,
// encodedInvoice,
lnurlParams,
paymentOption: SendOption.LNURL_PAY
})
}
@@ -174,8 +173,7 @@ const navigateWithIncomingData = async function (
const addressParamsResult = await LnurlClient.getLnurlAddressParams(incoming.encoded)
return navigation.navigate('Transfer', {
lnurlParams: addressParamsResult.lnurlParams,
// encodedInvoice: addressParamsResult.encodedInvoice,
lnurlParams: addressParamsResult.lnurlParams,
paymentOption: SendOption.LNURL_PAY
})
+1
View File
@@ -7,3 +7,4 @@ export * from "./minibitsService"
export * from "./nostrService"
export * from "./logService"
export * from "./torService"
// export * from "./restoreService"
+282 -121
View File
@@ -1,13 +1,17 @@
import * as _Keychain from 'react-native-keychain'
import AppError, {Err} from '../utils/AppError'
import QuickCrypto from 'react-native-quick-crypto'
import { generateNewMnemonic } from '@cashu/cashu-ts'
import * as nostrTools from 'nostr-tools'
import {btoa, atob, fromByteArray} from 'react-native-quick-base64'
import {log} from './logService'
export enum KeyChainServiceName {
MMKV = 'app.minibits.mmkv',
NOSTR = 'app.minibits.nostr',
SEED = 'app.minibits.seed',
MNEMONIC = 'app.minibits.mnemonic',
}
export type KeyPair = {
@@ -15,15 +19,170 @@ export type KeyPair = {
privateKey: string
}
/* const generateKeyPair = async function () {
try {
const keyPair = QuickCrypto.generateKeyPairSync('ed25519')
log.trace('New ed25519 keyPair:', keyPair)
return keyPair
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
const getSupportedBiometryType = async function () {
try {
const biometryType = await _Keychain.getSupportedBiometryType({})
log.trace('biometryType', biometryType)
return biometryType
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
}
}
const generateMnemonic = function () {
try {
log.trace('[generateMnemonic]', 'start')
const mnemonic = generateNewMnemonic()
log.trace('[generateMnemonic]', 'New mnemonic created:', mnemonic)
return mnemonic as string
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
}
}
const saveMnemonic = async function (
mnemonic: string,
): Promise<_Keychain.Result | false> {
try {
const result = await _Keychain.setGenericPassword(
KeyChainServiceName.MNEMONIC,
mnemonic,
{
service: KeyChainServiceName.MNEMONIC
},
)
log.trace('[saveMnemonic]', 'Saved new mnemonic to the KeyChain', mnemonic)
return result
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
}
}
} */
/**
* Loads mnemonic from the KeyChain/KeyStore
*
*/
const loadMnemonic = async function (): Promise<string | undefined> {
try {
log.trace('[loadMnemonic]', 'start')
const result = await _Keychain.getGenericPassword({
service: KeyChainServiceName.MNEMONIC
})
if (result) {
const mnemonic = result.password
log.trace('[loadMnemonic]', 'complete', {mnemonic})
return mnemonic
}
log.trace('[loadMnemonic]', 'Did not find existing mnemonic in the KeyChain')
return undefined
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
/**
* Removes mnemonic from KeyChain/KeyStore
*
* @param service The key to kill.
*/
const removeMnemonic = async function (): Promise<boolean> {
try {
const result = await _Keychain.resetGenericPassword({
service: KeyChainServiceName.MNEMONIC
})
log.trace('[removeMnemonic]', 'Removed mnemonic.')
return result
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
/**
* Save seed to KeyChain/KeyStore
*
* @param seed The key to save.
*/
const saveSeed = async function (
seed: Uint8Array,
): Promise<_Keychain.Result | false> {
try {
const seedStr: string = Buffer.from(seed).toString('base64')
log.trace('[saveSeed]', 'Saved seed base64', seedStr)
const result = await _Keychain.setGenericPassword(
KeyChainServiceName.SEED,
seedStr,
{
service: KeyChainServiceName.SEED
},
)
log.trace('[saveSeed]', 'Saved seed to the KeyChain')
return result
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
}
}
/**
* Loads mnemonic from the KeyChain/KeyStore
*
*/
const loadSeed = async function (): Promise<Uint8Array | undefined> {
try {
log.trace('[loadSeed]', 'start')
const result = await _Keychain.getGenericPassword({
service: KeyChainServiceName.SEED
})
if (result) {
const seedStr = result.password
log.trace('[loadSeed]', 'base64', {seedStr})
const seed: Uint8Array = new Uint8Array(Buffer.from(seedStr, 'base64'))
return seed
}
log.trace('[loadSeed]', 'Did not find existing seed in the KeyChain')
return undefined
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
/**
* Removes mnemonic from KeyChain/KeyStore
*
* @param service The key to kill.
*/
const removeSeed = async function (): Promise<boolean> {
try {
const result = await _Keychain.resetGenericPassword({
service: KeyChainServiceName.SEED
})
log.trace('[removeSeed]', 'Removed seed.')
return result
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
const generateNostrKeyPair = function () {
@@ -39,33 +198,6 @@ const generateNostrKeyPair = function () {
}
}
const generateMmkvEncryptionKey = (): string => {
try {
const keyLength = 32 // Length of the encryption key in bytes
const encryptionKey = QuickCrypto.randomBytes(keyLength)
const uint8Array = new Uint8Array(encryptionKey)
const stringKey = fromByteArray(uint8Array)
const base64Key = btoa(stringKey)
log.trace('New Base64 encryptionKey created:', base64Key)
return base64Key
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, [e])
}
}
const getSupportedBiometryType = async function () {
try {
const biometryType = await _Keychain.getSupportedBiometryType({})
log.trace('biometryType', biometryType)
return biometryType
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
}
}
/**
* Save keypair to KeyChain/KeyStore
*
@@ -83,7 +215,7 @@ const saveNostrKeyPair = async function (
},
)
log.trace('Saved keypair to the KeyChain', '', 'saveNostrKeyPair')
log.trace('[saveNostrKeyPair]', 'Saved keypair to the KeyChain')
return result
} catch (e: any) {
@@ -96,51 +228,59 @@ const saveNostrKeyPair = async function (
*
*/
const loadNostrKeyPair = async function (): Promise<KeyPair | undefined> {
try {
const result = await _Keychain.getGenericPassword({
service: KeyChainServiceName.NOSTR
})
try {
const result = await _Keychain.getGenericPassword({
service: KeyChainServiceName.NOSTR
})
if (result) {
const keyPair = JSON.parse(result.password)
return keyPair
if (result) {
const keyPair = JSON.parse(result.password)
return keyPair
}
log.trace('[loadNostrKeyPair]', 'Did not find existing keyPair in the KeyChain')
return undefined
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
log.trace('Did not find existing keyPair in the KeyChain', '', 'loadNostrKeyPair')
return undefined
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
/**
* Loads keypair from KeyChain/KeyStore
*
* Removes keypair from KeyChain/KeyStore
*
* @param service The key to kill.
*/
const loadMmkvEncryptionKey = async function (): Promise<string | undefined> {
try {
const result = await _Keychain.getGenericPassword({
service: KeyChainServiceName.MMKV,
authenticationPrompt: {
title: 'Authentication required',
subtitle: '',
description: 'Your Minibits wallet data is encrypted. Please authenticate to get access.',
cancel: 'Cancel',
},
})
if (result) {
const key = result.password
return key
const removeNostrKeypair = async function (): Promise<boolean> {
try {
const result = await _Keychain.resetGenericPassword({
service: KeyChainServiceName.NOSTR
})
log.trace('[removeNostrKeypair]', 'Removed nostr keypair.')
return result
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
log.trace('Did not find existing encryptionKey in the KeyChain')
return undefined
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
const generateMmkvEncryptionKey = (): string => {
try {
const keyLength = 32 // Length of the encryption key in bytes
const encryptionKey = QuickCrypto.randomBytes(keyLength)
const uint8Array = new Uint8Array(encryptionKey)
const stringKey = fromByteArray(uint8Array)
const base64Key = btoa(stringKey)
log.trace('New Base64 encryptionKey created:', base64Key)
return base64Key
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message, [e])
}
}
/**
* Loads keypair from KeyChain/KeyStore
*
@@ -148,66 +288,87 @@ const loadMmkvEncryptionKey = async function (): Promise<string | undefined> {
* @param service KeyChainServiceName.
*/
const saveMmkvEncryptionKey = async function (
key: string,
): Promise<_Keychain.Result | false> {
try {
const result = await _Keychain.setGenericPassword(
KeyChainServiceName.MMKV,
key,
{
service: KeyChainServiceName.MMKV,
accessControl: _Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE
}
)
log.trace('Saved encryptionKey to the KeyChain')
return result
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
/**
* Removes keypair from KeyChain/KeyStore
*
* @param service The key to kill.
*/
const removeNostrKeypair = async function (): Promise<boolean> {
try {
const result = await _Keychain.resetGenericPassword({
service: KeyChainServiceName.NOSTR
})
log.trace('Removed nostr keypair', {}, 'removeNostrKeypair')
return result
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
/**
* Removes keypair from KeyChain/KeyStore
*
* @param service The key to kill.
*/
const removeMmkvEncryptionKey = async function (): Promise<boolean> {
key: string,
): Promise<_Keychain.Result | false> {
try {
const result = await _Keychain.resetGenericPassword({
service: KeyChainServiceName.MMKV
})
log.trace('Removed mmkv key', {}, 'removeMmkvEncryptionKey')
const result = await _Keychain.setGenericPassword(
KeyChainServiceName.MMKV,
key,
{
service: KeyChainServiceName.MMKV,
accessControl: _Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE
}
)
log.trace('[saveMmkvEncryptionKey]', 'Saved encryptionKey to the KeyChain.')
return result
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
/**
* Loads keypair from KeyChain/KeyStore
*
*
*/
const loadMmkvEncryptionKey = async function (): Promise<string | undefined> {
try {
const result = await _Keychain.getGenericPassword({
service: KeyChainServiceName.MMKV,
authenticationPrompt: {
title: 'Authentication required',
subtitle: '',
description: 'Your Minibits wallet data is encrypted. Please authenticate to get access.',
cancel: 'Cancel',
},
})
export const KeyChain = {
if (result) {
const key = result.password
return key
}
log.trace('[loadMmkvEncryptionKey]', 'Did not find existing encryptionKey in the KeyChain.')
return undefined
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
/**
* Removes key from KeyChain/KeyStore
*
* @param service The key to kill.
*/
const removeMmkvEncryptionKey = async function (): Promise<boolean> {
try {
const result = await _Keychain.resetGenericPassword({
service: KeyChainServiceName.MMKV
})
log.trace('[removeMmkvEncryptionKey]', 'Removed mmkv key.')
return result
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
export const KeyChain = {
getSupportedBiometryType,
generateMnemonic,
saveMnemonic,
loadMnemonic,
removeMnemonic,
saveSeed,
loadSeed,
removeSeed,
generateNostrKeyPair,
saveNostrKeyPair,
loadNostrKeyPair,
+3 -3
View File
@@ -7,7 +7,7 @@ import { MinibitsClient } from './minibitsService'
export type LNURLPayParams = JSLNURLPayParams & {address?: string}
export type LnurlParamsResult = {
lnurlParams: LNURLWithdrawParams | LNURLPayParams
lnurlParams: LNURLWithdrawParams | LNURLPayParams & {address: string}
encodedInvoice?: string
}
@@ -89,14 +89,14 @@ const getLnurlAddressParams = async (lnurlAddress: string) => {
const name = LnurlUtils.getNameFromLnurlAddress(lnurlAddress)
if(!domain || !name) {
throw new AppError(Err.NOTFOUND_ERROR, 'Could not get lightning address name or domain', {caller: 'getLnurlAddressParams'})
throw new AppError(Err.NOTFOUND_ERROR, 'Could not get lightning address name or domain', {caller: 'getLnurlAddressParams', lnurlAddress })
}
const url = `https://${domain}/.well-known/lnurlp/${name}`
const method = 'GET'
const headers = MinibitsClient.getPublicHeaders()
const lnurlParams = await MinibitsClient.fetchApi(url, {
const lnurlParams: LNURLPayParams & LNURLResponse & {address?: string} = await MinibitsClient.fetchApi(url, {
method,
headers,
})
+70 -48
View File
@@ -706,11 +706,14 @@ const receive = async function (
mintsStore.addMint(newMint)
}
const proofsCounter = mintsStore.currentProofsCounterValue(mintToReceive)
// Now we ask all mints to get fresh outputs for their tokenEntries, and create from them new proofs
// 0.8.0-rc3 implements multimints receive however CashuMint constructor still expects single mintUrl
const {updatedToken, errorToken, newKeys} = await MintClient.receiveFromMint(
const {updatedToken, errorToken, newKeys, errors} = await MintClient.receiveFromMint(
mintToReceive,
encodedToken as string,
proofsCounter
)
if(newKeys) {_updateMintKeys(mintToReceive, newKeys)}
@@ -732,27 +735,31 @@ const receive = async function (
if (errorToken && errorToken.token.length > 0) {
amountWithErrors += CashuUtils.getTokenAmounts(errorToken as Token).totalAmount
log.warn('[receive]', 'receiveToken amountWithErrors', amountWithErrors)
log.warn('[receive]', 'receiveToken amountWithErrors', amountWithErrors, errors)
}
if (amountWithErrors === amountToReceive) {
throw new AppError(
Err.VALIDATION_ERROR,
'Received ecash token is not valid and can not be redeemed.',
'Ecash could not be redeemed.',
{caller: 'receive', message: errors?.length ? errors[0]?.message : undefined}
)
}
let receivedAmount = 0
for (const entry of updatedToken.token) {
// create ProofModel instances and store them into the proofsStore
_addCashuProofs(
const { addedProofs, addedAmount } = _addCashuProofs(
entry.proofs,
entry.mint,
transactionId as number
)
receivedAmount += addedAmount
}
// This should be amountToReceive - amountWithErrors but let's set it from updated token
const receivedAmount = CashuUtils.getTokenAmounts(updatedToken as Token).totalAmount
// const receivedAmount = CashuUtils.getTokenAmounts(updatedToken as Token).totalAmount
log.debug('[receive]', `Received amount: ${receivedAmount}`)
// Update tx amount if full amount was not received
@@ -995,11 +1002,14 @@ const receiveOfflineComplete = async function (
mintsStore.addMint(newMint)
}
const proofsCounter = mintsStore.currentProofsCounterValue(mintToReceive)
// Now we ask all mints to get fresh outputs for their tokenEntries, and create from them new proofs
// 0.8.0-rc3 implements multimints receive however CashuMint constructor still expects single mintUrl
const {updatedToken, errorToken, newKeys} = await MintClient.receiveFromMint(
const {updatedToken, errorToken, newKeys, errors} = await MintClient.receiveFromMint(
tokenMints[0],
encodedToken as string,
proofsCounter
)
let amountWithErrors = 0
@@ -1012,21 +1022,25 @@ const receiveOfflineComplete = async function (
if (amountWithErrors === transaction.amount) {
throw new AppError(
Err.VALIDATION_ERROR,
'Received ecash token is not valid and can not be redeemed.',
'Ecash could not be redeemed.',
{caller: 'receiveOfflineComplete', message: errors?.length ? errors[0]?.message : undefined}
)
}
let receivedAmount = 0
for (const entry of updatedToken.token) {
// create ProofModel instances and store them into the proofsStore
_addCashuProofs(
const { addedProofs, addedAmount } = _addCashuProofs(
entry.proofs,
entry.mint,
transaction.id as number
)
receivedAmount += addedAmount
}
// This should be amountToReceive - amountWithErrors but let's set it from updated token
const receivedAmount = CashuUtils.getTokenAmounts(updatedToken as Token).totalAmount
// const receivedAmount = CashuUtils.getTokenAmounts(updatedToken as Token).totalAmount
log.debug('[receiveOfflineComplete]', 'Received amount', receivedAmount)
// Update tx amount if full amount was not received
@@ -1163,48 +1177,40 @@ const _sendFromMint = async function (
)
log.debug('[_sendFromMint]', 'proofsToSendFrom', proofsToSendFrom)
const proofsCounter = mintsStore.currentProofsCounterValue(mintUrl)
// if split to required denominations was necessary, this gets it done with the mint and we get the return
const {returnedProofs, proofsToSend, newKeys} = await MintClient.sendFromMint(
mintUrl,
amountToSend,
proofsToSendFrom,
proofsCounter
)
log.debug('[_sendFromMint]', 'returnedProofs', returnedProofs)
log.debug('[_sendFromMint]', 'proofsToSend', proofsToSend)
// log.debug('[_sendFromMint]', 'returnedProofs', returnedProofs)
// log.debug('[_sendFromMint]', 'proofsToSend', proofsToSend)
if (newKeys) {_updateMintKeys(mintUrl, newKeys)}
// these might be original proofToSendFrom if they matched the exact amount and split was not necessary
for (const proof of proofsToSend) {
if (isStateTreeNode(proof)) {
proof.setTransactionId(transactionId)
proof.setMintUrl(mintUrl)
} else {
;(proof as Proof).tId = transactionId
;(proof as Proof).mintUrl = mintUrl
}
}
// add proofs returned by the mint after the split
if (returnedProofs.length > 0) {
// these should be fresh proofs from the mint but let's be defensive and cover the case that we over-send proofs while matching denominations
for (const proof of returnedProofs) {
if (isStateTreeNode(proof)) {
proof.setTransactionId(transactionId)
proof.setMintUrl(mintUrl)
} else {
;(proof as Proof).tId = transactionId
;(proof as Proof).mintUrl = mintUrl
}
}
proofsStore.addProofs(returnedProofs)
const { addedProofs, addedAmount } = _addCashuProofs(
returnedProofs,
mintUrl,
transactionId
)
}
// remove used proofs and move sent proofs to pending
proofsStore.removeProofs(proofsToSendFrom)
proofsStore.addProofs(proofsToSend, true) // pending true
// these might be original proofToSendFrom if they matched the exact amount and split was not necessary
const { addedProofs, addedAmount } = _addCashuProofs(
proofsToSend,
mintUrl,
transactionId,
true
)
// Clean private properties to not to send them out. This returns plain js array, not model objects.
const cleanedProofsToSend = proofsToSend.map(proof => {
@@ -1450,6 +1456,8 @@ const transfer = async function (
JSON.stringify(transactionData),
)
const proofsCounter = mintsStore.currentProofsCounterValue(mintUrl as string)
// Use prepared proofs to settle with the mint the payment of the invoice on wallet behalf
const {feeSavedProofs, isPaid, preimage, newKeys} =
await MintClient.payLightningInvoice(
@@ -1457,6 +1465,7 @@ const transfer = async function (
encodedInvoice,
proofsToPay,
estimatedFee,
proofsCounter
)
if (newKeys) {_updateMintKeys(mintUrl, newKeys)}
@@ -1511,12 +1520,13 @@ const transfer = async function (
let finalFee = estimatedFee
if (feeSavedProofs.length) {
const {addedAmount: feeSaved} = _addCashuProofs(
feeSavedProofs,
mintUrl,
transactionId
)
finalFee = estimatedFee - feeSaved
}
// Save final fee in db
@@ -1612,26 +1622,34 @@ const transfer = async function (
const _addCashuProofs = function (
proofsToAdd: CashuProof[],
mintUrl: string,
transactionId: number
transactionId: number,
isPending: boolean = false
): {
amountToAdd: number,
addedAmount: number
addedAmount: number,
addedProofs: Proof[]
} {
// Add internal references
for (const proof of proofsToAdd as Proof[]) {
proof.tId = transactionId
proof.mintUrl = mintUrl
if (isStateTreeNode(proof)) {
proof.setTransactionId(transactionId)
proof.setMintUrl(mintUrl)
} else {
;(proof as Proof).tId = transactionId
;(proof as Proof).mintUrl = mintUrl
}
}
const amountToAdd = CashuUtils.getProofsAmount(proofsToAdd as Proof[])
// Creates proper model instances and adds them to the wallet
const addedAmount = proofsStore.addProofs(proofsToAdd as Proof[])
const { addedAmount, addedProofs} = proofsStore.addProofs(proofsToAdd as Proof[], isPending)
log.trace('[_addCashuProofs]', 'Added proofs to the wallet with amount', { amountToAdd, addedAmount })
return {
amountToAdd,
addedAmount
addedAmount,
addedProofs
}
}
@@ -1851,10 +1869,14 @@ const checkPendingTopups = async function () {
try {
for (const pr of paymentRequests) {
// claim tokens if invoice is paid
const proofsCounter = mintsStore.currentProofsCounterValue(pr.mint as string)
const {proofs, newKeys} = (await MintClient.requestProofs(
pr.mint as string,
pr.amount,
pr.paymentHash,
proofsCounter
)) as {proofs: Proof[], newKeys: MintKeys}
if (!proofs || proofs.length === 0) {
@@ -1893,7 +1915,7 @@ const checkPendingTopups = async function () {
proofs,
pr.mint as string,
pr.transactionId as number
)
)
if (receivedAmount !== pr.amount) {
throw new AppError(
@@ -1952,7 +1974,7 @@ const _updateMintKeys = function (mintUrl: string, newKeys: MintKeys) {
const _formatError = function (e: AppError) {
return {
name: e.name,
message: e.message.slice(0, 150),
message: e.message.slice(0, 200),
params: e.params || {},
} as AppError
}
+1
View File
@@ -8,6 +8,7 @@ declare module '@env' {
export const MINIBITS_SERVER_API_HOST: string
export const MINIBITS_NIP05_DOMAIN: string
export const MINIBITS_RELAY_URL: string
export const MINIBITS_MINT_URL: string
export const JS_BUNDLE_VERSION: string
export const COMMIT: string
}
+13 -4
View File
@@ -25,14 +25,19 @@ export const getImageSource = function(img: string) {
export const infoMessage = function(message: string, description?: string) {
const backgroundColor = colors.dark.info
const backgroundColor = colors.palette.success300
const textColor = 'white'
return showMessage({
message,
description,
backgroundColor,
color: 'white',
style: {minHeight: spacing.screenHeight * 0.15, borderTopLeftRadius: spacing.medium, borderTopRightRadius: spacing.medium},
color: textColor,
style: {
minHeight: spacing.screenHeight * 0.15,
borderTopLeftRadius: spacing.medium,
borderTopRightRadius: spacing.medium
},
})
}
@@ -46,7 +51,11 @@ export const warningMessage = function(message: string, description?: string) {
duration: 3000,
backgroundColor,
color: 'white',
style: {minHeight: spacing.screenHeight * 0.15, borderTopLeftRadius: spacing.medium, borderTopRightRadius: spacing.medium},
style: {
minHeight: spacing.screenHeight * 0.15,
borderTopLeftRadius: spacing.medium,
borderTopRightRadius: spacing.medium
},
})
}
+20 -13
View File
@@ -1625,12 +1625,12 @@
dependencies:
"@noble/hashes" "1.3.2"
"@noble/hashes@1.3.1", "@noble/hashes@~1.3.0", "@noble/hashes@~1.3.1":
"@noble/hashes@1.3.1", "@noble/hashes@~1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
"@noble/hashes@1.3.2", "@noble/hashes@~1.3.2":
"@noble/hashes@1.3.2", "@noble/hashes@~1.3.0", "@noble/hashes@~1.3.2":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39"
integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==
@@ -1972,12 +1972,12 @@
color "^4.2.3"
warn-once "^0.1.0"
"@scure/base@1.1.1", "@scure/base@~1.1.0":
"@scure/base@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
"@scure/base@~1.1.2":
"@scure/base@~1.1.0", "@scure/base@~1.1.2":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.3.tgz#8584115565228290a6c6c4961973e0903bb3df2f"
integrity sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==
@@ -6999,20 +6999,20 @@ react-native-json-tree@^1.3.0:
prop-types "^15.7.2"
react-base16-styling "^0.8.0"
react-native-keychain@^8.1.1:
version "8.1.1"
resolved "https://registry.yarnpkg.com/react-native-keychain/-/react-native-keychain-8.1.1.tgz#3bb5e37946b964a7bcf7df2fe470dd244e01a340"
integrity sha512-8fxgeHKwGcL657eAYpdBTkDIxNhbIHI+kyyO0Yac2dgVAN184JoIwQcW2z6snahwDaCObQOu0biLFHnsH+4KSg==
react-native-keychain@^8.1.2:
version "8.1.2"
resolved "https://registry.yarnpkg.com/react-native-keychain/-/react-native-keychain-8.1.2.tgz#34291ae472878e5124d081211af5ede7d810e64f"
integrity sha512-bhHEui+yMp3Us41NMoRGtnWEJiBE0g8tw5VFpq4mpmXAx6XJYahuM6K3WN5CsUeUl83hYysSL9oFZNKSTPSvYw==
react-native-logs@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/react-native-logs/-/react-native-logs-5.0.1.tgz#53665919587b499e22f514e50cb6a834cedba1ce"
integrity sha512-30aQugeLFfcBEKd6U4I75SKc5q5fb9Qrwv0Vnsy3jwpC716xON9SxRbBEnxoPSDJjzK527r4zi+qYqzig6lPlQ==
react-native-mmkv@^2.8.0:
version "2.10.1"
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-2.10.1.tgz#1a4e64edbf9561fe81a5ae3cefde17e88dfc669f"
integrity sha512-MCrYytUJ3JhSVg7LwVXCj7ExbMHe48FoQdDA1xo1q/M4c1/QyI3M9L9d2dyJUhYPnsSO0+5c2mJICm7v39XA0w==
react-native-mmkv@^2.11.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-2.11.0.tgz#51b9985f6a5c09fe9c16d8c1861cc2901856ace1"
integrity sha512-28PdUHjZJmAw3q+8zJDAAdohnZMpDC7WgRUJxACOMkcmJeqS3u5cKS/lSq2bhf1CvaeIiHYHUWiyatUjMRCDQQ==
react-native-modal@^13.0.1:
version "13.0.1"
@@ -7035,13 +7035,20 @@ react-native-qrcode-svg@^6.2.0:
prop-types "^15.8.0"
qrcode "^1.5.1"
react-native-quick-base64@^2.0.5, react-native-quick-base64@^2.0.6:
react-native-quick-base64@^2.0.5:
version "2.0.6"
resolved "https://registry.yarnpkg.com/react-native-quick-base64/-/react-native-quick-base64-2.0.6.tgz#be5d02186697dafea9cce6b982ba6a2322f2ce42"
integrity sha512-sCHsp4EzOL5OZF+77UfMolhgFDo1nM9TbwNFNExVqOFbMwj13KTGwStwjM9diunCHR9hKEt4LEGf9Afq3FOXzA==
dependencies:
base64-js "^1.5.1"
react-native-quick-base64@^2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/react-native-quick-base64/-/react-native-quick-base64-2.0.7.tgz#70ec863fb5dba8cd858a262f6b901dbbfbfdd3df"
integrity sha512-QmOon3zXAWFi3KvQVCJjCC7N66rwfl1R4nLPuJ+OVs8nWysvKlaU8mKxe2BV4Ud1nB3nTDgyi2VCJFpjtjJxKw==
dependencies:
base64-js "^1.5.1"
react-native-quick-crypto@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/react-native-quick-crypto/-/react-native-quick-crypto-0.6.1.tgz#7b89c67c4a5d3669c4491fe7884621c1c74d01bc"