Multi static deposit addresses (#1012)

* Add accumulation address cap helper + pair key (Task 1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add forceNew + cap enforcement + addressesForOption to accumulation hook (Task 2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Guard createAddress against re-entrant double-mint (Task 2 review fix)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add i18n keys for multi accumulation addresses (Task 3)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add accumulation address + option selector half-modals (Task 4)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Reuse getPairKey in option selector modal (Task 4 review fix)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Group accumulation home by option with per-option count (Task 5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Manage multiple addresses + option switching in accumulation detail (Task 6)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Guard accumulation detail against externally-emptied option (Task 6 review fix)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add create-new-address button + direct-address display to deposit QR view (Task 7)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add address-selector list icon to deposit QR header (Task 8)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Cap-gate create wizard + always mint additional address (Task 9)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* remove manual create button in favor of automatic

* fix select address page

* fix opacity

* improve details page

* fixing translations

* adding dislcimer to address details page

* adding reindex scan

* fixing background color

* fixing camel case var name

* minor correctness fixes

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blake Kaufman
2026-07-20 13:36:39 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a5df591ac7
commit cb506c8c11
23 changed files with 1044 additions and 150 deletions
@@ -0,0 +1,51 @@
import {
MAX_ADDRESSES_PER_OPTION_FREE,
MAX_ADDRESSES_PER_OPTION_PREMIUM,
getAccumulationAddressLimit,
getPairKey,
} from '../../app/constants/accumulationAddresses';
describe('getAccumulationAddressLimit', () => {
it('returns 5 for undefined', () => {
expect(getAccumulationAddressLimit(undefined)).toBe(5);
});
it('returns 5 for an empty object', () => {
expect(getAccumulationAddressLimit({})).toBe(5);
});
it('returns 5 when isPremium is false', () => {
expect(getAccumulationAddressLimit({ isPremium: false })).toBe(5);
});
it('returns 20 when isPremium is true', () => {
expect(getAccumulationAddressLimit({ isPremium: true })).toBe(20);
});
});
describe('address cap constants', () => {
it('exposes the free and premium caps', () => {
expect(MAX_ADDRESSES_PER_OPTION_FREE).toBe(5);
expect(MAX_ADDRESSES_PER_OPTION_PREMIUM).toBe(20);
});
});
describe('getPairKey', () => {
it('builds a stable key from sourceChain, sourceAsset, destinationAsset', () => {
expect(
getPairKey({ sourceChain: 'base', sourceAsset: 'USDC', destinationAsset: 'BTC' }),
).toBe('base:USDC:BTC');
});
it('produces equal keys for the same triple', () => {
const a = getPairKey({ sourceChain: 'base', sourceAsset: 'USDC', destinationAsset: 'BTC' });
const b = getPairKey({ sourceChain: 'base', sourceAsset: 'USDC', destinationAsset: 'BTC' });
expect(a).toBe(b);
});
it('produces a different key for a different destination', () => {
const a = getPairKey({ sourceChain: 'base', sourceAsset: 'USDC', destinationAsset: 'BTC' });
const b = getPairKey({ sourceChain: 'base', sourceAsset: 'USDC', destinationAsset: 'USDB' });
expect(a).not.toBe(b);
});
});
@@ -0,0 +1,66 @@
import {
REINDEX_WINDOW_MS,
classifyReindexResponse,
getReindexThrottle,
} from '../../app/constants/accumulationAddresses';
describe('classifyReindexResponse', () => {
it('treats a false wrapper response as error', () => {
expect(classifyReindexResponse(false)).toBe('error');
});
it('treats a backend { error } as error', () => {
expect(classifyReindexResponse({ error: 'boom' })).toBe('error');
});
it('treats an errorCode/errorMessage response as error', () => {
expect(classifyReindexResponse({ errorCode: 'X' })).toBe('error');
expect(classifyReindexResponse({ errorMessage: 'nope' })).toBe('error');
});
it('reports funds when triggeredCurrencies is non-empty', () => {
expect(
classifyReindexResponse({ triggeredCurrencies: ['USDC'] }),
).toBe('funds');
});
it('reports no_funds when a scan ran but nothing triggered', () => {
expect(
classifyReindexResponse({
checkedCurrencies: ['USDC'],
triggeredCurrencies: [],
}),
).toBe('no_funds');
});
it('treats an unknown shape as error', () => {
expect(classifyReindexResponse({})).toBe('error');
});
});
describe('getReindexThrottle', () => {
const addr = 'addr1';
const now = 1_000_000_000;
it('does not throttle a never-checked address', () => {
expect(getReindexThrottle({}, addr, now)).toEqual({
throttled: false,
remainingMs: 0,
});
});
it('throttles within the 15m window and reports remaining time', () => {
const last = now - 60_000; // checked 1 min ago
const result = getReindexThrottle({ [addr]: last }, addr, now);
expect(result.throttled).toBe(true);
expect(result.remainingMs).toBe(REINDEX_WINDOW_MS - 60_000);
});
it('clears once the window has elapsed', () => {
const last = now - REINDEX_WINDOW_MS;
expect(getReindexThrottle({ [addr]: last }, addr, now)).toEqual({
throttled: false,
remainingMs: 0,
});
});
});
@@ -0,0 +1,54 @@
import { resolveCreateAddressAction } from '../../app/constants/accumulationAddresses';
describe('resolveCreateAddressAction', () => {
it('reuses an existing address when not forcing new', () => {
const r = resolveCreateAddressAction({
matching: [{ depositAddress: 'a' }],
forceNew: false,
limit: 5,
});
expect(r).toEqual({ type: 'reuse', address: 'a' });
});
it('forceNew bypasses reuse and mints when below cap', () => {
const r = resolveCreateAddressAction({
matching: [{ depositAddress: 'a' }],
forceNew: true,
limit: 5,
});
expect(r).toEqual({ type: 'mint' });
});
it('forceNew does not bypass the cap', () => {
const matching = Array.from({ length: 5 }, (_, i) => ({
depositAddress: `addr${i}`,
}));
const r = resolveCreateAddressAction({ matching, forceNew: true, limit: 5 });
expect(r).toEqual({ type: 'limit_reached' });
});
it('reuse wins over cap when matching exists without forceNew', () => {
const matching = Array.from({ length: 5 }, (_, i) => ({
depositAddress: `addr${i}`,
}));
const r = resolveCreateAddressAction({ matching, forceNew: false, limit: 5 });
expect(r.type).toBe('reuse');
});
it('mints when below cap with forceNew', () => {
const matching = Array.from({ length: 4 }, (_, i) => ({
depositAddress: `addr${i}`,
}));
const r = resolveCreateAddressAction({ matching, forceNew: true, limit: 5 });
expect(r).toEqual({ type: 'mint' });
});
it('mints when there is no matching address regardless of forceNew', () => {
expect(
resolveCreateAddressAction({ matching: [], forceNew: false, limit: 5 }),
).toEqual({ type: 'mint' });
expect(
resolveCreateAddressAction({ matching: [], forceNew: true, limit: 5 }),
).toEqual({ type: 'mint' });
});
});
@@ -7,13 +7,13 @@ import { Image } from 'expo-image';
import { ICONS, SIZES } from '../../../../constants';
import { useTranslation } from 'react-i18next';
export default function AccumulationAddressCard({ address }) {
export default function AccumulationAddressCard({ group }) {
const navigate = useNavigation();
const { t } = useTranslation();
const chainMeta = ACCUMULATION_CHAINS.find(c => c.id === address.sourceChain);
const chainLabel = chainMeta?.label ?? address.sourceChain ?? '';
const label = `${address.sourceAsset}${
address.destinationAsset === 'BTC'
const chainMeta = ACCUMULATION_CHAINS.find(c => c.id === group.sourceChain);
const chainLabel = chainMeta?.label ?? group.sourceChain ?? '';
const label = `${group.sourceAsset}${
group.destinationAsset === 'BTC'
? t('constants.bitcoin_upper')
: t('constants.dollars_upper')
}`;
@@ -25,7 +25,11 @@ export default function AccumulationAddressCard({ address }) {
activeOpacity={0.7}
style={styles.card}
onPress={() =>
navigate.navigate('AccumulationAddressDetail', { address })
navigate.navigate('AccumulationAddressDetail', {
sourceChain: group.sourceChain,
sourceAsset: group.sourceAsset,
destinationAsset: group.destinationAsset,
})
}
>
<View style={styles.leftSection}>
@@ -38,6 +42,14 @@ export default function AccumulationAddressCard({ address }) {
<View style={styles.middleSection}>
<ThemeText styles={styles.label} content={chainLabel} />
<ThemeText styles={styles.swapMeta} content={label} />
{group.count > 1 && (
<ThemeText
styles={styles.swapMeta}
content={t('screens.accumulationAddresses.addressCount', {
count: group.count,
})}
/>
)}
</View>
<ThemeIcon iconName={'ChevronRight'} size={20} />
</TouchableOpacity>
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
ScrollView,
@@ -8,6 +8,7 @@ import {
} from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { Image } from 'expo-image';
import ViewShot from 'react-native-view-shot';
import * as FileSystem from 'expo-file-system/legacy';
import {
@@ -20,13 +21,36 @@ import ThemeIcon from '../../../../functions/CustomElements/themeIcon';
import { useToast } from '../../../../../context-store/toastManager';
import { copyToClipboard } from '../../../../functions';
import { useAccumulationAddresses } from '../../../../hooks/useAccumulationAddresses';
import { ACCUMULATION_CHAINS } from '../../../../constants/accumulationAddresses';
import { CENTER, COLORS } from '../../../../constants';
import {
ACCUMULATION_CHAINS,
classifyReindexResponse,
getPairKey,
getReindexThrottle,
REINDEX_TIMES_KEY,
} from '../../../../constants/accumulationAddresses';
import {
getLocalStorageItem,
setLocalStorageItem,
} from '../../../../functions/localStorage';
import {
CENTER,
COLORS,
CONTENT_KEYBOARD_OFFSET,
ICONS,
SIZES,
} from '../../../../constants';
import { createPdf } from 'react-native-pdf-from-image';
import { shareFile } from '../../../../functions/handleShare';
import { INSET_WINDOW_WIDTH, SIZES } from '../../../../constants/theme';
import {
HIDDEN_OPACITY,
INSET_WINDOW_WIDTH,
} from '../../../../constants/theme';
import QrCodeWrapper from '../../../../functions/CustomElements/QrWrapper';
import FullLoadingScreen from '../../../../functions/CustomElements/loadingScreen';
import GetThemeColors from '../../../../hooks/themeColors';
import { useGlobalThemeContext } from '../../../../../context-store/theme';
import fetchBackend from '../../../../../db/handleBackend';
import { useKeysContext } from '../../../../../context-store/keys';
function normalizeFileUri(uri) {
return uri.startsWith('file://') ? uri : `file://${uri}`;
@@ -35,31 +59,74 @@ function normalizeFileUri(uri) {
export default function AccumulationAddressDetail() {
const navigate = useNavigation();
const route = useRoute();
const { address } = route.params;
const { sourceChain, sourceAsset, destinationAsset } = route.params;
const triple = { sourceChain, sourceAsset, destinationAsset };
const { t } = useTranslation();
const { showToast } = useToast();
const { deleteAddress } = useAccumulationAddresses();
const { contactsPrivateKey, publicKey } = useKeysContext();
const { textColor, backgroundColor, backgroundOffset } = GetThemeColors();
const { theme, darkModeType } = useGlobalThemeContext();
const { addresses, addressesForOption, deleteAddress } =
useAccumulationAddresses();
const viewShotRef = useRef(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
const [isCheckingBalance, setIsCheckingBalance] = useState(false);
const [selectedId, setSelectedId] = useState(null);
const groupAddresses = addressesForOption(triple);
const selected =
groupAddresses.find(a => a.accumulationAddressId === selectedId) ??
groupAddresses[0];
// Leave the screen if the viewed option is emptied externally (e.g. another
// device's masterInfoObject sync). Ref latch avoids firing on the transient
// pre-hydration mount where addresses is briefly empty.
const hasHadAddressesRef = useRef(false);
useEffect(() => {
if (groupAddresses.length > 0) hasHadAddressesRef.current = true;
else if (hasHadAddressesRef.current && !isDeleting) navigate.goBack();
}, [groupAddresses.length, isDeleting, navigate]);
const chainLabel =
ACCUMULATION_CHAINS.find(c => c.id === address.sourceChain)?.label ??
address.sourceChain;
const depositAddress = address.depositAddress ?? '';
ACCUMULATION_CHAINS.find(c => c.id === selected?.sourceChain)?.label ??
selected?.sourceChain;
const depositAddress = selected?.depositAddress ?? '';
const shortAddress = `${depositAddress.slice(0, 8)}...${depositAddress.slice(
-8,
)}`;
const disclaimer =
selected?.destinationAsset === 'BTC'
? t('wallet.halfModal.depositQRInstruction_accumulation_bitcoin', {
asset: selected?.sourceAsset,
chain: chainLabel,
})
: t('wallet.halfModal.depositQRInstruction_stablecoins', {
asset: selected?.sourceAsset,
chain: chainLabel,
});
const handleCopy = useCallback(() => {
copyToClipboard(depositAddress, showToast);
}, [depositAddress, showToast]);
const handleSelectAddress = useCallback(() => {
navigate.navigate('CustomHalfModal', {
wantedContent: 'accumulationAddressSelect',
sliderHight: 0.5,
addresses: groupAddresses,
selectedId: selected.accumulationAddressId,
onSelect: addr => setSelectedId(addr.accumulationAddressId),
});
}, [navigate, groupAddresses, selected]);
const handlePrint = useCallback(async () => {
try {
setIsPrinting(true);
const imageURI = await viewShotRef.current.capture();
const pdfName = `accumulation-address-${address.accumulationAddressId}.pdf`;
const pdfName = `accumulation-address-${selected.accumulationAddressId}.pdf`;
const response = await createPdf({
imagePaths: [imageURI],
name: pdfName,
@@ -79,7 +146,69 @@ export default function AccumulationAddressDetail() {
} finally {
setIsPrinting(false);
}
}, [address.accumulationAddressId, t]);
}, [selected, t]);
const handleAddressReindex = useCallback(async () => {
if (!depositAddress) return;
const showError = () =>
navigate.navigate('ErrorScreen', {
errorMessage: t('screens.accumulationAddresses.detail.reindexError'),
});
try {
const now = Date.now();
const map =
JSON.parse(await getLocalStorageItem(REINDEX_TIMES_KEY)) || {};
const { throttled, remainingMs } = getReindexThrottle(
map,
depositAddress,
now,
);
if (throttled) {
showToast({
type: 'error',
title: t('screens.accumulationAddresses.detail.reindexThrottled', {
minutes: Math.ceil(remainingMs / 60000),
}),
});
return;
}
setIsCheckingBalance(true);
const reindexResponse = await fetchBackend(
'reindexAccumulationAddress',
{
depositAddress,
requsetTime: now,
},
contactsPrivateKey,
publicKey,
);
const outcome = classifyReindexResponse(reindexResponse);
if (outcome === 'error') {
// Don't burn the 15m window on failure — allow immediate retry.
showError();
return;
}
map[depositAddress] = now;
await setLocalStorageItem(REINDEX_TIMES_KEY, JSON.stringify(map));
showToast({
type: 'error',
title: t(
outcome === 'funds'
? 'screens.accumulationAddresses.detail.reindexFundsFound'
: 'screens.accumulationAddresses.detail.reindexNoFunds',
),
});
} catch (err) {
console.log('reindex error', err);
showError();
} finally {
setIsCheckingBalance(false);
}
}, [depositAddress, t, contactsPrivateKey, publicKey, showToast, navigate]);
const handleDelete = useCallback(() => {
navigate.navigate('ConfirmActionPage', {
@@ -88,10 +217,15 @@ export default function AccumulationAddressDetail() {
),
confirmFunction: async () => {
setIsDeleting(true);
const ok = await deleteAddress(address.accumulationAddressId);
const ok = await deleteAddress(selected.accumulationAddressId);
setIsDeleting(false);
if (ok) {
navigate.goBack();
if (groupAddresses.length > 1) {
const next = groupAddresses.find(
a => a.accumulationAddressId !== selected.accumulationAddressId,
);
setSelectedId(next?.accumulationAddressId ?? null);
}
} else {
navigate.navigate('ErrorScreen', {
errorMessage: t(
@@ -101,9 +235,9 @@ export default function AccumulationAddressDetail() {
}
},
});
}, [address, deleteAddress, navigate, t]);
}, [selected, groupAddresses, deleteAddress, navigate, t]);
if (isDeleting) {
if (isDeleting || !selected) {
return <FullLoadingScreen />;
}
@@ -116,12 +250,90 @@ export default function AccumulationAddressDetail() {
iconNew="Trash2"
leftImageFunction={handleDelete}
textStyles={{ textTransform: 'capitalize' }}
rightContent={
groupAddresses.length >= 1 ? (
<TouchableOpacity
style={{ width: 22, height: 22 }}
onPress={handlePrint}
>
{isPrinting ? (
<FullLoadingScreen
loadingColor={
theme && darkModeType ? COLORS.darkModeText : COLORS.primary
}
size="small"
showText={false}
/>
) : (
<ThemeIcon iconName="Printer" size={22} />
)}
</TouchableOpacity>
) : null
}
/>
<View style={styles.innerContainer}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* Asset flow */}
<View style={styles.assetRow}>
<View style={styles.confirmIconWrapper}>
<View
style={[
styles.confirmChainCircle,
{ backgroundColor: backgroundOffset },
]}
>
<Image
style={styles.confirmChainIcon}
source={ICONS[`chain_${chainLabel.toLowerCase()}`]}
contentFit="contain"
/>
</View>
<View
style={[
styles.confirmCurrencyBadge,
{ borderColor: backgroundColor },
]}
>
<Image
style={styles.confirmCurrencyIcon}
source={ICONS[`${selected.sourceAsset.toLowerCase()}Logo`]}
contentFit="contain"
/>
</View>
</View>
<ThemeIcon styles={{ opacity: 0.7 }} iconName={'ArrowRight'} />
<View style={styles.confirmIconWrapper}>
<View
style={[
styles.confirmChainCircle,
{
backgroundColor:
theme && darkModeType
? backgroundOffset
: selected.destinationAsset === 'BTC'
? COLORS.bitcoinOrange
: COLORS.dollarGreen,
},
]}
>
<Image
style={[styles.confirmChainIcon, { width: 30, height: 30 }]}
source={
ICONS[
selected.destinationAsset === 'BTC'
? 'bitcoinIcon'
: 'dollarIcon'
]
}
contentFit="contain"
/>
</View>
</View>
</View>
{/* QR Code */}
<ViewShot ref={viewShotRef} options={{ format: 'jpg', quality: 0.9 }}>
<QrCodeWrapper
@@ -136,55 +348,36 @@ export default function AccumulationAddressDetail() {
{/* Address display */}
<TouchableOpacity
activeOpacity={0.7}
style={styles.addressRow}
style={[styles.addressRow, { backgroundColor: backgroundOffset }]}
onPress={handleCopy}
>
<ThemeText content={shortAddress} />
<ThemeIcon iconName="Copy" size={18} />
</TouchableOpacity>
{/* Metadata */}
<View style={styles.metaRow}>
<MetaCell
label={t('screens.accumulationAddresses.detail.chain')}
value={chainLabel}
textStyles={{ textTransform: 'capitalize' }}
/>
<MetaCell
label={t('screens.accumulationAddresses.detail.source')}
value={address.sourceAsset}
/>
<MetaCell
label={t('screens.accumulationAddresses.detail.Destination')}
value={
address.destinationAsset === 'BTC'
? t('constants.bitcoin_upper')
: t('constants.dollars_upper')
}
/>
</View>
</ScrollView>
{/* Print button */}
<CustomButton
buttonStyles={styles.printBtn}
textContent={t('screens.accumulationAddresses.detail.printQR')}
actionFunction={handlePrint}
useLoading={isPrinting}
textContent={t('screens.accumulationAddresses.detail.reindex')}
actionFunction={handleAddressReindex}
useLoading={isCheckingBalance}
/>
{/* View all address button*/}
{groupAddresses.length > 1 && (
<CustomButton
buttonStyles={styles.viewAllBtn}
textStyles={{ color: textColor }}
textContent={t('screens.accumulationAddresses.detail.viewAll')}
actionFunction={handleSelectAddress}
/>
)}
{/* Disclaimer */}
<ThemeText styles={styles.disclaimer} content={disclaimer} />
</View>
</GlobalThemeView>
);
}
function MetaCell({ label, value, textStyles = {} }) {
return (
<View style={styles.metaCell}>
<ThemeText styles={styles.metaLabel} content={label} />
<ThemeText styles={[styles.metaValue, textStyles]} content={value} />
</View>
);
}
const styles = StyleSheet.create({
innerContainer: {
flex: 1,
@@ -205,22 +398,56 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 20,
marginTop: 20,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 50,
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
marginBottom: 24,
},
metaCell: { alignItems: 'center' },
metaLabel: { fontSize: SIZES.small, opacity: 0.6, marginBottom: 2 },
metaValue: {
fontSize: SIZES.smedium,
disclaimer: {
fontSize: SIZES.small,
opacity: HIDDEN_OPACITY,
textAlign: 'center',
marginTop: 12,
includeFontPadding: false,
},
printBtn: { width: '100%' },
assetRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginVertical: 10,
},
confirmIconWrapper: {
position: 'relative',
},
confirmChainCircle: {
width: 50,
height: 50,
borderRadius: 45,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
confirmChainIcon: {
width: 50,
height: 50,
},
confirmCurrencyBadge: {
position: 'absolute',
width: 25,
height: 25,
borderRadius: 15,
bottom: -4,
right: -4,
borderWidth: 2,
overflow: 'hidden',
},
confirmCurrencyIcon: {
width: '100%',
height: '100%',
},
printBtn: { width: '100%', marginTop: CONTENT_KEYBOARD_OFFSET },
viewAllBtn: { backgroundColor: 'transparent' },
optionBtn: { width: '100%', marginBottom: 10 },
deleteBtn: {
width: '100%',
backgroundColor: COLORS.cancelRed,
@@ -0,0 +1,109 @@
import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ThemeText } from '../../../../functions/CustomElements';
import ThemeIcon from '../../../../functions/CustomElements/themeIcon';
import { CENTER, SIZES } from '../../../../constants';
import { COLORS, INSET_WINDOW_WIDTH } from '../../../../constants/theme';
import { useGlobalThemeContext } from '../../../../../context-store/theme';
import GetThemeColors from '../../../../hooks/themeColors';
export default function AccumulationAddressSelectHalfModal({
addresses,
selectedId,
onSelect,
handleBackPressFunction,
setContentHeight,
}) {
const { theme, darkModeType } = useGlobalThemeContext();
const { backgroundColor, backgroundOffset } = GetThemeColors();
const { t } = useTranslation();
useEffect(() => {
setContentHeight(450);
}, [setContentHeight]);
const handleSelect = addr => {
onSelect(addr);
handleBackPressFunction();
};
return (
<View style={styles.container}>
<ThemeText
styles={styles.title}
content={t('screens.accumulationAddresses.detail.selectAddress')}
/>
<ScrollView showsVerticalScrollIndicator={false}>
{(addresses || []).map(addr => {
const depositAddress = addr.depositAddress ?? '';
const shortAddress = `${depositAddress.slice(
0,
8,
)}${depositAddress.slice(-6)}`;
const isSelected = addr.accumulationAddressId === selectedId;
return (
<TouchableOpacity
disabled={isSelected}
key={addr.accumulationAddressId}
activeOpacity={0.7}
style={[
styles.row,
{
backgroundColor: isSelected
? theme && darkModeType
? backgroundColor
: backgroundOffset
: 'transparent',
borderWidth: isSelected ? 1 : 0,
borderColor:
theme && darkModeType
? COLORS.darkModeText
: COLORS.primary,
},
]}
onPress={() => handleSelect(addr)}
>
<ThemeText styles={styles.addressText} content={shortAddress} />
{!isSelected && <ThemeIcon size={20} iconName={'ChevronRight'} />}
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: INSET_WINDOW_WIDTH,
...CENTER,
},
title: {
fontSize: SIZES.large,
fontWeight: 500,
marginBottom: 10,
includeFontPadding: false,
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 50,
padding: 16,
borderRadius: 16,
borderWidth: 1,
},
addressText: {
includeFontPadding: false,
},
rightSection: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
},
copyButton: {
padding: 4,
},
});
@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { FlatList, StyleSheet, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
@@ -16,6 +16,7 @@ import { useAccumulationAddresses } from '../../../../hooks/useAccumulationAddre
import { useAppStatus } from '../../../../../context-store/appStatus';
import { INSET_WINDOW_WIDTH } from '../../../../constants/theme';
import { CENTER, CONTENT_KEYBOARD_OFFSET, SIZES } from '../../../../constants';
import { getPairKey } from '../../../../constants/accumulationAddresses';
export default function AccumulationAddressesHome() {
const navigate = useNavigation();
@@ -36,8 +37,28 @@ export default function AccumulationAddressesHome() {
});
}, [isConnectedToTheInternet, navigate, t]);
const groups = useMemo(() => {
const byKey = new Map();
for (const address of addresses) {
const key = getPairKey(address);
const existing = byKey.get(key);
if (existing) {
existing.count += 1;
} else {
byKey.set(key, {
sourceChain: address.sourceChain,
sourceAsset: address.sourceAsset,
destinationAsset: address.destinationAsset,
representative: address,
count: 1,
});
}
}
return Array.from(byKey.values());
}, [addresses]);
const renderAddressItem = useCallback(({ item }) => {
return <AccumulationAddressCard address={item} />;
return <AccumulationAddressCard group={item} />;
}, []);
const renderEmptyState = () => (
@@ -56,11 +77,9 @@ export default function AccumulationAddressesHome() {
<View style={styles.innerContainer}>
<FlatList
data={addresses}
data={groups}
renderItem={renderAddressItem}
keyExtractor={(item, index) =>
item.accumulationAddressId ?? `fallback-${index}`
}
keyExtractor={item => getPairKey(item)}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmptyState}
@@ -17,6 +17,7 @@ import {
ACCUMULATION_CHAINS,
ACCUMULATION_DESTINATIONS,
getChainExpandHeight,
getAccumulationAddressLimit,
} from '../../../../constants/accumulationAddresses';
import { CENTER, ICONS } from '../../../../constants';
import { Image } from 'expo-image';
@@ -31,6 +32,7 @@ import Animated, {
import { useGlobalInsets } from '../../../../../context-store/insetsProvider';
import ChainRow from './chainRow';
import useExpandAutoScroll from '../../../../hooks/useExpandAutoScroll';
import { useGlobalContextProvider } from '../../../../../context-store/context';
// Steps: 'chain' | 'destination' | 'confirm'
const STEPS = ['chain', 'destination', 'confirm'];
@@ -46,6 +48,7 @@ export default function CreateAccumulationAddressModal({
const { theme, darkModeType } = useGlobalThemeContext();
const { backgroundOffset, textColor, backgroundColor } = GetThemeColors();
const { addresses, createAddress } = useAccumulationAddresses();
const { masterInfoObject } = useGlobalContextProvider();
const { bottomPadding } = useGlobalInsets();
const [step, setStep] = useState('chain');
@@ -58,15 +61,15 @@ export default function CreateAccumulationAddressModal({
const [isCreating, setIsCreating] = useState(false);
const [mountedSteps, setMountedSteps] = useState(() => new Set(['chain']));
const isPairTaken = useCallback(
const isPairAtCap = useCallback(
(chainId, asset, dest) =>
addresses.some(
addresses.filter(
a =>
a.sourceChain === chainId &&
a.sourceAsset === asset &&
a.destinationAsset === dest,
),
[addresses],
).length >= getAccumulationAddressLimit(masterInfoObject),
[addresses, masterInfoObject],
);
const { scrollViewRef, handleRowLayout, onScroll, onLayout } =
@@ -182,10 +185,15 @@ export default function CreateAccumulationAddressModal({
sourceChain: selectedChain.id,
sourceAsset: selectedAsset,
destinationAsset: selectedDestination,
forceNew: true,
});
setIsCreating(false);
if (result?.address || result?.error === 'already_exists') {
if (result?.address) {
handleBackPressFunction();
} else if (result?.error === 'limit_reached') {
navigate.navigate('ErrorScreen', {
errorMessage: t('screens.accumulationAddresses.create.limitReached'),
});
} else {
navigate.navigate('ErrorScreen', {
errorMessage: t('screens.accumulationAddresses.errors.createFailed'),
@@ -239,13 +247,13 @@ export default function CreateAccumulationAddressModal({
}}
isAssetTaken={
forcedDestination
? asset => isPairTaken(chain.id, asset, forcedDestination)
? asset => isPairAtCap(chain.id, asset, forcedDestination)
: () => false
}
onDisabledAssetPress={() =>
navigate.navigate('ErrorScreen', {
errorMessage: t(
'screens.accumulationAddresses.create.alreadyExists',
'screens.accumulationAddresses.create.limitReached',
),
})
}
@@ -269,20 +277,20 @@ export default function CreateAccumulationAddressModal({
pointerEvents={step === 'destination' ? 'auto' : 'none'}
>
{ACCUMULATION_DESTINATIONS.map(dest => {
const taken = isPairTaken(selectedChain?.id, selectedAsset, dest);
const atCap = isPairAtCap(selectedChain?.id, selectedAsset, dest);
return (
<TouchableOpacity
key={dest}
activeOpacity={taken ? HIDDEN_OPACITY : 0.2}
activeOpacity={atCap ? HIDDEN_OPACITY : 0.2}
style={[
styles.optionRow,
{ opacity: taken ? HIDDEN_OPACITY : 1 },
{ opacity: atCap ? HIDDEN_OPACITY : 1 },
]}
onPress={() => {
if (taken) {
if (atCap) {
navigate.navigate('ErrorScreen', {
errorMessage: t(
'screens.accumulationAddresses.create.alreadyExists',
'screens.accumulationAddresses.create.limitReached',
),
});
return;
@@ -89,7 +89,7 @@ export default function ChainRow({
</View>
<ThemeText styles={styles.optionLabel} content={chain.label} />
<Animated.View style={[{ opacity: HIDDEN_OPACITY }, chevronStyle]}>
<Animated.View style={[chevronStyle]}>
<ThemeIcon
iconName={disableExpand ? 'ChevronRight' : 'ChevronDown'}
size={18}
@@ -73,7 +73,7 @@ export default function DepositQRView({
const { isUsingAltAccount, currentWalletMnemoinc } =
useActiveCustodyAccount();
const { contactsPrivateKey, publicKey: contactsPublicKey } = useKeysContext();
const { createAddress } = useAccumulationAddresses();
const { createAddress, addressesForOption } = useAccumulationAddresses();
const { minMaxLiquidSwapAmounts, screenDimensions } = useAppStatus();
const { bottomPadding } = useGlobalInsets();
@@ -105,11 +105,92 @@ export default function DepositQRView({
if (isActive) setContentHeight(700);
}, [isActive]);
const handleCreateNew = async cancelled => {
const triple = {
sourceChain: config.sourceChain,
sourceAsset: config.sourceAsset,
destinationAsset: config.destinationAsset,
};
setAddressState({
generatedAddress: '',
isGeneratingInvoice: true,
errorMessageText: { type: null, text: '' },
fee: 0,
});
try {
if (config.depositAddress) {
setAddressState(prev => ({
...prev,
generatedAddress: config.depositAddress,
isGeneratingInvoice: false,
}));
return;
}
const result = await createAddress({ ...triple, forceNew: true });
// At the cap: fall back to an already-saved address instead of erroring.
if (result?.error === 'limit_reached') {
const saved = addressesForOption(triple)[0]?.depositAddress;
if (saved) {
setAddressState(prev => ({
...prev,
generatedAddress: saved,
isGeneratingInvoice: false,
}));
return;
}
if (cancelled) return;
setAddressState(prev => ({ ...prev, isGeneratingInvoice: false }));
navigate.navigate('ErrorScreen', {
errorMessage: t('screens.accumulationAddresses.create.limitReached'),
});
return;
}
if (result?.error) {
const saved = addressesForOption(triple)[0]?.depositAddress;
if (saved) {
if (cancelled) return;
setAddressState(prev => ({
...prev,
generatedAddress: saved,
isGeneratingInvoice: false,
}));
return;
}
if (cancelled) return;
setAddressState(prev => ({ ...prev, isGeneratingInvoice: false }));
navigate.navigate('ErrorScreen', {
errorMessage: t('screens.accumulationAddresses.errors.createFailed'),
});
return;
}
const newAddress =
typeof result.address === 'string'
? result.address
: result.address?.depositAddress;
if (cancelled) return;
setAddressState(prev => ({
...prev,
generatedAddress: newAddress || '',
isGeneratingInvoice: false,
}));
} catch {
if (cancelled) return;
setAddressState(prev => ({ ...prev, isGeneratingInvoice: false }));
}
};
useEffect(() => {
if (!config) return;
let cancelled = false;
if (config.selectedRecieveOption?.toLowerCase() === 'stablecoins') {
handleCreateNew(cancelled);
return; // skip initializeAddressProcess; a specific address was picked from the selector
}
setAddressState({
generatedAddress: '',
isGeneratingInvoice: true,
@@ -24,6 +24,7 @@ import { useAppStatus } from '../../../../../context-store/appStatus';
import SelectOtherReceiveOptionHalfModal from './halfModalOtherOptions';
import AddFundsFromBankHalfModal from './halfModalBank';
import DepositQRView from './depositQRView';
import { useAccumulationAddresses } from '../../../../hooks/useAccumulationAddresses';
const capitalize = value =>
value ? value[0].toUpperCase() + value.slice(1) : '';
@@ -45,6 +46,7 @@ export default function HalfModalDepositFunds({
const { t } = useTranslation();
const { backgroundColor, backgroundOffset, textColor } = GetThemeColors();
const { bottomPadding, screenDimensions } = useAppStatus();
const { addressesForOption } = useAccumulationAddresses();
// Fix 1: Separate shared values per subview so exit animation can play
const stablecoinsOpacity = useSharedValue(0);
@@ -143,6 +145,32 @@ export default function HalfModalDepositFunds({
[showView],
);
// Addresses for the current qr option (only populated for the stablecoins subview).
const qrGroupAddresses = useMemo(() => {
if (activeView !== 'qr') return [];
const option = qrConfig?.selectedRecieveOption?.toLowerCase();
if (option !== 'stablecoins') return [];
return addressesForOption({
sourceChain: qrConfig.sourceChain,
sourceAsset: qrConfig.sourceAsset,
destinationAsset: qrConfig.destinationAsset,
});
}, [activeView, qrConfig, addressesForOption]);
const openAddressSelector = useCallback(() => {
const currentId = qrGroupAddresses.find(
a => a.depositAddress === qrConfig?.depositAddress,
)?.accumulationAddressId;
navigate.push('CustomHalfModal', {
wantedContent: 'accumulationAddressSelect',
sliderHight: 0.5,
addresses: qrGroupAddresses,
selectedId: currentId || qrGroupAddresses[0]?.accumulationAddressId,
onSelect: addr =>
handleShowQR({ ...qrConfig, depositAddress: addr.depositAddress }),
});
}, [navigate, qrGroupAddresses, qrConfig, handleShowQR]);
const handleStepBack = useCallback(() => {
if (activeView === 'options') return false;
if (activeView === 'stablecoins' && expandedChain) {
@@ -193,10 +221,35 @@ export default function HalfModalDepositFunds({
if (activeView === 'options') {
setBackNav?.(null);
} else {
setBackNav?.({ onPress: handleStepBack, title: headerTitle });
setBackNav?.({
onPress: handleStepBack,
title: headerTitle,
rightElement:
activeView === 'qr' && qrGroupAddresses.length > 1 ? (
<TouchableOpacity
style={[
styles.backButtonCircle,
{
backgroundColor:
theme && darkModeType ? backgroundColor : backgroundOffset,
},
]}
onPress={openAddressSelector}
>
<ThemeIcon iconName="Menu" size={22} />
</TouchableOpacity>
) : undefined,
});
}
return () => setBackNav?.(null);
}, [activeView, headerTitle, handleStepBack, setBackNav]);
}, [
activeView,
headerTitle,
handleStepBack,
setBackNav,
qrGroupAddresses,
openAddressSelector,
]);
return (
<View style={styles.container}>
@@ -249,9 +302,8 @@ export default function HalfModalDepositFunds({
content={t('wallet.halfModal.lightningInvoiceSubtitle')}
/>
</View>
<View style={{ opacity: HIDDEN_OPACITY }}>
<ThemeIcon iconName={'ChevronRight'} size={18} />
</View>
<ThemeIcon iconName={'ChevronRight'} size={18} />
</TouchableOpacity>
)}
@@ -286,9 +338,8 @@ export default function HalfModalDepositFunds({
content={t('wallet.halfModal.onChainBitcoinSubtitle')}
/>
</View>
<View style={{ opacity: HIDDEN_OPACITY }}>
<ThemeIcon iconName={'ChevronRight'} size={18} />
</View>
<ThemeIcon iconName={'ChevronRight'} size={18} />
</TouchableOpacity>
{/* Stablecoins */}
@@ -322,9 +373,8 @@ export default function HalfModalDepositFunds({
content={t('wallet.halfModal.stablecoinsSubtitle')}
/>
</View>
<View style={{ opacity: HIDDEN_OPACITY }}>
<ThemeIcon iconName={'ChevronRight'} size={18} />
</View>
<ThemeIcon iconName={'ChevronRight'} size={18} />
</TouchableOpacity>
{/* Other Bitcoin */}
@@ -357,9 +407,8 @@ export default function HalfModalDepositFunds({
content={t('wallet.halfModal.otherBitcoinSubtitle')}
/>
</View>
<View style={{ opacity: HIDDEN_OPACITY }}>
<ThemeIcon iconName={'ChevronRight'} size={18} />
</View>
<ThemeIcon iconName={'ChevronRight'} size={18} />
</TouchableOpacity>
</ScrollView>
</Animated.View>
@@ -455,6 +504,13 @@ const styles = StyleSheet.create({
...CENTER,
flex: 1,
},
backButtonCircle: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
animatedContainer: {
flex: 1,
},
@@ -93,9 +93,7 @@ function ChainRow({
/>
</View>
<View style={{ opacity: HIDDEN_OPACITY }}>
<ThemeIcon iconName="ChevronRight" size={18} />
</View>
<ThemeIcon iconName="ChevronRight" size={18} />
</TouchableOpacity>
);
}
+56
View File
@@ -18,3 +18,59 @@ export const getChainExpandHeight = chainId => {
if (!chain) return null;
return chain.assets.length * CHAIN_ASSET_ROW_HEIGHT + CHAIN_EXPAND_PADDING;
};
export const MAX_ADDRESSES_PER_OPTION_FREE = 5;
export const MAX_ADDRESSES_PER_OPTION_PREMIUM = 20;
// ponytail: premium flag not built yet; reads a future masterInfoObject field.
// When premium ships, only this function changes.
export const getAccumulationAddressLimit = masterInfoObject =>
masterInfoObject?.isPremium
? MAX_ADDRESSES_PER_OPTION_PREMIUM
: MAX_ADDRESSES_PER_OPTION_FREE;
// Stable key identifying one option.
export const getPairKey = a =>
`${a.sourceChain}:${a.sourceAsset}:${a.destinationAsset}`;
// Pure decision logic for createAddress (reuse vs cap vs mint).
export const resolveCreateAddressAction = ({ matching, forceNew, limit }) => {
if (!forceNew && matching.length)
return { type: 'reuse', address: matching[0].depositAddress };
if (matching.length >= limit) return { type: 'limit_reached' };
return { type: 'mint' };
};
// Reindex throttle: one rescan per address per 15 min so a user tapping the
// button repeatedly can't spam FlashNet.
export const REINDEX_WINDOW_MS = 15 * 60 * 1000;
export const REINDEX_TIMES_KEY = 'ACCUMULATION_REINDEX_TIMES';
// map: { [depositAddress]: windowStartMs }
export const getReindexThrottle = (map, address, now) => {
const last = map?.[address];
if (!last) return { throttled: false, remainingMs: 0 };
const remainingMs = REINDEX_WINDOW_MS - (now - last);
return remainingMs > 0
? { throttled: true, remainingMs }
: { throttled: false, remainingMs: 0 };
};
// Robust to FlashNet's unknown `success`/`ok` shape: key off the currency
// arrays + explicit error fields rather than trusting `success` alone.
export const classifyReindexResponse = response => {
if (
!response ||
response.error ||
response.errorCode ||
response.errorMessage
)
return 'error';
if (
Array.isArray(response.triggeredCurrencies) &&
response.triggeredCurrencies.length
)
return 'funds';
if (response.checkedCurrencies) return 'no_funds';
return 'error'; // unknown shape → treat as error, don't burn the window
};
+12
View File
@@ -44,6 +44,7 @@ import ViewGiftCardCodePage from '../../components/admin/homeComponents/contacts
import ViewAllGiftCards from '../../components/admin/homeComponents/contacts/viewAllGiftCards';
import ViewAllTokensHalfModal from '../../components/admin/homeComponents/homeLightning/viewAllTokensHalfModal';
import CreateAccumulationAddressModal from '../../components/admin/homeComponents/accumulationAddresses/CreateAccumulationAddressModal';
import AccumulationAddressSelectHalfModal from '../../components/admin/homeComponents/accumulationAddresses/AccumulationAddressSelectHalfModal';
import Animated, {
useSharedValue,
@@ -698,6 +699,17 @@ export default function CustomHalfModal(props) {
setBackNav={setBackNav}
/>
);
case 'accumulationAddressSelect':
return (
<AccumulationAddressSelectHalfModal
addresses={props?.route?.params?.addresses}
selectedId={props?.route?.params?.selectedId}
onSelect={props?.route?.params?.onSelect}
handleBackPressFunction={handleBackPressFunction}
setContentHeight={setContentHeight}
/>
);
case 'RemoveBudgetHalfModal':
return (
<RemoveBudgetHalfModal
+78 -29
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { useGlobalContextProvider } from '../../context-store/context';
import { useKeysContext } from '../../context-store/keys';
import {
@@ -10,6 +10,10 @@ import {
deriveSparkAddress,
deriveSparkIdentityKey,
} from '../functions/gift/deriveGiftWallet';
import {
getAccumulationAddressLimit,
resolveCreateAddressAction,
} from '../constants/accumulationAddresses';
export function useAccumulationAddresses() {
const { masterInfoObject, toggleMasterInfoObject } =
@@ -17,6 +21,10 @@ export function useAccumulationAddresses() {
const { contactsPrivateKey, publicKey, accountMnemoinc } = useKeysContext();
// Serialize mints so two rapid taps can't both read a pre-cap count and
// double-mint (cap bypass) or clobber each other's persist write.
const isCreatingRef = useRef(false);
// Decrypt the stored addresses array
const addresses = useMemo(() => {
const raw = masterInfoObject?.accumulationAddresses;
@@ -46,46 +54,87 @@ export function useAccumulationAddresses() {
[contactsPrivateKey, publicKey, toggleMasterInfoObject],
);
// Addresses matching a given (sourceChain, sourceAsset, destinationAsset) option.
const addressesForOption = useCallback(
({ sourceChain, sourceAsset, destinationAsset }) =>
addresses.filter(
addr =>
addr?.sourceChain === sourceChain &&
addr?.sourceAsset === sourceAsset &&
addr?.destinationAsset === destinationAsset,
),
[addresses],
);
// Create a new address
const createAddress = useCallback(
async ({ sourceChain, sourceAsset, destinationAsset }) => {
async ({
sourceChain,
sourceAsset,
destinationAsset,
forceNew = false,
}) => {
if (!contactsPrivateKey || !publicKey) return { error: 'no_keys' };
try {
const alreadySaved = addresses.find(
addr =>
addr?.sourceAsset === sourceAsset &&
addr?.sourceChain === sourceChain &&
addr?.destinationAsset === destinationAsset,
);
const matching = addressesForOption({
sourceChain,
sourceAsset,
destinationAsset,
});
if (alreadySaved) return { address: alreadySaved?.depositAddress };
const action = resolveCreateAddressAction({
matching,
forceNew,
limit: getAccumulationAddressLimit(masterInfoObject),
});
const identityKey = await deriveSparkIdentityKey(accountMnemoinc, 1);
const sparkAddress = await deriveSparkAddress(identityKey.publicKey);
// reuse/limit_reached are synchronous and never lock, so legitimate
// reuse keeps working even while a mint is in flight.
if (action.type === 'reuse') return { address: action.address };
if (action.type === 'limit_reached') return { error: 'limit_reached' };
const result = await fetchBackend(
'createAccumulationAddress',
{
sourceChain,
sourceAsset,
destinationAsset,
recipientSparkAddress: sparkAddress.address,
},
contactsPrivateKey,
publicKey,
);
// Only the async mint path is serialized. A second concurrent tap bails
// out rather than reading the same stale count and double-minting.
if (isCreatingRef.current) return { error: 'in_progress' };
isCreatingRef.current = true;
try {
const identityKey = await deriveSparkIdentityKey(accountMnemoinc, 1);
const sparkAddress = await deriveSparkAddress(identityKey.publicKey);
if (!result || result.error) return { error: 'create_failed' };
// result is the new address object from the server
const updated = [...addresses, result];
await persistAddresses(updated);
return { address: result };
const result = await fetchBackend(
'createAccumulationAddress',
{
sourceChain,
sourceAsset,
destinationAsset,
recipientSparkAddress: sparkAddress.address,
},
contactsPrivateKey,
publicKey,
);
if (!result || result.error) return { error: 'create_failed' };
// result is the new address object from the server
const updated = [...addresses, result];
await persistAddresses(updated);
return { address: result };
} finally {
isCreatingRef.current = false;
}
} catch (err) {
console.log('createAddress error', err);
return { error: 'create_failed' };
}
},
[contactsPrivateKey, publicKey, addresses, persistAddresses],
[
contactsPrivateKey,
publicKey,
addresses,
addressesForOption,
masterInfoObject,
accountMnemoinc,
persistAddresses,
],
);
// Delete an address
@@ -112,5 +161,5 @@ export function useAccumulationAddresses() {
[contactsPrivateKey, publicKey, addresses, persistAddresses],
);
return { addresses, createAddress, deleteAddress };
return { addresses, addressesForOption, createAddress, deleteAddress };
}
+14 -2
View File
@@ -1051,6 +1051,7 @@
"depositQRError": "Beim Erstellen Ihrer {{addressType}}-Adresse ist ein Problem aufgetreten.",
"changeReceiveCurrency": "Empfangswährung ändern",
"depositQRInstruction_stablecoins": "Senden Sie nur {{asset}} über {{chain}} an diese Adresse. Der Betrag wird direkt Ihrem Dollar-Guthaben gutgeschrieben.",
"depositQRInstruction_accumulation_bitcoin": "Senden Sie nur {{asset}} über {{chain}} an diese Adresse. Der Betrag wird direkt Ihrem BTC-Guthaben gutgeschrieben.",
"depositQRInstruction_bitcoin": "Senden Sie nur Bitcoin an diese Adresse. Das Geld kommt nach drei On-Chain-Bestätigungen an.",
"depositQRInstruction_liquid": "Senden Sie nur Liquid Bitcoin an diese Adresse. Der Betrag wird direkt Ihrem Bitcoin-Guthaben gutgeschrieben.",
"depositQRInstruction_rootstock": "Senden Sie nur Rootstock Bitcoin an diese Adresse. Der Betrag wird direkt Ihrem Bitcoin-Guthaben gutgeschrieben.",
@@ -2624,6 +2625,7 @@
"subtitle": "Erstellen Sie eine Adresse, um Stablecoin-Einzahlungen automatisch in Bitcoin oder Dollar umzuwandeln"
},
"createButton": "Adresse erstellen",
"addressCount": "{{count}} Adressen",
"info": {
"title": "Was ist das?",
"whatAre": "Was ist das?",
@@ -2635,13 +2637,21 @@
},
"detail": {
"copyAddress": "Adresse kopieren",
"reindex": "Kontostand prüfen",
"reindexThrottled": "Diese Adresse wurde bereits geprüft. Versuchen Sie es in {{minutes}} Min. erneut.",
"reindexFundsFound": "Guthaben gefunden, es wird in Kürze Ihrer Wallet gutgeschrieben.",
"reindexNoFunds": "Für diese Adresse wurden keine neuen Guthaben gefunden.",
"reindexError": "Diese Adresse konnte nicht geprüft werden. Bitte versuchen Sie es später erneut.",
"printQR": "QR-Code drucken",
"deleteButton": "Adresse löschen",
"deleteConfirmTitle": "Adresse löschen?",
"deleteConfirmBody": "Das Löschen dieser Adresse verhindert zukünftige Weiterleitungen von Zahlungen und kann zu verlorenen Geldern führen. Stellen Sie sicher, dass alle wissen, dass sie nicht mehr aktiv ist, bevor Sie sie entfernen.",
"chain": "Netzwerk",
"source": "Quelle",
"Destination": "Ziel"
"Destination": "Ziel",
"selectAnotherOption": "Andere Option auswählen",
"selectAddress": "Adresse auswählen",
"viewAll": "Alle Adressen anzeigen"
},
"create": {
"pickAsset": "Zahlungswährung auswählen",
@@ -2649,7 +2659,9 @@
"pickDestination": "Ziel auswählen",
"confirm": "Adresse bestätigen",
"alreadyExists": "Sie haben bereits eine Adresse für diese Kombination",
"convertDesc": "Ihre {{asset}} auf {{chain}} werden in {{receiveCurrency}} umgewandelt"
"convertDesc": "Ihre {{asset}} auf {{chain}} werden in {{receiveCurrency}} umgewandelt",
"createNewAddress": "Neue Adresse erstellen",
"limitReached": "Sie haben die maximale Anzahl an Adressen für diese Option erreicht."
},
"summary": {
"chain": "Netzwerk",
+14 -2
View File
@@ -1051,6 +1051,7 @@
"depositQRError": "We ran into a problem when generating your {{addressType}} address",
"changeReceiveCurrency": "Change receive currency",
"depositQRInstruction_stablecoins": "Only send {{asset}} on {{chain}} to this address, you will receive it directly to your Dollar balance",
"depositQRInstruction_accumulation_bitcoin": "Only send {{asset}} on {{chain}} to this address, you will receive it directly to your Bitcoin balance",
"depositQRInstruction_bitcoin": "Only send Bitcoin to this address. Funds arrive after three on-chain confirmations.",
"depositQRInstruction_liquid": "Only send Liquid Bitcoin to this address, you will receive it directly to your Bitcoin balance",
"depositQRInstruction_rootstock": "Only send Rootstock Bitcoin to this address, you will receive it directly to your Bitcoin balance.",
@@ -2624,6 +2625,7 @@
"subtitle": "Create an address to auto-convert deposits into Bitcoin or Dollars"
},
"createButton": "Create Address",
"addressCount": "{{count}} addresses",
"info": {
"title": "What are they?",
"whatAre": "What are they?",
@@ -2635,13 +2637,21 @@
},
"detail": {
"copyAddress": "Copy Address",
"reindex": "Check balance",
"reindexThrottled": "Already checked this address. Try again in {{minutes}} min.",
"reindexFundsFound": "Funds found, they'll arrive in your wallet shortly.",
"reindexNoFunds": "No new funds found for this address.",
"reindexError": "Couldn't check this address. Please try again later.",
"printQR": "Print QR",
"deleteButton": "Delete Address",
"deleteConfirmTitle": "Delete Address?",
"deleteConfirmBody": "Deleting this address stops future payments from being redirected and may result in lost funds. Make sure everyone knows its no longer active before removing it.",
"chain": "Chain",
"source": "Source",
"Destination": "Destination"
"Destination": "Destination",
"selectAnotherOption": "Select Another Option",
"selectAddress": "Select Address",
"viewAll": "View All Addresses"
},
"create": {
"pickAsset": "Select Payment Currency",
@@ -2649,7 +2659,9 @@
"pickDestination": "Select Destination",
"confirm": "Confirm Address",
"alreadyExists": "You already have an address for this combination",
"convertDesc": "Your {{chain}} {{asset}} will be converted into {{receiveCurrency}}"
"convertDesc": "Your {{chain}} {{asset}} will be converted into {{receiveCurrency}}",
"createNewAddress": "Create New Address",
"limitReached": "You've reached the maximum number of addresses for this option."
},
"summary": {
"chain": "Chain",
+14 -2
View File
@@ -1051,6 +1051,7 @@
"depositQRError": "Hubo un problema al generar tu dirección de {{addressType}}",
"changeReceiveCurrency": "Cambiar moneda de recepción",
"depositQRInstruction_stablecoins": "Envía solo {{asset}} en {{chain}} a esta dirección. Lo recibirás directamente en tu saldo en dólares",
"depositQRInstruction_accumulation_bitcoin": "Envía solo {{asset}} en {{chain}} a esta dirección. Lo recibirás directamente en tu saldo en bitcoin",
"depositQRInstruction_bitcoin": "Envía solo Bitcoin a esta dirección. Los fondos llegan después de tres confirmaciones en la cadena.",
"depositQRInstruction_liquid": "Envía solo Liquid Bitcoin a esta dirección. Lo recibirás directamente en tu saldo de Bitcoin",
"depositQRInstruction_rootstock": "Envía solo Rootstock Bitcoin a esta dirección. Lo recibirás directamente en tu saldo de Bitcoin.",
@@ -2624,6 +2625,7 @@
"subtitle": "Crea una dirección para convertir automáticamente depósitos en Bitcoin o dólares"
},
"createButton": "Crear dirección",
"addressCount": "{{count}} direcciones",
"info": {
"title": "¿Qué son?",
"whatAre": "¿Qué son?",
@@ -2635,13 +2637,21 @@
},
"detail": {
"copyAddress": "Copiar dirección",
"reindex": "Comprobar saldo",
"reindexThrottled": "Esta dirección ya se comprobó. Inténtalo de nuevo en {{minutes}} min.",
"reindexFundsFound": "Se encontraron fondos. Llegarán a tu billetera en breve.",
"reindexNoFunds": "No se encontraron fondos nuevos para esta dirección.",
"reindexError": "No se pudo comprobar esta dirección. Inténtalo de nuevo más tarde.",
"printQR": "Imprimir QR",
"deleteButton": "Eliminar dirección",
"deleteConfirmTitle": "¿Eliminar dirección?",
"deleteConfirmBody": "Eliminar esta dirección detiene la redirección de futuros pagos y puede resultar en pérdida de fondos. Asegúrate de que todos sepan que ya no está activa antes de eliminarla.",
"chain": "Red",
"source": "Origen",
"Destination": "Destino"
"Destination": "Destino",
"selectAnotherOption": "Seleccionar otra opción",
"selectAddress": "Seleccionar dirección",
"viewAll": "Ver todas las direcciones"
},
"create": {
"pickAsset": "Seleccionar moneda de pago",
@@ -2649,7 +2659,9 @@
"pickDestination": "Seleccionar destino",
"confirm": "Confirmar dirección",
"alreadyExists": "Ya tienes una dirección para esta combinación",
"convertDesc": "Tu {{asset}} en {{chain}} se convertirá en {{receiveCurrency}}"
"convertDesc": "Tu {{asset}} en {{chain}} se convertirá en {{receiveCurrency}}",
"createNewAddress": "Crear nueva dirección",
"limitReached": "Has alcanzado el número máximo de direcciones para esta opción."
},
"summary": {
"chain": "Red",
+14 -2
View File
@@ -1051,6 +1051,7 @@
"depositQRError": "Un problème est survenu lors de la génération de votre adresse {{addressType}}",
"changeReceiveCurrency": "Changer la devise de réception",
"depositQRInstruction_stablecoins": "Envoyez uniquement {{asset}} sur {{chain}} à cette adresse. Vous le recevrez directement dans votre solde en dollars",
"depositQRInstruction_accumulation_bitcoin": "Envoyez uniquement {{asset}} sur {{chain}} à cette adresse. Vous le recevrez directement dans votre solde en bitcoin",
"depositQRInstruction_bitcoin": "Envoyez uniquement du Bitcoin à cette adresse. Les fonds arrivent après trois confirmations on-chain.",
"depositQRInstruction_liquid": "Envoyez uniquement du Liquid Bitcoin à cette adresse. Vous le recevrez directement dans votre solde Bitcoin",
"depositQRInstruction_rootstock": "Envoyez uniquement du Rootstock Bitcoin à cette adresse. Vous le recevrez directement dans votre solde Bitcoin.",
@@ -2624,6 +2625,7 @@
"subtitle": "Créez une adresse pour convertir automatiquement les dépôts en Bitcoin ou en dollars"
},
"createButton": "Créer une adresse",
"addressCount": "{{count}} adresses",
"info": {
"title": "Que sont-elles ?",
"whatAre": "Que sont-elles ?",
@@ -2635,13 +2637,21 @@
},
"detail": {
"copyAddress": "Copier ladresse",
"reindex": "Vérifier le solde",
"reindexThrottled": "Cette adresse a déjà été vérifiée. Réessayez dans {{minutes}} min.",
"reindexFundsFound": "Des fonds ont été trouvés. Ils arriveront bientôt dans votre portefeuille.",
"reindexNoFunds": "Aucun nouveau fonds trouvé pour cette adresse.",
"reindexError": "Impossible de vérifier cette adresse. Veuillez réessayer plus tard.",
"printQR": "Imprimer le QR",
"deleteButton": "Supprimer ladresse",
"deleteConfirmTitle": "Supprimer ladresse ?",
"deleteConfirmBody": "La suppression de cette adresse empêche la redirection des paiements futurs et peut entraîner une perte de fonds. Assurez-vous que tout le monde sait quelle nest plus active avant de la supprimer.",
"chain": "Réseau",
"source": "Source",
"Destination": "Destination"
"Destination": "Destination",
"selectAnotherOption": "Sélectionner une autre option",
"selectAddress": "Sélectionner une adresse",
"viewAll": "Voir toutes les adresses"
},
"create": {
"pickAsset": "Sélectionner la devise de paiement",
@@ -2649,7 +2659,9 @@
"pickDestination": "Sélectionner la destination",
"confirm": "Confirmer ladresse",
"alreadyExists": "Vous avez déjà une adresse pour cette combinaison",
"convertDesc": "Votre {{asset}} sur {{chain}} sera converti en {{receiveCurrency}}"
"convertDesc": "Votre {{asset}} sur {{chain}} sera converti en {{receiveCurrency}}",
"createNewAddress": "Créer une nouvelle adresse",
"limitReached": "Vous avez atteint le nombre maximal d'adresses pour cette option."
},
"summary": {
"chain": "Réseau",
+14 -2
View File
@@ -1051,6 +1051,7 @@
"depositQRError": "Si è verificato un problema durante la generazione del tuo indirizzo {{addressType}}",
"changeReceiveCurrency": "Cambia valuta di ricezione",
"depositQRInstruction_stablecoins": "Invia solo {{asset}} su {{chain}} a questo indirizzo. Lo riceverai direttamente nel tuo saldo in dollari",
"depositQRInstruction_accumulation_bitcoin": "Invia solo {{asset}} su {{chain}} a questo indirizzo. Lo riceverai direttamente nel tuo saldo in bitcoin",
"depositQRInstruction_bitcoin": "Invia solo Bitcoin a questo indirizzo. I fondi arrivano dopo tre conferme on-chain.",
"depositQRInstruction_liquid": "Invia solo Liquid Bitcoin a questo indirizzo. Lo riceverai direttamente nel tuo saldo Bitcoin",
"depositQRInstruction_rootstock": "Invia solo Rootstock Bitcoin a questo indirizzo. Lo riceverai direttamente nel tuo saldo Bitcoin.",
@@ -2624,6 +2625,7 @@
"subtitle": "Crea un indirizzo per convertire automaticamente i depositi in Bitcoin o dollari"
},
"createButton": "Crea indirizzo",
"addressCount": "{{count}} indirizzi",
"info": {
"title": "Cosa sono?",
"whatAre": "Cosa sono?",
@@ -2635,13 +2637,21 @@
},
"detail": {
"copyAddress": "Copia indirizzo",
"reindex": "Controlla saldo",
"reindexThrottled": "Questo indirizzo è già stato controllato. Riprova tra {{minutes}} min.",
"reindexFundsFound": "Fondi trovati: arriveranno nel tuo portafoglio a breve.",
"reindexNoFunds": "Nessun nuovo fondo trovato per questo indirizzo.",
"reindexError": "Impossibile controllare questo indirizzo. Riprova più tardi.",
"printQR": "Stampa QR",
"deleteButton": "Elimina indirizzo",
"deleteConfirmTitle": "Eliminare indirizzo?",
"deleteConfirmBody": "L'eliminazione di questo indirizzo interrompe il reindirizzamento dei pagamenti futuri e può comportare la perdita di fondi. Assicurati che tutti sappiano che non è più attivo prima di rimuoverlo.",
"chain": "Rete",
"source": "Origine",
"Destination": "Destinazione"
"Destination": "Destinazione",
"selectAnotherOption": "Seleziona un'altra opzione",
"selectAddress": "Seleziona indirizzo",
"viewAll": "Visualizza tutti gli indirizzi"
},
"create": {
"pickAsset": "Seleziona valuta di pagamento",
@@ -2649,7 +2659,9 @@
"pickDestination": "Seleziona destinazione",
"confirm": "Conferma indirizzo",
"alreadyExists": "Hai già un indirizzo per questa combinazione",
"convertDesc": "Il tuo {{asset}} su {{chain}} verrà convertito in {{receiveCurrency}}"
"convertDesc": "Il tuo {{asset}} su {{chain}} verrà convertito in {{receiveCurrency}}",
"createNewAddress": "Crea nuovo indirizzo",
"limitReached": "Hai raggiunto il numero massimo di indirizzi per questa opzione."
},
"summary": {
"chain": "Rete",
+14 -2
View File
@@ -1050,6 +1050,7 @@
"depositQRError": "Encontramos um problema ao gerar seu endereço {{addressType}}",
"changeReceiveCurrency": "Alterar moeda de recebimento",
"depositQRInstruction_stablecoins": "Envie apenas {{asset}} na rede {{chain}} para este endereço. Você receberá diretamente no seu saldo em dólares",
"depositQRInstruction_accumulation_bitcoin": "Envie apenas {{asset}} na rede {{chain}} para este endereço. Você receberá diretamente no seu saldo em bitcoin",
"depositQRInstruction_bitcoin": "Envie apenas Bitcoin para este endereço. Os fundos chegam após três confirmações na rede Bitcoin",
"depositQRInstruction_liquid": "Envie apenas Liquid Bitcoin para este endereço. Você receberá diretamente no seu saldo em Bitcoin",
"depositQRInstruction_rootstock": "Envie apenas Bitcoin na rede Rootstock para este endereço. Você receberá diretamente no seu saldo em Bitcoin",
@@ -2623,6 +2624,7 @@
"subtitle": "Crie endereços USDT ou USDC em que os depósitos recebidos são convertidos automaticamente em bitcoin ou dólares"
},
"createButton": "Criar endereço",
"addressCount": "{{count}} endereços",
"info": {
"title": "Para que serve?",
"whatAre": "Para que serve?",
@@ -2634,13 +2636,21 @@
},
"detail": {
"copyAddress": "Copiar endereço",
"reindex": "Verificar saldo",
"reindexThrottled": "Este endereço já foi verificado. Tente novamente em {{minutes}} min.",
"reindexFundsFound": "Fundos encontrados, eles chegarão à sua carteira em instantes.",
"reindexNoFunds": "Nenhum novo fundo encontrado para este endereço.",
"reindexError": "Não foi possível verificar este endereço. Tente novamente mais tarde.",
"printQR": "Imprimir QR",
"deleteButton": "Excluir endereço",
"deleteConfirmTitle": "Excluir endereço?",
"deleteConfirmBody": "Excluir este endereço impede o redirecionamento de pagamentos futuros e pode resultar na perda de fundos. Certifique-se de que todos saibam que este endereço não está mais ativo antes de removê-lo.",
"chain": "Rede",
"source": "Origem",
"Destination": "Destino"
"Destination": "Destino",
"selectAnotherOption": "Selecionar outra opção",
"selectAddress": "Selecionar endereço",
"viewAll": "Ver todos os endereços"
},
"create": {
"pickAsset": "Selecionar moeda",
@@ -2648,7 +2658,9 @@
"pickDestination": "Selecionar destino",
"confirm": "Confirmar endereço",
"alreadyExists": "Você já tem um endereço para esta combinação",
"convertDesc": "Seu {{asset}} na rede {{chain}} será convertido em {{receiveCurrency}}"
"convertDesc": "Seu {{asset}} na rede {{chain}} será convertido em {{receiveCurrency}}",
"createNewAddress": "Criar novo endereço",
"limitReached": "Você atingiu o número máximo de endereços para esta opção."
},
"summary": {
"chain": "Rede",
+14 -2
View File
@@ -1051,6 +1051,7 @@
"depositQRError": "Возникла проблема при создании вашего адреса {{addressType}}",
"changeReceiveCurrency": "Изменить валюту получения",
"depositQRInstruction_stablecoins": "Отправляйте на этот адрес только {{asset}} в сети {{chain}}. Средства поступят прямо на ваш долларовый баланс",
"depositQRInstruction_accumulation_bitcoin": "Отправляйте на этот адрес только {{asset}} в сети {{chain}}. вы получите средства непосредственно на свой баланс в биткоинах.",
"depositQRInstruction_bitcoin": "Отправляйте на этот адрес только Bitcoin. Средства поступят после трёх подтверждений в сети.",
"depositQRInstruction_liquid": "Отправляйте на этот адрес только Liquid Bitcoin. Средства поступят прямо на ваш баланс Bitcoin",
"depositQRInstruction_rootstock": "Отправляйте на этот адрес только Rootstock Bitcoin. Средства поступят прямо на ваш баланс Bitcoin.",
@@ -2631,6 +2632,7 @@
"subtitle": "Создайте адрес, чтобы автоматически конвертировать депозиты в Bitcoin или доллары"
},
"createButton": "Создать адрес",
"addressCount": "{{count}} адресов",
"info": {
"title": "Что это?",
"whatAre": "Что это?",
@@ -2642,13 +2644,21 @@
},
"detail": {
"copyAddress": "Скопировать адрес",
"reindex": "Проверить баланс",
"reindexThrottled": "Этот адрес уже был проверен. Повторите попытку через {{minutes}} мин.",
"reindexFundsFound": "Средства найдены, они скоро поступят в ваш кошелёк.",
"reindexNoFunds": "Для этого адреса новых средств не найдено.",
"reindexError": "Не удалось проверить этот адрес. Попробуйте позже.",
"printQR": "Печать QR",
"deleteButton": "Удалить адрес",
"deleteConfirmTitle": "Удалить адрес?",
"deleteConfirmBody": "Удаление этого адреса остановит перенаправление будущих платежей и может привести к потере средств. Убедитесь, что все знают, что он больше не активен, прежде чем удалять его.",
"source": "Источник",
"Destination": "Назначение",
"chain": "Сеть"
"chain": "Сеть",
"selectAnotherOption": "Выбрать другой вариант",
"selectAddress": "Выбрать адрес",
"viewAll": "Показать все адреса"
},
"create": {
"pickAsset": "Выберите валюту платежа",
@@ -2656,7 +2666,9 @@
"pickDestination": "Выберите назначение",
"confirm": "Подтвердить адрес",
"alreadyExists": "У вас уже есть адрес для этой комбинации",
"convertDesc": "Ваш {{asset}} в сети {{chain}} будет конвертирован в {{receiveCurrency}}"
"convertDesc": "Ваш {{asset}} в сети {{chain}} будет конвертирован в {{receiveCurrency}}",
"createNewAddress": "Создать новый адрес",
"limitReached": "Вы достигли максимального количества адресов для этого варианта."
},
"summary": {
"chain": "Сеть",
+14 -2
View File
@@ -1051,6 +1051,7 @@
"depositQRError": "Vi stötte på ett problem när din {{addressType}}-adress skulle genereras",
"changeReceiveCurrency": "Byt mottagningsvaluta",
"depositQRInstruction_stablecoins": "Skicka bara {{asset}} på {{chain}} till den här adressen. Du får det direkt till ditt dollarsaldo",
"depositQRInstruction_accumulation_bitcoin": "Skicka bara {{asset}} på {{chain}} till den här adressen. Du får det direkt till ditt bitcoin",
"depositQRInstruction_bitcoin": "Skicka bara Bitcoin till den här adressen. Pengarna kommer efter tre bekräftelser på kedjan.",
"depositQRInstruction_liquid": "Skicka bara Liquid Bitcoin till den här adressen. Du får det direkt till ditt Bitcoin-saldo",
"depositQRInstruction_rootstock": "Skicka bara Rootstock Bitcoin till den här adressen. Du får det direkt till ditt Bitcoin-saldo.",
@@ -2624,6 +2625,7 @@
"subtitle": "Skapa en adress för att automatiskt konvertera insättningar till Bitcoin eller dollar"
},
"createButton": "Skapa adress",
"addressCount": "{{count}} adresser",
"info": {
"title": "Vad är de?",
"whatAre": "Vad är de?",
@@ -2635,13 +2637,21 @@
},
"detail": {
"copyAddress": "Kopiera adress",
"reindex": "Kontrollera saldo",
"reindexThrottled": "Den här adressen har redan kontrollerats. Försök igen om {{minutes}} min.",
"reindexFundsFound": "Medel hittades, de kommer snart till din plånbok.",
"reindexNoFunds": "Inga nya medel hittades för den här adressen.",
"reindexError": "Det gick inte att kontrollera den här adressen. Försök igen senare.",
"printQR": "Skriv ut QR",
"deleteButton": "Ta bort adress",
"deleteConfirmTitle": "Ta bort adress?",
"deleteConfirmBody": "Att ta bort denna adress stoppar omdirigering av framtida betalningar och kan leda till förlorade medel. Se till att alla vet att den inte längre är aktiv innan du tar bort den.",
"chain": "Kedja",
"source": "Källa",
"Destination": "Destination"
"Destination": "Destination",
"selectAnotherOption": "Välj ett annat alternativ",
"selectAddress": "Välj adress",
"viewAll": "Visa alla adresser"
},
"create": {
"pickAsset": "Välj betalningsvaluta",
@@ -2649,7 +2659,9 @@
"pickDestination": "Välj destination",
"confirm": "Bekräfta adress",
"alreadyExists": "Du har redan en adress för denna kombination",
"convertDesc": "Din {{asset}} på {{chain}} kommer att konverteras till {{receiveCurrency}}"
"convertDesc": "Din {{asset}} på {{chain}} kommer att konverteras till {{receiveCurrency}}",
"createNewAddress": "Skapa ny adress",
"limitReached": "Du har nått det maximala antalet adresser för detta alternativ."
},
"summary": {
"chain": "Kedja",