Skip to content

Commit 48e6e33

Browse files
Add delegated VTXO refresh flow (#228)
Replaces the VTXO detail exit CTA, separates expired VTXO state, adds the expired list filter, and includes formatting updates.
1 parent ad9974f commit 48e6e33

11 files changed

Lines changed: 200 additions & 85 deletions

client/src/components/PendingRoundStatusBanner.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,7 @@ const PendingRoundDetail = ({ round }: { round: PendingRoundStatus }) => {
9696

9797
return (
9898
<View className="mb-4 rounded-lg bg-card p-4">
99-
<Text className="mb-3 text-base font-semibold text-foreground">
100-
Round #{round.round_id}
101-
</Text>
99+
<Text className="mb-3 text-base font-semibold text-foreground">Round #{round.round_id}</Text>
102100
<RoundDetailRow label="Status" value={formatRoundStatus(round.status)} />
103101
<RoundDetailRow label="Final" value={round.is_final ? "Yes" : "No"} />
104102
<RoundDetailRow label="Successful" value={round.is_success ? "Yes" : "No"} />
@@ -157,11 +155,7 @@ export const PendingRoundStatusBanner = () => {
157155
actionLabel="View"
158156
onActionPress={() => setIsSheetOpen(true)}
159157
/>
160-
<AppBottomSheet
161-
isOpen={isSheetOpen}
162-
onClose={() => setIsSheetOpen(false)}
163-
scrollable
164-
>
158+
<AppBottomSheet isOpen={isSheetOpen} onClose={() => setIsSheetOpen(false)} scrollable>
165159
<View className="px-1 pb-2">
166160
<View className="mb-5 flex-row items-center">
167161
<Pressable onPress={() => setIsSheetOpen(false)} className="mr-4">

client/src/components/SendSuccessBottomSheet.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,7 @@ export const SendSuccessBottomSheet: React.FC<SendSuccessBottomSheetProps> = ({
6464
btcPrice,
6565
fiatCurrency,
6666
}) => {
67-
const fiatAmount = btcPrice
68-
? satsToFiat(parsedResult.amount_sat, btcPrice, fiatCurrency)
69-
: null;
67+
const fiatAmount = btcPrice ? satsToFiat(parsedResult.amount_sat, btcPrice, fiatCurrency) : null;
7068
const colors = useThemeColors();
7169

7270
return (

client/src/components/StatusBannerStrip.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type React from "react";
2-
import { Pressable, View } from "react-native";
2+
import { Pressable, View, type TextStyle } from "react-native";
33
import { Text } from "~/components/ui/text";
44

55
export type StatusBannerTone = "info" | "success" | "failed";
@@ -11,6 +11,7 @@ type StatusBannerStripProps = {
1111
tone: StatusBannerTone;
1212
actionLabel?: string | null;
1313
actionBusyLabel?: string;
14+
actionTextStyle?: TextStyle;
1415
isActionLoading?: boolean;
1516
onPress?: () => void;
1617
onActionPress?: () => void;
@@ -24,6 +25,7 @@ export const StatusBannerStrip = ({
2425
tone,
2526
actionLabel,
2627
actionBusyLabel = "Working",
28+
actionTextStyle,
2729
isActionLoading = false,
2830
onPress,
2931
onActionPress,
@@ -65,7 +67,7 @@ export const StatusBannerStrip = ({
6567
accessibilityLabel={actionLabel}
6668
className="ml-3 h-8 items-center justify-center rounded-full border border-border/70 bg-background/60 px-3 active:opacity-80 disabled:opacity-50"
6769
>
68-
<Text className={`text-xs font-semibold ${actionTextClassName}`}>
70+
<Text className={`text-xs font-semibold ${actionTextClassName}`} style={actionTextStyle}>
6971
{isActionLoading ? actionBusyLabel : actionLabel}
7072
</Text>
7173
</Pressable>

client/src/components/ui/AppBottomSheet.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import type React from "react";
22
import { ScrollView, StyleSheet, useWindowDimensions, View } from "react-native";
33
import { useSafeAreaInsets } from "react-native-safe-area-context";
4-
import {
5-
ModalBottomSheet,
6-
type Detent,
7-
} from "@swmansion/react-native-bottom-sheet";
4+
import { ModalBottomSheet, type Detent } from "@swmansion/react-native-bottom-sheet";
85

96
type AppBottomSheetProps = {
107
isOpen: boolean;

client/src/hooks/useWallet.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
loadWalletIfNeeded as loadWalletAction,
1111
sync as syncAction,
1212
onchainSync as onchainSyncAction,
13+
maintenanceWithOnchainDelegated,
1314
getVtxos,
1415
getExpiringVtxos,
1516
closeWalletIfLoaded,
@@ -184,6 +185,35 @@ export function useGetExpiringVtxos() {
184185
});
185186
}
186187

188+
export function useRefreshExpiringVtxos() {
189+
const { showAlert } = useAlert();
190+
191+
return useMutation({
192+
mutationFn: async () => {
193+
const result = await maintenanceWithOnchainDelegated();
194+
if (result.isErr()) {
195+
throw result.error;
196+
}
197+
},
198+
onSuccess: async () => {
199+
await Promise.all([
200+
queryClient.invalidateQueries({ queryKey: ["vtxos"] }),
201+
queryClient.invalidateQueries({ queryKey: ["expiring-vtxos"] }),
202+
queryClient.invalidateQueries({ queryKey: ["balance"] }),
203+
queryClient.invalidateQueries({ queryKey: ["pending-rounds"] }),
204+
]);
205+
showAlert({
206+
title: "Refresh scheduled",
207+
description: "A delegated refresh has been scheduled for eligible VTXOs.",
208+
});
209+
},
210+
onError: (error: Error) => {
211+
log.e("Failed to refresh expiring VTXOs", [error]);
212+
showAlert({ title: "Failed to refresh VTXO", description: error.message });
213+
},
214+
});
215+
}
216+
187217
export function useCloseWallet() {
188218
const { setWalletUnloaded } = useWalletStore();
189219
const { showAlert } = useAlert();

client/src/lib/fiatCurrency.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ export const FIAT_CURRENCY_INFO: Record<FiatCurrencyCode, FiatCurrencyInfo> = {
2424
export type FiatRates = Partial<Record<FiatCurrencyCode, number>>;
2525

2626
export const isFiatCurrencyCode = (value: unknown): value is FiatCurrencyCode =>
27-
typeof value === "string" &&
28-
SUPPORTED_FIAT_CURRENCIES.includes(value as FiatCurrencyCode);
27+
typeof value === "string" && SUPPORTED_FIAT_CURRENCIES.includes(value as FiatCurrencyCode);
2928

3029
export const getFiatCurrencyInfo = (currency: FiatCurrencyCode): FiatCurrencyInfo =>
3130
FIAT_CURRENCY_INFO[currency];
@@ -39,10 +38,7 @@ export const fiatToSats = (amount: number, btcPrice: number): number => {
3938
return Math.round((amount / btcPrice) * 100_000_000);
4039
};
4140

42-
export const formatFiatAmount = (
43-
amount: number | string,
44-
currency: FiatCurrencyCode,
45-
): string => {
41+
export const formatFiatAmount = (amount: number | string, currency: FiatCurrencyCode): string => {
4642
const { decimals, symbol } = getFiatCurrencyInfo(currency);
4743
const numericAmount = typeof amount === "number" ? amount : Number(amount);
4844
const formattedAmount = Number.isFinite(numericAmount)

client/src/screens/BoardArkScreen.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,9 @@ const BoardArkScreen = () => {
795795
)}
796796

797797
{/* Action Button */}
798-
<View className={`flex-row items-center gap-3 ${flow === "offboard" ? "mt-5" : "mt-8"}`}>
798+
<View
799+
className={`flex-row items-center gap-3 ${flow === "offboard" ? "mt-5" : "mt-8"}`}
800+
>
799801
<Button
800802
onPress={handleClearForm}
801803
variant="outline"

client/src/screens/TransactionDetailScreen.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,10 @@ export const TransactionDetailContent = ({
205205
/>
206206
) : null}
207207
{transaction.hasOnchainFee && typeof transaction.onchainFeeSat === "number" ? (
208-
<TransactionDetailRow label="Onchain Fee" value={formatBip177(transaction.onchainFeeSat)} />
208+
<TransactionDetailRow
209+
label="Onchain Fee"
210+
value={formatBip177(transaction.onchainFeeSat)}
211+
/>
209212
) : null}
210213
{typeof transaction.confirmationHeight === "number" ? (
211214
<TransactionDetailRow
@@ -214,7 +217,11 @@ export const TransactionDetailContent = ({
214217
/>
215218
) : null}
216219
{transaction.confirmationHash ? (
217-
<TransactionDetailRow label="Block Hash" value={transaction.confirmationHash} copyable />
220+
<TransactionDetailRow
221+
label="Block Hash"
222+
value={transaction.confirmationHash}
223+
copyable
224+
/>
218225
) : null}
219226
{transaction.txHex ? (
220227
<TransactionDetailRow label="Raw Transaction" value={transaction.txHex} copyable />
@@ -225,7 +232,9 @@ export const TransactionDetailContent = ({
225232
{hasMovementDetails ? (
226233
<View className="bg-card p-4 rounded-lg mb-4">
227234
<Text className="text-lg font-semibold text-foreground mb-3">Ark Movement</Text>
228-
{movementKindLabel ? <TransactionDetailRow label="Type" value={movementKindLabel} /> : null}
235+
{movementKindLabel ? (
236+
<TransactionDetailRow label="Type" value={movementKindLabel} />
237+
) : null}
229238
{movementStatusLabel ? (
230239
<TransactionDetailRow label="Status" value={movementStatusLabel} />
231240
) : null}

client/src/screens/TransactionsScreen.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,7 @@ const TransactionsScreen = () => {
6565
};
6666

6767
const exportToCSV = async () => {
68-
const csvHeader =
69-
`Payment ID,Date,Type,Direction,Amount (₿),BTC Price (${fiatCurrency}),Transaction ID,Destination\n`;
68+
const csvHeader = `Payment ID,Date,Type,Direction,Amount (₿),BTC Price (${fiatCurrency}),Transaction ID,Destination\n`;
7069
const csvRows = filteredTransactions
7170
.map((transaction) => {
7271
const date =

client/src/screens/VTXODetailScreen.tsx

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ import type { BarkVtxo } from "react-native-nitro-ark";
1212
import { useGetBlockHeight } from "~/hooks/useMarketData";
1313
import { formatBip177 } from "~/lib/utils";
1414
import { getMempoolTxUrl } from "~/constants";
15-
import { NoahButton } from "~/components/ui/NoahButton";
1615
import type { SettingsStackParamList } from "~/Navigators";
16+
import { useGetExpiringVtxos, useGetVtxos, useRefreshExpiringVtxos } from "~/hooks/useWallet";
17+
import { StatusBannerStrip } from "~/components/StatusBannerStrip";
1718

1819
type VTXOWithStatus = BarkVtxo & {
1920
isExpiring: boolean;
21+
isExpired: boolean;
2022
};
2123

24+
const EXPIRED_COLOR = "#ef4444";
25+
2226
const VTXODetailRow = ({
2327
label,
2428
value,
@@ -93,27 +97,54 @@ const VTXODetailScreen = () => {
9397
const navigation = useNavigation<NativeStackNavigationProp<SettingsStackParamList>>();
9498
const iconColor = useIconColor();
9599
const { data: blockHeight } = useGetBlockHeight();
96-
const { vtxo } = route.params as { vtxo: VTXOWithStatus };
100+
const { vtxo: routeVtxo } = route.params as { vtxo: VTXOWithStatus };
101+
const { data: allVtxos = [] } = useGetVtxos();
102+
const { data: expiringVtxos } = useGetExpiringVtxos();
103+
const refreshExpiringVtxos = useRefreshExpiringVtxos();
104+
const latestVtxo = allVtxos.find((item) => item.point === routeVtxo.point);
105+
const currentVtxo = latestVtxo ?? routeVtxo;
106+
const isLatestExpiring = expiringVtxos?.some((item) => item.point === currentVtxo.point);
107+
const vtxo: VTXOWithStatus = {
108+
...currentVtxo,
109+
isExpiring: isLatestExpiring ?? routeVtxo.isExpiring,
110+
isExpired: routeVtxo.isExpired ?? false,
111+
};
97112
const anchorExplorerUrl = getMempoolTxUrl(vtxo.anchor_point);
113+
const isExpired =
114+
vtxo.state !== "Locked" &&
115+
(blockHeight !== undefined ? vtxo.expiry_height <= blockHeight : vtxo.isExpired);
116+
const canRefresh = vtxo.state !== "Locked" && (vtxo.isExpiring || isExpired);
117+
const statusLabel =
118+
vtxo.state === "Locked"
119+
? "Locked"
120+
: isExpired
121+
? "Expired"
122+
: vtxo.isExpiring
123+
? "Expiring"
124+
: "Active";
98125

99126
const getStatusColor = (vtxo: VTXOWithStatus) => {
100127
if (vtxo.state === "Locked") return "text-gray-500";
128+
if (isExpired) return "text-red-500";
101129
return vtxo.isExpiring ? "text-orange-500" : "text-green-500";
102130
};
103131

104132
const getStatusIcon = (vtxo: VTXOWithStatus) => {
105133
if (vtxo.state === "Locked") return "lock-closed-outline";
134+
if (isExpired) return "alert-circle-outline";
106135
return vtxo.isExpiring ? "warning-outline" : "checkmark-circle-outline";
107136
};
108137

109138
const getVtxoIcon = (vtxo: VTXOWithStatus) => {
110139
if (vtxo.state === "Locked") return "lock-closed-outline";
140+
if (isExpired) return "alert-circle-outline";
111141
return vtxo.isExpiring ? "warning-outline" : "cube-outline";
112142
};
113143

114144
const getVtxoColor = (vtxo: VTXOWithStatus) => {
115145
if (vtxo.state === "Locked") return "#6b7280";
116-
return vtxo.isExpiring ? "#f97316" : "#22c55e";
146+
if (isExpired) return EXPIRED_COLOR;
147+
return vtxo.isExpiring ? COLORS.BITCOIN_ORANGE : "#22c55e";
117148
};
118149

119150
return (
@@ -131,6 +162,27 @@ const VTXODetailScreen = () => {
131162
showsVerticalScrollIndicator={false}
132163
contentContainerStyle={{ paddingBottom: 50 }}
133164
>
165+
{canRefresh ? (
166+
<StatusBannerStrip
167+
className="mb-4"
168+
title={isExpired ? "VTXO expired" : "VTXO expiring soon"}
169+
message="Refresh this VTXO to keep it available."
170+
icon={
171+
<Icon
172+
name={isExpired ? "alert-circle-outline" : "warning-outline"}
173+
size={16}
174+
color={isExpired ? EXPIRED_COLOR : COLORS.BITCOIN_ORANGE}
175+
/>
176+
}
177+
tone={isExpired ? "failed" : "info"}
178+
actionLabel="Refresh"
179+
actionBusyLabel="Refreshing"
180+
actionTextStyle={{ color: COLORS.BITCOIN_ORANGE }}
181+
isActionLoading={refreshExpiringVtxos.isPending}
182+
onActionPress={() => refreshExpiringVtxos.mutate()}
183+
/>
184+
) : null}
185+
134186
<View className="items-center my-8">
135187
<View className="mb-4">
136188
<Icon name={getVtxoIcon(vtxo)} size={64} color={getVtxoColor(vtxo)} />
@@ -141,18 +193,15 @@ const VTXODetailScreen = () => {
141193
<View className="flex-row items-center">
142194
<Icon name={getStatusIcon(vtxo)} size={20} color={getVtxoColor(vtxo)} />
143195
<Text className={`text-xl font-medium ml-2 ${getStatusColor(vtxo)}`}>
144-
{vtxo.state === "Locked" ? "Locked" : vtxo.isExpiring ? "Expiring" : "Active"}
196+
{statusLabel}
145197
</Text>
146198
</View>
147199
</View>
148200

149201
<View className="bg-card p-4 rounded-lg mb-4">
150202
<VTXODetailRow label="Amount" value={formatBip177(vtxo.amount)} />
151203
<VTXODetailRow label="State" value={vtxo.state} />
152-
<VTXODetailRow
153-
label="Status"
154-
value={vtxo.state === "Locked" ? "Locked" : vtxo.isExpiring ? "Expiring" : "Active"}
155-
/>
204+
<VTXODetailRow label="Status" value={statusLabel} />
156205
<VTXODetailRow
157206
label="Current Block Height"
158207
value={blockHeight ? blockHeight.toLocaleString() : "Loading..."}
@@ -182,21 +231,6 @@ const VTXODetailScreen = () => {
182231
/>
183232
<VTXODetailRow label="Server Public Key" value={vtxo.server_pubkey} copyable />
184233
</View>
185-
186-
<View className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
187-
<Text className="text-base font-semibold text-amber-700 dark:text-amber-300">
188-
Emergency exit
189-
</Text>
190-
<Text className="mt-1 text-sm leading-5 text-amber-700/90 dark:text-amber-200/90">
191-
Use only if the Ark server is unavailable and normal offboarding cannot be used.
192-
</Text>
193-
<NoahButton
194-
className="mt-4"
195-
onPress={() => navigation.navigate("UnilateralExit", { vtxoIds: [vtxo.point] })}
196-
>
197-
Exit This VTXO
198-
</NoahButton>
199-
</View>
200234
</ScrollView>
201235
</View>
202236
</NoahSafeAreaView>

0 commit comments

Comments
 (0)