Compare commits

..

10 Commits

Author SHA1 Message Date
GLaDOS
32d3f77f4f
Merge pull request #8694 from BlueWallet/renovate/rubygems-concurrent-ruby-vulnerability
Some checks failed
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
OPS: Update dependency concurrent-ruby to '< 1.3.8' [SECURITY]
2026-06-22 17:13:35 +01:00
GLaDOS
f26ff9189c
Merge pull request #8695 from BlueWallet/fix-walletcarouselclipping
FIX: clipping wallet balance on carousel
2026-06-22 17:13:29 +01:00
ncoelho
1fa290652c FIX: clipping wallet balance on carousel 2026-06-22 15:41:14 +02:00
renovate[bot]
099f6f46a6
OPS: Update dependency concurrent-ruby to '< 1.3.8' [SECURITY] 2026-06-22 13:32:53 +00:00
Nuno
01a11bc8dd
FIX: text size on main app views (#8689)
* fix: text size on wallet view

* fix big font sizes

* fix lint

* fix Glados comments

* fix: run prettier

---------

Co-authored-by: Ivan Vershigora <ivan.vershigora@gmail.com>
2026-06-22 15:27:48 +02:00
GLaDOS
6639891c24
Merge pull request #8632 from BlueWallet/fix-custom-input-lag
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
FIX: Amount input lag in rbf custom fee input
2026-06-20 11:45:47 +01:00
GLaDOS
4029d294f8
Merge pull request #8566 from BlueWallet/cryptojs
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
REF: swap crypto-js for @noble/ciphers + hashes
2026-06-19 15:00:49 +01:00
Ivan Vershigora
276a9ea8f8
REF: swap crypto-js for @noble/ciphers + hashes 2026-06-19 12:23:35 +01:00
Ojok Emmanuel Nsubuga
81cf0011b3
Merge branch 'master' into fix-custom-input-lag 2026-06-14 14:27:05 +03:00
Ojok Emmanuel Nsubuga
d259e68a85 FIX: Amount input lag in rbf custom fee input 2026-06-08 08:44:57 +03:00
25 changed files with 630 additions and 772 deletions

View File

@ -7,7 +7,7 @@ gem "fastlane", "~> 2.234.0"
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.4'
gem 'concurrent-ruby', '< 1.3.8'
# Ruby 3.4.0 removed these from the standard library
gem 'bigdecimal'

View File

@ -87,7 +87,7 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.3)
concurrent-ruby (1.3.7)
connection_pool (3.0.2)
csv (3.3.5)
declarative (0.0.20)
@ -337,7 +337,7 @@ DEPENDENCIES
benchmark
bigdecimal
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
concurrent-ruby (< 1.3.4)
concurrent-ruby (< 1.3.8)
fastlane (~> 2.234.0)
fastlane-plugin-browserstack
fastlane-plugin-bugsnag
@ -377,7 +377,7 @@ CHECKSUMS
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9

View File

@ -1494,84 +1494,6 @@ export function getServerBanner(): Promise<string> {
return mainClient.request('server.banner', []);
}
export function getTxBlockHeight(txHash: string): number | undefined {
return txhashHeightCache[txHash];
}
export async function getCurrentBlockTip(): Promise<number> {
if (!mainClient) throw new Error('Electrum client is not connected');
const header = await mainClient.blockchainHeaders_subscribe();
if (header && header.height) {
latestBlock = { height: header.height, time: Math.floor(+new Date() / 1000) };
return header.height;
}
return estimateCurrentBlockheight();
}
/**
* Returns the confirmed block height for a given txHash.
* 1. Tries the in-memory txhashHeightCache (populated from address history).
* 2. Falls back to a DIRECT server call (bypassing Realm cache) to get fresh confirmations.
* Uses standard Bitcoin convention: height = tip - confirmations + 1.
*/
export async function getConfirmedBlockHeight(txHash: string): Promise<number | null> {
const cached = txhashHeightCache[txHash];
if (cached && cached > 0) return cached;
if (!mainClient) return null;
try {
const [tip, verboseTx] = await Promise.all([
getCurrentBlockTip(),
mainClient.blockchainTransaction_get(txHash, true),
]);
if (typeof verboseTx === 'string') {
// Server didn't support verbose — decode locally.
// Without txhashHeightCache entry we can't determine height.
return null;
}
const confirmations = Number(verboseTx?.confirmations);
if (!confirmations || confirmations <= 0) return null;
const height = tip - confirmations + 1;
txhashHeightCache[txHash] = height;
return height;
} catch (e) {
console.warn('getConfirmedBlockHeight: failed', e);
return null;
}
}
/**
* Fetches actual block header timestamps from the Electrum server for the given heights.
* Parses the 80-byte hex-encoded header to extract the 4-byte LE timestamp at byte offset 68.
*/
export async function getBlockTimestamps(heights: number[]): Promise<Record<number, number>> {
if (!mainClient) throw new Error('Electrum client is not connected');
const result: Record<number, number> = {};
const promises = heights.map(async height => {
try {
const headerHex: string = await mainClient.blockchainBlock_header(height);
// timestamp is at bytes 6871 of the 80-byte header (hex chars 136143), little-endian uint32
const tsHex = headerHex.slice(136, 144);
/* eslint-disable no-bitwise */
const timestamp =
parseInt(tsHex.slice(0, 2), 16) |
(parseInt(tsHex.slice(2, 4), 16) << 8) |
(parseInt(tsHex.slice(4, 6), 16) << 16) |
((parseInt(tsHex.slice(6, 8), 16) << 24) >>> 0);
/* eslint-enable no-bitwise */
result[height] = timestamp;
} catch (e) {
console.warn(`Failed to fetch block header for height ${height}:`, e);
}
});
await Promise.all(promises);
return result;
}
const splitIntoChunks = function (arr: any[], chunkSize: number) {
const groups = [];
let i;

View File

@ -1,23 +1,98 @@
import AES from 'crypto-js/aes';
import Utf8 from 'crypto-js/enc-utf8';
import { cbc } from '@noble/ciphers/aes';
import { md5 } from '@noble/hashes/legacy';
import { randomBytes } from '@noble/hashes/utils';
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 ciphertext = AES.encrypt(data, password);
return ciphertext.toString();
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]));
}
/**
* 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 {
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;
// 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;
}
}

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 CryptoJS from 'crypto-js';
import { cbc } from '@noble/ciphers/aes';
import ecc from '../blue_modules/noble_ecc';
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
import { fetch } from '../util/fetch';
@ -321,13 +321,24 @@ export default class Lnurl {
}
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
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);
// 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 '';
}
}
getCommentAllowed(): number | false {

View File

@ -1,397 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
LayoutChangeEvent,
ListRenderItemInfo,
StyleSheet,
Text,
TextStyle,
TouchableOpacity,
useColorScheme,
useWindowDimensions,
View,
} from 'react-native';
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import LottieView from 'lottie-react-native';
import LinearGradient from 'react-native-linear-gradient';
import dayjs from 'dayjs';
import { useTheme } from './themes';
import * as BlueElectrum from '../blue_modules/BlueElectrum';
import loc, { formatBalanceWithoutSuffix } from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
const BLOCK_COUNT = 5;
const COLLAPSED_HEIGHT = 0;
const BLOCKS_HEIGHT = 130;
const ANIMATION_DURATION = 280;
const ANIMATION_EASING = Easing.out(Easing.cubic);
const BLOCK_CARD_WIDTH = 120;
const BLOCK_CARD_GAP = 8;
const ITEM_LENGTH = BLOCK_CARD_WIDTH + BLOCK_CARD_GAP;
const HORIZONTAL_PADDING = 8;
const STATE_CARD_MARGIN_H = 24;
const txblockAnimation = require('../img/txblock.json');
const BLOCK_GRADIENT_EDGE_OPACITY = 0.45;
const BLOCK_GRADIENT_TOP_START = { x: 0.5, y: 0 };
const BLOCK_GRADIENT_TOP_END = { x: 0.5, y: 1 };
const BLOCK_GRADIENT_BOTTOM_START = { x: 0.5, y: 1 };
const BLOCK_GRADIENT_BOTTOM_END = { x: 0.5, y: 0 };
interface BlocksAccordionProps {
txHash: string;
isSent: boolean;
isExpanded: boolean;
confirmations: number;
vsize?: number | null;
feeSats?: number | null;
feeRate?: number | null;
onPress?: () => void;
}
interface BlockData {
height: number;
timestamp?: number;
}
const getItemLayout = (_: unknown, index: number) => ({
length: ITEM_LENGTH,
offset: HORIZONTAL_PADDING + index * ITEM_LENGTH,
index,
});
const renderBoldFormattedParts = (template: string, values: Record<string, string>, boldStyle: TextStyle): React.ReactNode[] => {
const regex = /\{(\w+)\}/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
let index = 0;
while ((match = regex.exec(template)) !== null) {
if (match.index > lastIndex) {
parts.push(template.substring(lastIndex, match.index));
}
const value = values[match[1]];
if (value !== undefined) {
parts.push(
<Text key={`bold-${index++}`} style={boldStyle}>
{value}
</Text>,
);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < template.length) {
parts.push(template.substring(lastIndex));
}
return parts;
};
const BlocksAccordion: React.FC<BlocksAccordionProps> = ({ txHash, isSent, isExpanded, confirmations, vsize, feeSats, feeRate, onPress }) => {
const { colors } = useTheme();
const colorScheme = useColorScheme();
const { width: windowWidth } = useWindowDimensions();
const [blocks, setBlocks] = useState<BlockData[]>([]);
const [confirmedHeight, setConfirmedHeight] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [measuredHeight, setMeasuredHeight] = useState(0);
const animatedHeight = useSharedValue(COLLAPSED_HEIGHT);
const fetchStartedRef = useRef(false);
const accentColor = isSent ? colors.transactionSentColor : colors.transactionReceivedColor;
const borderAccent = isSent ? colors.outgoingForegroundColor : colors.incomingForegroundColor;
const blockCardBg = isSent ? 'rgba(208, 2, 27, 0.16)' : 'rgba(30, 138, 106, 0.16)';
const lottieColorFilters = useMemo(() => [{ keypath: '**', color: borderAccent }], [borderAccent]);
const blockGradientColors = useMemo(
() =>
colorScheme === 'dark'
? [`rgba(0, 0, 0, ${BLOCK_GRADIENT_EDGE_OPACITY})`, 'rgba(0, 0, 0, 0)']
: [`rgba(255, 255, 255, ${BLOCK_GRADIENT_EDGE_OPACITY})`, 'rgba(255, 255, 255, 0)'],
[colorScheme],
);
const stylesHook = StyleSheet.create({
blockCardBase: { backgroundColor: blockCardBg },
confirmedBlockCard: { borderColor: borderAccent, borderWidth: 2, backgroundColor: 'transparent' },
summaryText: { color: accentColor },
summaryBold: { color: accentColor, fontWeight: '700' },
});
const fetchBlockData = useCallback(async () => {
if (fetchStartedRef.current) return;
fetchStartedRef.current = true;
setLoading(true);
try {
const [txHeight, currentTip] = await Promise.all([BlueElectrum.getConfirmedBlockHeight(txHash), BlueElectrum.getCurrentBlockTip()]);
if (!txHeight || txHeight <= 0) {
setLoading(false);
return;
}
setConfirmedHeight(txHeight);
const distance = currentTip - txHeight;
let startHeight = txHeight - 2;
if (distance < 2) {
startHeight = currentTip - (BLOCK_COUNT - 1);
}
const heights = Array.from({ length: BLOCK_COUNT }, (_, i) => startHeight + i);
const timestamps = await BlueElectrum.getBlockTimestamps(heights);
setBlocks(heights.map(h => ({ height: h, timestamp: timestamps[h] })));
} catch (e) {
console.warn('BlocksAccordion: failed to fetch block data', e);
} finally {
setLoading(false);
}
}, [txHash]);
useEffect(() => {
fetchStartedRef.current = false;
setBlocks([]);
setConfirmedHeight(null);
setMeasuredHeight(0);
}, [txHash]);
useEffect(() => {
fetchBlockData();
}, [fetchBlockData]);
const onMeasureLayout = useCallback((e: LayoutChangeEvent) => {
const height = Math.ceil(e.nativeEvent.layout.height);
if (height > 0) {
setMeasuredHeight(height);
}
}, []);
useEffect(() => {
const target = isExpanded ? (measuredHeight > 0 ? measuredHeight : BLOCKS_HEIGHT) : COLLAPSED_HEIGHT;
animatedHeight.value = withTiming(target, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING });
}, [isExpanded, measuredHeight, animatedHeight]);
const confirmedIndex = useMemo(() => {
if (confirmedHeight === null) return 0;
return blocks.findIndex(b => b.height === confirmedHeight);
}, [blocks, confirmedHeight]);
const containerWidth = windowWidth - STATE_CARD_MARGIN_H * 2;
const initialOffset = useMemo(() => {
if (confirmedIndex <= 0) return 0;
const itemCenter = HORIZONTAL_PADDING + confirmedIndex * ITEM_LENGTH + BLOCK_CARD_WIDTH / 2;
return Math.max(0, itemCenter - containerWidth / 2);
}, [confirmedIndex, containerWidth]);
const animatedContentStyle = useAnimatedStyle(() => ({
height: animatedHeight.value,
overflow: 'hidden' as const,
}));
const blocksAgoText = useMemo(() => {
const blocksAgo = Math.max(0, confirmations - 1);
if (blocksAgo === 1) {
return loc.transactions.block_ago;
}
return loc.formatString(loc.transactions.blocks_ago, { count: String(blocksAgo) });
}, [confirmations]);
const summaryContent = useMemo(() => {
if (confirmedHeight === null) return null;
const confirmationParts = renderBoldFormattedParts(
loc.transactions.blocks_confirmed_summary,
{
blocksAgo: blocksAgoText,
blockHeight: String(confirmedHeight),
},
stylesHook.summaryBold,
);
const hasFeeDetails = vsize != null && feeRate != null && feeSats != null;
if (!hasFeeDetails) {
return <Text style={[styles.summaryText, stylesHook.summaryText]}>{confirmationParts}</Text>;
}
const feeDisplay = `${formatBalanceWithoutSuffix(feeSats, BitcoinUnit.SATS, true)} sats`;
const feeParts = renderBoldFormattedParts(
loc.transactions.blocks_confirmed_fee_summary,
{
vsize: `${vsize} vb`,
feeRate: `${Number(feeRate.toFixed(1))} sats/vb`,
fee: feeDisplay,
},
stylesHook.summaryBold,
);
return (
<Text style={[styles.summaryText, stylesHook.summaryText]}>
{confirmationParts} {feeParts}
</Text>
);
}, [blocksAgoText, confirmedHeight, feeRate, feeSats, stylesHook.summaryBold, stylesHook.summaryText, vsize]);
const keyExtractor = useCallback((item: BlockData) => String(item.height), []);
const renderBlock = useCallback(
({ item }: ListRenderItemInfo<BlockData>) => {
const isConfirmed = confirmedHeight !== null && item.height === confirmedHeight;
return (
<View style={[styles.blockCard, stylesHook.blockCardBase, isConfirmed && stylesHook.confirmedBlockCard]}>
{isConfirmed && (
<>
<LottieView
style={styles.blockLottie}
source={txblockAnimation}
autoPlay
loop
resizeMode="cover"
colorFilters={lottieColorFilters}
/>
<LinearGradient
colors={blockGradientColors}
locations={[0, 0.3]}
start={BLOCK_GRADIENT_TOP_START}
end={BLOCK_GRADIENT_TOP_END}
style={styles.blockGradient}
pointerEvents="none"
/>
<LinearGradient
colors={blockGradientColors}
locations={[0.02, 0.3]}
start={BLOCK_GRADIENT_BOTTOM_START}
end={BLOCK_GRADIENT_BOTTOM_END}
style={styles.blockGradient}
pointerEvents="none"
/>
</>
)}
<Text style={[styles.blockHeight, stylesHook.summaryText]}>{item.height}</Text>
<View style={styles.blockDateContainer}>
<Text style={[styles.blockDate, stylesHook.summaryText]}>
{item.timestamp ? dayjs(item.timestamp * 1000).format('DD/MM/YYYY') : '-'}
</Text>
</View>
</View>
);
},
[
blockGradientColors,
confirmedHeight,
lottieColorFilters,
stylesHook.blockCardBase,
stylesHook.confirmedBlockCard,
stylesHook.summaryText,
],
);
const measureContent = loading ? (
<View style={styles.loadingContainer} />
) : (
<>
{summaryContent && <View style={styles.summaryContainer}>{summaryContent}</View>}
<View style={styles.blocksList} />
</>
);
const visibleContent = loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={accentColor} />
</View>
) : (
<>
{summaryContent && (
<TouchableOpacity onPress={onPress} activeOpacity={0.7} disabled={!onPress}>
<View style={styles.summaryContainer}>{summaryContent}</View>
</TouchableOpacity>
)}
<FlatList
data={blocks}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyExtractor={keyExtractor}
renderItem={renderBlock}
getItemLayout={getItemLayout}
contentOffset={{ x: initialOffset, y: 0 }}
snapToInterval={ITEM_LENGTH}
decelerationRate="fast"
style={styles.blocksList}
/>
</>
);
return (
<>
<View style={styles.measureLayer} onLayout={onMeasureLayout} pointerEvents="none">
{measureContent}
</View>
<Animated.View style={animatedContentStyle}>{visibleContent}</Animated.View>
</>
);
};
const styles = StyleSheet.create({
measureLayer: {
position: 'absolute',
opacity: 0,
width: '100%',
zIndex: -1,
},
loadingContainer: {
justifyContent: 'center',
alignItems: 'center',
minHeight: BLOCKS_HEIGHT,
},
summaryContainer: {
paddingHorizontal: 16,
paddingTop: 0,
paddingBottom: 8,
},
summaryText: {
fontSize: 13,
lineHeight: 16,
fontWeight: '500',
letterSpacing: -0.5,
},
blocksList: {
height: BLOCKS_HEIGHT,
},
scrollContent: {
paddingHorizontal: HORIZONTAL_PADDING,
paddingVertical: 8,
},
blockCard: {
width: BLOCK_CARD_WIDTH,
height: 110,
borderRadius: 12,
padding: 12,
justifyContent: 'space-between',
borderWidth: 1,
borderColor: 'transparent',
marginRight: BLOCK_CARD_GAP,
overflow: 'hidden',
},
blockLottie: {
...StyleSheet.absoluteFill,
},
blockGradient: {
...StyleSheet.absoluteFill,
},
blockHeight: {
fontSize: 15,
fontWeight: '700',
},
blockDateContainer: {
marginTop: 'auto',
},
blockDate: {
fontSize: 13,
fontWeight: '500',
},
});
export default BlocksAccordion;

View File

@ -52,7 +52,14 @@ const useFloatButtonAnimation = (initialHeight: number) => {
};
};
const useFloatButtonLayout = (width: number, sizeClass: SizeClass) => {
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 lastVerticalDecision = useRef(false);
const shouldUseVerticalLayout = useCallback(
@ -152,15 +159,19 @@ const useFloatButtonLayout = (width: number, sizeClass: SizeClass) => {
[width, sizeClass, shouldUseVerticalLayout],
);
const calculateContainerHeight = useCallback((childrenCount: number, isVerticalLayout: boolean) => {
if (!isVerticalLayout) return { height: '8%', minHeight: LAYOUT.BUTTON_HEIGHT };
const calculateContainerHeight = useCallback(
(childrenCount: number, isVerticalLayout: boolean) => {
const buttonHeight = getScaledButtonHeight(fontScale);
if (!isVerticalLayout) return { height: '8%', minHeight: buttonHeight };
const totalButtonsHeight = childrenCount * LAYOUT.BUTTON_HEIGHT;
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
const totalButtonsHeight = childrenCount * buttonHeight;
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
return { height: calculatedHeight };
}, []);
return { height: calculatedHeight };
},
[fontScale],
);
const calculateButtonFontSize = useMemo(() => {
const divisor = sizeClass === SizeClass.Large ? 22 : sizeClass === SizeClass.Regular ? 24 : 28;
@ -267,6 +278,7 @@ interface FButtonProps {
isVertical?: boolean;
borderRadius?: number;
fontSize?: number;
buttonHeight?: number;
disabled?: boolean;
testID?: string;
onPress: () => void;
@ -277,13 +289,14 @@ 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 }: ButtonContentProps) => {
const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentProps) => {
const computedStyle = StyleSheet.flatten(textStyle);
const fontSize = computedStyle.fontSize || LAYOUT.MAX_BUTTON_FONT_SIZE;
const iconSize = getScaledIconSize(Number(fontSize));
@ -307,9 +320,14 @@ const ButtonContent = ({ icon, text, textStyle }: ButtonContentProps) => {
}
return (
<View style={buttonContentStaticStyles.contentContainer}>
<View style={[buttonContentStaticStyles.contentContainer, { minHeight: buttonHeight }]}>
<View style={buttonStyles.iconContainer}>{scaledIcon}</View>
<Text numberOfLines={1} adjustsFontSizeToFit style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}>
<Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}
>
{text}
</Text>
</View>
@ -325,6 +343,7 @@ export const FButton = ({
isVertical,
borderRadius = LAYOUT.PILL_BORDER_RADIUS,
fontSize = LAYOUT.MAX_BUTTON_FONT_SIZE,
buttonHeight = LAYOUT.BUTTON_HEIGHT,
testID,
...props
}: FButtonProps) => {
@ -347,6 +366,8 @@ export const FButton = ({
return {
root: {
...baseStyles,
height: buttonHeight,
minHeight: buttonHeight,
backgroundColor: colors.buttonBackgroundColor,
},
text: {
@ -360,7 +381,7 @@ export const FButton = ({
marginBottom: buttonContentStaticStyles.marginBottom,
textBase: buttonContentStaticStyles.textBase,
};
}, [colors, fontSize]);
}, [colors, fontSize, buttonHeight]);
const style: Record<string, any> = {};
const additionalStyles = !last ? (isVertical ? customButtonStyles.marginBottom : customButtonStyles.marginRight) : {};
@ -397,7 +418,7 @@ export const FButton = ({
style={[buttonStyles.root, customButtonStyles.root, style, { borderRadius }]}
{...props}
>
<ButtonContent icon={icon} text={text} textStyle={textStyle} />
<ButtonContent icon={icon} text={text} textStyle={textStyle} buttonHeight={buttonHeight} />
</TouchableOpacity>
</Animated.View>
);
@ -405,8 +426,9 @@ export const FButton = ({
export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
const insets = useSafeAreaInsets();
const { height, width } = useWindowDimensions();
const { height, width, fontScale } = useWindowDimensions();
const { sizeClass } = useSizeClass();
const scaledButtonHeight = getScaledButtonHeight(fontScale);
const childrenCount = React.Children.toArray(props.children).filter(Boolean).length;
@ -419,6 +441,7 @@ 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,
@ -508,7 +531,7 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
useEffect(() => {
debouncedCalculateLayout();
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass]);
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass, fontScale]);
const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => {
const { width: currentLayoutWidth } = event.nativeEvent.layout;
@ -545,6 +568,7 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
isVertical,
borderRadius: buttonBorderRadius,
fontSize: buttonFontSize,
buttonHeight: scaledButtonHeight,
});
};
@ -561,10 +585,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 : null,
isVertical ? containerHeight : { minHeight: scaledButtonHeight },
{ transform: [{ translateY: slideAnimation }] },
],
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation],
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation, scaledButtonHeight],
);
return (

View File

@ -1,10 +1,13 @@
import React, { useMemo } from 'react';
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, View, ViewStyle } from 'react-native';
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, useWindowDimensions, 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>;
@ -55,12 +58,20 @@ 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: {
@ -72,7 +83,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
color: colors.alternativeTextColor,
fontWeight: '400',
paddingVertical: switchProps ? 8 : 0,
lineHeight: 20,
lineHeight: Math.round(20 * fontScale),
fontSize: 14,
marginTop: 2,
},
@ -93,7 +104,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
const enableFeedback = !noFeedback && !!onPress && !disabled;
const renderContent = () => (
<View style={styles.contentRow}>
<View style={[styles.contentRow, contentRowStyle]}>
{leftAvatar && (
<View style={styles.leftAvatarContainer}>
{leftAvatar}
@ -114,7 +125,14 @@ const ListItem: React.FC<ListItemProps> = React.memo(
{rightTitle || rightSubtitle ? (
<View style={styles.rightColumn}>
{rightTitle ? (
<Text style={rightTitleStyle} numberOfLines={1} accessibilityRole="text" selectable={rightTitleSelectable}>
<Text
style={rightTitleStyle}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.75}
accessibilityRole="text"
selectable={rightTitleSelectable}
>
{rightTitle}
</Text>
) : null}
@ -192,16 +210,20 @@ const styles = StyleSheet.create({
},
content: {
flex: 1,
flexShrink: 1,
minWidth: 0,
justifyContent: 'center',
},
leftAvatarContainer: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'center',
},
rightColumn: {
marginStart: 8,
minWidth: 0,
flexShrink: 0,
alignItems: 'flex-end',
alignSelf: 'center',
},
rightMemoWrapper: {
flexShrink: 1,

View File

@ -67,26 +67,25 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
}, []);
const handleFeeSelection = (feeType: NetworkTransactionFeeType) => {
if (feeType !== NetworkTransactionFeeType.CUSTOM) {
Keyboard.dismiss();
if (feeType === NetworkTransactionFeeType.CUSTOM) {
setSelectedFeeType(feeType);
return;
}
Keyboard.dismiss();
if (networkFees) {
let selectedFee: number;
switch (feeType) {
case NetworkTransactionFeeType.FAST:
selectedFee = networkFees.fastestFee;
onFeeSelected(networkFees.fastestFee);
break;
case NetworkTransactionFeeType.MEDIUM:
selectedFee = networkFees.mediumFee;
onFeeSelected(networkFees.mediumFee);
break;
case NetworkTransactionFeeType.SLOW:
selectedFee = networkFees.slowFee;
break;
case NetworkTransactionFeeType.CUSTOM:
selectedFee = Number(customFeeValue);
onFeeSelected(networkFees.slowFee);
break;
}
onFeeSelected(selectedFee);
setSelectedFeeType(feeType);
}
};
@ -94,7 +93,8 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
const handleCustomFeeChange = (customFee: string) => {
const sanitizedFee = customFee.replace(/[^0-9]/g, '');
setCustomFeeValue(sanitizedFee);
handleFeeSelection(NetworkTransactionFeeType.CUSTOM);
onFeeSelected(Number(sanitizedFee));
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
};
return (
@ -156,7 +156,10 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
ref={customTextInput}
maxLength={9}
style={[styles.customFeeInput, stylesHook.customFeeInput]}
onFocus={() => handleCustomFeeChange(customFeeValue)}
onFocus={() => {
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
onFeeSelected(Number(customFeeValue));
}}
placeholder={loc.send.fee_satvbyte}
placeholderTextColor="#81868e"
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}

View File

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

View File

@ -1,7 +1,7 @@
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 } from 'react-native';
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View, useWindowDimensions } from 'react-native';
import Lnurl from '../class/lnurl';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { LightningTransaction, Transaction } from '../class/wallets/types';
@ -29,9 +29,6 @@ import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
import ListItem from './ListItem';
const styles = StyleSheet.create({
dateLine: {
fontSize: 13,
},
fullWidthButton: {
width: '100%',
alignSelf: 'stretch',
@ -133,6 +130,7 @@ 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,
@ -248,6 +246,7 @@ 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,
@ -262,6 +261,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
item.ispaid,
insets.right,
insets.left,
fontScale,
]);
const determineTransactionTypeAndAvatar = () => {
@ -549,7 +549,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
<ListItem
leftAvatar={avatar}
title={listTitle}
subtitle={<Text style={styles.dateLine}>{dateLine}</Text>}
subtitle={dateLine}
chevron={false}
rightTitle={rowTitle}
rightTitleStyle={rowTitleStyle}

View File

@ -254,6 +254,7 @@ const styles = StyleSheet.create({
position: 'relative',
},
contentContainer: {
flex: 1,
paddingTop: WALLET_LABEL_TOP_GAP,
paddingHorizontal: 16,
paddingBottom: HERO_BOTTOM_PADDING,

View File

@ -30,6 +30,7 @@ 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';
@ -37,6 +38,30 @@ 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 {
@ -160,23 +185,28 @@ 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: {
height: 40,
minHeight: 40,
justifyContent: 'center',
},
balanceContainerCompact: {
height: 32,
minHeight: 32,
justifyContent: 'center',
},
image: {
width: 99,
@ -189,9 +219,6 @@ const iStyles = StyleSheet.create({
width: 78,
height: 74,
},
br: {
backgroundColor: 'transparent',
},
label: {
backgroundColor: 'transparent',
fontSize: 19,
@ -206,7 +233,6 @@ const iStyles = StyleSheet.create({
},
balanceCompact: {
fontSize: 28,
lineHeight: 34,
},
latestTx: {
backgroundColor: 'transparent',
@ -282,11 +308,32 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const { colors } = useTheme();
const { width } = useWindowDimensions();
const { width, fontScale } = 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;
@ -431,23 +478,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]}>
<LinearGradient
colors={WalletGradient.gradientsFor(item.type)}
style={[iStyles.grad, isCompact && iStyles.gradCompact, scaledCardStyles.grad]}
>
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact]}>
<Text style={iStyles.br} />
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact, !isCompact && scaledCardStyles.gradContent]}>
{!isPlaceHolder && (
<>
<Text
numberOfLines={1}
style={[
iStyles.label,
isCompact && iStyles.labelCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
style={[iStyles.label, isCompact && iStyles.labelCompact, scaledCardStyles.label, cardTextStyle]}
>
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
</Text>
<View style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact]}>
<View
style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact, scaledCardStyles.balanceContainer]}
>
{hideBalance ? (
<>
<BlueSpacing10 />
@ -457,11 +504,13 @@ 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,
{ color: colors.inverseForegroundColor, writingDirection: direction },
isCompact ? scaledCardStyles.balanceCompact : scaledCardStyles.balance,
cardTextStyle,
animatedBalanceStyle,
]}
>
@ -469,24 +518,20 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
</Animated.Text>
)}
</View>
<Text style={iStyles.br} />
<View style={scaledCardStyles.textSpacer} />
<Text
numberOfLines={1}
style={[
iStyles.latestTx,
isCompact && iStyles.latestTxCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[iStyles.latestTx, isCompact && iStyles.latestTxCompact, scaledCardStyles.latestTx, cardTextStyle]}
>
{loc.wallets.list_latest_transaction}
</Text>
<Text
numberOfLines={1}
style={[
iStyles.latestTxTime,
isCompact && iStyles.latestTxTimeCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[iStyles.latestTxTime, isCompact && iStyles.latestTxTimeCompact, scaledCardStyles.latestTxTime, cardTextStyle]}
>
{latestTransactionText}
</Text>
@ -541,7 +586,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
animateChanges = false,
} = props;
const { width } = useWindowDimensions();
const { width, fontScale } = useWindowDimensions();
const itemWidth = React.useMemo(() => getWalletCarouselItemWidth(width), [width]);
const snapInterval = React.useMemo(() => itemWidth, [itemWidth]);
const snapOffsets = React.useMemo(() => {
@ -650,7 +695,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 : 195; // 195 is the approximate height of wallet card
const itemSize = horizontal ? itemWidth : WALLET_CAROUSEL_HEIGHT;
flatListRef.current.scrollToOffset({
offset: itemSize * walletIndex,
animated,
@ -772,7 +817,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
const sliderHeight = 195;
const sliderHeight = getWalletCarouselHeight(fontScale);
useEffect(() => {
return () => {
@ -855,7 +900,8 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
const cStyles = StyleSheet.create({
content: {
paddingTop: 16,
paddingTop: scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale),
paddingBottom: scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale),
},
contentLargeScreen: {
paddingHorizontal: sizeClass === SizeClass.Large ? 16 : 12,
@ -886,7 +932,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
automaticallyAdjustContentInsets
automaticallyAdjustKeyboardInsets
automaticallyAdjustsScrollIndicatorInsets
style={{ minHeight: sliderHeight + 12 }}
style={{ minHeight: sliderHeight }}
onScrollToIndexFailed={onScrollToIndexFailed}
ListFooterComponent={onNewWalletPress ? <NewWalletPanel onPress={onNewWalletPress} /> : null}
{...props}

View File

@ -28,10 +28,10 @@ export const BlueDefaultTheme = {
buttonBlueBackgroundColor: '#ccddf9',
buttonGrayBackgroundColor: '#EEEEEE',
incomingBackgroundColor: '#d2f8d6',
incomingForegroundColor: '#1e8a6a',
incomingForegroundColor: '#37c0a1',
outgoingBackgroundColor: '#f8d2d2',
outgoingForegroundColor: '#d0021b',
successColor: '#1e8a6a',
successColor: '#37c0a1',
failedColor: '#ff0000',
placeholderTextColor: '#81868e',
shadowColor: '#000000',
@ -53,7 +53,7 @@ export const BlueDefaultTheme = {
scanLabel: '#9AA0AA',
feeText: '#81868e',
feeLabel: '#d2f8d6',
feeValue: '#1e8a6a',
feeValue: '#37c0a1',
feeActive: '#d2f8d6',
labelText: '#81868e',
cta2: '#062453',
@ -62,7 +62,7 @@ export const BlueDefaultTheme = {
mainColor: '#CFDCF6',
success: '#ccddf9',
successCheck: '#0f5cc0',
msSuccessBG: '#1e8a6a',
msSuccessBG: '#37c0a1',
msSuccessCheck: '#ffffff',
newBlue: '#007AFF',
redBG: '#F8D2D2',
@ -70,7 +70,7 @@ export const BlueDefaultTheme = {
changeBackground: '#FDF2DA',
changeText: '#F38C47',
receiveBackground: '#D1F9D6',
receiveText: '#1e8a6a',
receiveText: '#37C0A1',
androidRippleColor: '#CCCCCC',
transactionPendingColor: '#2757C6',
transactionPendingBackgroundColor: '#DBEFFD',
@ -79,7 +79,7 @@ export const BlueDefaultTheme = {
transactionStateBumpButtonBackground: 'rgba(255, 255, 255, 0.4)',
transactionStateCancelButtonBackground: 'rgba(0, 0, 0, 0.05)',
transactionSentColor: '#BF2828',
transactionReceivedColor: '#1e8a6a',
transactionReceivedColor: '#63BDA2',
cardSectionBackground: '#F9F9F9',
cardSectionHeaderBackground: '#F2F2F2',
cardBorderColor: 'rgba(0, 0, 0, 0.05)',

File diff suppressed because one or more lines are too long

View File

@ -339,10 +339,6 @@
"transaction_loading_error": "There was an issue loading the transaction. Please try again later.",
"transaction_not_available": "Transaction not available",
"confirmations_lowercase": "{confirmations} confirmations",
"blocks_ago": "{count} blocks ago",
"block_ago": "1 block ago",
"blocks_confirmed_summary": "Confirmed {blocksAgo} on block {blockHeight}.",
"blocks_confirmed_fee_summary": "With a size of {vsize} and a fee rate of {feeRate}, paying {fee} fee.",
"expand_note": "Expand Note",
"cpfp_create": "Create",
"cpfp_exp": "We will create another transaction that spends your unconfirmed transaction. The total fee will be higher than the original transaction fee, so it should be mined faster. This is called CPFP—Child Pays for Parent.",

32
package-lock.json generated
View File

@ -17,7 +17,8 @@
"@bugsnag/source-maps": "2.3.3",
"@keystonehq/bc-ur-registry": "0.7.1",
"@ngraveio/bc-ur": "1.1.13",
"@noble/hashes": "1.3.3",
"@noble/ciphers": "1.3.0",
"@noble/hashes": "1.8.0",
"@noble/secp256k1": "3.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-clipboard/clipboard": "1.16.3",
@ -57,7 +58,6 @@
"buffer": "6.0.3",
"coinselect": "github:BlueWallet/coinselect#35f8038",
"crypto-browserify": "3.12.1",
"crypto-js": "4.2.0",
"dayjs": "1.11.21",
"detox": "20.51.3",
"ecpair": "3.0.1",
@ -126,7 +126,6 @@
"@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",
@ -3497,6 +3496,18 @@
"eslint-scope": "5.1.1"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
@ -3525,10 +3536,12 @@
}
},
"node_modules/@noble/hashes": {
"version": "1.3.3",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@ -5263,11 +5276,6 @@
"@types/node": "*"
}
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"dev": true,
"license": "MIT"
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"dev": true,
@ -7955,10 +7963,6 @@
"node": ">= 0.10"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"license": "MIT"
},
"node_modules/css-select": {
"version": "5.1.0",
"license": "BSD-2-Clause",

View File

@ -27,7 +27,6 @@
"@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",
@ -100,7 +99,8 @@
"@bugsnag/source-maps": "2.3.3",
"@keystonehq/bc-ur-registry": "0.7.1",
"@ngraveio/bc-ur": "1.1.13",
"@noble/hashes": "1.3.3",
"@noble/ciphers": "1.3.0",
"@noble/hashes": "1.8.0",
"@noble/secp256k1": "3.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-clipboard/clipboard": "1.16.3",
@ -140,7 +140,6 @@
"buffer": "6.0.3",
"coinselect": "github:BlueWallet/coinselect#35f8038",
"crypto-browserify": "3.12.1",
"crypto-js": "4.2.0",
"dayjs": "1.11.21",
"detox": "20.51.3",
"ecpair": "3.0.1",

View File

@ -2,10 +2,9 @@ import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState }
import { ActivityIndicator, BackHandler, Linking, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { sha256 } from '@noble/hashes/sha256';
import { RouteProp, useRoute } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
import Icon from '../../components/Icon';
import dayjs from 'dayjs';
import Animated, { useAnimatedStyle, useSharedValue, withSequence, withTiming } from 'react-native-reanimated';
import relativeTime from 'dayjs/plugin/relativeTime';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import { satoshiToLocalCurrency } from '../../blue_modules/currency';
@ -23,7 +22,6 @@ import CopyTextToClipboard from '../../components/CopyTextToClipboard';
import TransactionIncomingIcon from '../../components/icons/TransactionIncomingIcon';
import TransactionOutgoingIcon from '../../components/icons/TransactionOutgoingIcon';
import TransactionPendingIcon from '../../components/icons/TransactionPendingIcon';
import BlocksAccordion from '../../components/BlocksAccordion';
import SafeAreaScrollView from '../../components/SafeAreaScrollView';
import { useTheme } from '../../components/themes';
import prompt from '../../helpers/prompt';
@ -65,6 +63,10 @@ enum ButtonStatus {
type RouteProps = RouteProp<DetailViewStackParamList, 'TransactionStatus'>;
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList, 'TransactionStatus'>;
type TransactionStatusHeaderOptions = NativeStackNavigationOptions & {
headerTitleContainerStyle?: { flex: number; maxWidth: number };
};
enum ActionType {
SetCPFPPossible,
SetRBFBumpFeePossible,
@ -138,8 +140,12 @@ type TransactionDetailHeaderTitleProps = {
const TransactionDetailHeaderTitle: React.FC<TransactionDetailHeaderTitleProps> = ({ direction, date, directionStyle, dateStyle }) => (
<View style={styles.headerTitleContainer}>
<BlueText style={directionStyle}>{direction}</BlueText>
<BlueText style={dateStyle}>{date}</BlueText>
<BlueText style={directionStyle} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
{direction}
</BlueText>
<BlueText style={dateStyle} numberOfLines={2} adjustsFontSizeToFit minimumFontScale={0.8}>
{date}
</BlueText>
</View>
);
@ -155,25 +161,61 @@ const TransactionStatus: React.FC = () => {
const subscribedWallet = useWalletSubscribe(walletID);
const { navigate, goBack, setOptions } = useExtendedNavigation<NavigationProps>();
const { colors } = useTheme();
const { width: windowWidth } = useWindowDimensions();
const { width: windowWidth, fontScale } = useWindowDimensions();
const { selectedBlockExplorer } = useSettings();
const fetchTxInterval = useRef<NodeJS.Timeout | undefined>(undefined);
const scaledStyles = useMemo(() => {
const valueLineHeight = Math.round(48 * fontScale);
const valuePaddingTop = Math.round(8 * fontScale);
return {
value: {
lineHeight: valueLineHeight,
paddingTop: valuePaddingTop,
minHeight: valueLineHeight + valuePaddingTop,
},
localCurrency: {
lineHeight: Math.round(20 * fontScale),
marginTop: Math.round(6 * fontScale),
},
headerTitleDirection: {
lineHeight: Math.round(22 * fontScale),
},
headerTitleDate: {
lineHeight: Math.round(18 * fontScale),
},
stateLabel: {
lineHeight: Math.round(22 * fontScale),
},
stateValue: {
lineHeight: Math.round(18 * fontScale),
},
advancedHeader: {
minHeight: Math.round(44 * fontScale),
},
explorerButton: {
paddingVertical: Math.round(6 * fontScale),
paddingHorizontal: Math.round(12 * fontScale),
},
addButton: {
paddingVertical: Math.round(4 * fontScale),
paddingHorizontal: Math.round(12 * fontScale),
},
detailRow: {
minHeight: Math.round(24 * fontScale),
paddingVertical: Math.round(12 * fontScale),
},
sectionTitle: {
paddingVertical: Math.round(16 * fontScale),
},
};
}, [fontScale]);
// Explicit width for To/ID text so Android StaticLayout can apply ellipsis (flex alone often fails on Android)
const detailValueMaxWidth = useMemo(() => Math.max(0, Math.floor((windowWidth - 48) / 2)), [windowWidth]);
const detailValueWidthStyle = useMemo(() => ({ width: detailValueMaxWidth }), [detailValueMaxWidth]);
// Blocks accordion state
const [isBlocksExpanded, setIsBlocksExpanded] = useState(false);
const stateCardScale = useSharedValue(1);
const stateCardAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: stateCardScale.value }],
}));
const toggleBlocksExpanded = useCallback(() => {
setIsBlocksExpanded(prev => !prev);
stateCardScale.value = withSequence(withTiming(0.98, { duration: 100 }), withTiming(1, { duration: 150 }));
}, [stateCardScale]);
// Advanced section state
const [isAdvancedExpanded, setIsAdvancedExpanded] = useState(false);
const [txHex, setTxHex] = useState<string | null>(null);
@ -851,14 +893,6 @@ const TransactionStatus: React.FC = () => {
const isPending = resolveTxDisplayState(tx) === 'pending';
const preferredBalanceUnit = wallet?.preferredBalanceUnit ?? BitcoinUnit.BTC;
const showBlocksAccordion = !isPending && parsedConfirmations > 0;
const onBlocksHeaderPress = useCallback(() => {
if (!showBlocksAccordion) return;
triggerHapticFeedback(HapticFeedbackTypes.ImpactLight);
toggleBlocksExpanded();
}, [showBlocksAccordion, toggleBlocksExpanded]);
// Get transaction direction and date
const transactionDirection = txValue !== null && txValue < 0 ? loc.transactions.details_sent : loc.transactions.details_received;
const transactionDate = tx?.timestamp ? dayjs(tx.timestamp * 1000).format('LLL') : '-';
@ -942,15 +976,20 @@ const TransactionStatus: React.FC = () => {
<TransactionDetailHeaderTitle
direction={transactionDirection}
date={transactionDate}
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection]}
dateStyle={[styles.headerTitleDate, stylesHook.titleDate]}
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection, scaledStyles.headerTitleDirection]}
dateStyle={[styles.headerTitleDate, stylesHook.titleDate, scaledStyles.headerTitleDate]}
/>
),
});
headerTitleAlign: 'left',
headerTitleContainerStyle: {
flex: 1,
maxWidth: Math.max(0, windowWidth - 96),
},
} as TransactionStatusHeaderOptions);
}
// stylesHook is derived from colors; omitting to avoid unnecessary effect runs
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tx, transactionDirection, transactionDate, setOptions, colors]);
}, [tx, transactionDirection, transactionDate, setOptions, colors, windowWidth, scaledStyles]);
if (loadingError) {
return (
@ -983,15 +1022,20 @@ const TransactionStatus: React.FC = () => {
{/* Value Section */}
<View style={styles.valueCard}>
<View style={styles.valueContent}>
<Text style={[styles.value, stylesHook.value]} selectable numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.55}>
<Text
style={[styles.value, stylesHook.value, scaledStyles.value, styles.valueFullWidth]}
selectable
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.55}
>
{txValue !== null ? formatBalanceWithoutSuffix(txValue, preferredBalanceUnit, true) : '-'}
{` `}
{preferredBalanceUnit !== BitcoinUnit.LOCAL_CURRENCY && (
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{preferredBalanceUnit}</Text>
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{` ${preferredBalanceUnit}`}</Text>
)}
</Text>
{txValue !== null && (
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>
<Text style={[styles.localCurrency, stylesHook.localCurrency, scaledStyles.localCurrency]}>
{preferredBalanceUnit === BitcoinUnit.LOCAL_CURRENCY
? `${formatBalanceWithoutSuffix(Math.abs(txValue), BitcoinUnit.BTC, true)} ${BitcoinUnit.BTC}`
: satoshiToLocalCurrency(Math.abs(txValue))}
@ -1001,7 +1045,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* State Section */}
<Animated.View
<View
style={[
styles.stateCard,
isPending
@ -1009,7 +1053,6 @@ const TransactionStatus: React.FC = () => {
: txValue !== null && txValue < 0
? stylesHook.stateCardSent
: stylesHook.stateCardReceived,
stateCardAnimatedStyle,
]}
>
<View style={styles.stateSection}>
@ -1018,8 +1061,10 @@ const TransactionStatus: React.FC = () => {
<View style={styles.stateIndicator}>
<TransactionPendingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending]}>{loc.transactions.pending}</BlueText>
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline]}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending, scaledStyles.stateLabel]}>
{loc.transactions.pending}
</BlueText>
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline, scaledStyles.stateValue]}>
{eta || loc.transactions.details_eta_analyzing}
</BlueText>
</View>
@ -1048,58 +1093,40 @@ const TransactionStatus: React.FC = () => {
)}
</>
) : txValue !== null && txValue < 0 ? (
<TouchableOpacity style={styles.stateHeaderRow} onPress={onBlocksHeaderPress} activeOpacity={0.7}>
<View style={styles.stateIndicator}>
<TransactionOutgoingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
</View>
<View style={styles.stateIndicator}>
<TransactionOutgoingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent, scaledStyles.stateLabel]}>
{loc.transactions.details_sent}
</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline, scaledStyles.stateValue]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
</View>
{showBlocksAccordion && (
<Icon name="information-circle-outline" type="ionicons" size={20} color={colors.transactionSentColor} />
)}
</TouchableOpacity>
</View>
) : (
<TouchableOpacity style={styles.stateHeaderRow} onPress={onBlocksHeaderPress} activeOpacity={0.7}>
<View style={styles.stateIndicator}>
<TransactionIncomingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
</View>
<View style={styles.stateIndicator}>
<TransactionIncomingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived, scaledStyles.stateLabel]}>
{loc.transactions.details_received}
</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline, scaledStyles.stateValue]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
</View>
{showBlocksAccordion && (
<Icon name="information-circle-outline" type="ionicons" size={20} color={colors.transactionReceivedColor} />
)}
</TouchableOpacity>
</View>
)}
</View>
{showBlocksAccordion && tx?.hash && (
<BlocksAccordion
txHash={tx.hash}
isSent={txValue !== null && txValue < 0}
isExpanded={isBlocksExpanded}
confirmations={parsedConfirmations}
vsize={tx.vsize}
feeSats={calculatedFee}
feeRate={feeRate}
onPress={onBlocksHeaderPress}
/>
)}
</Animated.View>
</View>
{/* Counterparty badge (read-only, matches contact list style) */}
{counterpartyDisplayName && (
@ -1124,20 +1151,29 @@ const TransactionStatus: React.FC = () => {
{/* Details Section */}
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
{/* Details Title */}
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_section}</BlueText>
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle, scaledStyles.sectionTitle]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]}>
{loc.transactions.details_section}
</BlueText>
{tx?.hash && (
<TouchableOpacity
onPress={handleOpenBlockExplorer}
style={[styles.explorerButton, stylesHook.explorerButton]}
style={[styles.explorerButton, stylesHook.explorerButton, scaledStyles.explorerButton]}
activeOpacity={0.7}
>
<BlueText style={[styles.explorerButtonText, stylesHook.explorerButtonText]}>{loc.transactions.details_explorer}</BlueText>
<BlueText
style={[styles.explorerButtonText, stylesHook.explorerButtonText]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
>
{loc.transactions.details_explorer}
</BlueText>
</TouchableOpacity>
)}
</View>
{/* Network Fee */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_network_fee}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1161,7 +1197,7 @@ const TransactionStatus: React.FC = () => {
const displayText = externalAddresses.map(shortenCounterpartyName).join(', ');
const copyText = externalAddresses.join(', ');
return (
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_to_address}</BlueText>
<View style={styles.detailValueContainer}>
<View style={styles.detailValueCopyContainer}>
@ -1187,7 +1223,7 @@ const TransactionStatus: React.FC = () => {
{/* Transaction ID - display shortened so it stays on one line on Android; copy still gets full hash */}
{tx.hash && (
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_id}</BlueText>
<View style={styles.detailValueContainer}>
<View style={styles.detailValueCopyContainer}>
@ -1214,7 +1250,7 @@ const TransactionStatus: React.FC = () => {
)}
{/* Note/Memo */}
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow]}>
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_note}</BlueText>
<View style={styles.detailValueContainer}>
{memo ? (
@ -1224,8 +1260,19 @@ const TransactionStatus: React.FC = () => {
</BlueText>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handleNotePress} style={[styles.addButton, stylesHook.addButton]} activeOpacity={0.7}>
<BlueText style={[styles.addButtonText, stylesHook.addButtonText]}>{loc.transactions.details_add_note}</BlueText>
<TouchableOpacity
onPress={handleNotePress}
style={[styles.addButton, stylesHook.addButton, scaledStyles.addButton]}
activeOpacity={0.7}
>
<BlueText
style={[styles.addButtonText, stylesHook.addButtonText]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
>
{loc.transactions.details_add_note}
</BlueText>
</TouchableOpacity>
)}
</View>
@ -1235,15 +1282,14 @@ const TransactionStatus: React.FC = () => {
{/* Advanced Section */}
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
<TouchableOpacity
onPress={() => {
triggerHapticFeedback(HapticFeedbackTypes.ImpactLight);
setIsAdvancedExpanded(!isAdvancedExpanded);
}}
style={[styles.advancedHeader, stylesHook.advancedHeader]}
onPress={() => setIsAdvancedExpanded(!isAdvancedExpanded)}
style={[styles.advancedHeader, stylesHook.advancedHeader, scaledStyles.advancedHeader]}
activeOpacity={0.85}
>
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_advanced}</BlueText>
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow, scaledStyles.sectionTitle]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]} numberOfLines={2}>
{loc.transactions.details_advanced}
</BlueText>
<Icon
name={isAdvancedExpanded ? 'chevron-up' : 'chevron-down'}
type="font-awesome"
@ -1256,7 +1302,7 @@ const TransactionStatus: React.FC = () => {
{isAdvancedExpanded && (
<View style={[styles.advancedContent, stylesHook.advancedContent]}>
{/* Fee Rate */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_fee_rate}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1268,7 +1314,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* Size */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_size}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1280,7 +1326,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* Virtual Size */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_virtual_size}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1292,7 +1338,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* Transaction Hex */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_tx_hex}</BlueText>
<View style={styles.detailValueContainer}>
{txHex ? (
@ -1357,6 +1403,7 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
justifyContent: 'center',
flex: 1,
minWidth: 0,
},
headerTitleDirection: {
fontSize: 17,
@ -1404,15 +1451,20 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
justifyContent: 'flex-start',
overflow: 'visible',
width: '100%',
},
value: {
fontSize: 40,
fontWeight: '700',
letterSpacing: -0.5,
lineHeight: 32,
lineHeight: 48,
paddingTop: 8,
minHeight: 38,
},
valueFullWidth: {
width: '100%',
flexShrink: 1,
},
valueUnit: {
fontSize: 18,
fontWeight: '600',
@ -1430,7 +1482,6 @@ const styles = StyleSheet.create({
borderRadius: 12,
marginHorizontal: 24,
marginBottom: 42,
overflow: 'hidden',
},
stateSection: {
alignItems: 'flex-start',
@ -1438,23 +1489,17 @@ const styles = StyleSheet.create({
paddingBottom: 16,
paddingHorizontal: 20,
},
stateHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
},
stateIndicator: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
flex: 1,
},
stateLabelContainer: {
flexDirection: 'column',
alignItems: 'flex-start',
marginLeft: 8,
flex: 1,
minWidth: 0,
},
stateLabel: {
fontSize: 16,
@ -1540,17 +1585,23 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
},
sectionTitleText: {
fontSize: 17,
fontWeight: '600',
},
sectionTitleTextFlexible: {
flex: 1,
flexShrink: 1,
minWidth: 0,
},
explorerButton: {
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 6,
alignSelf: 'flex-end',
minWidth: 50,
flexShrink: 0,
alignItems: 'center',
justifyContent: 'center',
},
@ -1561,7 +1612,7 @@ const styles = StyleSheet.create({
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
alignItems: 'flex-start',
marginBottom: 0,
minHeight: 24,
paddingVertical: 12,
@ -1585,6 +1636,8 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '500',
flex: 1,
flexShrink: 1,
minWidth: 0,
lineHeight: 22,
paddingRight: 12,
},
@ -1598,11 +1651,12 @@ const styles = StyleSheet.create({
flex: 1,
minWidth: 0,
maxWidth: '100%',
flexWrap: 'nowrap',
alignItems: 'center',
flexWrap: 'wrap',
alignItems: 'flex-end',
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
flexShrink: 0,
},
detailValueCopyContainer: {
flex: 1,
@ -1650,7 +1704,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
borderRadius: 6,
alignSelf: 'flex-end',
minWidth: 50,
flexShrink: 0,
alignItems: 'center',
justifyContent: 'center',
},
@ -1668,7 +1722,6 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
overflow: 'hidden',
},
advancedContent: {
marginTop: 0,

View File

@ -33,6 +33,7 @@ import presentAlert, { AlertType } from '../../components/Alert';
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
import { useTheme } from '../../components/themes';
import { TransactionListItem } from '../../components/TransactionListItem';
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
import TransactionsNavigationHeader, { actionKeys } from '../../components/TransactionsNavigationHeader';
import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
@ -437,11 +438,17 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
[name, navigate, navigation, onWalletSelect, walletID, wallets],
);
const getItemLayout = (_: any, index: number) => ({
length: 64,
offset: 64 * index,
index,
});
const { fontScale } = useWindowDimensions();
const txRowHeight = Math.round(TX_ROW_BASE_HEIGHT * fontScale);
const getItemLayout = useCallback(
(_: any, index: number) => ({
length: txRowHeight,
offset: txRowHeight * index,
index,
}),
[txRowHeight],
);
const renderItem = useCallback(
// react/no-unused-prop-types misfires on inline arrow renderers: it reads the

View File

@ -8,10 +8,15 @@ import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/h
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
import { ExtendedTransaction, Transaction, TWallet } from '../../class/wallets/types';
import presentAlert from '../../components/Alert';
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
import { FButton, FContainer, FloatButtonsBottomFade, getFloatingButtonReservedHeight } from '../../components/FloatButtons';
import { useTheme } from '../../components/themes';
import { TransactionListItem } from '../../components/TransactionListItem';
import WalletsCarousel, { getWalletCarouselItemWidth, CarouselListRefType } from '../../components/WalletsCarousel';
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
import WalletsCarousel, {
getWalletCarouselItemWidth,
CarouselListRefType,
getWalletCarouselHeight,
} from '../../components/WalletsCarousel';
import { useSizeClass, SizeClass } from '../../blue_modules/sizeClass';
import loc from '../../loc';
import ActionSheet from '../ActionSheet';
@ -28,6 +33,7 @@ import { scanQrHelper } from '../../helpers/scan-qr';
import { isIOS26OrHigher } from '../../components/platform';
const WalletsListSections = { CAROUSEL: 'CAROUSEL', TRANSACTIONS: 'TRANSACTIONS' };
const SECTION_HEADER_BASE_HEIGHT = 56;
/** Electrum `ping` while the list is visible; detects mid-session drops without polling when user is elsewhere. */
const ELECTRUM_HEALTH_POLL_WHILE_WALLETS_LIST_FOCUSED_MS = 30_000;
@ -108,7 +114,11 @@ const WalletsList: React.FC = () => {
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
const { wallets, getTransactions, refreshAllWalletTransactions } = useStorage();
const { isTotalBalanceEnabled, isElectrumDisabled } = useSettings();
const { width } = useWindowDimensions();
const { width, fontScale } = useWindowDimensions();
const carouselHeight = getWalletCarouselHeight(fontScale);
const transactionItemHeight = Math.round(TX_ROW_BASE_HEIGHT * fontScale);
const sectionHeaderHeight = Math.round(SECTION_HEADER_BASE_HEIGHT * fontScale);
const floatingButtonHeight = getFloatingButtonReservedHeight(fontScale);
const { colors, scanImage } = useTheme();
const navigation = useExtendedNavigation<NavigationProps>();
const isFocused = useIsFocused();
@ -124,9 +134,11 @@ const WalletsList: React.FC = () => {
listHeaderBack: {
backgroundColor: colors.background,
paddingTop: sizeClass === SizeClass.Large ? 8 : 0,
minHeight: sectionHeaderHeight,
},
listHeaderText: {
color: colors.foregroundColor,
marginVertical: Math.round(16 * fontScale),
},
});
@ -493,14 +505,9 @@ const WalletsList: React.FC = () => {
}, [sizeClass, dataSource]);
// Constants for layout calculations
const TRANSACTION_ITEM_HEIGHT = 80;
const CAROUSEL_HEIGHT = 195;
const SECTION_HEADER_HEIGHT = 56; // Base height
const LARGE_TITLE_EXTRA_HEIGHT = 20; // Additional height for large titles
const getSectionHeaderHeight = useCallback(() => {
return SECTION_HEADER_HEIGHT + (sizeClass === SizeClass.Large ? LARGE_TITLE_EXTRA_HEIGHT : 0);
}, [sizeClass]);
return sectionHeaderHeight + (sizeClass === SizeClass.Large ? Math.round(20 * fontScale) : 0);
}, [sizeClass, sectionHeaderHeight, fontScale]);
const getItemLayout = useCallback(
(data: any, index: number) => {
@ -509,8 +516,8 @@ const WalletsList: React.FC = () => {
if (sizeClass === SizeClass.Large) {
// On large screens: only transaction items, no carousel
return {
length: TRANSACTION_ITEM_HEIGHT,
offset: TRANSACTION_ITEM_HEIGHT * index,
length: transactionItemHeight,
offset: transactionItemHeight * index,
index,
};
} else {
@ -518,7 +525,7 @@ const WalletsList: React.FC = () => {
// First section: Carousel
if (index === 0) {
return {
length: CAROUSEL_HEIGHT,
length: carouselHeight,
offset: 0,
index,
};
@ -531,13 +538,13 @@ const WalletsList: React.FC = () => {
// 3. Transaction items
const transactionIndex = index - 1; // Adjust index to account for carousel
return {
length: TRANSACTION_ITEM_HEIGHT,
offset: CAROUSEL_HEIGHT + headerHeight + TRANSACTION_ITEM_HEIGHT * transactionIndex,
length: transactionItemHeight,
offset: carouselHeight + headerHeight + transactionItemHeight * transactionIndex,
index,
};
}
},
[sizeClass, getSectionHeaderHeight],
[sizeClass, getSectionHeaderHeight, carouselHeight, transactionItemHeight],
);
return (
@ -550,7 +557,7 @@ const WalletsList: React.FC = () => {
initialNumToRender={10}
renderSectionFooter={renderSectionFooter}
sections={sections}
floatingButtonHeight={70}
floatingButtonHeight={floatingButtonHeight}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
getItemLayout={getItemLayout}

View File

@ -43,4 +43,19 @@ describe('unit - encryption', function () {
const decrypted = c.decrypt(crypted, 'password');
assert.deepEqual(data2decrypt, decrypted);
});
it('can decrypt a ciphertext produced by the OpenSSL CLI (wire-format check)', () => {
// Regenerate this fixture with (copy-pasteable, verified to reproduce the byte string below):
//
// { printf 'Salted__\x01\x02\x03\x04\x05\x06\x07\x08'; \
// printf 'hello world this is plaintext' \
// | openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5; \
// } | base64
//
// OpenSSL's `enc` only emits the `Salted__` envelope when it picks the salt itself;
// passing `-S <hex>` suppresses the header, so we prepend it manually. Pins the
// on-disk format against an independent reference beyond crypto-js.
const crypted = 'U2FsdGVkX18BAgMEBQYHCMqtJuZaneiHrVN/oMPPLvFplovZbI1K+lulGJn7NAvn';
assert.strictEqual(c.decrypt(crypted, 'mypassword'), 'hello world this is plaintext');
});
});

View File

@ -0,0 +1,51 @@
import assert from 'assert';
import { evpBytesToKeyMd5 } from '../../blue_modules/encryption';
import { hexToUint8Array, stringToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras';
describe('evpBytesToKeyMd5', () => {
// Vectors computed against the OpenSSL EVP_BytesToKey reference algorithm
// (MD5, 1 iteration). The KDF is purely deterministic, so a single fixed
// (password, salt) pair pins the bytes our wallet store relies on.
it('matches the OpenSSL CLI reference for password="mypassword"', () => {
// openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5 -p
const out = evpBytesToKeyMd5(stringToUint8Array('mypassword'), hexToUint8Array('0102030405060708'), 48);
assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), '20814c3ad75ac1d26c61a8e4702b5ff4d7baaee00c595bab71592aaf45bf41e4');
assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '43269499cb6d59f4e3b9dda68098b673');
});
it('matches a Node-crypto reference vector for a multi-word password', () => {
const out = evpBytesToKeyMd5(stringToUint8Array('correct horse'), hexToUint8Array('0102030405060708'), 48);
assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), 'bcf8d941d9291141709c9d56360eb7148e3960ab3dc44d832c4028568545c91d');
assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '5a7a1d12207f801d2f6f4cf578e8708c');
});
it('returns exactly the requested number of bytes', () => {
const pwd = stringToUint8Array('pw');
const salt = hexToUint8Array('00000000000000ff');
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 1).length, 1);
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 15).length, 15);
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 16).length, 16); // one MD5 block exactly
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 17).length, 17); // one block + 1
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 48).length, 48); // key + iv default
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 65).length, 65); // multi-block + spillover
});
it('is a prefix-stable stream (same first N bytes regardless of total length)', () => {
const pwd = stringToUint8Array('xyz');
const salt = hexToUint8Array('cafebabedeadbeef');
const long = evpBytesToKeyMd5(pwd, salt, 64);
for (const n of [1, 16, 17, 32, 48]) {
assert.strictEqual(uint8ArrayToHex(evpBytesToKeyMd5(pwd, salt, n)), uint8ArrayToHex(long.subarray(0, n)));
}
});
it('rejects non-integer or negative byteLength', () => {
const pwd = stringToUint8Array('pw');
const salt = hexToUint8Array('0102030405060708');
assert.throws(() => evpBytesToKeyMd5(pwd, salt, -1));
assert.throws(() => evpBytesToKeyMd5(pwd, salt, 1.5));
assert.throws(() => evpBytesToKeyMd5(pwd, salt, NaN));
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 0).length, 0);
});
});

View File

@ -208,6 +208,17 @@ describe('LNURL', function () {
assert.strictEqual(Lnurl.decipherAES(ciphertext, preimage, iv), '1234');
});
it('decipherAES returns empty string on malformed input (preserves crypto-js contract)', () => {
const preimage = 'bf62911aa53c017c27ba34391f694bc8bf8aaf59b4ebfd9020e66ac0412e189b';
const validIv = 'eTGduB45hWTOxHj1dR+LJw==';
// Non-block-aligned ciphertext — would throw under raw @noble/ciphers
assert.strictEqual(Lnurl.decipherAES('not-base64-aligned', preimage, validIv), '');
// Bad PKCS7 padding (random 16-byte block won't unpad cleanly)
assert.strictEqual(Lnurl.decipherAES('AAAAAAAAAAAAAAAAAAAAAA==', preimage, validIv), '');
// Empty ciphertext
assert.strictEqual(Lnurl.decipherAES('', preimage, validIv), '');
});
});
describe('lightning address', function () {

View File

@ -153,32 +153,11 @@ jest.mock('../../loc', () => ({
details_from: 'From',
txid: 'txid',
details_received: 'received',
details_sent: 'sent',
details_inputs: 'inputs',
details_outputs: 'outputs',
details_inputs_count: 'Inputs ({count})',
details_outputs_count: 'Outputs ({count})',
details_view_in_browser: 'view in browser',
details_note: 'Note',
details_add_note: 'Add note',
details_section: 'Details',
details_explorer: 'Explorer',
details_network_fee: 'Network Fee',
details_to_address: 'To',
details_id: 'ID',
details_fee_rate: 'Fee Rate',
details_size: 'Size',
details_virtual_size: 'Virtual Size',
details_tx_hex: 'TX Hex',
details_copy: 'Copy',
details_advanced: 'Advanced',
details_eta_analyzing: 'Analyzing...',
pending: 'Pending',
open_url_error: 'Unable to open URL',
blocks_ago: '{count} blocks ago',
block_ago: '1 block ago',
blocks_confirmed_summary: 'Confirmed {blocksAgo} on block {blockHeight}.',
blocks_confirmed_fee_summary: 'With a size of {vsize} and a fee rate of {feeRate}, paying {fee} fee.',
},
send: {
create_details: 'Details',