Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
0b31898cf0
chore(deps): update dependency ruby to v3.4.9 2026-05-18 12:12:05 +00:00
208 changed files with 3590 additions and 11262 deletions

View File

@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout Project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Ensures the full Git history is
@ -490,7 +490,7 @@ jobs:
BRANCH_NAME: ${{ needs.build.outputs.branch_name }}
steps:
- name: Checkout Project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set Up Ruby
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0

View File

@ -49,7 +49,7 @@ jobs:
- name: Checkout project
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

View File

@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: "0"
@ -135,7 +135,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Ruby
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0

View File

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@ -53,7 +53,6 @@ jobs:
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }}
HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}
@ -65,7 +64,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@ -84,7 +83,6 @@ jobs:
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }}
HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}

View File

@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Free disk space (Ubuntu)
run: |
@ -86,7 +86,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Free disk space (Ubuntu)
run: |

View File

@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@ -168,7 +168,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@ -194,6 +194,9 @@ jobs:
mkdir -p ios/build/Build/Products/Release-iphonesimulator
tar -xzf BlueWallet.app.tar.gz -C ios/build/Build/Products/Release-iphonesimulator
- name: Disable simulator animations
run: defaults write com.apple.iphonesimulator SlowMotionAnimation -bool NO
# Pre-boot simulator so first detox launchApp lands warm.
- name: Pre-boot iOS simulator
run: |
@ -207,13 +210,6 @@ jobs:
xcrun simctl bootstatus "$UDID" -b
xcrun simctl launch "$UDID" com.apple.springboard >/dev/null 2>&1 || true
# Cut animations so detox sync stays steady on slow CI VMs; Reduce Motion makes reanimated skip to final value.
- name: Disable simulator animations
run: |
defaults write com.apple.iphonesimulator SlowMotionAnimation -bool NO
xcrun simctl spawn booted defaults write com.apple.Accessibility ReduceMotionEnabled -bool true
xcrun simctl spawn booted notifyutil -p com.apple.Accessibility.ReduceMotionStatusDidChange
- name: Run detox tests
timeout-minutes: 360
run: |

View File

@ -1 +1 @@
3.4.9
3.4.9

199
BlueComponents.tsx Normal file
View File

@ -0,0 +1,199 @@
import { useLocale } from '@react-navigation/native';
import React, { forwardRef } from 'react';
import {
Dimensions,
Platform,
Pressable,
PressableProps,
StyleProp,
StyleSheet,
Text,
TextInput,
TextInputProps,
TextProps,
View,
ViewProps,
ViewStyle,
} from 'react-native';
import Icon from './components/Icon';
import { useTheme } from './components/themes';
const { height, width } = Dimensions.get('window');
const aspectRatio = height / width;
const isIpad = aspectRatio <= 1.6;
interface BlueButtonLinkProps extends PressableProps {
title: string;
}
export const BlueButtonLink = forwardRef<React.ElementRef<typeof Pressable>, BlueButtonLinkProps>((props, ref) => {
const { colors } = useTheme();
return (
<Pressable accessibilityRole="button" style={({ pressed }) => [styles.blueButtonLink, pressed && styles.pressed]} {...props} ref={ref}>
<Text style={[styles.blueButtonLinkText, { color: colors.foregroundColor }]}>{props.title}</Text>
</Pressable>
);
});
export const BlueCard: React.FC<ViewProps> = props => {
return <View {...props} style={styles.blueCard} />;
};
interface BlueTextProps extends TextProps {
bold?: boolean;
h1?: boolean;
h2?: boolean;
h3?: boolean;
h4?: boolean;
}
export const BlueText: React.FC<BlueTextProps> = ({ bold = false, h1, h2, h3, h4, style: passedStyle, ...props }) => {
const { colors } = useTheme();
const { direction } = useLocale();
let headingStyle = {};
if (h1) {
headingStyle = styles.h1;
} else if (h2) {
headingStyle = styles.h2;
} else if (h3) {
headingStyle = styles.h3;
} else if (h4) {
headingStyle = styles.h4;
}
const hasHeading = h1 || h2 || h3 || h4;
const style = StyleSheet.compose(
{
color: colors.foregroundColor,
writingDirection: direction,
fontWeight: hasHeading ? undefined : bold ? 'bold' : 'normal',
...headingStyle,
},
passedStyle,
);
return <Text style={style} {...props} />;
};
export const BlueTextCentered: React.FC<TextProps> = props => {
const { colors } = useTheme();
return <Text {...props} style={[styles.blueTextCentered, { color: colors.foregroundColor }]} />;
};
export const BlueFormLabel: React.FC<TextProps> = props => {
const { colors } = useTheme();
const { direction } = useLocale();
return <Text {...props} style={[styles.blueFormLabel, { color: colors.foregroundColor, writingDirection: direction }]} />;
};
export const BlueFormMultiInput: React.FC<TextInputProps> = props => {
const { colors } = useTheme();
const { style, editable, ...restProps } = props;
return (
<TextInput
multiline
underlineColorAndroid="transparent"
numberOfLines={4}
editable={editable}
style={[
styles.blueFormMultiInput,
{
borderColor: colors.formBorder,
borderBottomColor: colors.formBorder,
backgroundColor: colors.inputBackgroundColor,
color: colors.foregroundColor,
},
style,
]}
autoCorrect={false}
autoCapitalize="none"
spellCheck={false}
{...restProps}
selectTextOnFocus={false}
keyboardType={Platform.OS === 'android' ? 'visible-password' : 'default'}
/>
);
};
export class is {
static ipad() {
return isIpad;
}
}
interface BlueBigCheckmarkProps {
style?: StyleProp<ViewStyle>;
}
export function BlueBigCheckmark({ style }: BlueBigCheckmarkProps) {
const mergedStyles = [styles.checkmarkContainer, style];
return (
<View style={mergedStyles}>
<Icon name="check" size={50} type="font-awesome" color="#0f5cc0" />
</View>
);
}
const styles = StyleSheet.create({
blueButtonLink: {
minWidth: 100,
minHeight: 36,
justifyContent: 'center',
},
blueButtonLinkText: {
textAlign: 'center',
fontSize: 16,
},
blueCard: {
padding: 20,
},
h1: {
fontSize: 40,
fontWeight: 'bold',
},
h2: {
fontSize: 34,
fontWeight: 'bold',
},
h3: {
fontSize: 28,
fontWeight: 'bold',
},
h4: {
fontSize: 22,
fontWeight: 'bold',
},
blueTextCentered: {
textAlign: 'center',
},
blueFormLabel: {
fontWeight: '400',
marginHorizontal: 20,
},
blueFormMultiInput: {
paddingHorizontal: 8,
paddingVertical: 16,
flex: 1,
marginTop: 5,
marginHorizontal: 20,
borderWidth: 1,
borderBottomWidth: 0.5,
borderRadius: 4,
textAlignVertical: 'top',
},
pressed: {
opacity: 0.6,
},
checkmarkContainer: {
backgroundColor: '#ccddf9',
width: 120,
height: 120,
borderRadius: 60,
alignSelf: 'center',
justifyContent: 'center',
alignItems: 'center',
},
});

View File

@ -60,8 +60,6 @@ React Navigation 7.x with native stack. Typed params in `navigation/DetailViewSt
**Dependencies:** Do not add new dependencies without strong justification. Bonus for removing dependencies.
**Patches:** Local fixes to `node_modules` live in `patches/` and are applied by `patch-package` on `postinstall`. Each patch is documented in `patches/README.md` (what/why + upstream issue link); update it when adding or removing a patch.
**Components:** New components go in `components/`, not legacy `BlueComponents.js`.
**Linting Rules:**

View File

@ -2,12 +2,12 @@ source "https://rubygems.org"
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby "3.4.9"
gem "fastlane", "~> 2.234.0"
gem "fastlane", "~> 2.232.0"
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'concurrent-ruby', '< 1.3.8'
gem 'concurrent-ruby', '< 1.3.4'
# Ruby 3.4.0 removed these from the standard library
gem 'bigdecimal'

View File

@ -15,7 +15,7 @@ GEM
minitest (>= 5.1, < 6)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.9.0)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
@ -23,8 +23,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1252.0)
aws-sdk-core (3.247.0)
aws-partitions (1.1227.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -32,19 +32,19 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.127.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.223.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
bigdecimal (4.0.1)
claide (1.1.0)
cocoapods (1.15.2)
addressable (~> 2.8)
@ -87,7 +87,7 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.3)
connection_pool (3.0.2)
csv (3.3.5)
declarative (0.0.20)
@ -131,14 +131,14 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
fastlane (2.232.2)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
base64 (~> 0.2.0)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
@ -151,7 +151,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@ -164,9 +164,9 @@ GEM
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
@ -188,7 +188,8 @@ GEM
git
xml-simple
fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0)
fastlane-sirp (1.1.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
ffi (1.17.3)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
@ -198,7 +199,7 @@ GEM
addressable (~> 2.8)
process_executer (~> 4.0)
rchardet (~> 1.9)
google-apis-androidpublisher_v3 (0.100.0)
google-apis-androidpublisher_v3 (0.97.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@ -208,19 +209,19 @@ GEM
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.62.0)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.60.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@ -245,7 +246,7 @@ GEM
i18n (1.14.8)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.19.5)
json (2.19.2)
jwt (2.10.2)
base64
logger (1.7.0)
@ -257,7 +258,7 @@ GEM
mini_mime (1.1.5)
minitest (5.27.0)
molinillo (0.8.0)
multi_json (1.21.1)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.3.0)
@ -272,7 +273,7 @@ GEM
process_executer (4.0.2)
track_open_instances (~> 0.1)
public_suffix (4.0.7)
rake (13.4.2)
rake (13.3.1)
rchardet (1.10.0)
representable (3.2.0)
declarative (< 0.1.0)
@ -299,6 +300,7 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -337,8 +339,8 @@ DEPENDENCIES
benchmark
bigdecimal
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
concurrent-ruby (< 1.3.8)
fastlane (~> 2.234.0)
concurrent-ruby (< 1.3.4)
fastlane (~> 2.232.0)
fastlane-plugin-browserstack
fastlane-plugin-bugsnag
fastlane-plugin-bugsnag_sourcemaps_upload
@ -351,20 +353,20 @@ CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485
algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1252.0) sha256=b44c74136ebd634d35f3fb8fd37def5214db21b9375f22c6954dbe7a7f2a449d
aws-sdk-core (3.247.0) sha256=789864594ce8cef05ee3d81fa8ed506099280bda6ea12a7612b8b7c5e5e62851
aws-sdk-kms (1.127.0) sha256=5d540b6afb9574327202989db2217741211e1cce3fb443ad0e1e37de730202e5
aws-sdk-s3 (1.223.0) sha256=655e382af34926caa76b77cf0171caed5f61ff52b8b58ae50f6f3e22c39e6cbc
aws-partitions (1.1227.0) sha256=122dd20fe108cb38d38cccbc1f2592408bc1b30ca6e0d05797a7af2501567e29
aws-sdk-core (3.244.0) sha256=3e458c078b0c5bdee95bc370c3a483374b3224cf730c1f9f0faf849a5d9a18ea
aws-sdk-kms (1.123.0) sha256=d405f37e82f8fa32045ca8980be266c0b45b37aaf2012afe0254321a1e811f20
aws-sdk-s3 (1.217.0) sha256=6ea709272c666888b14e9c62345abd9a6a967759ae13667c28f01fde6823c24b
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
cocoapods (1.15.2) sha256=f0f5153de8d028d133b96f423e04f37fb97a1da0d11dda581a9f46c0cba4090a
cocoapods-core (1.15.2) sha256=322650d97fe1ad4c0831a09669764b888bd91c6d79d0f6bb07281a17667a2136
@ -377,7 +379,7 @@ CHECKSUMS
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
@ -403,25 +405,25 @@ CHECKSUMS
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.234.0) sha256=b74835681ad9a8e9c0931a5727dad1bab433895ac534c864a1ed5749625d26e9
fastlane (2.232.2) sha256=978689f60f0fc3d54699de86ef12be4eda9f5b52217c1798965257c390d2b112
fastlane-plugin-browserstack (0.3.4) sha256=a4f3e4a552e2390a4733570857512571535912100ffada177d5374413f2c1333
fastlane-plugin-bugsnag (3.0.0) sha256=8ddac4b79cb4b5d00432cccd5789a9e1a1119c29f7773a27d01b1d8a2363915d
fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0) sha256=a05afaefa81a7bf56c36386dddeb0931db31ead6886e3eae24f9683bda1a064d
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
fastlane-sirp (1.0.0) sha256=66478f25bcd039ec02ccf65625373fca29646fa73d655eb533c915f106c5e641
ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9
fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
git (4.3.1) sha256=91ca566c39766a033e61a148c8f470908bd4786b818f8f3ff566d3a9a0200c50
google-apis-androidpublisher_v3 (0.100.0) sha256=7a82935bee985190e8fe23bf5e53df3a27d65dd084114bb71b846b617de16489
google-apis-androidpublisher_v3 (0.97.0) sha256=0f3859844872ec09b64dde3bff6dee84458eb61d664337402adcbb4ac912322a
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
google-apis-iamcredentials_v1 (0.27.0) sha256=9289f29968610754ef11d98b9ec627f0153f3e2616fef839aef096de529f6d1e
google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
google-apis-storage_v1 (0.62.0) sha256=f62467c36df53287fb0252ebb4da85f9e25d7b4c5809d045c2aab1fc307760c1
google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1
google-cloud-storage (1.60.0) sha256=b21b752d37945d678a4533be5ef4303f15d33a964d8bc709c7c41c3600f650db
google-cloud-errors (1.5.0) sha256=b56be28b8c10628125214dde571b925cfcebdbc58619e598250c37a2114f7b4b
google-cloud-storage (1.58.0) sha256=1bedc07a9c75af169e1ede1dd306b9f941f9ffa9e7095d0364c0803c468fdffd
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-accept (1.7.0) sha256=c626860682bfbb3b46462f8c39cd470fd7b0584f61b3cc9df5b2e9eb9972a126
@ -429,7 +431,7 @@ CHECKSUMS
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
json (2.19.2) sha256=e7e1bd318b2c37c4ceee2444841c86539bc462e81f40d134cf97826cb14e83cf
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56
@ -438,7 +440,7 @@ CHECKSUMS
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.3.0) sha256=aaaedc60497070b864a7e220f7c4b4cad3a0daddda2c30055ba8dae306342376
@ -452,7 +454,7 @@ CHECKSUMS
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
process_executer (4.0.2) sha256=c73eb646d450044241c973a8360f6326e33ec5ad933f7acf503f6f3579873a71
public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
rchardet (1.10.0) sha256=d5ea2ed61a720a220f1914778208e718a0c7ed2a484b6d357ba695aa7001390f
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
rest-client (2.1.0) sha256=35a6400bdb14fae28596618e312776c158f7ebbb0ccad752ff4fa142bf2747e3
@ -466,6 +468,7 @@ CHECKSUMS
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
sysrandom (1.0.5) sha256=5ac1ac3c2ec64ef76ac91018059f541b7e8f437fbda1ccddb4f2c56a9ccf1e75
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
track_open_instances (0.1.15) sha256=7f0e48821e6b4c881daaa40fb1583e308937c22a9c84883c150b399c3b5c3029

View File

@ -87,7 +87,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "8.0.1"
versionName "8.0.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
// Keep compatibility across react-native-capture-protection flavor changes.

View File

@ -14,8 +14,6 @@ import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.react.modules.fresco.FrescoModule
import com.facebook.react.modules.i18nmanager.I18nUtil
import io.bluewallet.bluewallet.components.segmentedcontrol.SegmentedControlPackage
@ -99,13 +97,6 @@ class MainApplication : Application(), ReactApplication {
val sharedI18nUtilInstance = I18nUtil.getInstance()
sharedI18nUtilInstance.allowRTL(applicationContext, true)
// Initialize Fresco before RN mounts views. FrescoModule init can lag behind the first
// frame (e.g. UnlockWith logo) when OkHttp/SSL warms up network security config.
if (!FrescoModule.hasBeenInitialized()) {
Fresco.initialize(this)
}
loadReactNative(this)
initializeDeviceUID()

View File

@ -57,13 +57,6 @@ allprojects {
maven {
url("$rootDir/../node_modules/detox/Detox-android")
}
// react-native-background-fetch ships com.transistorsoft:tsbackgroundfetch
// as a bundled local Maven repo; the package's own build.gradle adds it
// for itself, but :app's runtime classpath resolution needs it visible
// at the root level too.
maven {
url("$rootDir/../node_modules/react-native-background-fetch/android/libs")
}
mavenCentral {
// We don't want to fetch react-native from Maven Central as there are
@ -92,17 +85,6 @@ if (buildscript != null) {
}
subprojects { project ->
// react-native-device-info's androidTest classpath pulls
// play-services-iid:16.0.1 -> play-services-base:16.0.1 -> support-v4:26.1.0,
// which collides with androidx.core:core:1.13.1 (Duplicate class
// android.support.v4.app.INotificationSideChannel). Exclude the pre-AndroidX
// support-* modules so the AndroidX equivalents in core win.
configurations.all {
exclude group: 'com.android.support', module: 'support-compat'
exclude group: 'com.android.support', module: 'support-annotations'
exclude group: 'com.android.support', module: 'support-core-utils'
}
// Remove and block any jcenter() repositories at both project and buildscript levels
def scrub = { repoContainer ->
repoContainer.all { repo ->

View File

@ -1,7 +1,4 @@
module.exports = {
// Pin the @babel/runtime version so Metro resolves a single copy instead of
// bundling duplicate helpers, which bloats the bundle.
// See https://github.com/babel/babel/issues/18050
presets: [['module:@react-native/babel-preset', { enableBabelRuntime: '^7.26.0' }]],
presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-worklets/plugin'],
};

View File

@ -30,7 +30,7 @@ type Utxo = {
wif?: string;
};
export type ElectrumTransaction = {
type ElectrumTransaction = {
txid: string;
hash: string;
version: number;
@ -58,14 +58,13 @@ export type ElectrumTransaction = {
addresses: string[];
};
}[];
// Confirmation-only fields: absent on mempool (unconfirmed) responses.
blockhash?: string;
confirmations?: number;
time?: number;
blocktime?: number;
blockhash: string;
confirmations: number;
time: number;
blocktime: number;
};
export type ElectrumTransactionWithHex = ElectrumTransaction & {
type ElectrumTransactionWithHex = ElectrumTransaction & {
hex: string;
};
@ -101,84 +100,24 @@ export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
}));
let mainClient: typeof ElectrumClient | undefined;
let mainConnected: boolean = false;
let wasConnectedAtLeastOnce: boolean = false;
let serverName: string | false = false;
let disableBatching: boolean = false;
let connectionAttempt: number = 0;
let currentPeerIndex = hardcodedPeers.findIndex(peer => peer.host === defaultPeer.host && peer.ssl === defaultPeer.ssl);
if (currentPeerIndex < 0) currentPeerIndex = 0;
let latestBlock: { height: number; time: number } | { height: undefined; time: undefined } = { height: undefined, time: undefined };
// --- Single source of truth for connection liveness -----------------------------
// We previously tracked `mainConnected` (boolean) separately from the client's own
// `mainClient.status`. They drifted on iOS suspend/resume: a transient `ping()`
// failure cleared the flag while the socket was still alive, then `waitTillConnected`
// blocked for ~30s on the stale flag and surfaced a false network-error alert. The
// state machine + `ensureConnected()` below is the only place that mutates the
// connection lifecycle, and UI is driven by subscribing to state changes.
const WAIT_TILL_CONNECTED_TICK_MS = 100;
/** After at least one successful Electrum session: wall ~30s before timeout (slow reconnect). */
const WAIT_TILL_CONNECTED_MAX_TICKS_AFTER_FIRST_CONNECT = 300;
/** First-ever connect: wall ~60s before timeout (cold start / slow TLS / flaky network). */
const WAIT_TILL_CONNECTED_MAX_TICKS_NEVER_CONNECTED = 600;
export type ConnectionState = 'disabled' | 'disconnected' | 'connecting' | 'connected';
let connState: ConnectionState = 'disconnected';
type ConnectionListener = (state: ConnectionState) => void;
const connectionListeners = new Set<ConnectionListener>();
function setConnectionState(next: ConnectionState): void {
if (connState === next) return;
connState = next;
for (const l of connectionListeners) {
try {
l(next);
} catch (e) {
console.warn('[electrum] connection listener threw:', e);
}
}
}
/** Current connection state for UI. */
export function getConnectionState(): ConnectionState {
return connState;
}
/** Subscribe to state changes. Returns an unsubscribe function. */
export function subscribeConnectionState(listener: ConnectionListener): () => void {
connectionListeners.add(listener);
return () => {
connectionListeners.delete(listener);
};
}
/** Convenience: `true` iff a usable Electrum connection is currently believed to exist. */
export function isConnected(): boolean {
return connState === 'connected';
}
// --- Connection lifecycle internals ---------------------------------------------
/** One liveness check (`server_ping`) wall-time before giving up and marking the socket dead. */
const PING_TIMEOUT_MS = 5_000;
/** One full connect attempt (TLS + `server_version` handshake) wall-time before retrying. */
const CONNECT_ATTEMPT_TIMEOUT_MS = 10_000;
/** Reconnect attempts inside a single `ensureConnected()` call before declaring failure. */
const CONNECT_MAX_ATTEMPTS = 5;
/** Backoff between attempts to avoid hammering a flaky server. */
const CONNECT_BACKOFF_MS = 500;
/** Delay before the auto-reconnect triggered by a live-socket `onError`. Onions are slower. */
const RECONNECT_ONION_DELAY_MS = 4_000;
const RECONNECT_TCP_DELAY_MS = 500;
/** Max wall time one `ensureConnected()` call may take when no live socket exists. */
export const ENSURE_CONNECTED_MAX_WALL_MS =
CONNECT_MAX_ATTEMPTS * CONNECT_ATTEMPT_TIMEOUT_MS + (CONNECT_MAX_ATTEMPTS - 1) * CONNECT_BACKOFF_MS;
/** Coalesces concurrent `ensureConnected()` callers — at most one connect attempt at a time. */
let ensureInFlight: Promise<boolean> | null = null;
/** If any coalesced caller asked for the failure alert, honour it once the in-flight attempt finishes. */
let ensureInFlightShowAlert = false;
/**
* Bumps every time the caller asks us to abandon the current connection
* (`forceDisconnect()` or user disabling Electrum). In-flight `ensureConnected()`
* checks this between attempts so it can bail out promptly instead of racing back
* to `connected` after a disconnect was requested.
*/
let disconnectGeneration = 0;
/** Max wall time for one `waitTillConnected` wait (ms); derived from ticks above for callers (e.g. refresh fetch race). */
export const WAIT_TILL_CONNECTED_MAX_WALL_MS_AFTER_FIRST = WAIT_TILL_CONNECTED_MAX_TICKS_AFTER_FIRST_CONNECT * WAIT_TILL_CONNECTED_TICK_MS;
export const WAIT_TILL_CONNECTED_MAX_WALL_MS_NEVER = WAIT_TILL_CONNECTED_MAX_TICKS_NEVER_CONNECTED * WAIT_TILL_CONNECTED_TICK_MS;
const txhashHeightCache: Record<string, number> = {};
let _realm: Realm | undefined;
@ -224,10 +163,10 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
console.log('[electrum] Getting preferred server:', { host, tcpPort, sslPort });
console.log('Getting preferred server:', { host, tcpPort, sslPort });
if (!host) {
console.warn('[electrum] Preferred server host is undefined');
console.warn('Preferred server host is undefined');
return;
}
@ -237,7 +176,7 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
ssl: sslPort ? Number(sslPort) : undefined,
};
} catch (error) {
console.error('[electrum] Error in getPreferredServer:', error);
console.error('Error in getPreferredServer:', error);
return undefined;
}
};
@ -245,12 +184,12 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
export const removePreferredServer = async () => {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
console.log('[electrum] Removing preferred server');
console.log('Removing preferred server');
await DefaultPreference.clear(ELECTRUM_HOST);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
} catch (error) {
console.error('[electrum] Error in removePreferredServer:', error);
console.error('Error in removePreferredServer:', error);
}
};
@ -259,14 +198,14 @@ export async function isDisabled(): Promise<boolean> {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
const savedValue = await DefaultPreference.get(ELECTRUM_CONNECTION_DISABLED);
console.log('[electrum] Getting Electrum connection disabled state:', savedValue);
console.log('Getting Electrum connection disabled state:', savedValue);
if (savedValue === null) {
result = false;
} else {
result = savedValue;
}
} catch (error) {
console.error('[electrum] Error getting Electrum connection disabled state:', error);
console.error('Error getting Electrum connection disabled state:', error);
result = false;
}
return !!result;
@ -274,23 +213,8 @@ export async function isDisabled(): Promise<boolean> {
export async function setDisabled(disabled = true) {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
console.log('[electrum] Setting Electrum connection disabled state to:', disabled);
const result = await DefaultPreference.set(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : '');
// Disabling must abort any in-flight ensureConnected() and tear down the live
// socket so callers don't have to remember to pair this with forceDisconnect().
// Without bumping the generation, an in-flight connect could race back to
// 'connected' after the user toggled Electrum off.
if (disabled) {
disconnectGeneration += 1;
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
setConnectionState('disabled');
}
return result;
console.log('Setting Electrum connection disabled state to:', disabled);
return DefaultPreference.set(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : '');
}
function getCurrentPeer() {
@ -314,7 +238,7 @@ async function getSavedPeer(): Promise<Peer | null> {
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
console.log('[electrum] Getting saved peer:', { host, tcpPort, sslPort });
console.log('Getting saved peer:', { host, tcpPort, sslPort });
if (!host) {
return null;
@ -330,98 +254,53 @@ async function getSavedPeer(): Promise<Peer | null> {
return null;
} catch (error) {
console.error('[electrum] Error in getSavedPeer:', error);
console.error('Error in getSavedPeer:', error);
return null;
}
}
/** Resolve to the peer this attempt should target (preferred saved peer, or rotate hardcoded list). */
async function pickPeer(): Promise<Peer> {
export async function connectMain(): Promise<void> {
if (await isDisabled()) {
console.log('Electrum connection disabled by user. Skipping connectMain call');
return;
}
let usingPeer = getNextPeer();
const savedPeer = await getSavedPeer();
if (savedPeer && savedPeer.host && (savedPeer.tcp || savedPeer.ssl)) {
usingPeer = savedPeer;
}
return usingPeer;
}
function scheduleReconnectFromClient(client: typeof ElectrumClient, usingPeer: Peer, reason: string): void {
if (connState !== 'connected' || mainClient !== client) return;
console.log(`[electrum] scheduling Electrum reconnect after ${reason}`);
try {
// Also neutralises electrum-client's own timers/reconnect hooks for this instance.
client.close();
} catch {}
if (mainClient === client) mainClient = undefined;
setConnectionState('disconnected');
const delay = usingPeer.host.endsWith('.onion') ? RECONNECT_ONION_DELAY_MS : RECONNECT_TCP_DELAY_MS;
const generationAtSchedule = disconnectGeneration;
setTimeout(() => {
if (generationAtSchedule !== disconnectGeneration) return;
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file
ensureConnected().catch(() => {
/* ensureConnected never throws, but be defensive */
});
}, delay);
}
/**
* One connect attempt: build a fresh `ElectrumClient`, run the version handshake,
* subscribe to headers. No retries, no UI side effects. Returns the peer used
* (for caller-side telemetry/alerts) and whether the attempt succeeded.
*/
async function attemptConnectOnce(): Promise<{ ok: boolean; peer: Peer }> {
const usingPeer = await pickPeer();
console.log('[electrum] Using peer:', JSON.stringify(usingPeer));
// Drop any prior client before allocating a new one. Closing also neutralises
// electrum-client's internal `reconnect()` loop on the old instance.
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
console.log('Using peer:', JSON.stringify(usingPeer));
try {
console.log('[electrum] begin connection:', JSON.stringify(usingPeer));
const client = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
mainClient = client;
console.log('begin connection:', JSON.stringify(usingPeer));
mainClient = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
// Live-socket errors after a successful handshake: schedule a single
// `ensureConnected()` (deduped). Errors during this attempt's own handshake
// are caught below — we must not double-handle them here.
client.onError = function (e: { message: string }) {
console.log('[electrum] electrum mainClient.onError():', e.message);
scheduleReconnectFromClient(client, usingPeer, 'socket error');
mainClient.onError = function (e: { message: string }) {
console.log('electrum mainClient.onError():', e.message);
if (mainConnected) {
// most likely got a timeout from electrum ping. lets reconnect
// but only if we were previously connected (mainConnected), otherwise theres other
// code which does connection retries
mainClient?.close();
mainClient = undefined;
mainConnected = false;
// dropping `mainConnected` flag ensures there wont be reconnection race condition if several
// errors triggered
console.log('reconnecting after socket error');
setTimeout(connectMain, usingPeer.host.endsWith('.onion') ? 4000 : 500);
}
};
const ver = await Promise.race([
client.initElectrum(
{ client: 'bluewallet', version: '1.4' },
{
maxRetry: 0,
callback: () => scheduleReconnectFromClient(client, usingPeer, 'socket close'),
},
),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('connect timeout')), CONNECT_ATTEMPT_TIMEOUT_MS)),
]);
if (mainClient !== client) {
// Caller raced `forceDisconnect()` while we were awaiting. Bail.
try {
client.close();
} catch {}
return { ok: false, peer: usingPeer };
}
const ver = await mainClient.initElectrum({ client: 'bluewallet', version: '1.4' });
if (ver && ver[0]) {
console.log('[electrum] connected to ', ver);
console.log('connected to ', ver);
serverName = ver[0];
mainConnected = true;
wasConnectedAtLeastOnce = true;
if (ver[0].startsWith('ElectrumPersonalServer') || ver[0].startsWith('electrs') || ver[0].startsWith('Fulcrum')) {
disableBatching = true;
// exeptions for versions:
const [electrumImplementation, electrumVersion] = ver[0].split(' ');
switch (electrumImplementation) {
case 'electrs':
@ -430,6 +309,8 @@ async function attemptConnectOnce(): Promise<{ ok: boolean; peer: Peer }> {
}
break;
case 'electrs-esplora':
// its a different one, and it does NOT support batching
// nop
break;
case 'Fulcrum':
if (semVerToInt(electrumVersion) >= semVerToInt('1.9.0')) {
@ -438,156 +319,38 @@ async function attemptConnectOnce(): Promise<{ ok: boolean; peer: Peer }> {
break;
}
}
const header = await client.blockchainHeaders_subscribe();
const header = await mainClient.blockchainHeaders_subscribe();
if (header && header.height) {
latestBlock = {
height: header.height,
time: Math.floor(+new Date() / 1000),
};
}
return { ok: true, peer: usingPeer };
// AsyncStorage.setItem(storageKey, JSON.stringify(peers)); TODO: refactor
}
return { ok: false, peer: usingPeer };
} catch (e) {
console.log('[electrum] bad connection:', JSON.stringify(usingPeer), e);
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
mainConnected = false;
console.log('bad connection:', JSON.stringify(usingPeer), e);
mainClient?.close();
mainClient = undefined;
}
if (!mainConnected) {
console.log('retry');
connectionAttempt = connectionAttempt + 1;
mainClient?.close();
mainClient = undefined;
if (connectionAttempt >= 5) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- `presentNetworkErrorAlert` is defined below after `connectMain`
presentNetworkErrorAlert(usingPeer);
} else {
console.log('reconnection attempt #', connectionAttempt);
await new Promise(resolve => setTimeout(resolve, 500)); // sleep
return connectMain();
}
return { ok: false, peer: usingPeer };
}
}
/** Single liveness check on the current `mainClient`, bounded by `PING_TIMEOUT_MS`. */
async function pingWithTimeout(timeoutMs: number = PING_TIMEOUT_MS): Promise<boolean> {
if (!mainClient) return false;
const client = mainClient;
try {
await Promise.race([
client.server_ping(),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('ping timeout')), timeoutMs)),
]);
return mainClient === client; // server replied AND client wasn't swapped while we waited
} catch {
return false;
}
}
export type EnsureConnectedOptions = {
/**
* Show the legacy "couldn't connect" alert (Try again / Reset / Cancel) on failure.
* Used by initial bootstrap (`SettingsProvider` re-enabling Electrum) and the manual
* help alert. Off-hot-path callers (refresh, broadcast, etc.) should leave this false
* and surface their own UI.
*/
showAlertOnFailure?: boolean;
};
/**
* Make sure a usable Electrum connection exists, healing if needed.
*
* - If we already think we're connected, run one fast `ping` to verify. If the ping
* succeeds, we're done. If it fails the client is torn down and we fall through
* to a reconnect.
* - Otherwise run up to `CONNECT_MAX_ATTEMPTS` connect attempts (each with its own
* timeout + backoff).
*
* Concurrent callers share the same in-flight promise there is at most one connect
* attempt at a time per process. This replaces the old `mainConnected`-flag-polling
* `waitTillConnected()`, which could block ~30s on a stale flag while the socket was
* still alive.
*/
export async function ensureConnected(opts: EnsureConnectedOptions = {}): Promise<boolean> {
const { showAlertOnFailure = false } = opts;
if (await isDisabled()) {
setConnectionState('disabled');
return false;
}
if (ensureInFlight) {
if (showAlertOnFailure) ensureInFlightShowAlert = true;
return ensureInFlight;
}
ensureInFlightShowAlert = showAlertOnFailure;
ensureInFlight = (async (): Promise<boolean> => {
const myGeneration = disconnectGeneration;
/** True iff the current generation no longer matches ours (i.e. `forceDisconnect()` ran). */
const aborted = (where: string): boolean => {
if (myGeneration === disconnectGeneration) return false;
console.log(`[electrum] ensureConnected aborted by forceDisconnect at ${where} (gen ${myGeneration}${disconnectGeneration})`);
return true;
};
let lastPeer: Peer | undefined;
try {
// Fast path: live ping on the existing client.
if (mainClient && connState === 'connected') {
if (await pingWithTimeout()) {
// If a disconnect/disable raced us, the bumper already set the right
// state ('disconnected' or 'disabled'); don't clobber it from here.
if (aborted('post-ping')) return false;
return true;
}
// Stale socket. Tear it down so the attempt loop starts fresh.
try {
mainClient.close();
} catch {}
mainClient = undefined;
setConnectionState('disconnected');
}
if (aborted('pre-loop')) return false;
setConnectionState('connecting');
for (let i = 0; i < CONNECT_MAX_ATTEMPTS; i++) {
if (await isDisabled()) {
setConnectionState('disabled');
return false;
}
// Generation-bumper (`forceDisconnect` or `setDisabled(true)`) already
// set the appropriate terminal state; we must not clobber 'disabled'
// back to 'disconnected' here.
if (aborted(`attempt ${i} start`)) return false;
const { ok, peer } = await attemptConnectOnce();
lastPeer = peer;
if (aborted(`attempt ${i} end`)) {
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
return false;
}
if (ok) {
setConnectionState('connected');
return true;
}
if (i < CONNECT_MAX_ATTEMPTS - 1) {
await new Promise(resolve => setTimeout(resolve, CONNECT_BACKOFF_MS));
}
}
setConnectionState('disconnected');
if (ensureInFlightShowAlert) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file
presentNetworkErrorAlert(lastPeer);
}
return false;
} finally {
ensureInFlight = null;
ensureInFlightShowAlert = false;
}
})();
return ensureInFlight;
}
export async function presentResetToDefaultsAlert(): Promise<boolean> {
const hasPreferredServer = await getPreferredServer();
const serverHistoryStr = await DefaultPreference.get(ELECTRUM_SERVER_HISTORY);
@ -607,7 +370,7 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
} catch (e) {
console.log('[electrum]', e); // Must be running on Android
console.log(e); // Must be running on Android
}
resolve(true);
},
@ -626,7 +389,7 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
} catch (e) {
console.log('[electrum]', e); // Must be running on Android
console.log(e); // Must be running on Android
}
resolve(true);
},
@ -652,7 +415,7 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
if (await isDisabled()) {
console.log(
'[electrum] Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.',
'Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.',
);
return;
}
@ -668,10 +431,10 @@ async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
{
text: loc.wallets.list_tryagain,
onPress: () => {
forceDisconnect();
setTimeout(() => {
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
}, 500);
connectionAttempt = 0;
mainClient?.close();
mainClient = undefined;
setTimeout(connectMain, 500);
},
style: 'default',
},
@ -680,10 +443,10 @@ async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
onPress: () => {
presentResetToDefaultsAlert().then(result => {
if (result) {
forceDisconnect();
setTimeout(() => {
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
}, 500);
connectionAttempt = 0;
mainClient?.close();
mainClient = undefined;
setTimeout(connectMain, 500);
}
});
},
@ -692,7 +455,9 @@ async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
{
text: loc._.cancel,
onPress: () => {
forceDisconnect();
connectionAttempt = 0;
mainClient?.close();
mainClient = undefined;
},
style: 'cancel',
},
@ -753,27 +518,18 @@ export const getBalanceByAddress = async function (address: string): Promise<{ c
balance.addr = address;
return balance;
} catch (error) {
console.error('[electrum] Error in getBalanceByAddress:', error);
console.error('Error in getBalanceByAddress:', error);
throw error;
}
};
export const getConfig = async function () {
if (!mainClient) {
return {
host: undefined,
port: undefined,
serverName: false as typeof serverName,
connected: connState === 'connected' ? 1 : 0,
};
}
if (!mainClient) throw new Error('Electrum client is not connected');
return {
host: mainClient.host,
port: mainClient.port,
serverName,
// Drive UI "connected" indicator from the single state machine so the settings
// screen agrees with the wallets-list header pill and with `ensureConnected()`.
connected: connState === 'connected' ? 1 : 0,
connected: mainClient.timeLastCall !== 0 && mainClient.status,
};
};
@ -802,26 +558,36 @@ export const getMempoolTransactionsByAddress = async function (address: string):
return mainClient.blockchainScripthash_getMempool(uint8ArrayToHex(reversedHash));
};
/**
* Read-only liveness probe. Does NOT trigger reconnects (use `ensureConnected()`
* for that). Updates the connection state machine to reflect the probe result so
* subscribers (UI pill, settings screen) stay in sync.
*
* - `true`: server replied within `PING_TIMEOUT_MS`.
* - `false`: client missing, timed out, or server errored.
*/
export const ping = async function (): Promise<boolean> {
if (await isDisabled()) return false;
const ok = await pingWithTimeout();
if (ok) {
// Heal stale `disconnected` state from a transient ping failure earlier.
if (connState !== 'connected') setConnectionState('connected');
} else if (connState === 'connected') {
setConnectionState('disconnected');
}
return ok;
export const ping = async function () {
try {
if (!mainClient) return false;
await mainClient.server_ping();
return true;
} catch (_) {}
mainConnected = false;
return false;
};
/**
* Verifies Electrum with server_ping. If the TCP socket died while the app was backgrounded,
* JS may still think we are connected ping fails, so we tear down the client and reconnect.
*/
export async function ensureElectrumConnection(): Promise<boolean> {
if (await isDisabled()) return true;
const believedConnected = mainConnected;
if (await ping()) return true;
console.log('ensureElectrumConnection: ping failed, forcing reconnect');
mainClient?.close();
mainClient = undefined;
mainConnected = false;
if (believedConnected) {
connectionAttempt = 0;
}
await connectMain();
return ping();
}
// exported only to be used in unit tests
export function txhexToElectrumTransaction(txhex: string): ElectrumTransactionWithHex {
const tx = bitcoin.Transaction.fromHex(txhex);
@ -1032,7 +798,7 @@ export const multiGetBalanceByAddress = async (addresses: string[], batchsize: n
}
for (const bal of balances) {
if (bal.error) console.warn('[electrum] multiGetBalanceByAddress():', bal.error);
if (bal.error) console.warn('multiGetBalanceByAddress():', bal.error);
ret.balance += +bal.result.confirmed;
ret.unconfirmed_balance += +bal.result.unconfirmed;
ret.addresses[scripthash2addr[bal.param]] = bal.result;
@ -1126,7 +892,7 @@ export const multiGetHistoryByAddress = async function (
}
for (const history of results) {
if (history.error) console.warn('[electrum] multiGetHistoryByAddress():', history.error);
if (history.error) console.warn('multiGetHistoryByAddress():', history.error);
ret[scripthash2addr[history.param]] = history.result || [];
for (const result of history.result || []) {
if (result.tx_hash) txhashHeightCache[result.tx_hash] = result.height; // cache tx height
@ -1167,7 +933,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
try {
ret[txid] = JSON.parse(jsonString.cache_value as string);
} catch (error) {
console.log('[electrum]', error, 'cache failed to parse', jsonString.cache_value);
console.log(error, 'cache failed to parse', jsonString.cache_value);
}
}
@ -1217,7 +983,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
tx = txhexToElectrumTransaction(tx);
results.push({ result: tx, param: txid });
} catch (err) {
console.log('[electrum]', err);
console.log(err);
}
}
} else {
@ -1233,7 +999,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
}
results.push({ result: tx, param: txid });
} catch (err) {
console.log('[electrum]', err);
console.log(err);
}
}
}
@ -1288,12 +1054,40 @@ export async function multiGetTransactionByTxid<T extends boolean>(
}
});
} catch (writeError) {
console.error('[electrum] Failed to write transaction cache:', writeError);
console.error('Failed to write transaction cache:', writeError);
}
return ret;
}
export const waitTillConnected = async function (): Promise<boolean> {
let waitTillConnectedInterval: NodeJS.Timeout | undefined;
let retriesCounter = 0;
if (await isDisabled()) {
console.warn('Electrum connections disabled by user. waitTillConnected skipping...');
return false;
}
return new Promise(function (resolve, reject) {
waitTillConnectedInterval = setInterval(() => {
if (mainConnected) {
clearInterval(waitTillConnectedInterval);
return resolve(true);
}
retriesCounter += 1;
const maxTicks = wasConnectedAtLeastOnce
? WAIT_TILL_CONNECTED_MAX_TICKS_AFTER_FIRST_CONNECT
: WAIT_TILL_CONNECTED_MAX_TICKS_NEVER_CONNECTED;
if (retriesCounter >= maxTicks) {
clearInterval(waitTillConnectedInterval);
presentNetworkErrorAlert();
reject(new Error('Waiting for Electrum connection timeout'));
}
}, WAIT_TILL_CONNECTED_TICK_MS);
});
};
// Returns the value at a given percentile in a sorted numeric array.
// "Linear interpolation between closest ranks" method
function percentile(arr: number[], p: number) {
@ -1445,11 +1239,10 @@ export const testConnection = async function (host: string, tcpPort?: number, ss
client.onError = () => {}; // mute
let timeoutId: NodeJS.Timeout | undefined;
const timeoutMs = host.endsWith('.onion') ? 21_000 : 5_000;
try {
const rez = await Promise.race([
new Promise(resolve => {
timeoutId = setTimeout(() => resolve('timeout'), timeoutMs);
timeoutId = setTimeout(() => resolve('timeout'), 5000);
}),
client.connect(),
]);
@ -1467,19 +1260,10 @@ export const testConnection = async function (host: string, tcpPort?: number, ss
return false;
};
/**
* Drop the current connection and tell any in-flight `ensureConnected()` to abort
* (so it doesn't race the disconnect by setting state back to `connected`).
*/
export const forceDisconnect = (): void => {
disconnectGeneration += 1;
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
setConnectionState('disconnected');
mainClient?.close();
mainClient = undefined;
mainConnected = false;
};
export const setBatchingDisabled = () => {

View File

@ -1,71 +0,0 @@
// Per-wallet Realm storage for notification-suppression entries.
//
// Lives inside the per-wallet Arkade Realm so suppression state is
// bucket-scoped, encrypted by the wallet's existing Realm key, and removed
// automatically when the wallet is deleted (deleteArkadeRealm tears down the
// whole file). Avoids leaking a stable per-wallet handle into a global
// AsyncStorage key.
export type ArkSwapNotificationAction = 'claim' | 'refund';
// Realm schema. `realm` is a peer dependency we don't import here directly;
// the schema is a plain object consumed by realmInstance.ts via the schemas
// array. Pattern matches BoltzSwapSchema in @arkade-os/boltz-swap.
export const ArkSwapNotificationSuppressionSchema = {
name: 'ArkSwapNotificationSuppression',
primaryKey: 'id',
properties: {
id: 'string',
swapId: 'string',
action: 'string',
postedAt: 'int',
},
};
const compositeId = (swapId: string, action: ArkSwapNotificationAction): string => `${swapId}:${action}`;
interface ArkSwapNotificationSuppressionRow {
id: string;
swapId: string;
action: ArkSwapNotificationAction;
postedAt: number;
}
export class RealmNotificationSuppressionRepository {
private readonly realm: any;
constructor(realm: any) {
this.realm = realm;
}
has(swapId: string, action: ArkSwapNotificationAction): boolean {
const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action));
return Boolean(row);
}
record(swapId: string, action: ArkSwapNotificationAction): void {
this.realm.write(() => {
const row: ArkSwapNotificationSuppressionRow = {
id: compositeId(swapId, action),
swapId,
action,
postedAt: Date.now(),
};
this.realm.create('ArkSwapNotificationSuppression', row, 'modified');
});
}
clearForSwap(swapId: string): void {
this.realm.write(() => {
const matches = this.realm.objects('ArkSwapNotificationSuppression').filtered('swapId == $0', swapId);
this.realm.delete(matches);
});
}
clearForSwapAction(swapId: string, action: ArkSwapNotificationAction): void {
this.realm.write(() => {
const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action));
if (row) this.realm.delete(row);
});
}
}

View File

@ -1,197 +0,0 @@
import RNFS from 'react-native-fs';
import Realm from 'realm';
import Keychain, { ACCESSIBLE, SECURITY_LEVEL } from 'react-native-keychain';
import { ArkRealmSchemas, ARK_REALM_SCHEMA_VERSION, runArkRealmMigrations } from '@arkade-os/sdk/repositories/realm';
import { BoltzRealmSchemas } from '@arkade-os/boltz-swap/repositories/realm';
import { randomBytes } from '../../../class/rng';
import { uint8ArrayToHex, hexToUint8Array } from '../../uint8array-extras';
import { ArkSwapNotificationSuppressionSchema } from './notificationSuppressionRepository';
const AllArkadeSchemas = [...ArkRealmSchemas, ...BoltzRealmSchemas, ArkSwapNotificationSuppressionSchema];
// App-owned schemas added on top of the SDK's. Bump when an app-owned schema
// changes; SDK bumps are handled by ARK_REALM_SCHEMA_VERSION. Realm requires
// a strictly increasing schemaVersion when objects are added; computing
// `SDK + offset` keeps the local additions ahead of any future SDK bump.
const LOCAL_ARK_SCHEMA_OFFSET = 1;
const ARKADE_REALM_SCHEMA_VERSION = ARK_REALM_SCHEMA_VERSION + LOCAL_ARK_SCHEMA_OFFSET;
const realmInstances: Map<string, Realm> = new Map();
const openInFlight: Map<string, Promise<Realm>> = new Map();
// Files live in a dedicated subdirectory so BlueApp.moveRealmFilesToCacheDirectory()
// — which sweeps top-level *.realm files from Documents into the OS-purgeable cache
// — never sees them. RNFS.readDir is non-recursive, so the subdirectory is invisible
// to that scan. Ark Realm holds non-recoverable swap/claim data and must stay in
// Documents.
const arkadeDir = (): string => `${RNFS.DocumentDirectoryPath}/arkade`;
const realmPathFor = (namespace: string): string => `${arkadeDir()}/arkade-${namespace}.realm`;
const keychainServiceFor = (namespace: string): string => `arkade_realm_${namespace}`;
async function ensureArkadeDir(): Promise<void> {
const dir = arkadeDir();
if (!(await RNFS.exists(dir))) await RNFS.mkdir(dir);
}
async function loadOrCreateEncryptionKey(namespace: string): Promise<Uint8Array> {
const service = keychainServiceFor(namespace);
const credentials = await Keychain.getGenericPassword({ service });
if (credentials) return hexToUint8Array(credentials.password);
const buf = await randomBytes(64);
const password = uint8ArrayToHex(buf);
// Accessibility: match the rest of the app's secret accessibility. RNSecureKeyStore
// in class/blue-app.ts and hooks/useBiometrics.ts both use WHEN_UNLOCKED_THIS_DEVICE_ONLY;
// the default of AFTER_FIRST_UNLOCK would expose the Realm key while the device is locked.
//
// Security level: preflight via getSecurityLevel() rather than try/catch around
// SECURE_HARDWARE. getSecurityLevel returns null on iOS (where the option is moot)
// and the highest supported level on Android. We only opt into SECURE_HARDWARE when
// the device actually backs it; otherwise let react-native-keychain pick its default.
// Catching every setGenericPassword error and silently retrying with ANY (the previous
// shape) downgrades on unrelated failures — preflight surfaces those instead.
const supportedLevel = await Keychain.getSecurityLevel();
const opts: Parameters<typeof Keychain.setGenericPassword>[2] = {
service,
accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
};
if (supportedLevel === SECURITY_LEVEL.SECURE_HARDWARE) {
opts.securityLevel = SECURITY_LEVEL.SECURE_HARDWARE;
}
await Keychain.setGenericPassword(service, password, opts);
return hexToUint8Array(password);
}
/**
* Returns a per-wallet Realm instance keyed by `namespace`. Each Ark wallet
* gets its own encrypted Realm file and its own Keychain entry so wallets
* never collide on WalletState/contracts/swaps and storage buckets stay
* isolated.
*
* Concurrent callers for the same namespace receive the same in-flight
* promise. Errors are surfaced to the caller; the in-flight entry is cleared
* so a later retry can succeed.
*/
export async function getArkadeRealm(namespace: string): Promise<Realm> {
const cached = realmInstances.get(namespace);
if (cached && !cached.isClosed) return cached;
if (cached && cached.isClosed) realmInstances.delete(namespace);
const inFlight = openInFlight.get(namespace);
if (inFlight) return inFlight;
const opening = (async () => {
await ensureArkadeDir();
const encryptionKey = await loadOrCreateEncryptionKey(namespace);
const realm = await Realm.open({
schema: AllArkadeSchemas as unknown as Realm.ObjectSchema[],
schemaVersion: ARKADE_REALM_SCHEMA_VERSION,
onMigration: (oldRealm, newRealm) => {
runArkRealmMigrations(oldRealm, newRealm);
},
path: realmPathFor(namespace),
encryptionKey,
excludeFromIcloudBackup: true,
});
realmInstances.set(namespace, realm);
return realm;
})();
openInFlight.set(namespace, opening);
try {
return await opening;
} finally {
openInFlight.delete(namespace);
}
}
/**
* Close the cached Realm for `namespace`, if any. The file and Keychain
* entry are preserved.
*/
export function closeArkadeRealm(namespace: string): void {
const realm = realmInstances.get(namespace);
if (realm && !realm.isClosed) {
realm.removeAllListeners();
realm.close();
}
realmInstances.delete(namespace);
}
/**
* Close every cached Arkade Realm instance. Used on app shutdown / sign out.
*/
export function closeAllArkadeRealms(): void {
for (const ns of Array.from(realmInstances.keys())) {
closeArkadeRealm(ns);
}
}
/**
* Delete the Realm file and the Keychain entry for `namespace`. Used when
* an Ark wallet is removed. Failures are logged but do not throw leaving
* an orphan file or Keychain entry is preferable to crashing the app's
* delete path. Ark Realm failures stay scoped to the Ark wallet path.
*
* The Keychain encryption key is reset only when the Realm file is gone
* (or never existed). Resetting the key while the encrypted file remains
* would leave the user unable to open the orphan on a future re-import:
* a fresh random key would be generated and the old file's ciphertext
* could not be decrypted.
*/
export async function deleteArkadeRealm(namespace: string): Promise<void> {
closeArkadeRealm(namespace);
const path = realmPathFor(namespace);
let realmRemoved = false;
try {
// Realm.deleteFile is sync and removes the .realm + .lock + .management
// siblings in one call. It is forgiving when the file does not exist
// (no-op), but we guard via Realm.exists to keep behavior explicit.
if (Realm.exists(path)) {
Realm.deleteFile({ path });
}
realmRemoved = true;
} catch (e: any) {
console.log(`[ArkadeRealm] Realm.deleteFile failed for ${path}:`, e?.message ?? e);
}
// Best-effort sweep of any sibling files Realm.deleteFile might have left
// behind. These are not load-bearing for re-import; failures are tolerated.
for (const suffix of ['.note']) {
const sibling = `${path}${suffix}`;
try {
if (await RNFS.exists(sibling)) await RNFS.unlink(sibling);
} catch (e: any) {
console.log(`[ArkadeRealm] failed to delete ${sibling}:`, e?.message ?? e);
}
}
if (!realmRemoved) {
console.log(
`[ArkadeRealm] keeping encryption key for ${namespace} because Realm file cleanup failed; key preserved so a future delete retry can still decrypt the orphan`,
);
return;
}
try {
await Keychain.resetGenericPassword({ service: keychainServiceFor(namespace) });
} catch (e: any) {
console.log(`[ArkadeRealm] failed to reset keychain for ${namespace}:`, e?.message ?? e);
}
}
// Exported for tests only.
export const __testing__ = {
realmInstances,
openInFlight,
realmPathFor,
keychainServiceFor,
};

View File

@ -1,423 +0,0 @@
// Background task module for Ark swap monitoring.
//
// Responsibilities:
// - Passive monitoring: poll Boltz swap status for non-terminal swaps in
// every Ark wallet's per-wallet Realm and persist remote changes through
// the SDK update helpers.
// - Post a local notification when an SDK predicate flags a swap as
// claimable/refundable. No claim, refund, recover, or signing happens in
// background — those remain foreground-only.
//
// State here is in-process: it survives configure→fetch→fetch ticks within a
// single JS runtime but is gone after process kill. Realm remains the
// durable source of truth for swap status and notification suppression.
import BackgroundFetch from 'react-native-background-fetch';
import {
BoltzSwapProvider,
isChainFinalStatus,
isReverseFinalStatus,
isSubmarineFinalStatus,
updateChainSwapStatus,
updateReverseSwapStatus,
updateSubmarineSwapStatus,
} from '@arkade-os/boltz-swap';
import type { BoltzChainSwap, BoltzReverseSwap, BoltzSubmarineSwap, BoltzSwap } from '@arkade-os/boltz-swap';
import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm';
import { BlueApp as BlueAppClass } from '../class/blue-app';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { getArkadeRealm } from './arkade-adapters/realm/realmInstance';
import {
RealmNotificationSuppressionRepository,
type ArkSwapNotificationAction,
} from './arkade-adapters/realm/notificationSuppressionRepository';
import { notifyArkSwapActionable, resolveActionableAction } from './arkade-notifications';
const BlueApp = BlueAppClass.getInstance();
// Single shared provider. The constructor only stores config; it does not
// open sockets. Re-using one instance avoids per-poll allocation.
const swapProvider = new BoltzSwapProvider({ network: 'bitcoin' });
const DEFAULT_MAX_RUN_MS = 25_000;
let maxRunMs = DEFAULT_MAX_RUN_MS;
interface ArkTaskState {
lastRegisteredAt: number | null;
lastUnregisteredAt: number | null;
lastRunStartedAt: number | null;
lastRunFinishedAt: number | null;
walletsScanned: number;
swapsPolled: number;
swapsUpdated: number;
lastError: string | null;
exitedDueToUnavailableStorage: boolean;
availability: 'unknown' | 'available' | 'denied' | 'restricted';
// Set whenever swapsUpdated is incremented. Used by reconcile() to detect
// updates that crossed run boundaries (per-run swapsUpdated is reset).
lastSwapUpdateAt: number;
lastReconciledAt: number;
}
const state: ArkTaskState = {
lastRegisteredAt: null,
lastUnregisteredAt: null,
lastRunStartedAt: null,
lastRunFinishedAt: null,
walletsScanned: 0,
swapsPolled: 0,
swapsUpdated: 0,
lastError: null,
exitedDueToUnavailableStorage: false,
availability: 'unknown',
lastSwapUpdateAt: 0,
lastReconciledAt: 0,
};
// Per-wallet last-seen status cache. Outer key: wallet namespace; inner key:
// swap ID; value: last status this background module observed. Diagnostic +
// reconciliation hint only — Realm is durable.
const swapStatusCache: Map<string, Map<string, string>> = new Map();
// Per-poll last-seen actionable action keyed by `${namespace}:${swapId}`.
// Used to detect predicate flips (true → false or claim ↔ refund) so we can
// clear the corresponding Realm suppression row even when the swap status
// has not yet reached a terminal state. In-process only; cleared by
// stopArkBackgroundTask so a later run does not falsely diagnose a flip on
// the first poll after restart.
const lastSeenActionMap: Map<string, ArkSwapNotificationAction> = new Map();
let configured = false;
let running = false;
let cancelRequested = false;
let runDeadline: number | null = null;
export function getArkTaskState(): Readonly<ArkTaskState> {
return Object.freeze({ ...state });
}
function recordError(message: string): void {
state.lastError = message;
}
function shouldStopRun(): boolean {
return cancelRequested || (runDeadline !== null && Date.now() >= runDeadline);
}
function remainingRunMs(): number {
if (runDeadline === null) return maxRunMs;
return Math.max(runDeadline - Date.now(), 0);
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('deadline exceeded')), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function isFinalStatus(swap: BoltzSwap): boolean {
switch (swap.type) {
case 'reverse':
return isReverseFinalStatus(swap.status);
case 'submarine':
return isSubmarineFinalStatus(swap.status);
case 'chain':
return isChainFinalStatus(swap.status);
}
}
async function persistStatusChange(swap: BoltzSwap, newStatus: BoltzSwap['status'], repo: RealmSwapRepository): Promise<void> {
if (swap.type === 'reverse') {
await updateReverseSwapStatus(swap as BoltzReverseSwap, newStatus, s => repo.saveSwap(s));
} else if (swap.type === 'submarine') {
await updateSubmarineSwapStatus(swap as BoltzSubmarineSwap, newStatus, s => repo.saveSwap(s));
} else {
await updateChainSwapStatus(swap as BoltzChainSwap, newStatus, s => repo.saveSwap(s));
}
}
async function pollSwap(
swap: BoltzSwap,
namespace: string,
repo: RealmSwapRepository,
suppression: RealmNotificationSuppressionRepository,
walletID: string,
walletLabel: string,
): Promise<void> {
if (shouldStopRun()) return;
state.swapsPolled += 1;
let response;
try {
response = await withTimeout(swapProvider.getSwapStatus(swap.id), remainingRunMs());
} catch (e: any) {
recordError(`getSwapStatus(${swap.id}): ${e?.message ?? e}`);
if (e?.message === 'deadline exceeded' || remainingRunMs() <= 0) cancelRequested = true;
return;
}
if (shouldStopRun()) return;
const remoteStatus = response.status;
const statusChanged = remoteStatus !== swap.status;
// The SDK update helpers (updateReverseSwapStatus etc.) save a copy and do
// not mutate `swap`, so any post-persist predicate or terminal check on
// `swap` would read the pre-update status. effectiveSwap carries the
// status we want subsequent checks to evaluate against.
const effectiveSwap: BoltzSwap = statusChanged ? ({ ...swap, status: remoteStatus } as BoltzSwap) : swap;
if (statusChanged) {
try {
await persistStatusChange(swap, remoteStatus, repo);
} catch (e: any) {
recordError(`persistStatusChange(${swap.id}): ${e?.message ?? e}`);
return;
}
state.swapsUpdated += 1;
state.lastSwapUpdateAt = Date.now();
let perWallet = swapStatusCache.get(namespace);
if (!perWallet) {
perWallet = new Map();
swapStatusCache.set(namespace, perWallet);
}
perWallet.set(swap.id, remoteStatus);
}
// Actionable evaluation runs on every non-terminal poll, NOT only after a
// status change. Otherwise a swap that became actionable in a previous run
// but never received a successful post (notify failed mid-run, OS-level
// drop, permission-denied skip, app cold-started with already-actionable
// Realm state) would never be re-checked because subsequent polls observe
// remoteStatus === swap.status and would otherwise exit. The Realm
// suppression repo is the dedup layer.
const lastKey = `${namespace}:${effectiveSwap.id}`;
if (isFinalStatus(effectiveSwap)) {
try {
suppression.clearForSwap(effectiveSwap.id);
} catch (e: any) {
recordError(`suppression.clearForSwap(${effectiveSwap.id}): ${e?.message ?? e}`);
}
lastSeenActionMap.delete(lastKey);
return;
}
const action = resolveActionableAction(effectiveSwap);
const lastSeen = lastSeenActionMap.get(lastKey);
if (lastSeen && lastSeen !== action) {
// Predicate flipped out of `lastSeen` (either to null or to the other
// action). Clear the stale suppression so the next observed flip back
// re-fires.
try {
suppression.clearForSwapAction(effectiveSwap.id, lastSeen);
} catch (e: any) {
recordError(`suppression.clearForSwapAction(${effectiveSwap.id}): ${e?.message ?? e}`);
}
}
if (action) {
try {
await notifyArkSwapActionable(effectiveSwap, suppression, walletID, walletLabel);
} catch (e: any) {
recordError(`notifyArkSwapActionable(${effectiveSwap.id}): ${e?.message ?? e}`);
}
lastSeenActionMap.set(lastKey, action);
} else {
lastSeenActionMap.delete(lastKey);
}
}
async function processWallet(wallet: LightningArkWallet): Promise<void> {
state.walletsScanned += 1;
const namespace = wallet.getNamespace();
const walletID = wallet.getID();
const walletLabel = wallet.getLabel();
let realm;
try {
realm = await getArkadeRealm(namespace);
} catch (e: any) {
// Most likely the Keychain is locked (WHEN_UNLOCKED_THIS_DEVICE_ONLY) or
// the Realm file is unreachable. Either way the background task no-ops
// for this wallet — claim/refund is foreground-only anyway.
state.exitedDueToUnavailableStorage = true;
recordError(`getArkadeRealm(${namespace}): ${e?.message ?? e}`);
return;
}
let swaps: BoltzSwap[];
const repo = new RealmSwapRepository(realm as any);
const suppression = new RealmNotificationSuppressionRepository(realm);
try {
swaps = await repo.getAllSwaps<BoltzSwap>();
} catch (e: any) {
recordError(`getAllSwaps(${namespace}): ${e?.message ?? e}`);
return;
}
for (const swap of swaps) {
if (isFinalStatus(swap)) continue;
if (shouldStopRun()) return;
await pollSwap(swap, namespace, repo, suppression, walletID, walletLabel);
}
}
export async function runArkBackgroundTask(taskId: string): Promise<void> {
if (running) {
BackgroundFetch.finish(taskId);
return;
}
running = true;
cancelRequested = false;
runDeadline = Date.now() + maxRunMs;
state.lastRunStartedAt = Date.now();
state.walletsScanned = 0;
state.swapsPolled = 0;
state.swapsUpdated = 0;
state.exitedDueToUnavailableStorage = false;
try {
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
if (wallets.length === 0) return;
for (const wallet of wallets) {
if (shouldStopRun()) break;
try {
await processWallet(wallet);
} catch (e: any) {
recordError(`processWallet: ${e?.message ?? e}`);
}
}
} finally {
state.lastRunFinishedAt = Date.now();
runDeadline = null;
cancelRequested = false;
running = false;
BackgroundFetch.finish(taskId);
}
}
export function onArkBackgroundTaskTimeout(taskId: string): void {
cancelRequested = true;
state.lastError = 'timeout';
state.lastRunFinishedAt = Date.now();
BackgroundFetch.finish(taskId);
}
function availabilityFromStatus(status: number): ArkTaskState['availability'] {
if (status === BackgroundFetch.STATUS_AVAILABLE) return 'available';
if (status === BackgroundFetch.STATUS_DENIED) return 'denied';
if (status === BackgroundFetch.STATUS_RESTRICTED) return 'restricted';
return 'unknown';
}
export async function registerArkBackgroundTask(): Promise<void> {
if (configured) {
await BackgroundFetch.start();
state.lastRegisteredAt = Date.now();
return;
}
const config: Parameters<typeof BackgroundFetch.configure>[0] = {
minimumFetchInterval: 15,
stopOnTerminate: false,
startOnBoot: true,
enableHeadless: true,
requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY,
};
try {
const status = await BackgroundFetch.configure(config, runArkBackgroundTask, onArkBackgroundTaskTimeout);
state.availability = availabilityFromStatus(status);
if (state.availability === 'available') {
configured = true;
state.lastRegisteredAt = Date.now();
} else {
console.warn(`[ArkBackground] Background fetch unavailable: ${state.availability}`);
}
} catch (e: any) {
recordError(`configure: ${e?.message ?? e}`);
}
}
export async function stopArkBackgroundTask(): Promise<void> {
cancelRequested = true;
try {
await BackgroundFetch.stop();
} catch (e: any) {
recordError(`stop: ${e?.message ?? e}`);
}
// Await in-flight run completion (draining). A live background run keeps
// Detox's FabricTimersIdlingResource busy and disconnects the JS bridge.
const start = Date.now();
// eslint-disable-next-line no-unmodified-loop-condition
while (running && Date.now() - start < 30_000) {
await new Promise(resolve => setTimeout(resolve, 50));
}
swapStatusCache.clear();
// Clear in-process predicate-flip tracker so a later run does not
// diagnose a flip on the first poll after restart. Persistent suppression
// (Realm) is intentionally untouched — re-registering must keep history.
lastSeenActionMap.clear();
state.lastUnregisteredAt = Date.now();
}
export function reconcileArkBackgroundTaskResults(triggerRefreshForWallet: (walletId: string) => void): void {
if (state.lastSwapUpdateAt <= state.lastReconciledAt) return;
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
for (const wallet of wallets) {
const namespace = wallet.getNamespace();
const perWallet = swapStatusCache.get(namespace);
if (perWallet && perWallet.size > 0) {
triggerRefreshForWallet(wallet.getID());
}
}
state.lastReconciledAt = Date.now();
}
// Exported for tests only.
export const __testing__ = {
state,
swapStatusCache,
lastSeenActionMap,
resetConfigured: (): void => {
configured = false;
},
setMaxRunMs: (ms: number): void => {
maxRunMs = ms;
},
reset: (): void => {
state.lastRegisteredAt = null;
state.lastUnregisteredAt = null;
state.lastRunStartedAt = null;
state.lastRunFinishedAt = null;
state.walletsScanned = 0;
state.swapsPolled = 0;
state.swapsUpdated = 0;
state.lastError = null;
state.exitedDueToUnavailableStorage = false;
state.availability = 'unknown';
state.lastSwapUpdateAt = 0;
state.lastReconciledAt = 0;
swapStatusCache.clear();
lastSeenActionMap.clear();
configured = false;
running = false;
cancelRequested = false;
runDeadline = null;
maxRunMs = DEFAULT_MAX_RUN_MS;
},
};

View File

@ -1,163 +0,0 @@
// Local-notification posting for actionable Ark swaps. Imported from headless
// background runtimes (no React dependency).
//
// Design notes:
// - Suppression state lives per-wallet in the Arkade Realm
// (RealmNotificationSuppressionRepository), not in a global AsyncStorage
// key — bucket-scoped and encrypted, so the suppression record never
// leaks a stable handle outside the wallet's encryption boundary.
// - Permission and app-level opt-out are checked read-only before each post
// (no prompting from headless context). Suppression is NOT recorded when
// the post is skipped, so a later state where the user grants permission
// triggers a fresh post on the next wake.
// - Notification payload deliberately does NOT include `namespace`. The OS
// notification database persists payloads and is global across BlueWallet
// encryption buckets; embedding a deterministic per-wallet identifier
// would tie a stable handle to the OS-visible record.
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState, Platform } from 'react-native';
import { Notification, Notifications } from 'react-native-notifications';
import { checkNotifications, RESULTS } from 'react-native-permissions';
import { isChainSwapClaimable, isChainSwapRefundable, isReverseSwapClaimable, isSubmarineSwapRefundable } from '@arkade-os/boltz-swap';
import type { BoltzSwap } from '@arkade-os/boltz-swap';
import loc from '../loc';
import { NOTIFICATIONS_NO_AND_DONT_ASK_FLAG } from './notifications';
import type {
RealmNotificationSuppressionRepository,
ArkSwapNotificationAction,
} from './arkade-adapters/realm/notificationSuppressionRepository';
export const ARK_SWAP_NOTIFICATION_TYPE = 100;
const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01';
let channelEnsured = false;
export function ensureArkNotificationChannel(): void {
if (Platform.OS !== 'android') return;
if (channelEnsured) return;
channelEnsured = true;
// Reuses the BlueWallet channel from blue_modules/notifications.ts:80-91 so
// headless runs do not register a second channel under a different name.
Notifications.setNotificationChannel({
channelId: ANDROID_NOTIFICATION_CHANNEL_ID,
name: 'BlueWallet notifications',
description: 'Notifications about incoming payments',
importance: 4,
enableVibration: true,
showBadge: true,
});
}
// Channel registration runs lazily on the first post (see notifyArkSwapActionable).
// Calling it at module-top would invoke the native bridge during JS bundle
// evaluation, which racy-blocks RN bootstrap on some devices and breaks
// Detox's RN-context wait. The existing blue_modules/notifications.ts pattern
// also defers channel setup to lazy invocation.
export function resolveActionableAction(swap: BoltzSwap): ArkSwapNotificationAction | null {
if (isReverseSwapClaimable(swap) || isChainSwapClaimable(swap)) return 'claim';
if (isSubmarineSwapRefundable(swap) || isChainSwapRefundable(swap)) return 'refund';
return null;
}
const interpolate = (template: string, walletLabel: string): string => template.replace('{walletLabel}', walletLabel);
// Static references so scripts/find-unused-loc.js can detect these keys.
const titleFor = (): string => loc.lndViewInvoice.notification_action_title;
const bodyFor = (action: ArkSwapNotificationAction): string =>
action === 'claim' ? loc.lndViewInvoice.notification_claim_body : loc.lndViewInvoice.notification_refund_body;
let appStateOverrideForTest: string | null = null;
let permissionResultOverrideForTest: string | null = null;
let optOutFlagOverrideForTest: string | null | undefined;
function currentAppState(): string {
return appStateOverrideForTest ?? AppState.currentState;
}
async function isOsNotificationPermissionGranted(): Promise<boolean> {
if (permissionResultOverrideForTest !== null) {
return permissionResultOverrideForTest === RESULTS.GRANTED;
}
try {
const { status } = await checkNotifications();
return status === RESULTS.GRANTED;
} catch {
return false;
}
}
async function isAppLevelOptedOut(): Promise<boolean> {
if (optOutFlagOverrideForTest !== undefined) {
return optOutFlagOverrideForTest === 'true';
}
try {
const flag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
return flag === 'true';
} catch {
return false;
}
}
export async function notifyArkSwapActionable(
swap: BoltzSwap,
suppression: RealmNotificationSuppressionRepository,
walletID: string,
walletLabel: string,
): Promise<void> {
const action = resolveActionableAction(swap);
if (!action) return;
if (currentAppState() === 'active') return;
if (suppression.has(swap.id, action)) return;
if (!(await isOsNotificationPermissionGranted())) return;
if (await isAppLevelOptedOut()) return;
ensureArkNotificationChannel();
const title = titleFor();
const body = interpolate(bodyFor(action), walletLabel);
try {
Notifications.postLocalNotification(
// namespace is intentionally omitted; tap routing re-derives it from the loaded wallet.
new Notification({
title,
body,
type: ARK_SWAP_NOTIFICATION_TYPE,
walletID,
swapId: swap.id,
action,
}),
);
} catch (e: any) {
console.warn('[ArkNotifications] postLocalNotification failed:', e?.message ?? e);
return;
}
try {
suppression.record(swap.id, action);
} catch (e: any) {
console.warn('[ArkNotifications] suppression.record failed:', e?.message ?? e);
}
}
export const __testing__ = {
resetChannel: (): void => {
channelEnsured = false;
},
setAppStateForTest: (state: string | null): void => {
appStateOverrideForTest = state;
},
setPermissionResultForTest: (result: string | null): void => {
permissionResultOverrideForTest = result;
},
setOptOutFlagForTest: (value: string | null | undefined): void => {
optOutFlagOverrideForTest = value;
},
};

View File

@ -2,7 +2,4 @@
* Let's keep config vars, constants and definitions here
*/
export const groundControlUri: string = 'https://groundcontrol.bluewallet.io';
/** bitcoin-payment-push-service base URL, no trailing slash. Empty = disabled. */
export const arkadePaymentPushUri: string = 'https://electrum2.bluewallet.io:444';
export const groundControlUri: string = 'https://groundcontrol-bluewallet.herokuapp.com';

View File

@ -1,98 +1,23 @@
import { cbc } from '@noble/ciphers/aes';
import { md5 } from '@noble/hashes/legacy';
import { randomBytes } from '@noble/hashes/utils';
import AES from 'crypto-js/aes';
import Utf8 from 'crypto-js/enc-utf8';
import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras';
/**
* OpenSSL EVP_BytesToKey using MD5 with 1 iteration.
*
* Reproduces the default key+IV derivation used by CryptoJS@4.x's
* `AES.encrypt(string, password)` so the on-disk wire format stays
* bit-identical after we swap the underlying library.
*
* D1 = MD5( password || salt )
* Di = MD5( D(i-1) || password || salt ) for i 2
* key||iv = D1 || D2 || ... (take first `byteLength` bytes)
*
* MD5 is intentional: it matches the legacy OpenSSL format. The
* cryptographic weakness of MD5 is not relevant here the function is
* only used as a deterministic byte-stretcher; the password's entropy is
* what protects the wallet, not MD5.
*/
export function evpBytesToKeyMd5(password: Uint8Array, salt: Uint8Array, byteLength: number): Uint8Array {
if (!Number.isInteger(byteLength) || byteLength < 0) {
throw new Error('evpBytesToKeyMd5: byteLength must be a non-negative integer');
}
const out = new Uint8Array(byteLength);
let written = 0;
let prev: Uint8Array = new Uint8Array(0);
while (written < byteLength) {
prev = md5(concatUint8Arrays([prev, password, salt]));
const take = Math.min(prev.length, byteLength - written);
out.set(prev.subarray(0, take), written);
written += take;
}
return out;
}
// "Salted__" — OpenSSL envelope magic. Hardcoded as bytes so the wire
// format cannot drift through any encoder.
const SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]);
const SALT_LEN = 8;
const KEY_LEN = 32;
const IV_LEN = 16;
const BLOCK_LEN = 16;
/**
* AES-256-CBC encrypt with the OpenSSL "Salted__" envelope, EVP_BytesToKey-MD5
* key derivation and PKCS7 padding. Output is base64-encoded.
*
* Wire format is bit-identical to CryptoJS@4.x's default
* `AES.encrypt(data, password).toString()` we kept the swap-the-library
* change a drop-in replacement so existing encrypted wallets on user
* devices remain readable, with no migration step.
*/
export function encrypt(data: string, password: string): string {
if (data.length < 10) throw new Error('data length cant be < 10');
const salt = randomBytes(SALT_LEN);
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
const key = kdf.subarray(0, KEY_LEN);
const iv = kdf.subarray(KEY_LEN);
const ciphertext = cbc(key, iv).encrypt(stringToUint8Array(data));
return uint8ArrayToBase64(concatUint8Arrays([SALT_MAGIC, salt, ciphertext]));
const ciphertext = AES.encrypt(data, password);
return ciphertext.toString();
}
/**
* Inverse of `encrypt`. Accepts the legacy CryptoJS wire format and returns
* the original UTF-8 plaintext. Any error (bad base64, missing magic, wrong
* password, bad padding) collapses to `false`.
*/
export function decrypt(data: string, password: string): string | false {
const bytes = AES.decrypt(data, password);
let str: string | false = false;
try {
// crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup
// export/import flows (manual file paste, clipboard transit, email-based
// wallet transfer) introduced stray newlines or padding spaces. Strip them
// before strict base64 decode so legacy backups still open. `\s` does not
// include `=`, so base64 padding survives.
const envelope = base64ToUint8Array(data.replace(/\s+/g, ''));
if (envelope.length < SALT_MAGIC.length + SALT_LEN + BLOCK_LEN) return false;
if (!areUint8ArraysEqual(envelope.subarray(0, SALT_MAGIC.length), SALT_MAGIC)) return false;
const salt = envelope.subarray(SALT_MAGIC.length, SALT_MAGIC.length + SALT_LEN);
const ciphertext = envelope.subarray(SALT_MAGIC.length + SALT_LEN);
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
const key = kdf.subarray(0, KEY_LEN);
const iv = kdf.subarray(KEY_LEN);
const plain = cbc(key, iv).decrypt(ciphertext);
// Strict UTF-8 decode — wrong-password decrypts that happen to survive
// PKCS7 unpadding overwhelmingly fail here (crypto-js's `enc.Utf8` was
// strict too; we preserve that gate by using `fatal: true`).
const str = new TextDecoder('utf-8', { fatal: true }).decode(plain);
// Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars
// (enforced by encrypt()), so anything shorter is rejected.
if (str.length < 10) return false;
return str;
} catch (e) {
return false;
}
str = bytes.toString(Utf8);
} catch (e) {}
// For some reason, sometimes decrypt would succeed with an incorrect password and return random characters.
// In this TypeScript version, we are not allowing the encryption of data that is shorter than
// 10 characters. If the decrypted data is less than 10 characters, we assume that the decrypt actually failed.
if (str && str.length < 10) return false;
return str;
}

View File

@ -26,93 +26,44 @@ export interface TinySecp256k1InterfaceExtended {
signDER(h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array;
}
// @noble/hashes types differ slightly from @noble/secp256k1 v3 hash slot typings.
necc.hashes.sha256 = sha256 as NonNullable<typeof necc.hashes.sha256>;
necc.hashes.hmacSha256 = ((key: Uint8Array, message: Uint8Array) => hmac(sha256, key, message)) as NonNullable<
typeof necc.hashes.hmacSha256
>;
// Removed from @noble/secp256k1 v1.7; vendored from noble test vectors.
// @see https://github.com/paulmillr/noble-secp256k1/blob/1.7.2/test/index.ts
type Hex = string | Uint8Array;
const { mod, secretKeyToScalar, numberToBytesBE, bytesToNumberBE, hexToBytes } = necc.etc;
const CURVE_N = necc.Point.CURVE().n;
function pointFromBytes(p: Uint8Array): necc.Point {
if (p.length === 32) {
const prefixed = new Uint8Array(33);
prefixed[0] = 0x02;
prefixed.set(p, 1);
return necc.Point.fromBytes(prefixed);
}
return necc.Point.fromBytes(p);
}
const tweakUtils = {
privateAdd: (privateKey: Hex, tweak: Hex): Uint8Array => {
const p = secretKeyToScalar(typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey);
const t = secretKeyToScalar(typeof tweak === 'string' ? hexToBytes(tweak) : tweak);
return numberToBytesBE(mod(p + t, CURVE_N));
},
privateNegate: (privateKey: Hex): Uint8Array => {
const p = secretKeyToScalar(typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey);
return numberToBytesBE(CURVE_N - p);
},
pointAddScalar: (p: Hex, tweak: Hex, isCompressed?: boolean): Uint8Array => {
const P = typeof p === 'string' ? necc.Point.fromHex(p) : pointFromBytes(p);
const t = secretKeyToScalar(typeof tweak === 'string' ? hexToBytes(tweak) : tweak);
const Q = P.add(necc.Point.BASE.multiply(t));
if (Q.is0()) throw new Error('Tweaked point at infinity');
return Q.toBytes(isCompressed);
},
pointMultiply: (p: Hex, tweak: Hex, isCompressed?: boolean): Uint8Array => {
const P = typeof p === 'string' ? necc.Point.fromHex(p) : pointFromBytes(p);
const tweakBytes = typeof tweak === 'string' ? hexToBytes(tweak) : tweak;
const t = mod(bytesToNumberBE(tweakBytes), CURVE_N);
if (t === 0n) throw new Error('Point at infinity');
return P.multiply(t).toBytes(isCompressed);
},
necc.utils.sha256Sync = (...messages: Uint8Array[]): Uint8Array => {
const combinedMessages = messages.reduce((acc, msg) => {
const newArray = new Uint8Array(acc.length + msg.length);
newArray.set(acc);
newArray.set(msg, acc.length);
return newArray;
}, new Uint8Array(0));
return sha256(combinedMessages);
};
necc.utils.hmacSha256Sync = (key: Uint8Array, ...messages: Uint8Array[]): Uint8Array => {
const combinedMessages = messages.reduce((acc, msg) => {
const newArray = new Uint8Array(acc.length + msg.length);
newArray.set(acc);
newArray.set(msg, acc.length);
return newArray;
}, new Uint8Array(0));
return hmac(sha256, key, combinedMessages);
};
/* const normal = necc.utils._normalizePrivateKey;
type Hex = string | Uint8Array;
type PrivKey = Hex | bigint | number;
necc.utils.privateAdd = (privateKey: PrivKey, tweak: Hex) => {
console.log({ privateKey, tweak });
const p = normal(privateKey);
const t = normal(tweak);
return necc.utils.privateAdd(necc.utils.mod(p + t, necc.CURVE.n));
}; */
const defaultTrue = (param?: boolean): boolean => param !== false;
function compactToDER(sig: Uint8Array): Uint8Array {
const encodeInt = (bytes: Uint8Array): Uint8Array => {
let i = 0;
while (i < bytes.length - 1 && bytes[i] === 0) i++;
let trimmed = bytes.subarray(i);
if (trimmed[0] >= 0x80) {
const prefixed = new Uint8Array(trimmed.length + 1);
prefixed[0] = 0;
prefixed.set(trimmed, 1);
trimmed = prefixed;
}
const encoded = new Uint8Array(2 + trimmed.length);
encoded[0] = 0x02;
encoded[1] = trimmed.length;
encoded.set(trimmed, 2);
return encoded;
};
const rDer = encodeInt(sig.subarray(0, 32));
const sDer = encodeInt(sig.subarray(32, 64));
const seqLen = rDer.length + sDer.length;
const der = new Uint8Array(2 + seqLen);
der[0] = 0x30;
der[1] = seqLen;
der.set(rDer, 2);
der.set(sDer, 2 + rDer.length);
return der;
}
function throwToNull<Type>(fn: () => Type): Type | null {
try {
return fn();
} catch (e) {
// console.log(e);
return null;
}
}
@ -120,8 +71,7 @@ function throwToNull<Type>(fn: () => Type): Type | null {
function isPoint(p: Uint8Array, xOnly: boolean): boolean {
if ((p.length === 32) !== xOnly) return false;
try {
pointFromBytes(p);
return true;
return !!necc.Point.fromHex(p);
} catch (e) {
return false;
}
@ -129,12 +79,23 @@ function isPoint(p: Uint8Array, xOnly: boolean): boolean {
const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256k1InterfaceBIP32 = {
isPoint: (p: Uint8Array): boolean => isPoint(p, false),
isPrivate: (d: Uint8Array): boolean => necc.utils.isValidSecretKey(d),
isPrivate: (d: Uint8Array): boolean => {
/* if (
[
'0000000000000000000000000000000000000000000000000000000000000000',
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141',
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142',
].includes(d.toString('hex'))
) {
return false;
} */
return necc.utils.isValidPrivateKey(d);
},
isXOnlyPoint: (p: Uint8Array): boolean => isPoint(p, true),
xOnlyPointAddTweak: (p: Uint8Array, tweak: Uint8Array): { parity: 0 | 1; xOnlyPubkey: Uint8Array } | null =>
throwToNull(() => {
const P = tweakUtils.pointAddScalar(p, tweak, true);
const P = necc.utils.pointAddScalar(p, tweak, true);
const parity = P[0] % 2 === 1 ? 1 : 0;
return { parity, xOnlyPubkey: P.slice(1) };
}),
@ -143,56 +104,60 @@ const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256
throwToNull(() => necc.getPublicKey(sk, defaultTrue(compressed))),
pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array => {
return pointFromBytes(p).toBytes(defaultTrue(compressed));
return necc.Point.fromHex(p).toRawBytes(defaultTrue(compressed));
},
pointMultiply: (a: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
throwToNull(() => tweakUtils.pointMultiply(a, tweak, defaultTrue(compressed))),
throwToNull(() => necc.utils.pointMultiply(a, tweak, defaultTrue(compressed))),
pointAdd: (a: Uint8Array, b: Uint8Array, compressed?: boolean): Uint8Array | null =>
throwToNull(() => {
const A = pointFromBytes(a);
const B = pointFromBytes(b);
return A.add(B).toBytes(defaultTrue(compressed));
const A = necc.Point.fromHex(a);
const B = necc.Point.fromHex(b);
return A.add(B).toRawBytes(defaultTrue(compressed));
}),
pointAddScalar: (p: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
throwToNull(() => tweakUtils.pointAddScalar(p, tweak, defaultTrue(compressed))),
throwToNull(() => necc.utils.pointAddScalar(p, tweak, defaultTrue(compressed))),
privateAdd: (d: Uint8Array, tweak: Uint8Array): Uint8Array | null =>
throwToNull(() => {
// console.log({ d, tweak });
if (d.join('') === '00000000000000000000000000000001' && tweak.join('') === '00000000000000000000000000000000') {
return new Uint8Array(d); // make test_ecc happy
}
const ret = tweakUtils.privateAdd(d, tweak);
const ret = necc.utils.privateAdd(d, tweak);
// console.log(ret);
if (ret.join('') === '00000000000000000000000000000000') {
return null;
}
return ret;
}),
privateNegate: (d: Uint8Array): Uint8Array => tweakUtils.privateNegate(d),
privateNegate: (d: Uint8Array): Uint8Array => necc.utils.privateNegate(d),
sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
return necc.sign(h, d, { prehash: false, extraEntropy: e });
return necc.signSync(h, d, { der: false, extraEntropy: e });
},
signDER: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
return compactToDER(necc.sign(h, d, { prehash: false, extraEntropy: e }));
return necc.signSync(h, d, { der: true, extraEntropy: e });
},
signSchnorr: (h: Uint8Array, d: Uint8Array, e: Uint8Array = new Uint8Array(32).fill(0x00)): Uint8Array => {
return necc.schnorr.sign(h, d, e);
return necc.schnorr.signSync(h, d, e);
},
verify: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array, strict?: boolean): boolean => {
return necc.verify(signature, h, Q, { prehash: false, lowS: strict !== false });
return necc.verify(signature, h, Q, { strict });
},
verifySchnorr: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean => {
return necc.schnorr.verify(signature, h, Q);
return necc.schnorr.verifySync(signature, h, Q);
},
};
export default ecc;
// module.exports.ecc = ecc;

View File

@ -8,16 +8,16 @@ import {
Notifications,
} from 'react-native-notifications';
import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions';
import type { BoltzReverseSwap } from '@arkade-os/boltz-swap';
import loc from '../loc';
import { arkadePaymentPushUri, groundControlUri } from './constants';
import { groundControlUri } from './constants';
import { fetch } from '../util/fetch';
const PUSH_TOKEN = 'PUSH_TOKEN';
const GROUNDCONTROL_BASE_URI = 'GROUNDCONTROL_BASE_URI';
const NOTIFICATIONS_STORAGE = 'NOTIFICATIONS_STORAGE';
const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01';
export const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG';
const baseURI = groundControlUri;
let baseURI = groundControlUri;
let notificationSubscriptions: EmitterSubscription[] = [];
let onProcessNotificationsHandler: undefined | (() => void | Promise<void>);
const handledNotificationKeys = new Set<string>();
@ -252,29 +252,6 @@ export const tryToObtainPermissions = async (): Promise<boolean> => {
return false;
}
};
export const enqueueTestPushNotification = async (): Promise<void> => {
const pushToken = await getPushToken();
if (!pushToken?.token || !pushToken?.os) {
throw new Error('No push token available');
}
const response = await fetch(`${baseURI}/enqueue`, {
method: 'POST',
headers: _getHeaders(),
body: JSON.stringify({
type: 5,
token: pushToken.token,
os: pushToken.os,
text: 'Test push notification',
}),
});
if (!response.ok) {
throw new Error(`Enqueue request failed with status ${response.status}: ${response.statusText}`);
}
};
/**
* Submits onchain bitcoin addresses and ln invoice preimage hashes to GroundControl server, so later we could
* be notified if they were paid
@ -350,44 +327,6 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
}
};
/**
* Registers an Ark swap with the bitcoin-payment-push-service so the device is
* pushed when the invoice gets paid. Fire-and-forget: never throws, gated by
* the same opt-out/token rules as majorTomToGroundControl(). The swap's
* preimage is always stripped before leaving the device.
*/
export const registerArkPaymentPush = async (paymentHash: string, label: string, pendingSwap: BoltzReverseSwap): Promise<void> => {
if (!arkadePaymentPushUri) return;
try {
const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
if (noAndDontAskFlag === 'true') {
console.warn('User has opted out of notifications.');
return;
}
const pushToken = await getPushToken();
if (!pushToken || !pushToken.token || !pushToken.os) {
return;
}
const response = await fetch(`${arkadePaymentPushUri}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
topic: paymentHash,
label,
swap: { ...pendingSwap, preimage: '' },
}),
});
if (!response.ok) {
throw new Error(`status ${response.status}`);
}
console.log('[ARK] payment push registration ok');
} catch (e: any) {
console.log('[ARK] payment push registration failed:', e?.message ?? e);
}
};
/**
* Returns a permissions object:
* alert: boolean
@ -590,6 +529,22 @@ const configureNotifications = async (onProcessNotifications?: () => void): Prom
}
};
/**
* Validates whether the provided GroundControl URI is valid by pinging it.
*
* @param uri {string}
* @returns {Promise<boolean>} TRUE if valid, FALSE otherwise
*/
export const isGroundControlUriValid = async (uri: string) => {
try {
const response = await fetch(`${uri}/ping`, { headers: _getHeaders() });
const json = await response.json();
return !!json.description;
} catch (_) {
return false;
}
};
export const isNotificationsCapable = hasGmsSync() || hasHmsSync() || Platform.OS !== 'android';
export const getPushToken = async (): Promise<TPushToken> => {
@ -721,6 +676,38 @@ export const removeAllDeliveredNotifications = () => {
Notifications.removeAllDeliveredNotifications();
};
export const getDefaultUri = () => {
return groundControlUri;
};
export const saveUri = async (uri: string) => {
try {
baseURI = uri || groundControlUri;
await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, baseURI);
} catch (error) {
console.error('Error saving URI:', error);
throw error;
}
};
export const getSavedUri = async () => {
try {
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
if (baseUriStored) {
baseURI = baseUriStored;
}
return baseUriStored;
} catch (e) {
console.error(e);
try {
await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, groundControlUri);
} catch (storageError) {
console.error('Failed to reset URI:', storageError);
}
throw e;
}
};
export const isNotificationsEnabled = async () => {
try {
const levels = await getLevels();
@ -770,6 +757,10 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
return;
}
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
baseURI = baseUriStored || groundControlUri;
console.log('Base URI set to:', baseURI);
setApplicationIconBadgeNumber(0);
// Only check permissions, never request
@ -790,5 +781,7 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
}
} catch (error) {
console.error('Failed to initialize notifications:', error);
baseURI = groundControlUri;
await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, groundControlUri).catch(err => console.error('Failed to reset URI:', err));
}
};

View File

@ -23,7 +23,7 @@ export const startAndDecrypt = async (retry?: boolean, passwordPrompt?: Password
password = await passwordPrompt();
} else {
do {
password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, { cancelable: false });
password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, false);
} while (!password);
}
}

View File

@ -1,41 +0,0 @@
// Display state for the transaction detail screen.
//
// On-chain rows (a real Bitcoin txid is present in `hash`) keep the existing
// confirmations-based logic. Ark/Lightning rows synthesized by
// LightningArkWallet.getTransactions() carry no on-chain `hash` and never a
// `confirmations` field, so their state is derived from row semantics instead.
// The off-chain branch mirrors the off-chain cases of
// components/TransactionListItem.tsx `listTitleKey` so the list row and the detail
// screen always agree. A `boarding-utxo-` row is a refill still awaiting
// settlement and is pending (matches TransactionListItem.isPendingRefill); a
// settled `boarding-` refill is a confirmed receive. Today only `bitcoind_tx` Ark
// rows reach the detail screen (swap rows route to LNDViewInvoice); the invoice
// cases are handled defensively.
export type TxDisplayState = 'pending' | 'sent' | 'received';
export function isOnChainTransaction(tx: any): boolean {
return typeof tx?.hash === 'string' && tx.hash.length > 0;
}
export function resolveTxDisplayState(tx: any): TxDisplayState {
if (isOnChainTransaction(tx)) {
const confs = Number(tx?.confirmations);
const pending = Number.isFinite(confs) ? confs <= 0 : !tx?.confirmations;
if (pending) return 'pending';
return Number(tx?.value) < 0 ? 'sent' : 'received';
}
// A refill awaiting settlement (boarding UTXO not yet swept into a VTXO) is
// pending until it promotes to a settled `boarding-<txid>` refill — mirror
// TransactionListItem.isPendingRefill so the list row and detail screen agree.
if (typeof tx?.txid === 'string' && tx.txid.startsWith('boarding-utxo-')) return 'pending';
// Off-chain Ark/Lightning row — never confirmations-based.
switch (tx?.type) {
case 'paid_invoice':
return 'sent';
case 'user_invoice':
case 'payment_request':
return tx?.ispaid ? 'received' : 'pending';
default: // settled refill (boarding-<txid>), native Ark legs (ark-), any other hash-less row
return Number(tx?.value) < 0 ? 'sent' : 'received';
}
}

View File

@ -147,10 +147,11 @@ export class BlueApp {
console.warn('error reading', key, error.message);
console.warn('fallback to realm');
const realmKeyValue = await this.openRealmKeyValue();
const obj = realmKeyValue.objectForPrimaryKey<{ key: string; value: string }>('KeyValue', key);
const obj = realmKeyValue.objectForPrimaryKey('KeyValue', key); // search for a realm object with a primary key
value = obj?.value;
realmKeyValue.close();
if (value) {
// @ts-ignore value.length
console.warn('successfully recovered', value.length, 'bytes from realm for key', key);
return value;
}
@ -546,11 +547,10 @@ export class BlueApp {
(walletToInflate._txs_by_internal_index[tx.index] as Transaction[]).push(transaction);
}
} else {
// Legacy single-address wallets - store under index 0
walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || {};
walletToInflate._txs_by_external_index[0] = walletToInflate._txs_by_external_index[0] || [];
if (!Array.isArray(walletToInflate._txs_by_external_index)) walletToInflate._txs_by_external_index = [];
walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || [];
const transaction = JSON.parse(tx.tx);
walletToInflate._txs_by_external_index[0].push(transaction);
(walletToInflate._txs_by_external_index as Transaction[]).push(transaction);
}
}
}
@ -559,6 +559,32 @@ export class BlueApp {
const id = wallet.getID();
const walletToSave = ('_hdWalletInstance' in wallet && wallet._hdWalletInstance) || wallet;
if (Array.isArray(walletToSave._txs_by_external_index)) {
// if this var is an array that means its a single-address wallet class, and this var is a flat array
// with transactions
realm.write(() => {
// cleanup all existing transactions for the wallet first
const walletTransactionsToDelete = realm.objects('WalletTransactions').filtered(`walletid = '${id}'`);
realm.delete(walletTransactionsToDelete);
// @ts-ignore walletToSave._txs_by_external_index is array
for (const tx of walletToSave._txs_by_external_index) {
realm.create(
'WalletTransactions',
{
walletid: id,
tx: JSON.stringify(tx),
},
Realm.UpdateMode.Modified,
);
}
});
return;
}
/// ########################################################################################################
if (walletToSave._txs_by_external_index) {
realm.write(() => {
// cleanup all existing transactions for the wallet first
@ -566,14 +592,16 @@ export class BlueApp {
realm.delete(walletTransactionsToDelete);
// insert new ones:
for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_external_index)) {
for (const index of Object.keys(walletToSave._txs_by_external_index)) {
// @ts-ignore index is number
const txs = walletToSave._txs_by_external_index[index];
for (const tx of txs) {
realm.create(
'WalletTransactions',
{
walletid: id,
internal: false,
index: parseInt(indexStr, 10),
index: parseInt(index, 10),
tx: JSON.stringify(tx),
},
Realm.UpdateMode.Modified,
@ -581,14 +609,16 @@ export class BlueApp {
}
}
for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_internal_index)) {
for (const index of Object.keys(walletToSave._txs_by_internal_index)) {
// @ts-ignore index is number
const txs = walletToSave._txs_by_internal_index[index];
for (const tx of txs) {
realm.create(
'WalletTransactions',
{
walletid: id,
internal: true,
index: parseInt(indexStr, 10),
index: parseInt(index, 10),
tx: JSON.stringify(tx),
},
Realm.UpdateMode.Modified,

View File

@ -390,7 +390,7 @@ export class HDSegwitBech32Transaction {
}
}
// Non-null assertions are safe here because the while loop always runs at least once (add starts at 0)
return { tx: tx!, inputs: inputs!, outputs: outputs!, fee: fee! };
// @ts-ignore stfu
return { tx, inputs, outputs, fee };
}
}

View File

@ -2,7 +2,7 @@ import { bech32 } from 'bech32';
import bolt11 from 'bolt11';
import { sha256 } from '@noble/hashes/sha256';
import { hmac } from '@noble/hashes/hmac';
import { cbc } from '@noble/ciphers/aes';
import CryptoJS from 'crypto-js';
import ecc from '../blue_modules/noble_ecc';
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
import { fetch } from '../util/fetch';
@ -321,24 +321,13 @@ export default class Lnurl {
}
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
// crypto-js's old implementation silently returned '' on malformed
// ciphertext (non-16-aligned bytes, bad PKCS7 padding) and threw on
// malformed UTF-8 plaintext. @noble/ciphers throws on the former. We
// catch every throw and return '' — the call site at
// screen/lnd/lnurlPaySuccess.tsx renders this directly without a
// try/catch, so a misbehaving LNURL server should not crash the screen.
// Note: unlike crypto-js's strict `enc.Utf8` decoder, `uint8ArrayToString`
// is lenient on bad UTF-8 (mojibake instead of throw); this is strictly
// safer than the old behaviour for this user-facing path.
try {
const key = hexToUint8Array(preimageHex);
const iv = base64ToUint8Array(ivBase64);
const ct = base64ToUint8Array(ciphertextBase64);
const pt = cbc(key, iv).decrypt(ct);
return uint8ArrayToString(pt);
} catch (_) {
return '';
}
const iv = CryptoJS.enc.Base64.parse(ivBase64);
const key = CryptoJS.enc.Hex.parse(preimageHex);
return CryptoJS.AES.decrypt(uint8ArrayToHex(base64ToUint8Array(ciphertextBase64)), key, {
iv,
mode: CryptoJS.mode.CBC,
format: CryptoJS.format.Hex,
}).toString(CryptoJS.enc.Utf8);
}
getCommentAllowed(): number | false {

View File

@ -106,31 +106,23 @@ export class MultisigCosigner {
this._valid = false;
}
// is it coldcard / unchained json?
// is it coldcard json?
try {
const json = JSON.parse(data);
// p2wsh_p2sh (Coldcard), p2sh_p2wsh (Unchained)
// same script type with reversed naming
const xpub = json.p2wsh_p2sh || json.p2sh_p2wsh;
const path = (json.p2wsh_p2sh_deriv || json.p2sh_p2wsh_deriv)?.replace(/h/g, "'");
const p2sh_deriv = json.p2sh_deriv?.replace(/h/g, "'");
const p2wsh_deriv = json.p2wsh_deriv?.replace(/h/g, "'");
if (json.p2sh && p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, p2sh_deriv));
if (json.p2sh && json.p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, json.p2sh_deriv));
this._valid = true;
this._cosigners.push(cc);
}
if (xpub && path && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, xpub, path));
if (json.p2wsh_p2sh && json.p2wsh_p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh_p2sh, json.p2wsh_p2sh_deriv));
this._valid = true;
this._cosigners.push(cc);
}
if (json.p2wsh && p2wsh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, p2wsh_deriv));
if (json.p2wsh && json.p2wsh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, json.p2wsh_deriv));
this._valid = true;
this._cosigners.push(cc);
}

View File

@ -11,7 +11,7 @@
* @return {Promise.<Uint8Array>} The random bytes
*/
export async function randomBytes(size: number): Promise<Uint8Array> {
const g = globalThis as any;
const g: any = globalThis as any;
const rnCrypto = g && g.crypto;
if (!rnCrypto || typeof rnCrypto.getRandomValues !== 'function') {
throw new Error('crypto.getRandomValues is not available');

View File

@ -216,35 +216,10 @@ const startImport = (
if (text.startsWith('arkade://')) {
const ark = new LightningArkWallet();
ark.setSecret(text);
// Defer init() to first wallet open when offline — init touches the ASP
// and delegator over the network. We still detect the wallet by prefix
// and persist it with its secret.
// A network or SDK failure during init must not abort the import: the
// wallet type and secret are known, and the SDK runtime can be brought
// up the next time the wallet is opened.
await ark.init();
if (!offline) {
try {
await ark.init();
// Restore any previous Boltz swap activity for this seed exactly
// once, here at import time. We never run this on later wallet
// opens — the app does not sweep all swaps on bootstrap. A failure
// must not block the import: the wallet itself is fine, the
// restored rows are an optional bonus for imported-from-elsewhere
// wallets.
try {
await ark.restoreSwaps();
} catch (e: any) {
console.log('[wallet-import] restoreSwaps failed:', e?.message ?? e);
}
try {
await ark.fetchBalance();
await ark.fetchTransactions();
} catch (e: any) {
console.log('[wallet-import] initial Ark sync failed:', e?.message ?? e);
}
} catch (e: any) {
console.log('[wallet-import] Ark init failed; deferring to next open:', e?.message ?? e);
}
await ark.fetchBalance();
await ark.fetchTransactions();
}
yield { wallet: ark };
}
@ -344,7 +319,6 @@ const startImport = (
}
yield { progress: 'wif' };
const segwitWallet = new SegwitP2SHWallet();
segwitWallet.setSecret(text);
if (segwitWallet.getAddress()) {
@ -408,89 +382,6 @@ const startImport = (
yield { wallet: legacyWallet };
}
yield { progress: 'Private key in hex/base64' };
// check if text is in hex or base64 format
const isHexKey = /^[0-9a-fA-F]{64}$/.test(text);
const isBase64Key = /^[A-Za-z0-9+/=]{43,44}$/.test(text);
let rawKeyBuffer;
let privateKey;
if (isHexKey) {
rawKeyBuffer = Buffer.from(text, 'hex');
} else if (isBase64Key) {
rawKeyBuffer = Buffer.from(text, 'base64');
}
if (rawKeyBuffer && rawKeyBuffer.length === 32) {
let walletFound = false;
// convert the bytes to Wallet import format, 0x80 for mainnet,
// start with uncompressed p2pkh
privateKey = wif.encode(0x80, rawKeyBuffer, false);
yield { progress: 'p2pkh uncompressed' };
const legacyWalletUncompressed = new LegacyWallet('Legacy (P2PKH) - Uncompressed');
legacyWalletUncompressed.setSecret(privateKey);
if (await wasUsed(legacyWalletUncompressed)) {
await fetch(legacyWalletUncompressed, true);
walletFound = true;
yield { wallet: legacyWalletUncompressed };
}
// compressed is true for other wallet types
privateKey = wif.encode(0x80, rawKeyBuffer, true);
yield { progress: 'p2wpkh' };
const segwitBech32Wallet = new SegwitBech32Wallet();
segwitBech32Wallet.setSecret(privateKey);
if (await wasUsed(segwitBech32Wallet)) {
await fetch(segwitBech32Wallet, true);
walletFound = true;
yield { wallet: segwitBech32Wallet };
}
yield { progress: 'p2tr' };
const taprootWallet = new TaprootWallet();
taprootWallet.setSecret(privateKey);
if (await wasUsed(taprootWallet)) {
await fetch(taprootWallet, true);
walletFound = true;
yield { wallet: taprootWallet };
}
yield { progress: 'p2wpkh-p2sh' };
segwitWallet.setSecret(privateKey);
if (await wasUsed(segwitWallet)) {
await fetch(segwitWallet, true);
walletFound = true;
yield { wallet: segwitWallet };
}
yield { progress: 'p2pkh compressed' };
const legacyWalletCompressed = new LegacyWallet('Legacy (P2PKH) - Compressed');
legacyWalletCompressed.setSecret(privateKey);
if (await wasUsed(legacyWalletCompressed)) {
await fetch(legacyWalletCompressed, true);
walletFound = true;
yield { wallet: legacyWalletCompressed };
}
if (!walletFound) {
yield { wallet: segwitBech32Wallet };
yield { wallet: segwitWallet };
yield { wallet: legacyWalletCompressed };
yield { wallet: taprootWallet };
yield { wallet: legacyWalletUncompressed };
}
}
// maybe its a watch-only address?
yield { progress: 'watch only' };
const wo1 = new WatchOnlyWallet();

View File

@ -45,7 +45,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
_balances_by_external_index: Record<number, BalanceByIndex>;
_balances_by_internal_index: Record<number, BalanceByIndex>;
// @ts-ignore
_txs_by_external_index: Record<number, Transaction[]>;
// @ts-ignore
_txs_by_internal_index: Record<number, Transaction[]>;
_utxo: any[];
@ -202,37 +204,70 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
return child.toWIF();
}
_getNodeByIndex(node: 0 | 1, index: number): BIP32Interface {
const cachedNode = node === 0 ? this._node0 : this._node1;
if (cachedNode) {
return cachedNode.derive(index);
_getNodeAddressByIndex(node: number, index: number): string {
index = index * 1; // cast to int
if (node === 0) {
if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit
}
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub).derive(node);
if (node === 1) {
if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit
}
if (node === 0 && !this._node0) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node0 = hdNode.derive(node);
}
if (node === 1 && !this._node1) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node1 = hdNode.derive(node);
}
let address: string;
if (node === 0) {
// @ts-ignore
address = this._hdNodeToAddress(this._node0.derive(index));
} else {
// tbh the only possible else is node === 1
// @ts-ignore
address = this._hdNodeToAddress(this._node1.derive(index));
}
if (node === 0) {
this._node0 = hdNode;
return (this.external_addresses_cache[index] = address);
} else {
this._node1 = hdNode;
// tbh the only possible else option is node === 1
return (this.internal_addresses_cache[index] = address);
}
}
_getNodePubkeyByIndex(node: number, index: number) {
index = index * 1; // cast to int
if (node === 0 && !this._node0) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node0 = hdNode.derive(node);
}
return hdNode.derive(index);
}
if (node === 1 && !this._node1) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node1 = hdNode.derive(node);
}
_getNodeAddressByIndex(node: 0 | 1, index: number): string {
const cache = node === 0 ? this.external_addresses_cache : this.internal_addresses_cache;
if (node === 0 && this._node0) {
return this._node0.derive(index).publicKey;
}
if (cache[index]) return cache[index]; // cache hit
if (node === 1 && this._node1) {
return this._node1.derive(index).publicKey;
}
const hdNode = this._getNodeByIndex(node, index);
const address = this._hdNodeToAddress(hdNode);
return (cache[index] = address);
}
_getNodePubkeyByIndex(node: 0 | 1, index: number) {
return this._getNodeByIndex(node, index).publicKey;
throw new Error('Internal error: this._node0 or this._node1 is undefined');
}
_getExternalAddressByIndex(index: number): string {
@ -389,95 +424,137 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
// now, we need to put transactions in all relevant `cells` of internal hashmaps:
// this._txs_by_internal_index, this._txs_by_external_index & this._txs_by_payment_code_index
// address -> index lookup maps; the single pass over transactions below uses them
// to find which cells a transaction belongs to
const externalIndexByAddress = new Map<string, number>();
for (let c = 0; c < next_free_address_index + this.gap_limit; c++) {
externalIndexByAddress.set(this._getExternalAddressByIndex(c), c);
for (const tx of Object.values(txdatas)) {
for (const vin of tx.vin) {
if (vin.addresses && vin.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) {
if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_external_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_external_index[c].push(clonedTx);
}
}
for (const vout of tx.vout) {
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) {
if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_external_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_external_index[c].push(clonedTx);
}
}
}
}
const internalIndexByAddress = new Map<string, number>();
for (let c = 0; c < next_free_change_address_index + this.gap_limit; c++) {
internalIndexByAddress.set(this._getInternalAddressByIndex(c), c);
for (const tx of Object.values(txdatas)) {
for (const vin of tx.vin) {
if (vin.addresses && vin.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) {
if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_internal_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_internal_index[c].push(clonedTx);
}
}
for (const vout of tx.vout) {
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) {
if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_internal_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_internal_index[c].push(clonedTx);
}
}
}
}
const paymentCodeIndexByAddress = new Map<string, { pc: string; c: number }>();
for (const pc of this._receive_payment_codes) {
for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) {
paymentCodeIndexByAddress.set(this._getBIP47AddressReceive(pc, c), { pc, c });
}
}
for (const tx of Object.values(txdatas)) {
// since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only iterate `tx.vout`
for (const vout of tx.vout) {
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getBIP47AddressReceive(pc, c)) !== -1) {
// this TX is related to our address
this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {};
this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// per-cell txid -> position lookup, used to replace-or-push a transaction into a cell in constant time
const cellPositionsByTxid = new Map<Transaction[], Map<string, number>>();
const getCellPositions = (cell: Transaction[]): Map<string, number> => {
let positions = cellPositionsByTxid.get(cell);
if (!positions) {
positions = new Map();
for (let cc = 0; cc < cell.length; cc++) positions.set(cell[cc].txid, cc);
cellPositionsByTxid.set(cell, positions);
}
return positions;
};
for (const tx of Object.values(txdatas)) {
// collecting which of our address `cells` this transaction touches:
const externalCells = new Set<number>();
const internalCells = new Set<number>();
const paymentCodeCells = new Map<string, { pc: string; c: number }>();
const matchAddress = (address: string, isVout: boolean) => {
const externalIndex = externalIndexByAddress.get(address);
if (externalIndex !== undefined) externalCells.add(externalIndex);
const internalIndex = internalIndexByAddress.get(address);
if (internalIndex !== undefined) internalCells.add(internalIndex);
if (isVout) {
// since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only check `tx.vout`
const paymentCodeIndex = paymentCodeIndexByAddress.get(address);
if (paymentCodeIndex) paymentCodeCells.set(address, paymentCodeIndex);
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_payment_code_index[pc][c].length; cc++) {
if (this._txs_by_payment_code_index[pc][c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_payment_code_index[pc][c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_payment_code_index[pc][c].push(clonedTx);
}
}
}
};
for (const vin of tx.vin) {
for (const address of vin.addresses ?? []) matchAddress(address, false);
}
for (const vout of tx.vout) {
for (const address of vout.scriptPubKey.addresses ?? []) matchAddress(address, true);
}
if (externalCells.size === 0 && internalCells.size === 0 && paymentCodeCells.size === 0) continue;
// this TX is related to our address(es)
const upsertClone = (cell: Transaction[]) => {
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
const positions = getCellPositions(cell);
const existingPosition = positions.get(clonedTx.txid);
if (existingPosition !== undefined) {
cell[existingPosition] = clonedTx;
} else {
positions.set(clonedTx.txid, cell.length);
cell.push(clonedTx);
}
};
for (const c of externalCells) {
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
upsertClone(this._txs_by_external_index[c]);
}
for (const c of internalCells) {
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
upsertClone(this._txs_by_internal_index[c]);
}
for (const { pc, c } of paymentCodeCells.values()) {
this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {};
this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || [];
upsertClone(this._txs_by_payment_code_index[pc][c]);
}
}
@ -524,7 +601,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
const ret: Transaction[] = [];
for (const tx of txs) {
tx.timestamp = tx.blocktime || Math.floor(+new Date() / 1000) - 30; // fallback for unconfirmed
tx.timestamp = tx.blocktime;
if (!tx.blocktime) tx.timestamp = Math.floor(+new Date() / 1000) - 30; // unconfirmed
tx.confirmations = tx.confirmations || 0; // unconfirmed
tx.hash = tx.txid;
tx.value = 0;
@ -575,7 +653,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
let lastHistoriesWithUsedAddresses = null;
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
// @ts-ignore
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
// in this particular chunk we have used addresses
lastChunkWithUsedAddressesNum = c;
lastHistoriesWithUsedAddresses = histories;
@ -617,7 +696,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
let lastHistoriesWithUsedAddresses = null;
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
// @ts-ignore
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
// in this particular chunk we have used addresses
lastChunkWithUsedAddressesNum = c;
lastHistoriesWithUsedAddresses = histories;
@ -659,7 +739,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
let lastHistoriesWithUsedAddresses = null;
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
const histories = await BlueElectrum.multiGetHistoryByAddress(generateChunkAddresses(c));
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
// @ts-ignore
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
// in this particular chunk we have used addresses
lastChunkWithUsedAddressesNum = c;
lastHistoriesWithUsedAddresses = histories;

View File

@ -315,7 +315,7 @@ export class AbstractHDWallet extends LegacyWallet {
throw new Error('Not implemented');
}
_getNodePubkeyByIndex(node: 0 | 1, index: number): Uint8Array | undefined {
_getNodePubkeyByIndex(node: number, index: number): Uint8Array | undefined {
throw new Error('Not implemented');
}

View File

@ -21,20 +21,14 @@ bitcoin.initEccLib(ecc);
*/
export class LegacyWallet extends AbstractWallet {
static readonly type = 'legacy';
static readonly defaultTypeReadable = 'Legacy (P2PKH)';
static readonly typeReadable = 'Legacy (P2PKH)';
// @ts-ignore: override
public readonly type = LegacyWallet.type;
// @ts-ignore: override
public readonly typeReadable: string;
public readonly typeReadable = LegacyWallet.typeReadable;
_txs_by_external_index: Record<number, Transaction[]> = {};
_txs_by_internal_index: Record<number, Transaction[]> = {};
constructor(typeReadable?: string) {
super();
this.typeReadable = typeReadable ?? LegacyWallet.defaultTypeReadable;
}
_txs_by_external_index: Transaction[] = [];
_txs_by_internal_index: Transaction[] = [];
/**
* Simple function which says that we havent tried to fetch balance
@ -344,14 +338,14 @@ export class LegacyWallet extends AbstractWallet {
}
}
this._txs_by_external_index = { 0: _txsByExternalIndex };
this._txs_by_external_index = _txsByExternalIndex;
this._lastTxFetch = +new Date();
}
getTransactions(): Transaction[] {
// a hacky code reuse from electrum HD wallet:
this._txs_by_external_index = this._txs_by_external_index || {};
this._txs_by_internal_index = {};
this._txs_by_external_index = this._txs_by_external_index || [];
this._txs_by_internal_index = [];
const { HDSegwitBech32Wallet } = require('./hd-segwit-bech32-wallet') as {
HDSegwitBech32Wallet: typeof HDSegwitBech32WalletT;

File diff suppressed because it is too large Load Diff

View File

@ -104,11 +104,6 @@ export type LightningTransaction = {
timestamp: number; // seconds, not milliseconds
expire_time?: number;
ispaid?: boolean;
// Terminal non-success state (failed/refunded/expired swap). Distinct from
// `ispaid:false`, which on its own only means "not settled yet" and is also
// true for in-flight rows. Consumers that gate on pending vs. dead state
// (e.g. the wallet-card pending pill) must treat `failed` rows as terminal.
failed?: boolean;
walletID?: string;
value?: number;
amt?: number;
@ -128,11 +123,10 @@ export type Transaction = {
locktime: number;
inputs: TransactionInput[];
outputs: TransactionOutput[];
// Confirmation-only fields: absent on mempool (unconfirmed) responses.
blockhash?: string;
confirmations?: number;
time?: number;
blocktime?: number;
blockhash: string;
confirmations: number;
time: number;
blocktime: number;
timestamp: number; // seconds, not milliseconds
value?: number;

View File

@ -197,13 +197,12 @@ export class WatchOnlyWallet extends LegacyWallet {
async fetchUtxo() {
if (this._hdWalletInstance) return this._hdWalletInstance.fetchUtxo();
// Single-address watch-only uses LegacyWallet UTXO + derivation from txs (no HD instance).
return super.fetchUtxo();
throw new Error('Not initialized');
}
getUtxo(...args: Parameters<THDWalletForWatchOnly['getUtxo']>) {
if (this._hdWalletInstance) return this._hdWalletInstance.getUtxo(...args);
return super.getUtxo(...args);
throw new Error('Not initialized');
}
combinePsbt(...args: Parameters<THDWalletForWatchOnly['combinePsbt']>) {

View File

@ -8,7 +8,7 @@ import loc from '../loc';
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
type AddWalletButtonProps = {
onPress: (event: GestureResponderEvent) => void;
onPress?: (event: GestureResponderEvent) => void;
};
const AddWalletButton: React.FC<AddWalletButtonProps> = ({ onPress }) => {

View File

@ -94,8 +94,6 @@ const styles = StyleSheet.create({
flex: 1,
paddingHorizontal: 8,
minHeight: 33,
fontSize: 15,
lineHeight: 19,
},
});

View File

@ -1,20 +1,21 @@
import Clipboard from '@react-native-clipboard/clipboard';
import BigNumber from 'bignumber.js';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Clipboard from '@react-native-clipboard/clipboard';
import {
Text,
Image,
Platform,
NativeSyntheticEvent,
Pressable,
StyleSheet,
Text,
TextInput,
TextInputProps,
TextInputSelectionChangeEvent,
TextInputSelectionChangeEventData,
TouchableOpacity,
View,
} from 'react-native';
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import Badge from './Badge';
import Icon from './Icon';
import BigNumber from 'bignumber.js';
import dayjs from 'dayjs';
import {
CurrencyRate,
@ -26,12 +27,10 @@ import {
updateExchangeRate,
} from '../blue_modules/currency';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
import { BlueText } from '../BlueComponents';
import confirm from '../helpers/confirm';
import loc, { formatBalancePlain, formatBalanceWithoutSuffix, removeTrailingZeros } from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
import Badge from './Badge';
import BlueText from './BlueText';
import Icon from './Icon';
import { useTheme } from './themes';
export const conversionCache: { [key: string]: string } = {};
@ -44,23 +43,6 @@ export const setCachedSatoshis = (amount: string, sats: string): void => {
conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY] = sats;
};
const INPUT_HORIZONTAL_PADDING = 6;
const INPUT_VERTICAL_PADDING = 2;
const MAX_INPUT_WIDTH = 320;
const CRYPTO_CONTAINER_OFFSET = -12;
const SWAP_ICON_SIZE = 24;
const CHAR_FADE_IN_DURATION_MS = 240;
const CHAR_FADE_OUT_DURATION_MS = 160;
const CHAR_LAYOUT_DURATION_MS = 180;
const SIZER_LAYOUT_DURATION_MS = 200;
const androidFontPaddingStyle = Platform.OS === 'android' ? { includeFontPadding: false } : null;
const sizerLayoutTransition = LinearTransition.duration(SIZER_LAYOUT_DURATION_MS).easing(Easing.out(Easing.quad));
const charLayoutTransition = LinearTransition.duration(CHAR_LAYOUT_DURATION_MS).easing(Easing.out(Easing.quad));
const charEntering = FadeIn.duration(CHAR_FADE_IN_DURATION_MS);
const charExiting = FadeOut.duration(CHAR_FADE_OUT_DURATION_MS);
type AmountInputProps = Omit<TextInputProps, 'onChangeText' | 'value'> & {
/**
* Whether the input is in a loading state
@ -112,7 +94,6 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
isLoading = false,
maxSendableAmount,
isMaxAmountEstimate,
style: styleOverride,
...otherProps
} = props;
const [isRateBeingUpdatedLocal, setIsRateBeingUpdatedLocal] = useState(false);
@ -139,12 +120,12 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
const inputFontSize = useMemo(() => (amount.length > 10 ? 20 : 36), [amount.length]);
const measureAmountText = displayAmount && displayAmount.length > 0 ? displayAmount : '0';
const inputTextAlign = useMemo((): 'left' | 'right' | 'center' => {
if (amount === BitcoinUnit.MAX) return 'center';
return unit === BitcoinUnit.LOCAL_CURRENCY ? 'left' : 'right';
}, [amount, unit]);
const inputWidth = useMemo(() => {
const valueLength = Math.max((displayAmount || '0').length, 1);
const estimatedCharWidth = inputFontSize * 0.68;
const calculatedWidth = Math.ceil(valueLength * estimatedCharWidth) + 12;
return Math.min(Math.max(calculatedWidth, 32), 320);
}, [displayAmount, inputFontSize]);
const secondaryDisplayCurrency = useMemo(() => {
if (amount === BitcoinUnit.MAX) {
@ -214,6 +195,7 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
* here we must recalculate old amont value (which was denominated in `previousUnit`) to new denomination `newUnit`
* and fill this value in input box, so user can switch between, for example, 0.001 BTC <=> 100000 sats
*/
const log = `${amount}(${previousUnit}) ->`;
let sats: string = '0';
switch (previousUnit) {
case BitcoinUnit.BTC:
@ -232,6 +214,7 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
}
const newInputValue = formatBalancePlain(+sats, newUnit, false);
console.log(`${log} ${sats}(sats) -> ${newInputValue}(${newUnit})`);
if (newUnit === BitcoinUnit.LOCAL_CURRENCY && previousUnit === BitcoinUnit.SATS) {
// we cache conversion, so when we will need reverse conversion there wont be a rounding error
@ -301,7 +284,7 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
}, [maxSendableAmount]);
const handleSelectionChange = useCallback(
(event: TextInputSelectionChangeEvent) => {
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const { selection } = event.nativeEvent;
if (selection.start !== selection.end || selection.start !== amount.length) {
textInputRef.current?.setNativeProps({ selection: { start: amount.length, end: amount.length } });
@ -310,120 +293,46 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
[amount],
);
const isCryptoUnit = unit !== BitcoinUnit.LOCAL_CURRENCY;
const amountCharacters = useMemo(() => measureAmountText.split(''), [measureAmountText]);
const displayJustifyContent = useMemo((): 'flex-start' | 'flex-end' | 'center' => {
if (inputTextAlign === 'right') return 'flex-end';
if (inputTextAlign === 'left') return 'flex-start';
return 'center';
}, [inputTextAlign]);
const inputTextColor = disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2;
const hiddenInputTextColor = Platform.OS === 'android' ? `${inputTextColor}00` : 'transparent';
const inputTypography = {
fontSize: inputFontSize,
lineHeight: Math.round(inputFontSize * 1.15),
minHeight: Math.round(inputFontSize * 1.15) + INPUT_VERTICAL_PADDING * 2,
textAlign: inputTextAlign,
...(isCryptoUnit && {
paddingLeft: INPUT_HORIZONTAL_PADDING + 4,
}),
};
const stylesHook = {
container: {
marginLeft: unit === BitcoinUnit.LOCAL_CURRENCY ? 0 : CRYPTO_CONTAINER_OFFSET,
},
localCurrency: { color: inputTextColor },
center: { padding: amount === BitcoinUnit.MAX ? 0 : 15 },
localCurrency: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2 },
input: {
color: inputTextColor,
...inputTypography,
color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2,
fontSize: inputFontSize,
lineHeight: inputFontSize,
minHeight: inputFontSize + 8,
width: inputWidth,
},
inputDisplay: {
justifyContent: displayJustifyContent,
...(isCryptoUnit && {
paddingLeft: INPUT_HORIZONTAL_PADDING + 4,
}),
},
inputGlyph: {
color: inputTextColor,
fontSize: inputTypography.fontSize,
lineHeight: inputTypography.lineHeight,
},
inputTransparent: {
color: hiddenInputTextColor,
},
cryptoCurrency: { color: inputTextColor },
cryptoCurrency: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2 },
};
return (
<Pressable accessibilityRole="button" accessibilityLabel={loc._.enter_amount} disabled={disabled} onPress={handleTextInputOnPress}>
<View style={styles.root}>
{!disabled && <View style={styles.sideRail} />}
{!disabled && <View style={[styles.center, stylesHook.center]} />}
<View style={styles.flex}>
<View style={[styles.container, stylesHook.container]}>
<View style={styles.container}>
{unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && (
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>{getCurrencySymbol()}</Text>
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>{getCurrencySymbol() + ' '}</Text>
)}
{amount !== BitcoinUnit.MAX ? (
<Animated.View layout={sizerLayoutTransition} style={styles.inputSizer}>
<Text
style={[styles.input, styles.inputMeasure, stylesHook.input, androidFontPaddingStyle]}
numberOfLines={1}
allowFontScaling={false}
accessible={false}
importantForAccessibility="no-hide-descendants"
>
{measureAmountText}
</Text>
<Animated.View layout={charLayoutTransition} style={[styles.inputDisplay, stylesHook.inputDisplay]} pointerEvents="none">
{amountCharacters.map((char, index) => (
<Animated.Text
key={index}
entering={charEntering}
exiting={charExiting}
layout={charLayoutTransition}
allowFontScaling={false}
style={[styles.inputGlyph, stylesHook.inputGlyph, androidFontPaddingStyle]}
>
{char}
</Animated.Text>
))}
</Animated.View>
<TextInput
{...otherProps}
allowFontScaling={false}
underlineColorAndroid="transparent"
onSelectionChange={handleSelectionChange}
testID="BitcoinAmountInput"
keyboardType="numeric"
onChangeText={handleChangeText}
placeholder="0"
maxLength={maxLength}
ref={textInputRef}
editable={!isLoading && !disabled}
value={displayAmount}
placeholderTextColor={inputTextColor}
cursorColor={inputTextColor}
selectionColor={inputTextColor}
style={[
styles.input,
styles.inputOverlay,
stylesHook.input,
stylesHook.inputTransparent,
androidFontPaddingStyle,
styleOverride,
]}
/>
</Animated.View>
<TextInput
onSelectionChange={handleSelectionChange}
testID="BitcoinAmountInput"
keyboardType="numeric"
onChangeText={handleChangeText}
placeholder="0"
maxLength={maxLength}
ref={textInputRef}
editable={!isLoading && !disabled}
value={displayAmount}
placeholderTextColor={disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2}
style={[styles.input, stylesHook.input]}
{...otherProps}
/>
) : (
<Pressable onPress={resetAmount} style={styles.maxPressable}>
<Text numberOfLines={1} style={[styles.input, styles.maxLabel, stylesHook.input]}>
{BitcoinUnit.MAX}
</Text>
<Text style={[styles.input, stylesHook.input]}>{BitcoinUnit.MAX}</Text>
{maxSendableAmount != null && (
<Text style={[styles.maxEstimate, stylesHook.localCurrency]} onLongPress={copyMaxEstimate}>
{(isMaxAmountEstimate ? '≈ ' : '') +
@ -435,7 +344,7 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
</Pressable>
)}
{unit !== BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && (
<Text style={[styles.cryptoCurrency, stylesHook.cryptoCurrency]}>{loc.units[unit]}</Text>
<Text style={[styles.cryptoCurrency, stylesHook.cryptoCurrency]}>{' ' + loc.units[unit]}</Text>
)}
</View>
<View style={styles.secondaryRoot}>
@ -444,20 +353,17 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
</Text>
</View>
</View>
{!disabled &&
(amount !== BitcoinUnit.MAX ? (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={loc._.change_input_currency}
testID="changeAmountUnitButton"
style={[styles.sideRail, styles.changeAmountUnit]}
onPress={changeAmountUnit}
>
<Image source={require('../img/round-compare-arrows-24-px.png')} />
</TouchableOpacity>
) : (
<View style={styles.sideRail} />
))}
{!disabled && amount !== BitcoinUnit.MAX && (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={loc._.change_input_currency}
testID="changeAmountUnitButton"
style={styles.changeAmountUnit}
onPress={changeAmountUnit}
>
<Image source={require('../img/round-compare-arrows-24-px.png')} />
</TouchableOpacity>
)}
</View>
{outdatedRefreshRate && (
<View style={styles.outdatedRateContainer}>
@ -470,7 +376,7 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
accessibilityLabel={loc._.refresh}
onPress={updateRate}
disabled={isRateBeingUpdatedLocal}
style={isRateBeingUpdatedLocal ? styles.disabledButton : undefined}
style={isRateBeingUpdatedLocal ? styles.disabledButton : styles.enabledButon}
>
<Icon name="arrows-rotate" type="font-awesome-6" size={16} color={colors.buttonAlternativeTextColor} />
</TouchableOpacity>
@ -485,15 +391,11 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
},
center: {
alignSelf: 'center',
},
flex: {
flex: 1,
overflow: 'visible',
},
sideRail: {
width: SWAP_ICON_SIZE,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
},
spacing8: {
width: 8,
@ -507,6 +409,9 @@ const styles = StyleSheet.create({
disabledButton: {
opacity: 0.5,
},
enabledButon: {
opacity: 1,
},
outdatedRateContainer: {
flexDirection: 'row',
justifyContent: 'center',
@ -520,50 +425,23 @@ const styles = StyleSheet.create({
justifyContent: 'center',
paddingTop: 16,
paddingBottom: 2,
overflow: 'visible',
},
localCurrency: {
fontSize: 18,
marginRight: 2,
marginHorizontal: 4,
fontWeight: 'bold',
alignSelf: 'center',
justifyContent: 'center',
},
inputSizer: {
maxWidth: MAX_INPUT_WIDTH,
position: 'relative',
overflow: 'visible',
},
input: {
fontWeight: 'bold',
margin: 0,
borderWidth: 0,
paddingHorizontal: INPUT_HORIZONTAL_PADDING,
paddingVertical: INPUT_VERTICAL_PADDING,
},
inputGlyph: {
fontWeight: 'bold',
margin: 0,
padding: 0,
},
inputMeasure: {
opacity: 0,
},
inputDisplay: {
...StyleSheet.absoluteFill,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: INPUT_HORIZONTAL_PADDING,
paddingVertical: INPUT_VERTICAL_PADDING,
zIndex: 1,
},
inputOverlay: {
...StyleSheet.absoluteFill,
zIndex: 2,
paddingHorizontal: 0,
paddingVertical: 0,
textAlign: 'center',
},
cryptoCurrency: {
fontSize: 15,
marginLeft: 2,
marginHorizontal: 4,
fontWeight: '600',
alignSelf: 'center',
justifyContent: 'center',
@ -584,12 +462,11 @@ const styles = StyleSheet.create({
},
maxPressable: {
alignItems: 'center',
flexShrink: 0,
},
maxLabel: {
flexShrink: 0,
},
changeAmountUnit: {
alignSelf: 'center',
marginRight: 16,
paddingLeft: 16,
paddingVertical: 16,
},
});

View File

@ -0,0 +1,93 @@
/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */
import React, { useState } from 'react';
import { Keyboard, Pressable, StyleSheet, Text, View } from 'react-native';
import Icon from './Icon';
import loc from '../loc';
import { useTheme } from './themes';
interface IHash {
[key: string]: string;
}
type ArrowPickerProps = {
onChange: (key: string) => void;
items: IHash;
isItemUnknown: boolean;
};
export const ArrowPicker = (props: ArrowPickerProps) => {
const keys = Object.keys(props.items);
const [keyIndex, setKeyIndex] = useState(0);
const { colors } = useTheme();
const stylesHook = {
text: {
color: colors.foregroundColor,
},
};
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Pressable
accessibilityRole="button"
accessibilityLabel={loc.send.dynamic_prev}
onPress={() => {
Keyboard.dismiss();
let newIndex = keyIndex;
if (keyIndex <= 0) {
newIndex = keys.length - 1;
} else {
newIndex--;
}
setKeyIndex(newIndex);
props.onChange(keys[newIndex]);
}}
style={({ pressed }) => [
{
backgroundColor: pressed ? 'rgb(210, 230, 255)' : 'white',
},
styles.wrapperCustom,
]}
>
<Icon size={24} name="chevron-back" type="ionicons" />
</Pressable>
<View style={{ width: 200 }}>
<Text style={[styles.text, stylesHook.text]}>{props.isItemUnknown ? loc.send.fee_custom : keys[keyIndex]}</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={loc.send.dynamic_next}
onPress={() => {
Keyboard.dismiss();
let newIndex = keyIndex;
if (keyIndex + 1 >= keys.length) {
newIndex = 0;
} else {
newIndex++;
}
setKeyIndex(newIndex);
props.onChange(keys[newIndex]);
}}
style={({ pressed }) => [
{
backgroundColor: pressed ? 'rgb(210, 230, 255)' : 'white',
},
styles.wrapperCustom,
]}
>
<Icon size={24} name="chevron-forward" type="ionicons" />
</Pressable>
</View>
);
};
const styles = StyleSheet.create({
wrapperCustom: {
borderRadius: 8,
padding: 5,
marginLeft: 20,
marginRight: 20,
},
text: { fontWeight: 'bold', fontSize: 12, textAlign: 'center' },
});

View File

@ -4,13 +4,13 @@ import Icon, { type IconProps } from './Icon';
export interface AvatarProps {
rounded?: boolean;
size: number;
containerStyle: StyleProp<ViewStyle>;
size?: number;
containerStyle?: StyleProp<ViewStyle>;
icon?: Pick<IconProps, 'name' | 'type' | 'color' | 'size'>;
onPress?: () => void;
}
const Avatar: React.FC<AvatarProps> = ({ rounded, size, containerStyle, icon, onPress }) => {
const Avatar: React.FC<AvatarProps> = ({ rounded, size = 40, containerStyle, icon, onPress }) => {
const dimensionStyle = { width: size, height: size, borderRadius: rounded ? size / 2 : 0 } as ViewStyle;
const content = (
<View style={[styles.container, dimensionStyle, containerStyle]}>

View File

@ -1,34 +0,0 @@
import React, { forwardRef } from 'react';
import { Pressable, PressableProps, StyleSheet, Text } from 'react-native';
import { useTheme } from './themes';
interface BlueButtonLinkProps extends PressableProps {
title: string;
}
const BlueButtonLink = forwardRef<React.ElementRef<typeof Pressable>, BlueButtonLinkProps>((props, ref) => {
const { colors } = useTheme();
return (
<Pressable accessibilityRole="button" style={({ pressed }) => [styles.blueButtonLink, pressed && styles.pressed]} {...props} ref={ref}>
<Text style={[styles.blueButtonLinkText, { color: colors.foregroundColor }]}>{props.title}</Text>
</Pressable>
);
});
const styles = StyleSheet.create({
blueButtonLink: {
minWidth: 100,
minHeight: 36,
justifyContent: 'center',
},
blueButtonLinkText: {
textAlign: 'center',
fontSize: 16,
},
pressed: {
opacity: 0.6,
},
});
export default BlueButtonLink;

View File

@ -1,14 +0,0 @@
import React from 'react';
import { StyleSheet, View, ViewProps } from 'react-native';
const BlueCard: React.FC<ViewProps> = props => {
return <View {...props} style={styles.blueCard} />;
};
const styles = StyleSheet.create({
blueCard: {
padding: 20,
},
});
export default BlueCard;

View File

@ -1,21 +0,0 @@
import { useLocale } from '@react-navigation/native';
import React from 'react';
import { StyleSheet, Text, TextProps } from 'react-native';
import { useTheme } from './themes';
const BlueFormLabel: React.FC<TextProps> = props => {
const { colors } = useTheme();
const { direction } = useLocale();
return <Text {...props} style={[styles.blueFormLabel, { color: colors.foregroundColor, writingDirection: direction }]} />;
};
const styles = StyleSheet.create({
blueFormLabel: {
fontWeight: '400',
marginHorizontal: 20,
},
});
export default BlueFormLabel;

View File

@ -1,50 +0,0 @@
import React from 'react';
import { Platform, StyleSheet, TextInput, TextInputProps } from 'react-native';
import { useTheme } from './themes';
const BlueFormMultiInput: React.FC<TextInputProps> = props => {
const { colors } = useTheme();
const { style, editable, ...restProps } = props;
return (
<TextInput
multiline
underlineColorAndroid="transparent"
numberOfLines={4}
editable={editable}
style={[
styles.blueFormMultiInput,
{
borderColor: colors.formBorder,
borderBottomColor: colors.formBorder,
backgroundColor: colors.inputBackgroundColor,
color: colors.foregroundColor,
},
style,
]}
autoCorrect={false}
autoCapitalize="none"
spellCheck={false}
{...restProps}
selectTextOnFocus={false}
keyboardType={Platform.OS === 'android' ? 'visible-password' : 'default'}
/>
);
};
const styles = StyleSheet.create({
blueFormMultiInput: {
paddingHorizontal: 8,
paddingVertical: 16,
flex: 1,
marginTop: 5,
marginHorizontal: 20,
borderWidth: 1,
borderBottomWidth: 0.5,
borderRadius: 4,
textAlignVertical: 'top',
},
});
export default BlueFormMultiInput;

View File

@ -1,62 +0,0 @@
import { useLocale } from '@react-navigation/native';
import React from 'react';
import { StyleSheet, Text, TextProps } from 'react-native';
import { useTheme } from './themes';
interface BlueTextProps extends TextProps {
bold?: boolean;
h1?: boolean;
h2?: boolean;
h3?: boolean;
h4?: boolean;
}
const BlueText: React.FC<BlueTextProps> = ({ bold = false, h1, h2, h3, h4, style: passedStyle, ...props }) => {
const { colors } = useTheme();
const { direction } = useLocale();
let headingStyle = {};
if (h1) {
headingStyle = styles.h1;
} else if (h2) {
headingStyle = styles.h2;
} else if (h3) {
headingStyle = styles.h3;
} else if (h4) {
headingStyle = styles.h4;
}
const hasHeading = h1 || h2 || h3 || h4;
const style = StyleSheet.compose(
{
color: colors.foregroundColor,
writingDirection: direction,
fontWeight: hasHeading ? undefined : bold ? 'bold' : 'normal',
...headingStyle,
},
passedStyle,
);
return <Text style={style} {...props} />;
};
const styles = StyleSheet.create({
h1: {
fontSize: 40,
fontWeight: 'bold',
},
h2: {
fontSize: 34,
fontWeight: 'bold',
},
h3: {
fontSize: 28,
fontWeight: 'bold',
},
h4: {
fontSize: 22,
fontWeight: 'bold',
},
});
export default BlueText;

View File

@ -1,17 +0,0 @@
import React from 'react';
import { StyleSheet, Text, TextProps } from 'react-native';
import { useTheme } from './themes';
const BlueTextCentered: React.FC<TextProps> = props => {
const { colors } = useTheme();
return <Text {...props} style={[styles.blueTextCentered, { color: colors.foregroundColor }]} />;
};
const styles = StyleSheet.create({
blueTextCentered: {
textAlign: 'center',
},
});
export default BlueTextCentered;

View File

@ -220,11 +220,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = React.m
useEffect(() => {
if (walletsInitialized) {
if (isElectrumDisabled) {
BlueElectrum.forceDisconnect();
} else {
BlueElectrum.ensureConnected({ showAlertOnFailure: true });
}
isElectrumDisabled ? BlueElectrum.forceDisconnect() : BlueElectrum.connectMain();
}
}, [isElectrumDisabled, walletsInitialized]);

View File

@ -1,20 +1,19 @@
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BlueApp as BlueAppClass, TCounterpartyMetadata, TTXMetadata } from '../../class/blue-app';
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
import { WatchOnlyWallet } from '../../class/wallets/watch-only-wallet';
import type { TWallet } from '../../class/wallets/types';
import presentAlert from '../../components/Alert';
import loc, { formatBalanceWithoutSuffix } from '../../loc';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
import { registerArkBackgroundTask, stopArkBackgroundTask } from '../../blue_modules/arkade-background';
import { startAndDecrypt } from '../../blue_modules/start-and-decrypt';
import { isNotificationsEnabled, majorTomToGroundControl, unsubscribe } from '../../blue_modules/notifications';
import { BitcoinUnit } from '../../models/bitcoinUnits';
import { navigationRef } from '../../NavigationService';
import { getScanWasBBQR } from '../../helpers/scan-qr.ts';
import { setWalletIdMustUseBBQR } from '../../blue_modules/ur';
import { AppState, AppStateStatus } from 'react-native';
const BlueApp = BlueAppClass.getInstance();
@ -176,15 +175,6 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
const deleteWallet = useCallback((wallet: TWallet) => {
BlueApp.deleteWallet(wallet);
setWallets([...BlueApp.getWallets()]);
if (wallet.type === LightningArkWallet.type) {
// Fire-and-forget: cleans up the per-wallet Arkade Realm (close + delete files)
// and the Keychain encryption key. Errors stay scoped to the Ark wallet path
// and never block deletion.
(wallet as LightningArkWallet).onDelete().catch(e => console.warn('[StorageProvider] Ark wallet cleanup failed:', e?.message ?? e));
if (!BlueApp.getWallets().some(w => w.type === LightningArkWallet.type)) {
stopArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task stop failed:', e?.message ?? e));
}
}
}, []);
const handleWalletDeletion = useCallback(
@ -318,11 +308,7 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
if (walletsInitialized) {
txMetadata.current = BlueApp.tx_metadata;
counterpartyMetadata.current = BlueApp.counterparty_metadata;
const loaded = BlueApp.getWallets();
setWallets(loaded);
if (loaded.some(w => w.type === LightningArkWallet.type)) {
registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e));
}
setWallets(BlueApp.getWallets());
}
}, [walletsInitialized]);
@ -345,22 +331,20 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
console.debug('[refreshAllWalletTransactions] Setting wallet transaction status to ALL');
setWalletTransactionUpdateStatus(WalletTransactionsStatus.ALL);
}
console.debug('[refreshAllWalletTransactions] Ensuring Electrum connection (ping / reconnect if stale)...');
await BlueElectrum.ensureElectrumConnection();
console.debug('[refreshAllWalletTransactions] Waiting for connectivity...');
// `ensureConnected()` ping-checks the existing socket and, only if needed,
// tears it down and reconnects. Replaces the old wait+ping+wait pattern
// which surfaced false "network error" alerts after iOS suspend/resume.
const connected = await BlueElectrum.ensureConnected();
if (!connected) {
console.log('[refreshAllWalletTransactions] could not establish Electrum connection, aborting refresh');
return;
}
await BlueElectrum.waitTillConnected();
console.debug('[refreshAllWalletTransactions] Connected to Electrum');
// Race only the post-connect work. We budget ample time so that a slow
// initial Electrum connection (cold start, slow TLS, flaky network) doesn't
// cause the fetch race to abort prematurely.
const REFRESH_FETCH_PHASE_TIMEOUT_MS = Math.max(120_000, BlueElectrum.ENSURE_CONNECTED_MAX_WALL_MS * 2);
// Race only the post-connect work. `waitTillConnected` can take up to
// WAIT_TILL_CONNECTED_MAX_WALL_MS_NEVER (+ a second wait); starting the timer earlier caused refresh to abort
// while Electrum was still legitimately connecting.
const REFRESH_FETCH_PHASE_TIMEOUT_MS = Math.max(
120_000,
BlueElectrum.WAIT_TILL_CONNECTED_MAX_WALL_MS_NEVER + BlueElectrum.WAIT_TILL_CONNECTED_MAX_WALL_MS_AFTER_FIRST,
);
const timeoutPromise = new Promise<never>(
(_resolve, reject) =>
(refreshTimeout = setTimeout(() => {
@ -419,6 +403,52 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
[saveToDisk],
);
const refreshAllWalletTransactionsRef = useRef(refreshAllWalletTransactions);
refreshAllWalletTransactionsRef.current = refreshAllWalletTransactions;
useEffect(() => {
if (!walletsInitialized) return;
let wasInBackground = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_MS = 1200;
const onAppStateChange = (next: AppStateStatus) => {
if (next === 'background') {
wasInBackground = true;
}
if (next !== 'active') {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
return;
}
if (!wasInBackground) return;
wasInBackground = false;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
(async () => {
if (AppState.currentState !== 'active') return;
if (await BlueElectrum.isDisabled()) return;
await refreshAllWalletTransactionsRef.current(undefined, false);
})().catch(() => {
/* refresh logs errors internally */
});
}, DEBOUNCE_MS);
};
const subscription = AppState.addEventListener('change', onAppStateChange);
return () => {
subscription.remove();
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [walletsInitialized]);
const fetchAndSaveWalletTransactions = useCallback(
async (walletID: string) => {
const index = wallets.findIndex(wallet => wallet.getID() === walletID);
@ -430,11 +460,8 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
}
_lastTimeTriedToRefetchWallet[walletID] = Date.now();
const connected = await BlueElectrum.ensureConnected();
if (!connected) {
console.log('[fetchAndSaveWalletTransactions] could not establish Electrum connection, aborting');
return;
}
await BlueElectrum.ensureElectrumConnection();
await BlueElectrum.waitTillConnected();
setWalletTransactionUpdateStatus(walletID);
const balanceStart = Date.now();
@ -468,9 +495,6 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
if (w.getLabel() === emptyWalletLabel) w.setLabel(loc.wallets.import_imported + ' ' + w.typeReadable);
w.setUserHasSavedExport(true);
addWallet(w);
if (w instanceof LightningArkWallet) {
registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e));
}
if (getScanWasBBQR()) {
// to avoid proxying `useBBQR` through a bunch of screens during import procedure, we use a trick:
// on add-wallet screen we reset `lastScanWasBBQR` to false. then potentially user scans QR in BBQR format

View File

@ -3,7 +3,7 @@ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo
import { StyleSheet, Text, TextProps, TextStyle, TouchableOpacity, View, ViewStyle } from 'react-native';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
import BlueText from './BlueText';
import { BlueText } from '../BlueComponents';
import loc from '../loc';
import { useTheme } from './themes';

View File

@ -1,7 +1,7 @@
import React from 'react';
import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native';
import { useTheme } from './themes';
import BlueButtonLink from './BlueButtonLink';
import { BlueButtonLink } from '../BlueComponents';
import loc from '../loc';
export const DismissKeyboardInputAccessoryViewID = 'DismissKeyboardInputAccessory';

View File

@ -1,6 +1,6 @@
import React from 'react';
import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native';
import BlueButtonLink from './BlueButtonLink';
import { BlueButtonLink } from '../BlueComponents';
import loc from '../loc';
import { useTheme } from './themes';
import Clipboard from '@react-native-clipboard/clipboard';

View File

@ -52,14 +52,7 @@ const useFloatButtonAnimation = (initialHeight: number) => {
};
};
const getScaledButtonHeight = (fontScale: number): number => Math.round(LAYOUT.BUTTON_HEIGHT * fontScale);
/** Scroll padding so list content clears float buttons (excludes safe-area inset). Default 70 at fontScale 1. */
const FLOAT_BUTTON_LIST_CLEARANCE = 18;
export const getFloatingButtonReservedHeight = (fontScale = 1): number => getScaledButtonHeight(fontScale) + FLOAT_BUTTON_LIST_CLEARANCE;
const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: number) => {
const useFloatButtonLayout = (width: number, sizeClass: SizeClass) => {
const lastVerticalDecision = useRef(false);
const shouldUseVerticalLayout = useCallback(
@ -159,19 +152,15 @@ const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: nu
[width, sizeClass, shouldUseVerticalLayout],
);
const calculateContainerHeight = useCallback(
(childrenCount: number, isVerticalLayout: boolean) => {
const buttonHeight = getScaledButtonHeight(fontScale);
if (!isVerticalLayout) return { height: '8%', minHeight: buttonHeight };
const calculateContainerHeight = useCallback((childrenCount: number, isVerticalLayout: boolean) => {
if (!isVerticalLayout) return { height: '8%', minHeight: LAYOUT.BUTTON_HEIGHT };
const totalButtonsHeight = childrenCount * buttonHeight;
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
const totalButtonsHeight = childrenCount * LAYOUT.BUTTON_HEIGHT;
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
return { height: calculatedHeight };
},
[fontScale],
);
return { height: calculatedHeight };
}, []);
const calculateButtonFontSize = useMemo(() => {
const divisor = sizeClass === SizeClass.Large ? 22 : sizeClass === SizeClass.Regular ? 24 : 28;
@ -278,7 +267,6 @@ interface FButtonProps {
isVertical?: boolean;
borderRadius?: number;
fontSize?: number;
buttonHeight?: number;
disabled?: boolean;
testID?: string;
onPress: () => void;
@ -289,14 +277,13 @@ interface ButtonContentProps {
icon: ReactNode;
text: string;
textStyle: StyleProp<TextStyle>;
buttonHeight: number;
}
const getScaledIconSize = (fontSize: number): number => {
return Math.max(Math.round(fontSize * 1.2), 16);
};
const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentProps) => {
const ButtonContent = ({ icon, text, textStyle }: ButtonContentProps) => {
const computedStyle = StyleSheet.flatten(textStyle);
const fontSize = computedStyle.fontSize || LAYOUT.MAX_BUTTON_FONT_SIZE;
const iconSize = getScaledIconSize(Number(fontSize));
@ -320,14 +307,9 @@ const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentPro
}
return (
<View style={[buttonContentStaticStyles.contentContainer, { minHeight: buttonHeight }]}>
<View style={buttonContentStaticStyles.contentContainer}>
<View style={buttonStyles.iconContainer}>{scaledIcon}</View>
<Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}
>
<Text numberOfLines={1} adjustsFontSizeToFit style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}>
{text}
</Text>
</View>
@ -343,7 +325,6 @@ export const FButton = ({
isVertical,
borderRadius = LAYOUT.PILL_BORDER_RADIUS,
fontSize = LAYOUT.MAX_BUTTON_FONT_SIZE,
buttonHeight = LAYOUT.BUTTON_HEIGHT,
testID,
...props
}: FButtonProps) => {
@ -366,8 +347,6 @@ export const FButton = ({
return {
root: {
...baseStyles,
height: buttonHeight,
minHeight: buttonHeight,
backgroundColor: colors.buttonBackgroundColor,
},
text: {
@ -381,7 +360,7 @@ export const FButton = ({
marginBottom: buttonContentStaticStyles.marginBottom,
textBase: buttonContentStaticStyles.textBase,
};
}, [colors, fontSize, buttonHeight]);
}, [colors, fontSize]);
const style: Record<string, any> = {};
const additionalStyles = !last ? (isVertical ? customButtonStyles.marginBottom : customButtonStyles.marginRight) : {};
@ -418,7 +397,7 @@ export const FButton = ({
style={[buttonStyles.root, customButtonStyles.root, style, { borderRadius }]}
{...props}
>
<ButtonContent icon={icon} text={text} textStyle={textStyle} buttonHeight={buttonHeight} />
<ButtonContent icon={icon} text={text} textStyle={textStyle} />
</TouchableOpacity>
</Animated.View>
);
@ -426,9 +405,8 @@ export const FButton = ({
export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
const insets = useSafeAreaInsets();
const { height, width, fontScale } = useWindowDimensions();
const { height, width } = useWindowDimensions();
const { sizeClass } = useSizeClass();
const scaledButtonHeight = getScaledButtonHeight(fontScale);
const childrenCount = React.Children.toArray(props.children).filter(Boolean).length;
@ -441,7 +419,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
const { calculateButtonWidth, calculateVisualParameters, calculateContainerHeight, buttonFontSize } = useFloatButtonLayout(
width,
sizeClass,
fontScale,
);
// Compute initial geometry up-front so the slide-in animation starts at the final (computed) size,
@ -531,7 +508,7 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
useEffect(() => {
debouncedCalculateLayout();
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass, fontScale]);
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass]);
const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => {
const { width: currentLayoutWidth } = event.nativeEvent.layout;
@ -568,7 +545,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
isVertical,
borderRadius: buttonBorderRadius,
fontSize: buttonFontSize,
buttonHeight: scaledButtonHeight,
});
};
@ -585,10 +561,10 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
props.inline ? containerStyles.rootInline : containerStyles.rootAbsolute,
bottomInsets,
effectiveNewWidth ? (isVertical ? containerStyles.rootPostVertical : containerStyles.rootPost) : containerStyles.rootPre,
isVertical ? containerHeight : { minHeight: scaledButtonHeight },
isVertical ? containerHeight : null,
{ transform: [{ translateY: slideAnimation }] },
],
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation, scaledButtonHeight],
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation],
);
return (

View File

@ -4,13 +4,13 @@ import { StyleSheet, Text, TouchableOpacity } from 'react-native';
import { useTheme } from './themes';
interface HeaderRightButtonProps {
disabled: boolean;
disabled?: boolean;
onPress?: () => void;
title: string;
testID?: string;
}
const HeaderRightButton: React.FC<HeaderRightButtonProps> = ({ disabled, onPress, title, testID }) => {
const HeaderRightButton: React.FC<HeaderRightButtonProps> = ({ disabled = true, onPress, title, testID }) => {
const { colors } = useTheme();
const opacity = disabled ? 0.5 : 1;
return (

View File

@ -1,6 +1,6 @@
import React from 'react';
import { InputAccessoryView, Keyboard, Platform, StyleSheet, Text, View } from 'react-native';
import BlueButtonLink from './BlueButtonLink';
import { BlueButtonLink } from '../BlueComponents';
import loc from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
import { useTheme } from './themes';

View File

@ -1,30 +1,24 @@
import React, { useMemo } from 'react';
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, useWindowDimensions, View, ViewStyle } from 'react-native';
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, View, ViewStyle } from 'react-native';
import { useLocale } from '@react-navigation/native';
import Icon from './Icon';
import { useTheme } from './themes';
/** Base row height for transaction list `getItemLayout` (padding + title + subtitle at fontScale 1). */
export const TX_ROW_BASE_HEIGHT = 64;
interface ListItemProps {
leftAvatar?: React.JSX.Element;
containerStyle?: StyleProp<ViewStyle>;
noFeedback?: boolean;
bottomDivider?: boolean;
testID?: string;
switchTestID?: string;
onPress?: () => void;
disabled?: boolean;
switch?: SwitchProps;
title: string;
titleStyle?: StyleProp<TextStyle>;
subtitle?: string | React.ReactNode;
subtitleNumberOfLines?: number;
rightTitle?: string;
rightTitleStyle?: StyleProp<TextStyle>;
rightTitleSelectable?: boolean;
rightSubtitle?: string | React.ReactNode;
rightSubtitleStyle?: StyleProp<TextStyle>;
chevron?: boolean;
@ -39,17 +33,14 @@ const ListItem: React.FC<ListItemProps> = React.memo(
noFeedback = false,
bottomDivider = true,
testID,
switchTestID,
onPress,
disabled,
switch: switchProps,
title,
titleStyle,
subtitle,
subtitleNumberOfLines,
rightTitle,
rightTitleStyle,
rightTitleSelectable,
rightSubtitle,
rightSubtitleStyle,
chevron,
@ -58,20 +49,12 @@ const ListItem: React.FC<ListItemProps> = React.memo(
}: ListItemProps) => {
const { colors } = useTheme();
const { direction } = useLocale();
const { fontScale } = useWindowDimensions();
const isRtl = direction === 'rtl';
const contentRowStyle = useMemo(
() => ({
paddingVertical: Math.round(12 * fontScale),
}),
[fontScale],
);
const stylesHook = StyleSheet.create({
title: {
color: disabled ? colors.buttonDisabledTextColor : colors.foregroundColor,
fontSize: 16,
fontWeight: '500',
lineHeight: Math.round(22 * fontScale),
writingDirection: direction,
},
rightMemoText: {
@ -83,7 +66,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
color: colors.alternativeTextColor,
fontWeight: '400',
paddingVertical: switchProps ? 8 : 0,
lineHeight: Math.round(20 * fontScale),
lineHeight: 20,
fontSize: 14,
marginTop: 2,
},
@ -100,11 +83,10 @@ const ListItem: React.FC<ListItemProps> = React.memo(
const memoizedSwitchProps = useMemo(() => {
return switchProps ? { ...switchProps } : undefined;
}, [switchProps]);
const resolvedSwitchTestID = switchTestID ?? memoizedSwitchProps?.testID;
const enableFeedback = !noFeedback && !!onPress && !disabled;
const renderContent = () => (
<View style={[styles.contentRow, contentRowStyle]}>
<View style={styles.contentRow}>
{leftAvatar && (
<View style={styles.leftAvatarContainer}>
{leftAvatar}
@ -112,7 +94,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
</View>
)}
<View style={styles.content}>
<Text style={[stylesHook.title, titleStyle]} numberOfLines={0} accessibilityRole="text">
<Text style={stylesHook.title} numberOfLines={0} accessibilityRole="text">
{title}
</Text>
{subtitle ? (
@ -125,14 +107,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
{rightTitle || rightSubtitle ? (
<View style={styles.rightColumn}>
{rightTitle ? (
<Text
style={rightTitleStyle}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.75}
accessibilityRole="text"
selectable={rightTitleSelectable}
>
<Text style={rightTitleStyle} numberOfLines={1} accessibilityRole="text">
{rightTitle}
</Text>
) : null}
@ -149,14 +124,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
<Icon name={isRtl ? 'angle-left' : 'angle-right'} type="font-awesome" color={colors.alternativeTextColor} size={18} />
) : null}
{switchProps ? (
<Switch
{...memoizedSwitchProps}
testID={resolvedSwitchTestID}
accessibilityLabel={title}
style={styles.margin16}
accessible
accessibilityRole="switch"
/>
<Switch {...memoizedSwitchProps} accessibilityLabel={title} style={styles.margin16} accessible accessibilityRole="switch" />
) : null}
{checkmark ? (
<View style={styles.checkmarkContainer}>
@ -210,20 +178,16 @@ const styles = StyleSheet.create({
},
content: {
flex: 1,
flexShrink: 1,
minWidth: 0,
justifyContent: 'center',
},
leftAvatarContainer: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'center',
},
rightColumn: {
marginStart: 8,
flexShrink: 0,
minWidth: 0,
alignItems: 'flex-end',
alignSelf: 'center',
},
rightMemoWrapper: {
flexShrink: 1,

View File

@ -153,7 +153,6 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
]}
onPress={onToggle}
accessibilityRole="button"
testID={isHidden ? 'SwipeShowBalance' : 'SwipeHideBalance'}
>
<Text style={[styles.rightActionText, { color: colors.buttonTextColor }]}>
{isHidden ? loc.wallets.swipe_balance_show : loc.wallets.swipe_balance_hide}

View File

@ -14,7 +14,7 @@ type ErrorCorrectionLevel = 'H' | 'Q' | 'M' | 'L';
interface QRCodeProps {
value: string;
size: number;
size?: number;
isLogoRendered?: boolean;
isMenuAvailable?: boolean;
logoSize?: number;
@ -144,7 +144,7 @@ const getCachedPlan = (value: string, ecl: ErrorCorrectionLevel, size: number, i
const QRCode: React.FC<QRCodeProps> = ({
value = '',
size,
size = 300,
isLogoRendered = true,
isMenuAvailable = true,
logoSize = 90,
@ -216,11 +216,24 @@ const QRCode: React.FC<QRCodeProps> = ({
const gradFill = `url(#${GRADIENT_ID})`;
const finderShapes: React.ReactElement[] = [];
const outerR = 2 * cell;
const holeR = 1.25 * cell;
const dotR = 0.9 * cell;
finderOrigins.forEach(([fr, fc], i) => {
const x = (fc + 1) * cell;
const y = (fr + 1) * cell;
finderShapes.push(
<Rect key={`finder-frame-${i}`} testID="qr-finder-frame" x={x} y={y} width={7 * cell} height={7 * cell} fill={gradFill} />,
<Rect
key={`finder-frame-${i}`}
testID="qr-finder-frame"
x={x}
y={y}
width={7 * cell}
height={7 * cell}
rx={outerR}
ry={outerR}
fill={gradFill}
/>,
<Rect
key={`finder-hole-${i}`}
testID="qr-finder-hole"
@ -228,6 +241,8 @@ const QRCode: React.FC<QRCodeProps> = ({
y={y + cell}
width={5 * cell}
height={5 * cell}
rx={holeR}
ry={holeR}
fill={BACKGROUND}
/>,
<Rect
@ -237,6 +252,8 @@ const QRCode: React.FC<QRCodeProps> = ({
y={y + 2 * cell}
width={3 * cell}
height={3 * cell}
rx={dotR}
ry={dotR}
fill={gradFill}
/>,
);
@ -260,7 +277,16 @@ const QRCode: React.FC<QRCodeProps> = ({
{finderShapes}
{isLogoRendered && logoCells > 0 && (
<>
<Rect testID="qr-logo-backdrop" x={backdropX} y={backdropY} width={backdropSize} height={backdropSize} fill={LOGO_BACKGROUND} />
<Rect
testID="qr-logo-backdrop"
x={backdropX}
y={backdropY}
width={backdropSize}
height={backdropSize}
rx={cell * 0.5}
ry={cell * 0.5}
fill={LOGO_BACKGROUND}
/>
<SvgImage
testID="qr-logo-image"
href={require('../img/qr-code.png')}

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, Text, TextInput, TouchableOpacity, Keyboard, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import BlueText from './BlueText';
import { BlueText } from '../BlueComponents';
import loc, { formatStringAddTwoWhiteSpaces } from '../loc';
import NetworkTransactionFees, { NetworkTransactionFee, NetworkTransactionFeeType } from '../models/networkTransactionFees';
import { useTheme } from './themes';
@ -67,25 +67,26 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
}, []);
const handleFeeSelection = (feeType: NetworkTransactionFeeType) => {
if (feeType === NetworkTransactionFeeType.CUSTOM) {
setSelectedFeeType(feeType);
return;
if (feeType !== NetworkTransactionFeeType.CUSTOM) {
Keyboard.dismiss();
}
Keyboard.dismiss();
if (networkFees) {
let selectedFee: number;
switch (feeType) {
case NetworkTransactionFeeType.FAST:
onFeeSelected(networkFees.fastestFee);
selectedFee = networkFees.fastestFee;
break;
case NetworkTransactionFeeType.MEDIUM:
onFeeSelected(networkFees.mediumFee);
selectedFee = networkFees.mediumFee;
break;
case NetworkTransactionFeeType.SLOW:
onFeeSelected(networkFees.slowFee);
selectedFee = networkFees.slowFee;
break;
case NetworkTransactionFeeType.CUSTOM:
selectedFee = Number(customFeeValue);
break;
}
onFeeSelected(selectedFee);
setSelectedFeeType(feeType);
}
};
@ -93,8 +94,7 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
const handleCustomFeeChange = (customFee: string) => {
const sanitizedFee = customFee.replace(/[^0-9]/g, '');
setCustomFeeValue(sanitizedFee);
onFeeSelected(Number(sanitizedFee));
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
handleFeeSelection(NetworkTransactionFeeType.CUSTOM);
};
return (
@ -156,10 +156,7 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
ref={customTextInput}
maxLength={9}
style={[styles.customFeeInput, stylesHook.customFeeInput]}
onFocus={() => {
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
onFeeSelected(Number(customFeeValue));
}}
onFocus={() => handleCustomFeeChange(customFeeValue)}
placeholder={loc.send.fee_satvbyte}
placeholderTextColor="#81868e"
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}

View File

@ -7,18 +7,10 @@ import { useTheme } from './themes';
interface SafeAreaScrollViewProps extends ScrollViewProps {
floatingButtonHeight?: number;
headerHeight?: number; // Additional header height to account for (e.g., when headerTransparent is true)
disableDefaultTopPadding?: boolean;
}
const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((props, ref) => {
const {
style,
contentContainerStyle,
floatingButtonHeight = 0,
headerHeight = 0,
disableDefaultTopPadding = false,
...otherProps
} = props;
const { style, contentContainerStyle, floatingButtonHeight = 0, headerHeight = 0, ...otherProps } = props;
const { colors } = useTheme();
const insets = useSafeAreaInsets();
@ -40,10 +32,7 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
if (headerHeight > 0) {
return headerHeight;
}
if (disableDefaultTopPadding) {
return 0;
}
// Preserve legacy behavior for existing screens
// iOS safe area or no status bar
return insets.top > 0 ? 5 : 0;
})(),
};
@ -59,7 +48,7 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
// Now compose with contentContainerStyle to ensure passed styles override defaults
return StyleSheet.compose(basePadding, contentContainerStyle);
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight, disableDefaultTopPadding]);
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight]);
return (
<ScrollView

View File

@ -10,8 +10,7 @@ type SecondButtonProps = {
backgroundColor?: string;
disabled?: boolean;
icon?: IconButtonProps;
title: string;
textColor?: string;
title?: string;
onPress?: () => void;
loading?: boolean;
testID?: string;
@ -20,7 +19,7 @@ type SecondButtonProps = {
export const SecondButton = forwardRef<React.ElementRef<typeof TouchableOpacity>, SecondButtonProps>((props, ref) => {
const { colors } = useTheme();
let backgroundColor = props.backgroundColor ? props.backgroundColor : colors.buttonGrayBackgroundColor;
let fontColor = props.textColor ?? colors.secondButtonTextColor;
let fontColor = colors.secondButtonTextColor;
if (props.disabled === true) {
backgroundColor = colors.buttonDisabledBackgroundColor;
fontColor = colors.buttonDisabledTextColor;

View File

@ -0,0 +1,87 @@
import React, { FC } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useTheme } from './themes';
export const StyledButtonType: Record<string, string> = { default: 'default', destroy: 'destroy', grey: 'grey' };
interface StyledButtonProps {
onPress: () => void;
text: string;
disabled?: boolean;
buttonStyle?: keyof typeof StyledButtonType;
}
const StyledButton: FC<StyledButtonProps> = ({ onPress, text, disabled = false, buttonStyle = StyledButtonType.default }) => {
const { colors } = useTheme();
const stylesHook = StyleSheet.create({
buttonGrey: {
backgroundColor: colors.lightButton,
},
textGray: {
color: colors.buttonTextColor,
},
container: {
opacity: disabled ? 0.5 : 1.0,
},
});
const textStyles = () => {
if (buttonStyle === StyledButtonType.grey) {
return stylesHook.textGray;
} else if (buttonStyle === StyledButtonType.destroy) {
return styles.textDestroy;
} else {
return styles.textDefault;
}
};
const buttonStyles = () => {
if (buttonStyle === StyledButtonType.grey) {
return stylesHook.buttonGrey;
} else if (buttonStyle === StyledButtonType.destroy) {
return styles.buttonDestroy;
} else {
return styles.buttonDefault;
}
};
return (
<TouchableOpacity accessibilityRole="button" onPress={onPress} disabled={disabled} style={stylesHook.container}>
<View style={[styles.buttonContainer, buttonStyles()]}>
<Text style={[styles.text, textStyles()]}>{text}</Text>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
buttonContainer: {
borderRadius: 9,
minHeight: 49,
paddingHorizontal: 8,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
alignSelf: 'auto',
flexGrow: 1,
marginHorizontal: 4,
},
buttonDefault: {
backgroundColor: '#EBF2FB',
},
buttonDestroy: {
backgroundColor: '#FFF5F5',
},
text: {
fontWeight: '600',
fontSize: 15,
},
textDefault: {
color: '#1961B9',
},
textDestroy: {
color: '#D0021B',
},
});
export default StyledButton;

View File

@ -1,7 +1,8 @@
import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
import { useTheme } from './themes';
import BlueText from './BlueText';
import { BlueText } from '../BlueComponents';
interface TipBoxProps {
number?: string;
title?: string;

View File

@ -89,23 +89,13 @@ const ToolTipMenu = (props: ToolTipMenuProps) => {
// Android gesture-cancel race documented above.
return (
<View
style={visibleStyle}
testID={testID}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
accessibilityRole={accessibilityRole}
accessibilityState={accessibilityState}
>
<ContextMenu
title={title}
previewBackgroundColor="transparent"
onPress={handlePressMenuItem}
actions={items}
dropdownMenuMode={!shouldOpenOnLongPress}
style={styles.menuFlex}
>
{children}
</ContextMenu>
{menu}
</View>
);
}

View File

@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from 'react';
import { TouchableOpacity, Text, StyleSheet, View, useWindowDimensions } from 'react-native';
import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
import { useStorage } from '../hooks/context/useStorage';
import loc, { formatBalanceWithoutSuffix } from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
@ -22,7 +22,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
setTotalBalancePreferredUnitStorage,
} = useSettings();
const { colors } = useTheme();
const { fontScale } = useWindowDimensions();
const totalBalanceFormatted = useMemo(() => {
const totalBalance = wallets.reduce((prev, curr) => {
@ -32,22 +31,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallets, totalBalancePreferredUnit, preferredFiatCurrency]);
const scaledStyles = useMemo(
() => ({
container: {
paddingVertical: Math.round(8 * fontScale),
},
label: {
lineHeight: Math.round(18 * fontScale),
marginBottom: Math.round(2 * fontScale),
},
balance: {
lineHeight: Math.round(38 * Math.max(1, fontScale)),
},
}),
[fontScale],
);
const toolTipActions = useMemo(
() => [
{
@ -109,20 +92,13 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
return (
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem} shouldOpenOnLongPress style={styles.menuContainer}>
<View style={[styles.container, scaledStyles.container]}>
<Text style={[styles.label, scaledStyles.label]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
{loc.wallets.total_balance}
</Text>
<TouchableOpacity onPress={handleBalanceOnPress} style={styles.balanceTouchable}>
<Text
style={[styles.balance, scaledStyles.balance, { color: colors.foregroundColor }]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.55}
>
{totalBalanceFormatted}
<View style={styles.container}>
<Text style={styles.label}>{loc.wallets.total_balance}</Text>
<TouchableOpacity onPress={handleBalanceOnPress}>
<Text style={[styles.balance, { color: colors.foregroundColor }]}>
{totalBalanceFormatted}{' '}
{totalBalancePreferredUnit !== BitcoinUnit.LOCAL_CURRENCY && (
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{` ${totalBalancePreferredUnit}`}</Text>
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{totalBalancePreferredUnit}</Text>
)}
</Text>
</TouchableOpacity>
@ -140,11 +116,6 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
paddingHorizontal: 16,
paddingVertical: 8,
width: '100%',
},
balanceTouchable: {
alignSelf: 'stretch',
width: '100%',
},
label: {
fontSize: 14,
@ -154,7 +125,6 @@ const styles = StyleSheet.create({
balance: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 38,
},
currency: {
fontSize: 18,

View File

@ -1,9 +1,8 @@
import React, { memo, useCallback, useMemo, useRef } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Clipboard from '@react-native-clipboard/clipboard';
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View, useWindowDimensions } from 'react-native';
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View } from 'react-native';
import Lnurl from '../class/lnurl';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { LightningTransaction, Transaction } from '../class/wallets/types';
import TransactionExpiredIcon from '../components/icons/TransactionExpiredIcon';
import TransactionIncomingIcon from '../components/icons/TransactionIncomingIcon';
@ -29,6 +28,9 @@ import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
import ListItem from './ListItem';
const styles = StyleSheet.create({
dateLine: {
fontSize: 13,
},
fullWidthButton: {
width: '100%',
alignSelf: 'stretch',
@ -103,7 +105,7 @@ const AnimatedPressableRow: React.FC<AnimatedPressableRowProps> = ({ onPress, ch
};
interface TransactionListItemProps {
itemPriceUnit: BitcoinUnit;
itemPriceUnit?: BitcoinUnit;
walletID: string;
item: Transaction & LightningTransaction; // using type intersection to have less issues with ts
searchQuery?: string;
@ -117,7 +119,7 @@ type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList>;
const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
item,
itemPriceUnit,
itemPriceUnit = BitcoinUnit.BTC,
walletID,
searchQuery,
style,
@ -130,7 +132,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
const { txMetadata, counterpartyMetadata, wallets } = useStorage();
const { language, selectedBlockExplorer } = useSettings();
const insets = useSafeAreaInsets();
const { fontScale } = useWindowDimensions();
const containerStyle = useMemo(
() => ({
backgroundColor: colors.background,
@ -153,30 +154,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
const txMemo = (counterparty ? `[${shortenContactName(counterparty)}] ` : '') + (txMetadata[item.hash]?.memo ?? '');
const noteForCopy = (txMemo || item.memo || '').trim() || undefined;
// For LightningArkWallet rows, prepend a kind tag to the date subtitle. Such a
// wallet transacts entirely via Boltz swaps, so every row is Lightning; the
// only genuinely on-chain activity is onboarding/refill (boarding UTXOs),
// tagged from the synthetic `boarding-…` txid set in
// lightning-ark-wallet.getTransactions(). Other wallet types are unaffected.
const arkRowKind = useMemo<'Lightning' | 'Refill' | undefined>(() => {
const wallet = wallets.find(w => w.getID() === item.walletID);
if (wallet?.type !== LightningArkWallet.type) return undefined;
const txid = (item as { txid?: string }).txid;
if (txid?.startsWith('boarding-')) return 'Refill';
return 'Lightning';
}, [item, wallets]);
// A refill is "Pending" until the SDK settles its boarding UTXO into a VTXO
// (also when it enters the spendable balance). getTransactions() pass 2 tags
// those not-yet-settled rows with a `boarding-utxo-…` id; settled refills use
// `boarding-…` and render as a normal confirmed receive.
const isPendingRefill = useMemo(
() => arkRowKind === 'Refill' && !!(item as { txid?: string }).txid?.startsWith('boarding-utxo-'),
[arkRowKind, item],
);
const listTitleKey = useMemo((): 'pending' | 'sent' | 'received' => {
if (isPendingRefill) return 'pending';
if (item.category === 'receive' && item.confirmations! < 3) return 'pending';
if (item.type === 'bitcoind_tx') return item.value! < 0 ? 'sent' : 'received';
if (item.type === 'paid_invoice') return 'sent';
@ -186,7 +164,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
}
if (!item.confirmations) return 'pending';
return item.value! < 0 ? 'sent' : 'received';
}, [isPendingRefill, item.category, item.confirmations, item.type, item.value, item.ispaid]);
}, [item.category, item.confirmations, item.type, item.value, item.ispaid]);
const listTitle = useMemo(() => {
if (listTitleKey === 'pending') return loc.transactions.pending;
@ -197,11 +175,11 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
const isPending = listTitleKey === 'pending';
const dateLine = useMemo(() => {
const formatted = isPending ? transactionTimeToReadable(item.timestamp) : formatTransactionListDate(item.timestamp * 1000);
return arkRowKind ? `${arkRowKind} · ${formatted}` : formatted;
if (isPending) return transactionTimeToReadable(item.timestamp);
return formatTransactionListDate(item.timestamp * 1000);
// language in deps so date format updates when locale changes (formatters use global locale)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPending, item.timestamp, language, arkRowKind]);
}, [isPending, item.timestamp, language]);
const formattedAmount = useMemo(() => {
return formatBalanceWithoutSuffix(item.value, itemPriceUnit, true).toString();
@ -246,7 +224,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
color,
fontSize: 14,
fontWeight: '600' as TextStyle['fontWeight'],
lineHeight: Math.round(20 * fontScale),
textAlign: 'right',
paddingRight: insets.right,
paddingLeft: insets.left,
@ -261,18 +238,9 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
item.ispaid,
insets.right,
insets.left,
fontScale,
]);
const determineTransactionTypeAndAvatar = () => {
// A refill awaiting settlement: show it as pending, not as a completed receive.
if (isPendingRefill) {
return {
label: loc.transactions.pending_transaction,
icon: <TransactionPendingIcon />,
};
}
if (item.category === 'receive' && item.confirmations! < 3) {
return {
label: loc.transactions.pending_transaction,
@ -280,14 +248,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
};
}
// Recovered Arkade Lightning legs are bitcoind_tx but represent Boltz swaps,
// not on-chain transfers — render them with the off-chain (Lightning) icon.
if (arkRowKind === 'Lightning' && item.type === 'bitcoind_tx') {
return item.value! < 0
? { label: loc.transactions.offchain, icon: <TransactionOffchainIcon /> }
: { label: loc.transactions.incoming_transaction, icon: <TransactionOffchainIncomingIcon /> };
}
if (item.type && item.type === 'bitcoind_tx') {
return {
label: loc.transactions.onchain,
@ -361,11 +321,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
pop();
}
navigate('TransactionStatus', { hash: item.hash, walletID, tx: item });
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) {
// A settled Arkade swap is an enriched native Ark leg (type 'bitcoind_tx')
// carrying the swap's invoice payload (payment_request/hash/preimage). Route
// it to the Lightning invoice view by that payload, not by type — otherwise
// it falls through to the on-chain TransactionStatus branch below.
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') {
const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID);
if (lightningWallet.length === 1) {
try {
@ -396,24 +352,15 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
walletID: lightningWallet[0].getID(),
});
}
} else if ((item as { txid?: string }).txid) {
// Hash-less Ark rows carry a synthetic `txid`. Native transfer legs
// (`ark-…`) open the hash-less-tolerant TransactionStatus detail. Refill
// rows (`boarding-…` / `boarding-utxo-…`) have no detail surface and are
// not tappable — matching master, where on-chain top-ups aren't tappable.
const txid = (item as { txid: string }).txid;
if (!txid.startsWith('boarding-')) {
navigate('TransactionStatus', { tx: item, hash: txid, walletID });
}
} else {
console.log('cant handle press');
}
}, [item, renderHighlightedText, navigate, walletID, wallets, customOnPress, disableNavigation]);
const handleOnDetailsPress = useCallback(() => {
if (walletID && item && item.hash) {
navigate('TransactionStatus', { hash: item.hash, walletID, tx: item });
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) {
// Settled Arkade swaps carry invoice data on a 'bitcoind_tx' leg; route by
// payload so they open the Lightning invoice view (see onPress above).
} else {
const lightningWallet = wallets.find(wallet => wallet?.getID() === item.walletID);
if (lightningWallet) {
navigate('LNDViewInvoice', {
@ -421,13 +368,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
walletID: lightningWallet.getID(),
});
}
} else if ((item as { txid?: string }).txid) {
// Match the regular tap path for Ark non-swap rows: native transfer legs
// open TransactionStatus; refills (`boarding-…`) are not tappable (master).
const txid = (item as { txid: string }).txid;
if (!txid.startsWith('boarding-')) {
navigate('TransactionStatus', { tx: item, hash: txid, walletID });
}
}
}, [item, navigate, walletID, wallets]);
@ -509,10 +449,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
if (renderHighlightedText && searchQuery) {
const highlighted = renderHighlightedText(subtitle, searchQuery);
if (React.isValidElement(highlighted)) {
const highlightedElement = highlighted as React.ReactElement<{
numberOfLines?: number;
style?: TextStyle | TextStyle[];
}>;
const highlightedElement = highlighted as React.ReactElement<{ numberOfLines?: number; style?: TextStyle | TextStyle[] }>;
const existingStyle = highlightedElement.props?.style;
const mergedStyle: TextStyle[] = (
Array.isArray(existingStyle)
@ -549,7 +486,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
<ListItem
leftAvatar={avatar}
title={listTitle}
subtitle={dateLine}
subtitle={<Text style={styles.dateLine}>{dateLine}</Text>}
chevron={false}
rightTitle={rowTitle}
rightTitleStyle={rowTitleStyle}

View File

@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Clipboard from '@react-native-clipboard/clipboard';
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from './themes';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet';
import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet';
@ -14,39 +14,34 @@ import { FiatUnit } from '../models/fiatUnit';
import { BlurredBalanceView } from './BlurredBalanceView';
import { useSettings } from '../hooks/context/useSettings';
import ToolTipMenu from './TooltipMenu';
import useAnimateOnChange from '../hooks/useAnimateOnChange';
import { useLocale } from '@react-navigation/native';
import ActionSheet from '../screen/ActionSheet';
const HERO_BASE_BODY_MIN_HEIGHT = 120;
const HERO_MIN_BODY_HEIGHT = Math.round(HERO_BASE_BODY_MIN_HEIGHT * 1.2);
const HERO_BOTTOM_PADDING = 32;
const WALLET_LABEL_TOP_GAP = 32;
interface TransactionsNavigationHeaderProps {
wallet: TWallet;
unit: BitcoinUnit;
headerOverlayHeight: number;
onWalletUnitChange: (unit: BitcoinUnit) => void;
onManageFundsPressed?: (id?: string) => void;
onWalletBalanceVisibilityChange?: (shouldHideBalance: boolean) => void;
onWalletBalanceVisibilityChange?: (isShouldBeVisible: boolean) => void;
unitSwitching?: boolean;
}
const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps> = ({
wallet,
headerOverlayHeight,
onWalletUnitChange,
onManageFundsPressed,
onWalletBalanceVisibilityChange,
unit = BitcoinUnit.BTC,
unitSwitching = false,
}) => {
const { colors } = useTheme();
const { hideBalance } = wallet;
const isLightningWallet = wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type;
const [allowOnchainAddress, setAllowOnchainAddress] = useState(isLightningWallet);
const { preferredFiatCurrency } = useSettings();
const { direction } = useLocale();
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const previousBalance = useRef<string | undefined>(undefined);
const verifyIfWalletAllowsOnchainAddress = useCallback(() => {
if (isLightningWallet) {
@ -77,14 +72,13 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
const handleBalanceVisibility = useCallback(() => {
onWalletBalanceVisibilityChange?.(!hideBalance);
}, [hideBalance, onWalletBalanceVisibilityChange]);
}, [onWalletBalanceVisibilityChange, hideBalance]);
const changeWalletBalanceUnit = () => {
if (hideBalance) {
return;
}
let newWalletPreferredUnit = wallet.getPreferredBalanceUnit();
console.debug('[UnitSwitch/UI] tap unit change', { walletID: wallet.getID?.(), current: newWalletPreferredUnit });
if (newWalletPreferredUnit === BitcoinUnit.BTC) {
newWalletPreferredUnit = BitcoinUnit.SATS;
} else if (newWalletPreferredUnit === BitcoinUnit.SATS) {
@ -93,6 +87,7 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
newWalletPreferredUnit = BitcoinUnit.BTC;
}
console.debug('[UnitSwitch/UI] next unit resolved', { walletID: wallet.getID?.(), next: newWalletPreferredUnit });
onWalletUnitChange(newWalletPreferredUnit);
};
@ -107,34 +102,29 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
const onPressMenuItem = useCallback(
(id: string) => {
if (id === actionKeys.WalletBalanceVisibility) {
if (id === 'walletBalanceVisibility') {
handleBalanceVisibility();
} else if (id === actionKeys.CopyToClipboard) {
} else if (id === 'copyToClipboard') {
handleCopyPress();
}
},
[handleBalanceVisibility, handleCopyPress],
);
// The Manage Funds menu is presented via a JS ActionSheet rather than the
// native context menu (ToolTipMenu): react-native-context-menu-view is
// Paper-only and, routed through Fabric's legacy interop on the New
// Architecture, its host view gets mispositioned to the header origin —
// overlapping the wallet label. A plain TouchableOpacity + ActionSheet lays
// out correctly (same pattern as the Multisig button below).
const showManageFundsActionSheet = useCallback(() => {
ActionSheet.showActionSheetWithOptions(
const toolTipActions = useMemo(() => {
return [
{
title: loc.lnd.title,
options: [loc._.cancel, loc.lnd.refill, loc.lnd.refill_external],
cancelButtonIndex: 0,
id: actionKeys.Refill,
text: loc.lnd.refill,
icon: actionIcons.Refill,
},
buttonIndex => {
if (buttonIndex === 1) handleManageFundsPressed(actionKeys.Refill);
else if (buttonIndex === 2) handleManageFundsPressed(actionKeys.RefillWithExternalWallet);
{
id: actionKeys.RefillWithExternalWallet,
text: loc.lnd.refill_external,
icon: actionIcons.RefillWithExternalWallet,
},
);
}, [handleManageFundsPressed]);
];
}, []);
const currentBalance = wallet ? wallet.getBalance() : 0;
const formattedBalance = useMemo(() => {
@ -144,48 +134,84 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
}, [unit, currentBalance]);
const balance = !wallet.hideBalance && formattedBalance;
const safeBalance = balance ? String(balance) : undefined;
useEffect(() => {
if (hideBalance) {
previousBalance.current = undefined;
balanceOpacity.value = 1;
balanceTranslateY.value = 0;
return;
}
if (previousBalance.current !== undefined && previousBalance.current !== safeBalance) {
balanceOpacity.value = 0;
balanceTranslateY.value = 6;
balanceOpacity.value = withTiming(1, { duration: 180 });
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
}
previousBalance.current = safeBalance;
}, [safeBalance, hideBalance, balanceOpacity, balanceTranslateY]);
const balanceAnimationKey = useMemo(
() => `${wallet.getID?.() ?? ''}-${unit}-${hideBalance}-${safeBalance ?? ''}`,
[safeBalance, hideBalance, unit, wallet],
);
const balanceAnimatedStyle = useAnimateOnChange(balanceAnimationKey);
const animatedBalanceTextStyle = useAnimatedStyle(() => ({
opacity: balanceOpacity.value,
transform: [{ translateY: balanceTranslateY.value }],
}));
const toolTipWalletBalanceActions = useMemo(() => {
return hideBalance
? [
{
id: actionKeys.WalletBalanceVisibility,
id: 'walletBalanceVisibility',
text: loc.transactions.details_balance_show,
icon: actionIcons.Eye,
icon: {
iconValue: 'eye',
},
},
]
: [
{
id: actionKeys.WalletBalanceVisibility,
id: 'walletBalanceVisibility',
text: loc.transactions.details_balance_hide,
icon: actionIcons.EyeSlash,
icon: {
iconValue: 'eye.slash',
},
},
{
id: actionKeys.CopyToClipboard,
id: 'copyToClipboard',
text: loc.transactions.details_copy,
icon: actionIcons.Clipboard,
icon: {
iconValue: 'doc.on.doc',
},
},
];
}, [hideBalance]);
useEffect(() => {
console.debug('[UnitSwitch/UI] render state', {
walletID: wallet.getID?.(),
unit,
hideBalance,
preferredFiat: preferredFiatCurrency?.endPointKey,
switching: unitSwitching,
});
}, [wallet, unit, hideBalance, preferredFiatCurrency, unitSwitching]);
return (
<View
style={[
styles.lineaderGradient,
{
paddingTop: headerOverlayHeight,
minHeight: headerOverlayHeight + HERO_MIN_BODY_HEIGHT,
backgroundColor: WalletGradient.headerColorFor(wallet.type),
},
]}
>
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={StyleSheet.absoluteFill} />
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={styles.lineaderGradient}>
<View style={styles.contentContainer}>
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
{wallet.getLabel()}
</Text>
<View style={styles.balanceSection}>
<View style={styles.walletBalanceAndUnitContainer}>
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
<ToolTipMenu
shouldOpenOnLongPress
isButton
@ -198,30 +224,39 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
{hideBalance ? (
<BlurredBalanceView />
) : (
<Text
testID="WalletBalance"
numberOfLines={1}
minimumFontScale={0.5}
adjustsFontSizeToFit
style={styles.walletBalanceText}
>
{balance}
</Text>
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
<Animated.Text
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
testID="WalletBalance"
numberOfLines={1}
minimumFontScale={0.5}
adjustsFontSizeToFit
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
>
{balance}
</Animated.Text>
</View>
)}
</View>
</ToolTipMenu>
{!hideBalance && (
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
<Text style={styles.walletPreferredUnitText}>
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
</Text>
</TouchableOpacity>
)}
</View>
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
<Text style={styles.walletPreferredUnitText}>
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
</Text>
</TouchableOpacity>
</Animated.View>
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
<View style={styles.manageFundsSection}>
<ToolTipMenu
shouldOpenOnLongPress={false}
isButton
onPressMenuItem={handleManageFundsPressed}
actions={toolTipActions}
buttonStyle={styles.manageFundsButton}
>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
</ToolTipMenu>
</View>
)}
</View>
{wallet.type === MultisigHDWallet.type && (
@ -230,75 +265,32 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
</TouchableOpacity>
)}
</View>
<View style={styles.bottomBarSpacer}>
<View
style={[
styles.bottomBar,
{
backgroundColor: colors.background,
...Platform.select({
ios: { shadowColor: colors.shadowColor },
android: {},
}),
},
]}
/>
</View>
</View>
</LinearGradient>
);
};
const styles = StyleSheet.create({
lineaderGradient: {
minHeight: 140,
justifyContent: 'flex-start',
position: 'relative',
},
contentContainer: {
flex: 1,
paddingTop: WALLET_LABEL_TOP_GAP,
paddingHorizontal: 16,
paddingBottom: HERO_BOTTOM_PADDING,
},
bottomBarSpacer: {
position: 'relative',
height: 12,
marginBottom: 0,
},
bottomBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: -1,
height: 13,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
...Platform.select({
ios: {
shadowOffset: { width: 0, height: -8 },
shadowOpacity: 0.1,
shadowRadius: 6,
},
android: {
elevation: 0.5,
},
}),
},
walletLabel: {
backgroundColor: 'transparent',
fontSize: 19,
color: 'rgba(255, 255, 255, 0.7)',
marginBottom: 4,
},
walletBalance: {
flexShrink: 1,
marginRight: 6,
minHeight: 39,
justifyContent: 'center',
padding: 15,
},
balanceSection: {
flexDirection: 'column',
alignItems: 'flex-start',
},
walletLabel: {
backgroundColor: 'transparent',
fontSize: 19,
color: '#fff',
marginBottom: 10,
},
walletBalance: {
flexShrink: 1,
marginRight: 6,
},
manageFundsButton: {
marginTop: 14,
marginBottom: 10,
@ -309,6 +301,10 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
manageFundsSection: {
width: '100%',
alignItems: 'flex-start',
},
manageFundsButtonText: {
fontWeight: '500',
fontSize: 14,
@ -318,13 +314,13 @@ const styles = StyleSheet.create({
walletBalanceAndUnitContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingRight: 10,
paddingRight: 10, // Ensure there's some padding to the right
},
walletBalanceText: {
color: '#fff',
fontWeight: 'bold',
fontSize: 36,
flexShrink: 1,
flexShrink: 1, // Allow the text to shrink if there's not enough space
},
walletPreferredUnitView: {
justifyContent: 'center',

View File

@ -107,7 +107,7 @@ const WalletListItem: React.FC<Props> = ({
)}
{wallet.hideBalance ? (
<View style={styles.hiddenBalance} testID="HiddenBalance">
<View style={styles.hiddenBalance}>
<View style={styles.hiddenBalanceBar} />
</View>
) : (

View File

@ -30,7 +30,6 @@ import WalletGradient from '../class/wallet-gradient';
import { useSizeClass, SizeClass } from '../blue_modules/sizeClass';
import loc, { formatBalance, transactionTimeToReadable } from '../loc';
import { BlurredBalanceView } from './BlurredBalanceView';
import { withAlpha } from './color';
import { useTheme } from './themes';
import { Transaction, TWallet } from '../class/wallets/types';
import { BlueSpacing10 } from './BlueSpacing';
@ -38,30 +37,6 @@ import { useLocale } from '@react-navigation/native';
export const WALLET_CAROUSEL_HEADER_WIDTH = 16;
/** Base card body height at default Dynamic Type — grows with larger Dynamic Type, never shrinks below default. */
export const WALLET_CARD_BASE_MIN_HEIGHT = 164;
/** Top inset above wallet cards in the horizontal home carousel. */
export const WALLET_CAROUSEL_PADDING_TOP = 12;
/** Bottom inset so iOS card shadows (offset 4 + radius 8) are not clipped by the list row. */
export const WALLET_CAROUSEL_PADDING_BOTTOM = 20;
/** Scale layout metrics up for accessibility sizes; keep the design default when fontScale ≤ 1. */
const scaleLayoutUp = (base: number, fontScale: number): number => Math.round(base * Math.max(1, fontScale));
export const getWalletCardMinHeight = (fontScale = 1): number => scaleLayoutUp(WALLET_CARD_BASE_MIN_HEIGHT, fontScale);
export const getWalletCarouselHeight = (fontScale = 1): number =>
scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale) +
getWalletCardMinHeight(fontScale) +
scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale);
/** Default carousel row height at `fontScale` 1 — prefer `getWalletCarouselHeight(fontScale)` when layout depends on Dynamic Type. */
export const WALLET_CAROUSEL_HEIGHT = getWalletCarouselHeight(1);
/** Vertical gap between the wallet title/balance block and the latest-tx footer on carousel cards. */
const WALLET_CARD_SECTION_GAP = 12;
const WALLET_CARD_TEXT_OPACITY = 0.85;
export const getWalletCarouselItemWidth = (screenWidth: number) => Math.round(screenWidth * 0.82 > 375 ? 375 : screenWidth * 0.82);
interface NewWalletPanelProps {
@ -185,28 +160,23 @@ const iStyles = StyleSheet.create({
borderRadius: 12,
minHeight: 164,
overflow: 'hidden',
justifyContent: 'flex-end',
},
gradCompact: {
borderRadius: 10,
minHeight: 132,
overflow: 'hidden',
justifyContent: 'flex-end',
},
gradContent: {
padding: 15,
width: '100%',
},
gradContentCompact: {
padding: 12,
},
balanceContainer: {
minHeight: 40,
justifyContent: 'center',
height: 40,
},
balanceContainerCompact: {
minHeight: 32,
justifyContent: 'center',
height: 32,
},
image: {
width: 99,
@ -219,6 +189,9 @@ const iStyles = StyleSheet.create({
width: 78,
height: 74,
},
br: {
backgroundColor: 'transparent',
},
label: {
backgroundColor: 'transparent',
fontSize: 19,
@ -233,6 +206,7 @@ const iStyles = StyleSheet.create({
},
balanceCompact: {
fontSize: 28,
lineHeight: 34,
},
latestTx: {
backgroundColor: 'transparent',
@ -308,32 +282,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const { colors } = useTheme();
const { width, fontScale } = useWindowDimensions();
const { width } = useWindowDimensions();
const itemWidth = getWalletCarouselItemWidth(width);
const { sizeClass } = useSizeClass();
const isCompact = sizeVariant === 'compact';
const { direction } = useLocale();
const scaledCardStyles = useMemo(
() => ({
grad: { minHeight: getWalletCardMinHeight(fontScale) },
gradContent: { padding: scaleLayoutUp(15, fontScale) },
balanceContainer: { minHeight: scaleLayoutUp(40, fontScale) },
textSpacer: { height: scaleLayoutUp(WALLET_CARD_SECTION_GAP, fontScale) },
label: { lineHeight: scaleLayoutUp(24, fontScale) },
balance: { lineHeight: scaleLayoutUp(38, fontScale) },
balanceCompact: { lineHeight: scaleLayoutUp(30, fontScale) },
latestTx: { lineHeight: scaleLayoutUp(18, fontScale) },
latestTxTime: { lineHeight: scaleLayoutUp(22, fontScale) },
}),
[fontScale],
);
const cardTextStyle = useMemo(
() => ({
color: withAlpha(colors.inverseForegroundColor, WALLET_CARD_TEXT_OPACITY),
writingDirection: direction,
}),
[colors.inverseForegroundColor, direction],
);
const previousBalance = useRef<string | undefined>(undefined);
const balance = !hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
const safeBalance = balance || undefined;
@ -430,21 +383,9 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
let latestTransactionText;
// Lightning / Ark wallets do not have on-chain confirmations — settlement is
// signaled by `ispaid`. Bitcoin/on-chain wallets keep the existing
// `confirmations === 0` rule unchanged so their pending-pill semantics
// never depend on a Lightning shape.
// `ispaid === false` alone is not "pending": it is also true for terminal
// failed/refunded swaps, which stay in history. Gate on `!tx.failed` so a
// dead swap doesn't pin the card to "pending" forever.
const isLightningShaped = item.type === LightningCustodianWallet.type || item.type === LightningArkWallet.type;
const hasPendingTx = isLightningShaped
? item.getTransactions().some((tx: any) => tx.ispaid === false && !tx.failed)
: item.getTransactions().some((tx: Transaction) => tx.confirmations === 0);
if (item.getBalance() !== 0 && item.getLatestTransactionTime() === 0) {
latestTransactionText = loc.wallets.pull_to_refresh;
} else if (hasPendingTx) {
} else if (item.getTransactions().find((tx: Transaction) => tx.confirmations === 0)) {
latestTransactionText = loc.transactions.pending;
} else {
latestTransactionText = transactionTimeToReadable(item.getLatestTransactionTime());
@ -478,23 +419,23 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
{ backgroundColor: colors.background, shadowColor: colors.shadowColor },
]}
>
<LinearGradient
colors={WalletGradient.gradientsFor(item.type)}
style={[iStyles.grad, isCompact && iStyles.gradCompact, scaledCardStyles.grad]}
>
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={[iStyles.grad, isCompact && iStyles.gradCompact]}>
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact, !isCompact && scaledCardStyles.gradContent]}>
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact]}>
<Text style={iStyles.br} />
{!isPlaceHolder && (
<>
<Text
numberOfLines={1}
style={[iStyles.label, isCompact && iStyles.labelCompact, scaledCardStyles.label, cardTextStyle]}
style={[
iStyles.label,
isCompact && iStyles.labelCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
>
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
</Text>
<View
style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact, scaledCardStyles.balanceContainer]}
>
<View style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact]}>
{hideBalance ? (
<>
<BlueSpacing10 />
@ -504,13 +445,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
<Animated.Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.55}
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
style={[
iStyles.balance,
isCompact && iStyles.balanceCompact,
isCompact ? scaledCardStyles.balanceCompact : scaledCardStyles.balance,
cardTextStyle,
{ color: colors.inverseForegroundColor, writingDirection: direction },
animatedBalanceStyle,
]}
>
@ -518,20 +457,24 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
</Animated.Text>
)}
</View>
<View style={scaledCardStyles.textSpacer} />
<Text style={iStyles.br} />
<Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[iStyles.latestTx, isCompact && iStyles.latestTxCompact, scaledCardStyles.latestTx, cardTextStyle]}
style={[
iStyles.latestTx,
isCompact && iStyles.latestTxCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
>
{loc.wallets.list_latest_transaction}
</Text>
<Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[iStyles.latestTxTime, isCompact && iStyles.latestTxTimeCompact, scaledCardStyles.latestTxTime, cardTextStyle]}
style={[
iStyles.latestTxTime,
isCompact && iStyles.latestTxTimeCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
>
{latestTransactionText}
</Text>
@ -560,7 +503,15 @@ interface WalletsCarouselProps extends Partial<FlatListProps<any>> {
animateChanges?: boolean;
}
export type CarouselListRefType = FlatList<TWallet>;
type FlatListRefType = FlatList<any> & {
scrollToEnd(params?: { animated?: boolean | null }): void;
scrollToIndex(params: { animated?: boolean | null; index: number; viewOffset?: number; viewPosition?: number }): void;
scrollToItem(params: { animated?: boolean | null; item: TWallet; viewPosition?: number }): void;
scrollToOffset(params: { animated?: boolean | null; offset: number }): void;
recordInteraction(): void;
flashScrollIndicators(): void;
getNativeScrollRef(): View;
};
const styles = StyleSheet.create({
listHeaderSeparator: {
@ -571,7 +522,7 @@ const styles = StyleSheet.create({
const ListHeaderSeparator = () => <View style={styles.listHeaderSeparator} />;
const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((props, ref) => {
const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props, ref) => {
const {
horizontal = true,
data,
@ -586,7 +537,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
animateChanges = false,
} = props;
const { width, fontScale } = useWindowDimensions();
const { width } = useWindowDimensions();
const itemWidth = React.useMemo(() => getWalletCarouselItemWidth(width), [width]);
const snapInterval = React.useMemo(() => itemWidth, [itemWidth]);
const snapOffsets = React.useMemo(() => {
@ -606,7 +557,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isInitialMount = useRef(true);
const flatListRef = useRef<FlatList<TWallet>>(null);
const flatListRef = useRef<FlatList<any>>(null);
const walletRefs = useRef<Record<string, React.MutableRefObject<View | null>>>({});
const { sizeClass } = useSizeClass();
@ -695,7 +646,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
console.warn('[WalletsCarousel] Error scrolling to wallet:', error);
// Fallback: try scrolling to offset
// Use different measurement based on orientation
const itemSize = horizontal ? itemWidth : WALLET_CAROUSEL_HEIGHT;
const itemSize = horizontal ? itemWidth : 195; // 195 is the approximate height of wallet card
flatListRef.current.scrollToOffset({
offset: itemSize * walletIndex,
animated,
@ -817,7 +768,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
const sliderHeight = getWalletCarouselHeight(fontScale);
const sliderHeight = 195;
useEffect(() => {
return () => {
@ -900,8 +851,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
const cStyles = StyleSheet.create({
content: {
paddingTop: scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale),
paddingBottom: scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale),
paddingTop: 16,
},
contentLargeScreen: {
paddingHorizontal: sizeClass === SizeClass.Large ? 16 : 12,
@ -932,7 +882,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
automaticallyAdjustContentInsets
automaticallyAdjustKeyboardInsets
automaticallyAdjustsScrollIndicatorInsets
style={{ minHeight: sliderHeight }}
style={{ minHeight: sliderHeight + 12 }}
onScrollToIndexFailed={onScrollToIndexFailed}
ListFooterComponent={onNewWalletPress ? <NewWalletPanel onPress={onNewWalletPress} /> : null}
{...props}

View File

@ -66,13 +66,10 @@ const getHandleCloseAction = (
const navigationStyle = (
{
closeButtonPosition,
closeButtonIfFirstInStack,
onCloseButtonPressed,
...opts
}: NativeStackNavigationOptions & {
closeButtonPosition?: CloseButtonPosition;
/** When set, show this close control only if this screen is the first route in the stack (e.g. Coin Control opened from wallet details). */
closeButtonIfFirstInStack?: CloseButtonPosition;
onCloseButtonPressed?: (deps: { navigation: any; route: any }) => void;
},
formatter?: OptionsFormatter,
@ -83,10 +80,7 @@ const navigationStyle = (
const isModal = route.params?.presentation === 'modal' || route.params?.presentation === 'transparentModal';
const isFormSheet = route.params?.presentation === 'formSheet';
const closeButton =
closeButtonIfFirstInStack && isFirstRouteInStack
? closeButtonIfFirstInStack
: getCloseButtonPosition(closeButtonPosition, isFirstRouteInStack, isModal);
const closeButton = getCloseButtonPosition(closeButtonPosition, isFirstRouteInStack, isModal);
const handleClose = getHandleCloseAction(onCloseButtonPressed, navigation, route);
let headerRight;

View File

@ -31,8 +31,6 @@ export { platformColors } from '../themes';
export const isAndroid = Platform.OS === 'android';
const isIOS = Platform.OS === 'ios';
const iosMajorVersion = isIOS ? Number(String(Platform.Version).split('.')[0]) : 0;
export const isIOS26OrHigher = isIOS && Number.isFinite(iosMajorVersion) && iosMajorVersion >= 26;
export const platformSizing = {
horizontalPadding: isIOS ? 16 : 20,
@ -109,15 +107,6 @@ export const getSettingsHeaderOptions = (
const cardColor = colors.lightButton ?? colors.modal ?? colors.elevated ?? defaultBackgroundColor;
const headerBackgroundColor = isIOS ? (dark ? defaultBackgroundColor : cardColor) : defaultBackgroundColor;
if (isIOS26OrHigher) {
return {
title,
headerLargeTitle: true,
headerLargeTitleShadowVisible: true,
headerBackButtonDisplayMode: 'minimal' as const,
};
}
return {
title,
headerLargeTitle: isIOS,
@ -203,7 +192,6 @@ export const SettingsScrollView = forwardRef<ScrollView, SettingsScrollViewProps
ref={ref}
style={[style, { backgroundColor: screenBackgroundColor }]}
headerHeight={resolvedHeaderHeight}
disableDefaultTopPadding={isIOS26OrHigher}
floatingButtonHeight={floatingButtonHeight}
contentContainerStyle={[staticStyles.contentContainer, contentContainerStyle]}
{...rest}

View File

@ -14,8 +14,6 @@ export const BlueDefaultTheme = {
foregroundColor: '#0c2550',
borderTopColor: 'rgba(0, 0, 0, 0.1)',
buttonBackgroundColor: '#ccddf9',
/** Softer fill for native iOS 26+ prominent header bar buttons (derived from `buttonBackgroundColor`). */
headerProminentButtonBackgroundColor: 'rgba(204, 221, 249, 0.9)',
buttonTextColor: '#0c2550',
secondButtonTextColor: '#50555C',
buttonAlternativeTextColor: '#2f5fb3',
@ -103,7 +101,6 @@ export const BlueDarkTheme: Theme = {
foregroundColor: '#ffffff',
buttonDisabledBackgroundColor: '#3A3A3C',
buttonBackgroundColor: '#3A3A3C',
headerProminentButtonBackgroundColor: 'rgba(58, 58, 60, 0.6)',
buttonTextColor: '#ffffff',
lightButton: 'rgba(255,255,255,.1)',
buttonAlternativeTextColor: '#ffffff',

View File

@ -2,18 +2,15 @@ import { Platform } from 'react-native';
import prompt from 'react-native-prompt-android';
import loc from '../loc';
type PromptHelperOptions = {
cancelable?: boolean;
type?: PromptType | PromptTypeIOS | PromptTypeAndroid;
destructive?: boolean; // applies only to the cancelable (two-button) layout
continueButtonText?: string;
defaultValue?: string;
};
export default (title: string, text: string, options: PromptHelperOptions = {}): Promise<string> => {
const { cancelable = true, destructive = false, continueButtonText = loc._.ok, defaultValue } = options;
let { type = 'secure-text' } = options;
export default (
title: string,
text: string,
isCancelable = true,
type: PromptType | PromptTypeIOS | PromptTypeAndroid = 'secure-text',
isOKDestructive = false,
continueButtonText = loc._.ok,
defaultInputValue?: string,
): Promise<string> => {
const keyboardType = type === 'numeric' ? 'numeric' : 'default';
if (Platform.OS === 'ios' && type === 'numeric') {
@ -22,7 +19,7 @@ export default (title: string, text: string, options: PromptHelperOptions = {}):
}
return new Promise((resolve, reject) => {
const buttons: Array<PromptButton> = cancelable
const buttons: Array<PromptButton> = isCancelable
? [
{
text: loc._.cancel,
@ -37,7 +34,7 @@ export default (title: string, text: string, options: PromptHelperOptions = {}):
console.log('OK Pressed');
resolve(password);
},
style: destructive ? 'destructive' : 'default',
style: isOKDestructive ? 'destructive' : 'default',
},
]
: [
@ -50,12 +47,13 @@ export default (title: string, text: string, options: PromptHelperOptions = {}):
},
];
const message = defaultValue !== undefined ? '' : text;
const message = defaultInputValue !== undefined ? '' : text;
prompt(title, message, buttons, {
type,
cancelable,
cancelable: isCancelable,
// @ts-ignore suppressed because its supported only on ios and is absent from type definitions
keyboardType,
...(defaultValue !== undefined && { defaultValue }),
...(defaultInputValue !== undefined && { defaultValue: defaultInputValue }),
});
});
};

View File

@ -1,7 +1,6 @@
import { CommonActions } from '@react-navigation/native';
import { useCallback, useEffect, useRef } from 'react';
import { AppState, AppStateStatus, Linking } from 'react-native';
import { reconcileArkBackgroundTaskResults } from '../blue_modules/arkade-background';
import { getClipboardContent } from '../blue_modules/clipboard';
import { updateExchangeRate } from '../blue_modules/currency';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
@ -14,7 +13,6 @@ import {
setApplicationIconBadgeNumber,
} from '../blue_modules/notifications';
import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import DeeplinkSchemaMatch from '../class/deeplink-schema-match';
import loc from '../loc';
import { Chain } from '../models/bitcoinUnits';
@ -88,47 +86,6 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction);
console.log('processing push notification:', payload);
// Local notification for actionable Ark swaps. Routed by walletID
// rather than address/txid because the payload is locally generated;
// see blue_modules/arkade-notifications.ts.
if (+payload.type === 100) {
const arkWallet = wallets.find(w => w.getID() === payload.walletID);
if (!arkWallet || !(arkWallet instanceof LightningArkWallet)) {
if (wasTapped) {
navigation.navigate('WalletTransactions', {
walletID: payload.walletID,
walletType: arkWallet?.type,
});
return true;
}
continue;
}
// Refresh swap-derived rows directly via the wallet method to
// bypass the 5-second NOP throttle in StorageProvider.fetchAndSaveWalletTransactions:
// reconcileArkBackgroundTaskResults often runs on app resume immediately
// before this handler, which would make a throttled call NOP and
// leave the synthetic row stale.
try {
await arkWallet.fetchTransactions();
await saveToDisk();
} catch (e: any) {
console.warn('[useCompanionListeners] arkWallet.fetchTransactions failed:', e?.message ?? e);
}
if (wasTapped) {
const arkWalletID = arkWallet.getID();
const row = arkWallet.getTransactions().find(tx => tx.txid === `swap-${payload.swapId}`);
if (row) {
navigation.navigate('LNDViewInvoice', { invoice: row, walletID: arkWalletID });
} else {
navigation.navigate('WalletTransactions', { walletID: arkWalletID, walletType: arkWallet.type });
}
return true;
}
continue;
}
let wallet;
switch (+payload.type) {
case 2:
@ -169,51 +126,6 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction);
console.log('processing push notification:', payload);
if (+payload.type === 100) {
const arkWallet = wallets.find(w => w.getID() === payload.walletID);
if (!arkWallet || !(arkWallet instanceof LightningArkWallet)) {
if (wasTapped) {
navigationRef.dispatch(
CommonActions.navigate({
name: 'WalletTransactions',
params: { walletID: payload.walletID, walletType: arkWallet?.type },
}),
);
return true;
}
continue;
}
try {
await arkWallet.fetchTransactions();
await saveToDisk();
} catch (e: any) {
console.warn('[useCompanionListeners] arkWallet.fetchTransactions failed:', e?.message ?? e);
}
if (wasTapped) {
const arkWalletID = arkWallet.getID();
const row = arkWallet.getTransactions().find(tx => tx.txid === `swap-${payload.swapId}`);
if (row) {
navigationRef.dispatch(
CommonActions.navigate({
name: 'LNDViewInvoice',
params: { invoice: row, walletID: arkWalletID },
}),
);
} else {
navigationRef.dispatch(
CommonActions.navigate({
name: 'WalletTransactions',
params: { walletID: arkWalletID, walletType: arkWallet.type },
}),
);
}
return true;
}
continue;
}
let wallet;
switch (+payload.type) {
case 2:
@ -267,7 +179,7 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
console.error('Failed to process push notifications:', error);
}
return false;
}, [shouldActivateListeners, wallets, fetchAndSaveWalletTransactions, saveToDisk, navigation, refreshAllWalletTransactions]);
}, [shouldActivateListeners, wallets, fetchAndSaveWalletTransactions, navigation, refreshAllWalletTransactions]);
useEffect(() => {
if (!shouldActivateListeners) return;
@ -302,12 +214,16 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
throw new Error(loc.send.qr_error_no_qrcode);
}
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
DeeplinkSchemaMatch.navigationRouteFor({ url: qrValue }, (value: [string, any]) => navigationRef.navigate(...value), {
wallets,
addWallet,
saveToDisk,
setSharedCosigner,
});
DeeplinkSchemaMatch.navigationRouteFor(
{ url: qrValue },
(value: [string, any]) => navigationRef.navigate(...value),
{
wallets,
addWallet,
saveToDisk,
setSharedCosigner,
},
);
} else {
DeeplinkSchemaMatch.navigationRouteFor(event, (value: [string, any]) => navigationRef.navigate(...value), {
wallets,
@ -361,12 +277,6 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
if ((appState.current.match(/inactive|background/) && nextAppState === 'active') || nextAppState === undefined) {
updateExchangeRate();
const processed = await processPushNotifications();
// Reconcile in-process Ark background task results before the
// notification-handled early return: if the background task observed
// status changes while the app was backgrounded, the affected
// wallets need a transactions refresh whether or not a notification
// also fired.
reconcileArkBackgroundTaskResults(fetchAndSaveWalletTransactions);
if (processed) return;
const clipboard = await getClipboardContent();
if (!clipboard) return;
@ -402,7 +312,7 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
appState.current = nextAppState;
}
},
[processPushNotifications, fetchAndSaveWalletTransactions, showClipboardAlert, wallets, shouldActivateListeners],
[processPushNotifications, showClipboardAlert, wallets, shouldActivateListeners],
);
const addListeners = useCallback(() => {

View File

@ -74,7 +74,7 @@ export const calculateBalanceAndTransactionTime = async (
const balance = await wallet.getBalance();
const transactions: Transaction[] = await wallet.getTransactions();
const confirmedTransactions = transactions.filter(t => (t.confirmations ?? 0) > 0);
const confirmedTransactions = transactions.filter(t => t.confirmations > 0);
const latestTransactionTime =
confirmedTransactions.length > 0
? secondsToMilliseconds(Math.max(...confirmedTransactions.map(t => t.timestamp || t.time || 0)))

View File

@ -5,23 +5,9 @@ import './shim.js';
import React, { useEffect } from 'react';
import { AppRegistry, LogBox } from 'react-native';
import BackgroundFetch from 'react-native-background-fetch';
import App from './App';
import { restoreSavedPreferredFiatCurrencyAndExchangeFromStorage } from './blue_modules/currency';
import { runArkBackgroundTask } from './blue_modules/arkade-background';
// Android headless execution boots a bare JS runtime without the React tree.
// The headless task callback must be registered at module scope before
// AppRegistry.registerComponent so the symbol exists when the OS dispatches a
// terminated-process wake.
BackgroundFetch.registerHeadlessTask(async event => {
if (event.timeout) {
BackgroundFetch.finish(event.taskId);
return;
}
await runArkBackgroundTask(event.taskId);
});
if (!Error.captureStackTrace) {
// captureStackTrace is only available when debugging

View File

@ -1356,7 +1356,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1703279999;
CURRENT_PROJECT_VERSION = 1703259999;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU;
@ -1383,7 +1383,7 @@
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
"$(inherited)",
);
MARKETING_VERSION = 8.0.1;
MARKETING_VERSION = 8.0.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@ -1418,7 +1418,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1703279999;
CURRENT_PROJECT_VERSION = 1703259999;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU;
@ -1440,7 +1440,7 @@
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
"$(inherited)",
);
MARKETING_VERSION = 8.0.1;
MARKETING_VERSION = 8.0.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@ -1476,7 +1476,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1703279999;
CURRENT_PROJECT_VERSION = 1703259999;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
@ -1489,7 +1489,7 @@
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
"$(inherited)",
);
MARKETING_VERSION = 8.0.1;
MARKETING_VERSION = 8.0.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
@ -1519,7 +1519,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1703279999;
CURRENT_PROJECT_VERSION = 1703259999;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
@ -1532,7 +1532,7 @@
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
"$(inherited)",
);
MARKETING_VERSION = 8.0.1;
MARKETING_VERSION = 8.0.0;
MTL_FAST_MATH = YES;
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.Stickers;
@ -1564,7 +1564,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1703279999;
CURRENT_PROJECT_VERSION = 1703259999;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
@ -1584,7 +1584,7 @@
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
"$(inherited)",
);
MARKETING_VERSION = 8.0.1;
MARKETING_VERSION = 8.0.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
@ -1625,7 +1625,7 @@
"CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1703279999;
CURRENT_PROJECT_VERSION = 1703259999;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
@ -1645,7 +1645,7 @@
"$(SDKROOT)/System/iOSSupport/usr/lib/swift",
"$(inherited)",
);
MARKETING_VERSION = 8.0.1;
MARKETING_VERSION = 8.0.0;
MTL_FAST_MATH = YES;
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.MarketWidget;
@ -1832,7 +1832,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1703279999;
CURRENT_PROJECT_VERSION = 1703259999;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
@ -1854,7 +1854,7 @@
"$(inherited)",
);
MACOSX_DEPLOYMENT_TARGET = 12.4;
MARKETING_VERSION = 8.0.1;
MARKETING_VERSION = 8.0.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
@ -1890,7 +1890,7 @@
"CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1703279999;
CURRENT_PROJECT_VERSION = 1703259999;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
@ -1912,7 +1912,7 @@
"$(inherited)",
);
MACOSX_DEPLOYMENT_TARGET = 12.4;
MARKETING_VERSION = 8.0.1;
MARKETING_VERSION = 8.0.0;
MTL_FAST_MATH = YES;
PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES;
PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.watch;

View File

@ -5,7 +5,6 @@
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>io.bluewallet.bluewallet.fetchTxsForWallet</string>
<string>com.transistorsoft.fetch</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
@ -245,6 +244,8 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIDesignRequiresCompatibility</key>
<true/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>

View File

@ -1,5 +1,5 @@
PODS:
- BugsnagReactNative (8.9.0):
- BugsnagReactNative (8.8.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -29,7 +29,7 @@ PODS:
- hermes-engine/Pre-built (= 250829098.0.10)
- hermes-engine/Pre-built (250829098.0.10)
- lottie-ios (4.6.0)
- lottie-react-native (7.3.8):
- lottie-react-native (7.3.6):
- hermes-engine
- lottie-ios (= 4.6.0)
- RCTRequired
@ -1529,7 +1529,7 @@ PODS:
- Yoga
- react-native-notifications (5.2.2):
- React-Core
- react-native-safe-area-context (5.8.0):
- react-native-safe-area-context (5.7.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -1541,8 +1541,8 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- react-native-safe-area-context/common (= 5.8.0)
- react-native-safe-area-context/fabric (= 5.8.0)
- react-native-safe-area-context/common (= 5.7.0)
- react-native-safe-area-context/fabric (= 5.7.0)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
@ -1553,7 +1553,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-safe-area-context/common (5.8.0):
- react-native-safe-area-context/common (5.7.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -1575,7 +1575,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-safe-area-context/fabric (5.8.0):
- react-native-safe-area-context/fabric (5.7.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2018,8 +2018,6 @@ PODS:
- ReactNativeDependencies (0.85.3)
- RealmJS (20.2.0):
- React
- RNBackgroundFetch (4.2.9):
- React-Core
- RNCAsyncStorage (2.2.0):
- hermes-engine
- RCTRequired
@ -2070,7 +2068,7 @@ PODS:
- React-Core
- RNFS (2.20.0):
- React-Core
- RNGestureHandler (2.31.2):
- RNGestureHandler (2.31.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2184,7 +2182,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNReanimated (4.3.1):
- RNReanimated (4.3.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2206,11 +2204,11 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/apple (= 4.3.1)
- RNReanimated/common (= 4.3.1)
- RNReanimated/apple (= 4.3.0)
- RNReanimated/common (= 4.3.0)
- RNWorklets
- Yoga
- RNReanimated/apple (4.3.1):
- RNReanimated/apple (4.3.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2234,7 +2232,7 @@ PODS:
- ReactNativeDependencies
- RNWorklets
- Yoga
- RNReanimated/common (4.3.1):
- RNReanimated/common (4.3.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2258,7 +2256,7 @@ PODS:
- ReactNativeDependencies
- RNWorklets
- Yoga
- RNScreens (4.25.2):
- RNScreens (4.24.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2280,9 +2278,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNScreens/common (= 4.25.2)
- RNScreens/common (= 4.24.0)
- Yoga
- RNScreens/common (4.25.2):
- RNScreens/common (4.24.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2327,7 +2325,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNSVG (15.15.5):
- RNSVG (15.15.4):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2348,9 +2346,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNSVG/common (= 15.15.5)
- RNSVG/common (= 15.15.4)
- Yoga
- RNSVG/common (15.15.5):
- RNSVG/common (15.15.4):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2545,7 +2543,6 @@ DEPENDENCIES:
- ReactNativeCameraKit (from `../node_modules/react-native-camera-kit-no-google`)
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
- RealmJS (from `../node_modules/realm`)
- RNBackgroundFetch (from `../node_modules/react-native-background-fetch`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
- RNDefaultPreference (from `../node_modules/react-native-default-preference`)
@ -2765,8 +2762,6 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
RealmJS:
:path: "../node_modules/realm"
RNBackgroundFetch:
:path: "../node_modules/react-native-background-fetch"
RNCAsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage"
RNCClipboard:
@ -2807,13 +2802,13 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
BugsnagReactNative: 73ce58aac04585e7cba3081c0abba06d848d62fc
BugsnagReactNative: bee770e3f497a8571feb1579bdc083a070bee1f3
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
hermes-engine: 4ed74710a31e8e31f20356c641eab1d8f7d54595
hermes-engine: 86cdbf283775c54dc008895c3eacd24a1f2a40b4
lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3
lottie-react-native: ee142214581f3bb68fbda7efcf07b835a189eeda
lottie-react-native: 615e5f4651bee144ea991ad8e900630b6b3daf5d
RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12
RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc
RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702
@ -2822,7 +2817,7 @@ SPEC CHECKSUMS:
React: e2dc35338068bbd299c66f043ae0d7f25de8499e
React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48
React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0
React-Core-prebuilt: 3445f1028d9b206cd45c8bbb7e2427ee891f810e
React-Core-prebuilt: 9e875134f667c471ab68bf9edf1661fa11b86540
React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146
React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716
React-debug: 92944dc4d89f56d640e75498266cbde557a48189
@ -2860,7 +2855,7 @@ SPEC CHECKSUMS:
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-image-picker: 23540feacc79c63c60857f318fdfa8477c26e70a
react-native-notifications: e2d3c022d6077de7e420ba5c01b4bd9464f3941d
react-native-safe-area-context: fb5c8ee9f6dd62ef710611b3d370c501f42a4ac0
react-native-safe-area-context: 6b4966397ada0f7dd481e4486a2ef936834861a1
react-native-secure-key-store: eb45b44bdec3f48e9be5cdfca0f49ddf64892ea6
react-native-tcp-socket: 7c7e53a07f122ecf00fb3626684bc0ca82c4f044
react-native-vector-icons-entypo: f9de1c24005da510dde0de27caf0d2f5471bd433
@ -2903,25 +2898,24 @@ SPEC CHECKSUMS:
ReactCodegen: 1bd7f2174582b0e142f8671735b5c906c08b72ea
ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
ReactNativeCameraKit: 5974256fc608631c1c812710cd98abe95dae0f88
ReactNativeDependencies: 75299c281f422106c723e79dc1f6ce7ef03241be
ReactNativeDependencies: 0a5c93845772e4b1c5ad065c59a859518b13a6b7
RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1
RNBackgroundFetch: 64b1215fbb8ec58afba877ca0ce177e009ce12b7
RNCAsyncStorage: 2ad919e88b8bc2cd80e8697ce66d04d006743283
RNCClipboard: 715fa7c6c8366f17d00f05a439ee7488f390fa5f
RNDefaultPreference: 8a089ee8ce829a66c5453e3c5434f0785499d1c3
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNGestureHandler: 2ff61eac036eaf89f6818bf4ed9c39771a17d134
RNGestureHandler: 187c5c7936abf427bc4d22d6c3b1ac80ad1f63c0
RNHandoff: bc8af5a86853ff13b033e7ba1114c3c5b38e6385
RNKeychain: 6778b35b5bd067c322f8479526ac09b1d61f31d0
RNLocalize: f370284ea42c48f29f0d8dd3a7bcc28a04f82155
RNPermissions: dfbe915a8ee532bc55018cf5e387407847713b02
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
RNReactNativeHapticFeedback: 576e23c1ad2d800ded4502be3f66b767308b63a1
RNReanimated: f735b1747a7a93bda7ca102c6d37a3cf54b6d5e8
RNScreens: 991cc417cd396602a6cf59a42139e5a9d91462a9
RNReanimated: c4e6659e58b793885ae6da476cb514fc913e7b85
RNScreens: 01b065ded2dfe7987bcce770ff3a196be417ff41
RNShare: 2afdc1739d80ac140b2870ae81e8b2098f4599d9
RNSVG: 0e52210d4d43165e7e2cf9c890a9848b27e513ac
RNSVG: 04044c3abcf177fd674a1a3d13097efa1adebcbe
RNWatch: 28fe1f5e0c6410d45fd20925f4796fce05522e3f
RNWorklets: dd3b2cb0750090d78d85cd3b3ec0fdbeab5ce118
Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b

View File

@ -11,13 +11,6 @@ module.exports = {
'^expo/fetch$': '<rootDir>/util/expo-fetch-nodejs.js',
'^@react-native-vector-icons/(.*)$': '<rootDir>/tests/mocks/vector-icons.js',
'^react-native-svg$': '<rootDir>/tests/mocks/react-native-svg.js',
// Mirror of metro.config.js resolveRequest: descriptors-core uses @noble/hashes v2
// subpaths (e.g. `sha2.js`, `legacy.js`) but does not declare it as a dep, so npm
// resolves up to v1.3.3 (which only exposes the no-extension subpaths via `exports`).
// Redirect any `.js`-suffixed @noble/hashes subpath to the v2 copy nested under
// descriptors-scure. bitcoinjs-lib imports `@noble/hashes/sha256` (no extension)
// so it is unaffected.
'^@noble/hashes/(.+\\.js)$': '<rootDir>/node_modules/@bitcoinerlab/descriptors-scure/node_modules/@noble/hashes/$1',
},
setupFiles: ['./tests/setup.js'],
watchPathIgnorePatterns: ['<rootDir>/node_modules'],

View File

@ -56,7 +56,6 @@
"errorInvoiceExpired": "Invoice expired.",
"expired": "Expired",
"expiresIn": "Expires in {time} minutes",
"network_fee": "Network fee: {fee}",
"payButton": "Pay",
"payment": "Payment",
"placeholder": "Invoice or address",
@ -77,13 +76,7 @@
"preimage": "Pre-image",
"sats": "sats.",
"date_time": "Date and Time",
"wasnt_paid_and_expired": "This invoice was not paid and has expired.",
"receiving_payment": "Receiving payment…",
"refund_funds": "Refund funds",
"refund_deferred": "Funds aren't refundable yet. Try again after the swap timelock expires.",
"notification_action_title": "Action needed",
"notification_claim_body": "{walletLabel}: tap to claim your incoming Lightning payment.",
"notification_refund_body": "{walletLabel}: tap to refund your stuck Lightning payment."
"wasnt_paid_and_expired": "This invoice was not paid and has expired."
},
"plausibledeniability": {
"create_fake_storage": "Create Encrypted Storage",
@ -288,6 +281,7 @@
"general": "General",
"general_continuity": "Continuity",
"general_continuity_e": "When enabled, you will be able to view selected wallets, and transactions, using your other Apple iCloud connected devices.",
"groundcontrol_explanation": "GroundControl is a free, open-source push notifications server for Bitcoin wallets. You can install your own GroundControl server and put its URL here to not rely on BlueWallets infrastructure. Leave blank to use GroundControls default server.",
"header": "Settings",
"language": "Language",
"last_updated": "Last Updated",
@ -303,6 +297,7 @@
"network_broadcast": "Broadcast Transaction",
"network_electrum": "Electrum Server",
"electrum_suggested_description": "When a preferred server is not set, a suggested server will be selected for use at random.",
"not_a_valid_uri": "Invalid URI",
"notifications": "Notifications",
"open_link_in_explorer": "Open link in explorer",
"password": "Password",
@ -320,6 +315,7 @@
"push_notifications_explanation": "By enabling notifications, your device token will be sent to the server, along with wallet addresses and transaction IDs for all wallets and transactions made after enabling notifications. The device token is used to send notifications, and the wallet information allows us to notify you about incoming Bitcoin or transaction confirmations.\n\nOnly information from after you enable notifications is transmitted—nothing from before is collected.\n\nDisabling notifications will remove all of this information from the server. Additionally, deleting a wallet from the app will also remove its associated information from the server.",
"selfTest": "Self-Test",
"save": "Save",
"saved": "Saved",
"success_transaction_broadcasted": "Your transaction has been successfully broadcasted!",
"total_balance": "Total Balance",
"total_balance_explanation": "Display the total balance of all your wallets on your home screen widgets.",
@ -380,6 +376,7 @@
"rbf_title": "Speed Up (RBF)",
"status_bump": "Speed Up",
"status_cancel": "Cancel",
"transactions_count": "Transactions Count",
"txid": "Transaction ID",
"updating": "Updating...",
"watchOnlyWarningTitle": "Security warning",
@ -441,18 +438,13 @@
"details_delete_wallet": "Delete Wallet",
"details_derivation_path": "derivation path",
"details_display": "Display in Home Screen",
"details_edit": "edit",
"details_export_backup": "Export/Backup",
"details_export_history": "Export History to CSV",
"details_master_fingerprint": "Master Fingerprint",
"details_multisig_type": "multisig",
"details_options": "Options",
"details_show_xpub": "Show Wallet XPUB",
"details_show_addresses": "Show addresses",
"details_stats_coins": "Coins",
"details_title": "Wallet",
"restore_swap_activity": "Restore swap activity",
"restore_swap_activity_done": "Swap activity restored.",
"wallets": "Wallets",
"swipe_balance_hide": "Hide",
"swipe_balance_show": "Show",

View File

@ -12,7 +12,7 @@ import * as RNLocalize from 'react-native-localize';
import { satoshiToLocalCurrency } from '../blue_modules/currency';
import { BitcoinUnit } from '../models/bitcoinUnits';
import { AvailableLanguages, LangCode } from './languages';
import { AvailableLanguages } from './languages';
import enJson from './en.json';
export const STORAGE_KEY = 'lang';
@ -27,83 +27,6 @@ interface ILocalization extends Omit<ILocalization1, 'formatString'> {
formatString: (...args: Parameters<ILocalization1['formatString']>) => string;
}
// Lazy loaders for non-en langs
type LanguageDict = typeof enJson;
const languageLoaders: Record<Exclude<LangCode, 'en'>, () => LanguageDict> = {
ar: () => require('./ar.json'),
be: () => require('./be@tarask.json'),
bg_bg: () => require('./bg_bg.json'),
bqi: () => require('./bqi.json'),
ca: () => require('./ca.json'),
cs_cz: () => require('./cs_cz.json'),
cy: () => require('./cy.json'),
da_dk: () => require('./da_dk.json'),
de_de: () => require('./de_de.json'),
el: () => require('./el.json'),
es: () => require('./es.json'),
es_419: () => require('./es_419.json'),
et: () => require('./et_EE.json'),
fa: () => require('./fa.json'),
fi_fi: () => require('./fi_fi.json'),
fo: () => require('./fo.json'),
fr_fr: () => require('./fr_fr.json'),
he: () => require('./he.json'),
hr_hr: () => require('./hr_hr.json'),
hu_hu: () => require('./hu_hu.json'),
id_id: () => require('./id_id.json'),
it: () => require('./it.json'),
jp_jp: () => require('./jp_jp.json'),
'kk@Cyrl': () => require('./kk@Cyrl.json'),
kn: () => require('./kn.json'),
ko_kr: () => require('./ko_KR.json'),
lrc: () => require('./lrc.json'),
ms: () => require('./ms.json'),
nb_no: () => require('./nb_no.json'),
ne: () => require('./ne.json'),
nl_nl: () => require('./nl_nl.json'),
pcm: () => require('./pcm.json'),
pl: () => require('./pl.json'),
pt_br: () => require('./pt_br.json'),
pt_pt: () => require('./pt_pt.json'),
ro: () => require('./ro.json'),
ru: () => require('./ru.json'),
si_lk: () => require('./si_LK.json'),
sk_sk: () => require('./sk_sk.json'),
sl_si: () => require('./sl_SI.json'),
sq_AL: () => require('./sq_AL.json'),
sr_rs: () => require('./sr_RS.json'),
sv_se: () => require('./sv_se.json'),
th_th: () => require('./th_th.json'),
tr_tr: () => require('./tr_tr.json'),
ua: () => require('./ua.json'),
vi_vn: () => require('./vi_vn.json'),
zar_afr: () => require('./zar_afr.json'),
zar_xho: () => require('./zar_xho.json'),
zh_cn: () => require('./zh_cn.json'),
zh_tw: () => require('./zh_tw.json'),
};
// Cache so toggling between languages does not re-parse the JSON.
export const parsedLanguages: Record<string, LanguageDict> = { en: enJson };
const loc = new Localization<typeof enJson>({ en: enJson }) as ILocalization;
const isKnownLang = (lang: string): lang is Exclude<LangCode, 'en'> => Object.prototype.hasOwnProperty.call(languageLoaders, lang);
const applyLanguage = (lang: string) => {
if (lang === 'en' || !isKnownLang(lang)) {
loc.setContent({ en: enJson });
loc.setLanguage('en');
return;
}
if (!parsedLanguages[lang]) {
parsedLanguages[lang] = languageLoaders[lang]();
}
// `setContent` resets active language to interface; explicit setLanguage after, with `en` as fallback.
loc.setContent({ en: enJson, [lang]: parsedLanguages[lang] });
loc.setLanguage(lang);
};
const setDateTimeLocale = async () => {
let lang = (await AsyncStorage.getItem(STORAGE_KEY)) ?? '';
let localeForDayJSAvailable = true;
@ -290,26 +213,98 @@ const setDateTimeLocale = async () => {
}
};
// Fire-and-forget; `loc` starts as `{en}` until this resolves, so synchronous reads on a cold launch with non-en saved preference render English briefly.
const init = async () => {
// finding out whether lang preference was saved
const lang = await AsyncStorage.getItem(STORAGE_KEY);
if (lang) {
await saveLanguage(lang);
await loc.setLanguage(lang);
if (process.env.JEST_WORKER_ID === undefined) {
const foundLang = AvailableLanguages.find(language => language.value === lang);
I18nManager.allowRTL(foundLang?.isRTL ?? false);
I18nManager.forceRTL(foundLang?.isRTL ?? false);
}
await setDateTimeLocale();
} else {
const locales = RNLocalize.getLocales();
const detected = locales[0]?.languageCode;
if (detected && AvailableLanguages.some(language => language.value === detected)) {
await saveLanguage(detected);
if (Object.values(AvailableLanguages).some(language => language.value === locales[0].languageCode)) {
await saveLanguage(locales[0].languageCode);
await loc.setLanguage(locales[0].languageCode);
if (process.env.JEST_WORKER_ID === undefined) {
I18nManager.allowRTL(locales[0].isRTL ?? false);
I18nManager.forceRTL(locales[0].isRTL ?? false);
}
} else {
await saveLanguage('en');
await loc.setLanguage('en');
if (process.env.JEST_WORKER_ID === undefined) {
I18nManager.allowRTL(false);
I18nManager.forceRTL(false);
}
}
await setDateTimeLocale();
}
};
init();
const loc: ILocalization = new Localization({
en: enJson,
ar: require('./ar.json'),
be: require('./be@tarask.json'),
bg_bg: require('./bg_bg.json'),
bqi: require('./bqi.json'),
ca: require('./ca.json'),
cs_cz: require('./cs_cz.json'),
cy: require('./cy.json'),
da_dk: require('./da_dk.json'),
de_de: require('./de_de.json'),
el: require('./el.json'),
es: require('./es.json'),
es_419: require('./es_419.json'),
et: require('./et_EE.json'),
fa: require('./fa.json'),
fi_fi: require('./fi_fi.json'),
fo: require('./fo.json'),
fr_fr: require('./fr_fr.json'),
he: require('./he.json'),
hr_hr: require('./hr_hr.json'),
hu_hu: require('./hu_hu.json'),
id_id: require('./id_id.json'),
it: require('./it.json'),
jp_jp: require('./jp_jp.json'),
'kk@Cyrl': require('./kk@Cyrl.json'),
kn: require('./kn.json'),
ko_kr: require('./ko_KR.json'),
lrc: require('./lrc.json'),
ms: require('./ms.json'),
nb_no: require('./nb_no.json'),
ne: require('./ne.json'),
nl_nl: require('./nl_nl.json'),
pcm: require('./pcm.json'),
pl: require('./pl.json'),
pt_br: require('./pt_br.json'),
pt_pt: require('./pt_pt.json'),
ro: require('./ro.json'),
ru: require('./ru.json'),
si_lk: require('./si_LK.json'),
sk_sk: require('./sk_sk.json'),
sl_si: require('./sl_SI.json'),
sq_AL: require('./sq_AL.json'),
sr_rs: require('./sr_RS.json'),
sv_se: require('./sv_se.json'),
th_th: require('./th_th.json'),
tr_tr: require('./tr_tr.json'),
ua: require('./ua.json'),
vi_vn: require('./vi_vn.json'),
zar_afr: require('./zar_afr.json'),
zar_xho: require('./zar_xho.json'),
zh_cn: require('./zh_cn.json'),
zh_tw: require('./zh_tw.json'),
});
export const saveLanguage = async (lang: string) => {
await AsyncStorage.setItem(STORAGE_KEY, lang);
applyLanguage(lang);
loc.setLanguage(lang);
// even tho it makes no effect changing it in this run, it will on the next run, so we are doign it here:
if (process.env.JEST_WORKER_ID === undefined) {
const foundLang = AvailableLanguages.find(language => language.value === lang);

View File

@ -1,11 +1,4 @@
export type TLanguage = {
label: string;
value: string;
isRTL?: boolean;
};
// Literal-typed tuple so `LangCode` is a literal union; widened on re-export.
const _availableLanguages = Object.freeze([
export const AvailableLanguages: Readonly<TLanguage[]> = Object.freeze([
{ label: 'English', value: 'en' },
{ label: 'Afrikaans (AFR)', value: 'zar_afr' },
{ label: 'العربية (AR)', value: 'ar', isRTL: true },
@ -58,9 +51,10 @@ const _availableLanguages = Object.freeze([
{ label: 'Українська (UA)', value: 'ua' },
{ label: 'Türkçe (TR)', value: 'tr_tr' },
{ label: 'Xhosa (XHO)', value: 'zar_xho' },
] as const) satisfies readonly TLanguage[];
]);
export const AvailableLanguages: readonly TLanguage[] = _availableLanguages;
// Drives the typed loader Record in loc/index.ts so drift becomes a TS error.
export type LangCode = (typeof _availableLanguages)[number]['value'];
export type TLanguage = {
label: string;
value: string;
isRTL?: boolean;
};

View File

@ -1,28 +1,11 @@
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const path = require('path');
// Force the Arkade SDK and its subpaths to resolve to their CJS builds. The ESM
// build uses `export * as ns from '...'` (ES2020), which the React Native babel
// preset does not transform, so loading the ESM entry triggers a Babel error.
// The boltz-swap realm subpath is forced to CJS for the same reason — it
// re-exports the SDK realm types.
const resolveAliases = {
'@arkade-os/sdk': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/index.cjs'),
'@arkade-os/sdk/adapters/expo': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/adapters/expo.cjs'),
'@arkade-os/sdk/repositories/realm': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/repositories/realm/index.cjs'),
'@arkade-os/boltz-swap/repositories/realm': path.join(__dirname, 'node_modules/@arkade-os/boltz-swap/dist/repositories/realm/index.cjs'),
'@arkade-os/sdk/adapters/expo': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/cjs/adapters/expo.js'),
'expo/fetch': path.join(__dirname, 'util/expo-fetch.js'),
};
// @bitcoinerlab/descriptors-core uses @noble/hashes 2.x APIs (`./legacy.js`,
// `./sha2.js`) but does not declare @noble/hashes as a direct dep. npm
// resolves up to the top-level @noble/hashes@1.3.3 (kept for bitcoinjs-lib),
// which doesn't expose those subpaths. Redirect any @noble/hashes import that
// originates inside descriptors-core to the v2 copy already nested under
// descriptors-scure.
const nobleHashesV2 = path.join(__dirname, 'node_modules/@bitcoinerlab/descriptors-scure/node_modules/@noble/hashes');
const descriptorsCoreDir = path.join('node_modules', '@bitcoinerlab', 'descriptors-core');
/**
* Metro configuration
* https://reactnative.dev/docs/metro
@ -44,13 +27,6 @@ const config = {
filePath: resolveAliases[moduleName],
};
if (moduleName.startsWith('@noble/hashes/') && context.originModulePath.includes(descriptorsCoreDir)) {
return {
type: 'sourceFile',
filePath: path.join(nobleHashesV2, moduleName.slice('@noble/hashes/'.length)),
};
}
// Fall back to default resolution
return context.resolveRequest(context, moduleName, platform);
},

View File

@ -1,6 +1,6 @@
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Animated, AppState, View, Platform, PlatformColor, Text, StyleSheet, Pressable } from 'react-native';
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import navigationStyle, { CloseButtonPosition } from '../components/navigationStyle';
import { useTheme } from '../components/themes';
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
@ -57,9 +57,6 @@ import { ConnectionPollContext } from './ConnectionPollContext';
import ManageWallets from '../screen/wallets/ManageWallets';
import ReceiveDetails from '../screen/receive/ReceiveDetails';
import ReceiveCustomAmountSheet from '../screen/receive/ReceiveCustomAmountSheet';
import { isIOS26OrHigher } from '../components/platform';
type HeaderRightItem = ReturnType<NonNullable<NativeStackNavigationOptions['unstable_headerRightItems']>>[number];
const PaymentCodesList = lazy(() => import('../screen/wallets/PaymentCodesList'));
const PaymentCodesListComponent = withLazySuspense(PaymentCodesList);
@ -101,51 +98,41 @@ const DetailViewStackScreensStack = () => {
const { sizeClass } = useSizeClass();
const [electrumConnected, setElectrumConnected] = useState<boolean | null>(null);
// Probe connection health from the UI (e.g. WalletsList focus / 30s timer).
// BlueElectrum.ping() reflects the result into the shared connection state, which
// we observe via the subscription below — no need to set local state here.
const pollConnection = useCallback(async () => {
if (isElectrumDisabled) return;
await BlueElectrum.ping();
const ok = await BlueElectrum.ping();
setElectrumConnected(ok);
}, [isElectrumDisabled]);
// Mirror BlueElectrum's connection state into local UI state.
useEffect(() => {
if (isElectrumDisabled) {
setElectrumConnected(null);
return;
}
const sync = () => setElectrumConnected(BlueElectrum.isConnected());
sync();
const unsubscribe = BlueElectrum.subscribeConnectionState(sync);
// Kick off an initial probe so the header pill reflects reality after mount.
BlueElectrum.ping().catch(() => {});
return unsubscribe;
}, [isElectrumDisabled]);
pollConnection();
}, [isElectrumDisabled, pollConnection]);
// On foreground transition, proactively heal: ensureConnected() takes the fast
// ping path when the socket is alive (no-op) and rebuilds the connection only
// when needed. This replaces the old "ping → maybe show network alert" path that
// could surface a false alert after iOS suspend/resume.
useEffect(() => {
if (isElectrumDisabled) return;
const subscription = AppState.addEventListener('change', nextState => {
if (nextState === 'active') {
BlueElectrum.ensureConnected().catch(() => {});
BlueElectrum.ensureElectrumConnection()
.then(ok => setElectrumConnected(ok))
.catch(() => setElectrumConnected(false));
}
});
return () => subscription.remove();
}, [isElectrumDisabled]);
// When starting up in an unknown state, we optimistically rely on ping(); on resume we
// call ensureElectrumConnection() (reconnect if the socket died in the background).
// Fast retry loop while disconnected uses ping only. Slow health checks while connected
// run only from WalletsList when that screen is focused (saves idle battery).
// While we believe we're disconnected, ask BlueElectrum to keep trying to
// reconnect (silently — the red "Not connected" pill is the only UI signal).
useEffect(() => {
if (isElectrumDisabled || electrumConnected !== false) return;
const interval = setInterval(() => {
BlueElectrum.ensureConnected().catch(() => {});
}, 3000);
const interval = setInterval(pollConnection, 3000);
return () => clearInterval(interval);
}, [isElectrumDisabled, electrumConnected]);
}, [isElectrumDisabled, electrumConnected, pollConnection]);
const connectionPollContextValue = useMemo(() => ({ pollConnection }), [pollConnection]);
@ -153,15 +140,6 @@ const DetailViewStackScreensStack = () => {
navigation.navigate('AddWalletRoot');
}, [navigation]);
const navigateToSettings = useCallback(() => {
navigation.navigate('DrawerRoot', {
screen: 'DetailViewStackScreensStack',
params: {
screen: 'Settings',
},
});
}, [navigation]);
const RightBarButtons = useMemo(
() =>
sizeClass === SizeClass.Large ? (
@ -231,53 +209,6 @@ const DetailViewStackScreensStack = () => {
return null;
};
if (isIOS26OrHigher) {
// Status pills: `unstable_headerLeftItems` + `hidesSharedBackground` avoids the
// navigation bar's shared liquid-glass chrome on the pill (solid colors only).
return {
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
headerLargeTitle: false,
headerTransparent: true,
unstable_headerLeftItems: (): NativeStackHeaderItem[] => {
const element = renderHeaderLeft();
if (element == null) {
return [];
}
return [{ type: 'custom', element, hidesSharedBackground: true }];
},
unstable_headerRightItems: () => {
if (isDesktop) {
return [];
}
const items: HeaderRightItem[] = [
{
type: 'button',
label: loc.wallets.add_title,
icon: { type: 'sfSymbol', name: 'plus' },
variant: 'prominent',
tintColor: theme.colors.headerProminentButtonBackgroundColor,
identifier: 'AddWalletButton',
accessibilityLabel: 'AddWalletButton',
sharesBackground: false,
onPress: navigateToAddWallet,
},
];
if (sizeClass !== SizeClass.Large) {
items.push({
type: 'button',
label: loc.settings.default_title,
icon: { type: 'sfSymbol', name: 'ellipsis' },
identifier: 'SettingsButton',
accessibilityLabel: 'SettingsButton',
sharesBackground: false,
onPress: navigateToSettings,
});
}
return items;
},
};
}
return {
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
headerLargeTitle: false,
@ -292,7 +223,6 @@ const DetailViewStackScreensStack = () => {
RightBarButtons,
sizeClass,
theme.colors.customHeader,
theme.colors.headerProminentButtonBackgroundColor,
theme.colors.foregroundColor,
theme.colors.lightButton,
theme.colors.redBG,
@ -302,8 +232,6 @@ const DetailViewStackScreensStack = () => {
electrumConnected,
isElectrumDisabled,
navigateToElectrumSettings,
navigateToAddWallet,
navigateToSettings,
walletTransactionUpdateStatus,
]);
@ -313,14 +241,6 @@ const DetailViewStackScreensStack = () => {
// Consistent header configuration for all settings screens
const getSettingsHeaderOptions = (title: string) => {
if (isIOS26OrHigher) {
return {
title,
headerLargeTitle: true,
headerLargeTitleShadowVisible: true,
headerBackButtonDisplayMode: 'minimal' as const,
};
}
// Use PlatformColor for iOS to match the Settings component, fallback to theme color
const titleColor = Platform.OS === 'ios' ? PlatformColor('label') : theme.colors.foregroundColor;
// Convert PlatformColor to string for TypeScript compatibility
@ -343,9 +263,6 @@ const DetailViewStackScreensStack = () => {
};
};
const settingsScreenOptions = (title: string) =>
isIOS26OrHigher ? getSettingsHeaderOptions(title) : navigationStyle(getSettingsHeaderOptions(title))(theme);
return (
<ConnectionPollContext.Provider value={connectionPollContextValue}>
<DetailViewStack.Navigator
@ -358,11 +275,7 @@ const DetailViewStackScreensStack = () => {
name="WalletDetails"
component={WalletDetails}
options={navigationStyle({
headerTitle: '',
statusBarStyle: 'auto',
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTitle: loc.wallets.details_title,
})(theme)}
/>
<DetailViewStack.Screen
@ -412,14 +325,22 @@ const DetailViewStackScreensStack = () => {
options={navigationStyle({ title: loc.lndViewInvoice.additional_info })(theme)}
/>
<DetailViewStack.Screen name="Broadcast" component={Broadcast} options={settingsScreenOptions(loc.send.create_broadcast)} />
<DetailViewStack.Screen
name="Broadcast"
component={Broadcast}
options={navigationStyle(getSettingsHeaderOptions(loc.send.create_broadcast))(theme)}
/>
<DetailViewStack.Screen
name="IsItMyAddress"
component={IsItMyAddress}
initialParams={{ address: undefined }}
options={settingsScreenOptions(loc.is_it_my_address.title)}
options={navigationStyle(getSettingsHeaderOptions(loc.is_it_my_address.title))(theme)}
/>
<DetailViewStack.Screen
name="GenerateWord"
component={GenerateWord}
options={navigationStyle(getSettingsHeaderOptions(loc.autofill_word.title))(theme)}
/>
<DetailViewStack.Screen name="GenerateWord" component={GenerateWord} options={settingsScreenOptions(loc.autofill_word.title)} />
<DetailViewStack.Screen
name="LnurlPay"
component={LnurlPay}
@ -462,90 +383,115 @@ const DetailViewStackScreensStack = () => {
<DetailViewStack.Screen
name="Settings"
component={Settings}
options={
isIOS26OrHigher
? getSettingsHeaderOptions(loc.settings.header)
: navigationStyle({
title: loc.settings.header,
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerShadowVisible: false,
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
headerLargeTitle: Platform.OS === 'ios',
headerLargeTitleStyle:
Platform.OS === 'ios'
? {
color:
typeof theme.colors.foregroundColor === 'string'
? theme.colors.foregroundColor
: String(theme.colors.foregroundColor),
}
: undefined,
headerTitleStyle: {
options={navigationStyle({
title: loc.settings.header,
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerShadowVisible: false,
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
headerLargeTitle: Platform.OS === 'ios',
headerLargeTitleStyle:
Platform.OS === 'ios'
? {
color:
typeof theme.colors.foregroundColor === 'string'
? theme.colors.foregroundColor
: String(theme.colors.foregroundColor),
},
headerTransparent: false,
headerBlurEffect: undefined,
headerStyle: {
backgroundColor: settingsHeaderBackgroundColor,
},
animationTypeForReplace: 'push',
})(theme)
}
}
: undefined,
headerTitleStyle: {
color: typeof theme.colors.foregroundColor === 'string' ? theme.colors.foregroundColor : String(theme.colors.foregroundColor),
},
headerTransparent: false,
headerBlurEffect: undefined,
headerStyle: {
backgroundColor: settingsHeaderBackgroundColor,
},
animationTypeForReplace: 'push',
})(theme)}
/>
<DetailViewStack.Screen
name="Currency"
component={Currency}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.currency))(theme)}
/>
<DetailViewStack.Screen
name="GeneralSettings"
component={GeneralSettings}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.general))(theme)}
/>
<DetailViewStack.Screen name="Currency" component={Currency} options={settingsScreenOptions(loc.settings.currency)} />
<DetailViewStack.Screen name="GeneralSettings" component={GeneralSettings} options={settingsScreenOptions(loc.settings.general)} />
<DetailViewStack.Screen
name="PlausibleDeniability"
component={PlausibleDeniability}
options={settingsScreenOptions(loc.plausibledeniability.title)}
options={navigationStyle(getSettingsHeaderOptions(loc.plausibledeniability.title))(theme)}
/>
<DetailViewStack.Screen
name="Licensing"
component={Licensing}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.license))(theme)}
/>
<DetailViewStack.Screen
name="NetworkSettings"
component={NetworkSettings}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.network))(theme)}
/>
<DetailViewStack.Screen name="Licensing" component={Licensing} options={settingsScreenOptions(loc.settings.license)} />
<DetailViewStack.Screen name="NetworkSettings" component={NetworkSettings} options={settingsScreenOptions(loc.settings.network)} />
<DetailViewStack.Screen
name="SettingsBlockExplorer"
component={SettingsBlockExplorer}
options={settingsScreenOptions(loc.settings.block_explorer)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.block_explorer))(theme)}
/>
<DetailViewStack.Screen name="About" component={About} options={settingsScreenOptions(loc.settings.about)} />
<DetailViewStack.Screen
name="About"
component={About}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about))(theme)}
/>
{/* <DetailViewStack.Screen
name="DefaultView"
component={DefaultView}
options={settingsScreenOptions(loc.settings.default_title)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.default_title))(theme)}
/> */}
<DetailViewStack.Screen
name="ElectrumSettings"
component={ElectrumSettings}
options={settingsScreenOptions(loc.settings.electrum_settings_server)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.electrum_settings_server))(theme)}
initialParams={{ server: undefined }}
/>
<DetailViewStack.Screen
name="EncryptStorage"
component={EncryptStorage}
options={settingsScreenOptions(loc.settings.encrypt_title)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.encrypt_title))(theme)}
/>
<DetailViewStack.Screen
name="Language"
component={Language}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.language))(theme)}
/>
<DetailViewStack.Screen name="Language" component={Language} options={settingsScreenOptions(loc.settings.language)} />
<DetailViewStack.Screen
name="LightningSettings"
component={LightningSettings}
options={settingsScreenOptions(loc.settings.lightning_settings)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.lightning_settings))(theme)}
/>
<DetailViewStack.Screen
name="NotificationSettings"
component={NotificationSettings}
options={settingsScreenOptions(loc.settings.notifications)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.notifications))(theme)}
/>
<DetailViewStack.Screen
name="SelfTest"
component={SelfTest}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.selfTest))(theme)}
/>
<DetailViewStack.Screen name="SelfTest" component={SelfTest} options={settingsScreenOptions(loc.settings.selfTest)} />
<DetailViewStack.Screen
name="ReleaseNotes"
component={ReleaseNotes}
options={settingsScreenOptions(loc.settings.about_release_notes)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about_release_notes))(theme)}
/>
<DetailViewStack.Screen
name="SettingsTools"
component={SettingsTools}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.tools))(theme)}
/>
<DetailViewStack.Screen name="SettingsTools" component={SettingsTools} options={settingsScreenOptions(loc.settings.tools)} />
<DetailViewStack.Screen
name="PromptPasswordConfirmationSheet"
component={PromptPasswordConfirmationSheet}

View File

@ -7,6 +7,7 @@ export type LNDStackParamsList = {
ScanLNDInvoice: {
walletID: string | undefined;
uri: string | undefined;
invoice: string | undefined;
onBarScanned: string | undefined;
};
LnurlPay: {

View File

@ -115,14 +115,7 @@ const SendDetailsStack = () => {
closeButtonPosition: CloseButtonPosition.Right,
})(theme)}
/>
<Stack.Screen
name="CoinControl"
component={CoinControlComponent}
options={navigationStyle({
title: loc.cc.header,
closeButtonIfFirstInStack: CloseButtonPosition.Left,
})(theme)}
/>
<Stack.Screen name="CoinControl" component={CoinControlComponent} options={navigationStyle({ title: loc.cc.header })(theme)} />
<Stack.Screen
name="PaymentCodeList"
component={PaymentCodesListComponent}

View File

@ -1,39 +0,0 @@
import { CommonActions, StackActions } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Utxo } from '../class/wallets/types';
import { BitcoinUnit } from '../models/bitcoinUnits';
import { SendDetailsStackParamList } from './SendDetailsStackParamList';
/**
* After choosing UTXO(s) in Coin Control, open Send with those coins.
* Uses popTo when SendDetails is already in the stack (normal send coin control).
* Resets the send stack when Coin Control was opened as the first screen (e.g. from wallet details).
*/
export function goFromCoinControlToSendDetails(
navigation: NativeStackNavigationProp<SendDetailsStackParamList>,
walletID: string,
utxos: Utxo[],
): void {
const state = navigation.getState();
const hasSendDetails = state.routes.some(r => r.name === 'SendDetails');
const params = {
walletID,
utxos,
isEditable: true as const,
feeUnit: BitcoinUnit.BTC,
amountUnit: BitcoinUnit.BTC,
};
if (hasSendDetails) {
navigation.dispatch(StackActions.popTo('SendDetails', params, { merge: true }));
} else {
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [{ name: 'SendDetails', params }],
}),
);
}
}

View File

@ -1,98 +1,44 @@
import React from 'react';
import { Platform, TouchableOpacity, StyleSheet } from 'react-native';
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { TouchableOpacity, StyleSheet } from 'react-native';
import Icon from '../../components/Icon';
import WalletGradient from '../../class/wallet-gradient';
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { DetailViewStackParamList } from '../DetailViewStackParamList';
import { navigationRef } from '../../NavigationService';
import { RouteProp } from '@react-navigation/native';
import { isDesktop } from '../../blue_modules/environment';
import { isIOS26OrHigher } from '../../components/platform';
import loc from '../../loc';
export type WalletTransactionsRouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
const HERO_HEADER_ICON_COLOR = '#FFFFFF';
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
const { isLoading = false, walletID, walletType } = route.params;
const navigateToWalletDetails = (walletID: string) => {
navigationRef.navigate('WalletDetails', {
walletID,
});
};
const onPress = () => {
navigationRef.navigate('WalletDetails', {
walletID,
});
};
/** Material "more" button for WalletTransactions header (preiOS 26 and Android). */
export const createWalletDetailsHeaderRight = ({
walletID,
isLoading = false,
iconColor = HERO_HEADER_ICON_COLOR,
}: {
walletID: string;
isLoading?: boolean;
iconColor?: string;
}): (() => React.ReactElement) => {
return () => (
<TouchableOpacity
accessibilityRole="button"
testID="WalletDetails"
disabled={isLoading}
style={styles.walletDetails}
onPress={() => navigateToWalletDetails(walletID)}
>
<Icon name="more-horiz" type="material" size={22} color={iconColor} />
const RightButton = (
<TouchableOpacity accessibilityRole="button" testID="WalletDetails" disabled={isLoading} style={styles.walletDetails} onPress={onPress}>
<Icon name="more-horiz" type="material" size={22} color="#FFFFFF" />
</TouchableOpacity>
);
};
/** Native toolbar ellipsis for WalletTransactions on iOS 26+. */
export const createWalletDetailsHeaderRightItems = ({
isLoading = false,
walletID,
}: {
isLoading?: boolean;
walletID: string;
}): (() => NativeStackHeaderItem[]) => {
return () => [
{
type: 'button',
label: loc.wallets.details_title,
icon: { type: 'sfSymbol', name: 'ellipsis' },
identifier: 'WalletDetails',
accessibilityLabel: 'WalletDetails',
sharesBackground: false,
onPress: () => navigateToWalletDetails(walletID),
disabled: isLoading,
},
];
};
const backgroundColor = WalletGradient.headerColorFor(walletType);
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
const { isLoading = false, walletID } = route.params;
const base: NativeStackNavigationOptions = {
return {
title: '',
headerBackTitleStyle: { fontSize: 0 },
headerTransparent: true,
headerStyle: {
backgroundColor: 'transparent',
backgroundColor,
},
headerBackButtonDisplayMode: 'minimal',
headerShadowVisible: false,
headerTintColor: HERO_HEADER_ICON_COLOR,
headerBlurEffect: undefined,
headerTintColor: '#FFFFFF',
statusBarStyle: 'light',
headerBackTitle: undefined,
headerRight: createWalletDetailsHeaderRight({ walletID, isLoading, iconColor: HERO_HEADER_ICON_COLOR }),
headerRight: () => RightButton,
};
if (Platform.OS === 'ios' && isIOS26OrHigher && !isDesktop) {
return {
...base,
headerRight: undefined,
experimental_userInterfaceStyle: 'dark' as const,
unstable_headerRightItems: createWalletDetailsHeaderRightItems({ isLoading, walletID }),
};
}
return base;
};
const styles = StyleSheet.create({

896
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "bluewallet",
"version": "8.0.1",
"version": "8.0.0",
"license": "MIT",
"repository": {
"type": "git",
@ -16,17 +16,19 @@
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
"@jest/reporters": "^27.5.1",
"@react-native/eslint-config": "^0.85.3",
"@react-native/jest-preset": "0.85.3",
"@react-native/js-polyfills": "^0.86.0",
"@react-native/js-polyfills": "^0.85.3",
"@react-native/metro-babel-transformer": "^0.85.3",
"@react-native/typescript-config": "^0.85.3",
"@testing-library/react-native": "^13.0.1",
"@types/bip38": "^3.1.2",
"@types/bs58check": "^2.1.0",
"@types/create-hash": "^1.2.2",
"@types/crypto-js": "^4.2.2",
"@types/jest": "^29.5.13",
"@types/react": "^19.2.0",
"@types/react-test-renderer": "^19.1.0",
@ -50,7 +52,6 @@
"node-fetch": "^2.6.7",
"patch-package": "^8.0.0",
"prettier": "^3.2.5",
"react-test-renderer": "19.2.3",
"ts-jest": "^29.1.1",
"typescript": "^5.9.3"
},
@ -89,39 +90,38 @@
"lint": " npm run tslint && node scripts/find-unused-loc.js && node scripts/find-english-leftovers.js && eslint --ext .js,.ts,.tsx '*.@(js|ts|tsx)' screen 'blue_modules/*.@(js|ts|tsx)' class models loc tests components navigation typings",
"lint:fix": "npm run lint -- --fix",
"lint:quickfix": "git status --porcelain | grep -v '\\.json' | grep -E '\\.js|\\.ts' --color=never | awk '{print $2}' | xargs eslint --fix; exit 0",
"unit": "jest -b tests/unit/*"
"unit": "jest -b -w tests/unit/*"
},
"dependencies": {
"@arkade-os/boltz-swap": "0.3.40",
"@arkade-os/sdk": "0.4.35",
"@babel/preset-env": "7.29.5",
"@bugsnag/react-native": "8.9.0",
"@arkade-os/boltz-swap": "0.2.19",
"@arkade-os/sdk": "0.3.12",
"@babel/preset-env": "7.29.2",
"@bugsnag/react-native": "8.8.1",
"@bugsnag/source-maps": "2.3.3",
"@keystonehq/bc-ur-registry": "0.7.1",
"@ngraveio/bc-ur": "1.1.13",
"@noble/ciphers": "1.3.0",
"@noble/hashes": "1.8.0",
"@noble/secp256k1": "3.1.0",
"@noble/hashes": "1.3.3",
"@noble/secp256k1": "1.6.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/cli": "20.1.3",
"@react-native-community/cli-platform-android": "20.1.3",
"@react-native-community/cli-platform-ios": "20.1.3",
"@react-native-documents/picker": "12.0.1",
"@react-native-vector-icons/entypo": "13.1.2",
"@react-native-vector-icons/fontawesome": "13.1.2",
"@react-native-vector-icons/fontawesome6": "13.1.2",
"@react-native-vector-icons/ionicons": "13.1.2",
"@react-native-vector-icons/material-design-icons": "13.1.2",
"@react-native-vector-icons/material-icons": "13.1.2",
"@react-native-vector-icons/entypo": "13.1.1",
"@react-native-vector-icons/fontawesome": "13.1.1",
"@react-native-vector-icons/fontawesome6": "13.1.1",
"@react-native-vector-icons/ionicons": "13.1.1",
"@react-native-vector-icons/material-design-icons": "13.1.1",
"@react-native-vector-icons/material-icons": "13.1.1",
"@react-native/babel-preset": "0.85.3",
"@react-native/codegen": "0.85.3",
"@react-native/gradle-plugin": "0.85.3",
"@react-native/metro-config": "0.85.3",
"@react-navigation/devtools": "7.0.62",
"@react-navigation/drawer": "7.12.0",
"@react-navigation/native": "7.3.1",
"@react-navigation/native-stack": "7.17.3",
"@react-navigation/devtools": "7.0.56",
"@react-navigation/drawer": "7.9.9",
"@react-navigation/native": "7.2.2",
"@react-navigation/native-stack": "7.14.12",
"@scure/base": "2.0.0",
"@spsina/bip47": "github:BlueWallet/bip47#df82345",
"aezeed": "0.0.5",
@ -140,13 +140,14 @@
"buffer": "6.0.3",
"coinselect": "github:BlueWallet/coinselect#35f8038",
"crypto-browserify": "3.12.1",
"dayjs": "1.11.21",
"detox": "20.51.3",
"crypto-js": "4.2.0",
"dayjs": "1.11.20",
"detox": "20.51.1",
"ecpair": "3.0.1",
"electrum-client": "github:BlueWallet/rn-electrum-client#83420b8",
"electrum-client": "github:BlueWallet/rn-electrum-client#d9f511d",
"electrum-mnemonic": "2.0.0",
"events": "3.3.0",
"lottie-react-native": "7.3.8",
"lottie-react-native": "7.3.7",
"pako": "file:blue_modules/pako",
"payjoin-client": "1.0.1",
"prop-types": "15.8.1",
@ -154,15 +155,15 @@
"react": "19.2.3",
"react-localization": "github:BlueWallet/react-localization#ae7969a",
"react-native": "0.85.3",
"react-native-background-fetch": "4.2.9",
"react-native-biometrics": "3.0.1",
"react-native-blue-crypto": "github:BlueWallet/react-native-blue-crypto#3cb5442",
"react-native-camera-kit-no-google": "github:BlueWallet/react-native-camera-kit-no-google#0ed049a62da29cf304019363ec9d9ef3a73652e6",
"react-native-capture-protection": "github:BlueWallet/react-native-capture-protection#b17b9ec",
"react-native-capture-protection": "github:BlueWallet/react-native-capture-protection#bb78a40",
"react-native-context-menu-view": "github:BlueWallet/react-native-context-menu-view#144110b02afdb11b431741aef5da95e91b942a9b",
"react-native-default-preference": "github:BlueWallet/react-native-default-preference#6338a1f1235e4130b8cfc2dd3b53015eeff2870c",
"react-native-device-info": "14.1.1",
"react-native-draggable-flatlist": "4.0.3",
"react-native-draglist": "github:BlueWallet/react-native-draglist#0c8049d",
"react-native-edge-to-edge": "1.8.1",
"react-native-fs": "2.20.0",
"react-native-gesture-handler": "2.31.2",
@ -174,18 +175,19 @@
"react-native-linear-gradient": "2.8.3",
"react-native-localize": "3.7.0",
"react-native-notifications": "5.2.2",
"react-native-permissions": "5.5.3",
"react-native-permissions": "5.5.1",
"react-native-prompt-android": "github:BlueWallet/react-native-prompt-android#ed168d66fed556bc2ed07cf498770f058b78a376",
"react-native-quick-actions": "0.3.13",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "5.8.0",
"react-native-screens": "4.25.2",
"react-native-reanimated": "4.3.0",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.24.0",
"react-native-secure-key-store": "github:BlueWallet/react-native-secure-key-store#2076b4849e88aa0a78e08bfbb4ce3923e0925cbc",
"react-native-share": "12.2.6",
"react-native-svg": "15.15.5",
"react-native-tcp-socket": "6.4.1",
"react-native-watch-connectivity": "1.1.0",
"react-native-worklets": "0.8.1",
"react-test-renderer": "19.2.3",
"readable-stream": "3.6.2",
"realm": "20.2.0",
"silent-payments": "github:BlueWallet/SilentPayments#59a037",

View File

@ -1,84 +0,0 @@
diff --git a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
index a42477a..3ff714c 100644
--- a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
+++ b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
@@ -159,7 +159,8 @@ export function useHeaderConfigProps({
route,
title,
unstable_headerLeftItems: headerLeftItems,
- unstable_headerRightItems: headerRightItems
+ unstable_headerRightItems: headerRightItems,
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption
}) {
const {
direction
@@ -365,7 +366,7 @@ export function useHeaderConfigProps({
children,
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
- experimental_userInterfaceStyle: dark ? 'dark' : 'light'
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')
};
}
//# sourceMappingURL=useHeaderConfigProps.js.map
\ No newline at end of file
diff --git a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
index 2f1351a..5742b66 100644
--- a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
+++ b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
@@ -302,6 +302,14 @@ export type NativeStackNavigationOptions = {
* @platform ios
*/
unstable_headerRightItems?: (props: NativeStackHeaderItemProps) => NativeStackHeaderItem[];
+ /**
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
+ *
+ * @platform ios
+ * @experimental
+ */
+ experimental_userInterfaceStyle?: import('react-native-screens').ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
/**
* String or a function that returns a React Element to be used by the header.
* Defaults to screen `title` or route name.
diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx
index 7488b1c..542333e 100644
--- a/node_modules/@react-navigation/native-stack/src/types.tsx
+++ b/node_modules/@react-navigation/native-stack/src/types.tsx
@@ -350,6 +350,15 @@ export type NativeStackNavigationOptions = {
unstable_headerRightItems?: (
props: NativeStackHeaderItemProps
) => NativeStackHeaderItem[];
+ /**
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
+ *
+ * @platform ios
+ * @experimental
+ * @see {@link https://github.com/react-navigation/react-navigation/issues/13069}
+ */
+ experimental_userInterfaceStyle?: ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
/**
* String or a function that returns a React Element to be used by the header.
* Defaults to screen `title` or route name.
diff --git a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
index 6f74856..d12cf7d 100644
--- a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
+++ b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
@@ -217,6 +217,7 @@ export function useHeaderConfigProps({
title,
unstable_headerLeftItems: headerLeftItems,
unstable_headerRightItems: headerRightItems,
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption,
}: Props): ScreenStackHeaderConfigProps {
const { direction } = useLocale();
const { colors, fonts, dark } = useTheme();
@@ -527,6 +528,7 @@ export function useHeaderConfigProps({
children,
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
- experimental_userInterfaceStyle: dark ? 'dark' : 'light',
+ experimental_userInterfaceStyle:
+ experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light'),
} as const;
}

View File

@ -1,111 +0,0 @@
# patches
Local patches applied to `node_modules` by [`patch-package`](https://github.com/ds300/patch-package)
on `postinstall` (see `package.json``scripts.patches`).
When upstream ships an equivalent fix, drop the patch here and bump the dependency.
---
## `react-native-tcp-socket+6.4.1.patch`
**What:** in `TcpSockets.m onConnect:`, read the socket addresses once and
emit `connect` only when both are valid; otherwise emit an `error` event
for that client.
**Why:** `onConnect:` builds an `NSDictionary` literal from
`[socket localHost]` / `[socket connectedHost]`. The socket can disconnect
between this callback being queued and run, in which case those getters
return `nil`; a dictionary literal with a `nil` value throws
`NSInvalidArgumentException`, which is uncaught and aborts the whole app
(SIGABRT). It is intermittent and was seen against Electrum TLS
connections, but is not TLS-specific.
```
NSInvalidArgumentException — attempt to insert nil object from objects[0]
-[TcpSockets onConnect:] -> -[TcpSocketClient socketDidSecure:]
→ Signal 6 (abort)
```
The `error`-event path (rather than just skipping the event) is
deliberate: skipping silently leaves the JS side waiting forever for a
`connect` callback that never arrives. Emitting `error` lets the JS
connection fail fast so the caller can retry.
**Upstream:**
- Bug: https://github.com/Rapsssito/react-native-tcp-socket/issues/197 (open)
- https://github.com/Rapsssito/react-native-tcp-socket/pull/225 (open) —
proposes the same nil guard but skips the event, which the maintainer
noted would hang JS; this patch emits `error` instead.
- https://github.com/Rapsssito/react-native-tcp-socket/pull/172 (closed) —
earlier attempt with the same error-event structure.
**Remove this patch once an upstream fix is merged and
`react-native-tcp-socket` is bumped past 6.4.1.**
---
## `react-native-notifications+5.2.2.patch`
**What:** rewrites `FcmToken.sendTokenToJS()` (Android) to obtain the
`ReactContext` from `ReactHost` first (bridgeless / New Architecture),
falling back to `ReactInstanceManager` only if that fails — and wraps
both lookups in `try/catch`.
**Why:** under the New Architecture (bridgeless, RN 0.76+) there is no
`ReactInstanceManager`. The stock code calls
`getReactNativeHost().getReactInstanceManager()` first, which throws
`UnsupportedOperationException: ReactInstanceManager.createReactContext
is unsupported` and crashes the app when the FCM push token is
delivered.
**Upstream:** https://github.com/wix/react-native-notifications/issues/1071 (open)
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8424
during a React Native bump. Remove once `react-native-notifications`
ships New-Architecture-safe token delivery.
---
## `@react-navigation+native-stack+7.15.1.patch`
**What:** adds an `experimental_userInterfaceStyle` navigation option to
`NativeStackNavigationOptions` (typed in `src/types.tsx` and the built
`lib/typescript` d.ts) and threads it through `useHeaderConfigProps` so a
screen can override the header's `UIUserInterfaceStyle`. When omitted it
falls back to the previous behaviour via
`experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')`.
**Why:** on iOS 26 the navigation bar's liquid-glass material and tint are
resolved from `UIUserInterfaceStyle`. React Navigation hard-codes this from
the theme `dark` boolean, so a screen cannot force a light/dark header
independent of the active theme. The iOS 26 glass header
(`screen/wallets/WalletTransactions.tsx`) needs that per-screen override.
**Upstream:** https://github.com/react-navigation/react-navigation/issues/13069 (open)
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
Remove once `@react-navigation/native-stack` exposes a header
`UIUserInterfaceStyle` override upstream. When bumping the dependency,
rename this patch to the new version and re-confirm the hunks still apply
(`npx patch-package`).
---
## `react-native-screens+4.25.2.patch`
**What:** in `RNSBarButtonItem.mm`, also set `self.accessibilityIdentifier`
when the JS `identifier` is provided (one line, alongside the existing
`self.identifier = identifier`).
**Why:** the iOS 26 glass header builds nav-bar buttons through
`unstable_headerRightItems`. The native `identifier` is not exposed as an
accessibility identifier, so Detox/XCUITest could not target those bar
buttons. Mirroring it onto `accessibilityIdentifier` makes them reachable
from e2e tests.
**Upstream:** no issue filed yet — local accessibility enhancement.
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
When bumping `react-native-screens`, rename this patch to the new version
and re-confirm the hunk still applies (`npx patch-package`).

View File

@ -1,12 +0,0 @@
diff --git a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
index 0eb1f09..324b888 100644
--- a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
+++ b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
@@ -81,6 +81,7 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict
NSString *identifier = dict[@"identifier"];
if (identifier != nil) {
self.identifier = identifier;
+ self.accessibilityIdentifier = identifier;
}
NSDictionary *badgeConfig = dict[@"badge"];
if (badgeConfig != nil) {

View File

@ -1,54 +0,0 @@
diff --git a/node_modules/react-native-tcp-socket/ios/TcpSockets.m b/node_modules/react-native-tcp-socket/ios/TcpSockets.m
index 0fe15d2..a5553c6 100644
--- a/node_modules/react-native-tcp-socket/ios/TcpSockets.m
+++ b/node_modules/react-native-tcp-socket/ios/TcpSockets.m
@@ -219,20 +219,35 @@ - (void)onWrittenData:(TcpSocketClient *)client msgId:(NSNumber *)msgId {
- (void)onConnect:(TcpSocketClient *)client {
GCDAsyncSocket *socket = [client getSocket];
- [self sendEventWithName:@"connect"
- body:@{
- @"id" : client.id,
- @"connection" : @{
- @"localAddress" : [socket localHost],
- @"localPort" :
- [NSNumber numberWithInt:[socket localPort]],
- @"remoteAddress" : [socket connectedHost],
- @"remotePort" : [NSNumber
- numberWithInt:[socket connectedPort]],
- @"remoteFamily" : [socket isIPv4] ? @"IPv4"
- : @"IPv6"
- }
- }];
+ NSString *localAddress = [socket localHost];
+ NSString *remoteAddress = [socket connectedHost];
+ // The socket can disconnect between this callback being queued and run,
+ // in which case the address getters return nil. Building the connection
+ // dictionary with a nil value throws NSInvalidArgumentException and
+ // crashes the app, so emit an error instead of the connect event -
+ // skipping silently would hang the JS side waiting for a callback.
+ if (localAddress != nil && remoteAddress != nil) {
+ [self sendEventWithName:@"connect"
+ body:@{
+ @"id" : client.id,
+ @"connection" : @{
+ @"localAddress" : localAddress,
+ @"localPort" :
+ [NSNumber numberWithInt:[socket localPort]],
+ @"remoteAddress" : remoteAddress,
+ @"remotePort" : [NSNumber
+ numberWithInt:[socket connectedPort]],
+ @"remoteFamily" : [socket isIPv4] ? @"IPv4"
+ : @"IPv6"
+ }
+ }];
+ } else {
+ [self sendEventWithName:@"error"
+ body:@{
+ @"id" : client.id,
+ @"error" : @"Socket disconnected before connect could be reported"
+ }];
+ }
}
- (void)onListen:(TcpSocketClient *)server {

View File

@ -1,29 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:best-practices",
":disableMajorUpdates",
":preserveSemverRanges"
],
"ignoreDeps": ["react-native"],
"schedule": ["before 6am on monday"],
"prConcurrentLimit": 1,
"prHourlyLimit": 0,
"minimumReleaseAge": "3 days",
"semanticCommits": "disabled",
"commitMessagePrefix": "OPS:",
"packageRules": [
{
"matchPackageNames": ["*"],
"groupName": "all dependencies",
"groupSlug": "all"
}
],
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 6am on monday"]
},
"vulnerabilityAlerts": {
"schedule": ["at any time"]
}
"ignoreDeps": ["react-native"]
}

View File

@ -1,7 +1,6 @@
import React, { useCallback, useReducer } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import BlueCard from '../components/BlueCard';
import BlueText from '../components/BlueText';
import { BlueCard, BlueText } from '../BlueComponents';
import Button from '../components/Button';
import loc from '../loc';
import { MODAL_TYPES } from './PromptPasswordConfirmationSheet.types';

View File

@ -12,7 +12,7 @@ import {
View,
} from 'react-native';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
import BlueTextCentered from '../components/BlueTextCentered';
import { BlueTextCentered } from '../BlueComponents';
import Button from '../components/Button';
import SafeArea from '../components/SafeArea';
import { BiometricType, unlockWithBiometrics, useBiometrics } from '../hooks/useBiometrics';

Some files were not shown because too many files have changed in this diff Show More