Skip to content

Commit 4c1f494

Browse files
NateIsernclaude
andcommitted
refactor: apply cleanup review to the buy, refresh and SPV changes
Quality pass over the recent work. No behaviour intended to change except where the old behaviour was plainly wrong. Reuse: - BuyHistoryList defined its own `formatFairAmount`, shadowing the exported helper of the same name in `src/i18n`. Mine used `toLocaleString(undefined)`, i.e. the *device* locale, so buy history formatted numbers differently from every other screen once the in-app language differed from the phone's. - quote.tsx kept its own TERMINAL_STATUSES list and `isTerminal`, duplicating the predicate added in buy-history. Two lists that must be edited together every time the bridge adds a status. - sync-anchor hand-rolled `hexToBytes`; core exports one, and the local copy silently mapped a typo to 0 instead of throwing — the exact failure its provenance docblock guards against. - Replaced three private `bytesEqual` copies in src/p2p with core's export. Efficiency: - The rainbow band was mounted unconditionally on all five refreshable screens: 70 views plus an infinite `withRepeat` transform running forever behind a zero-height clip. It now mounts only while revealed, and the hook returns it as an element so the clip and mount condition cannot drift. - `fetchPrice` replaced the cached object and notified subscribers on every poll, so `useSyncExternalStore` re-rendered the Send sheet once a minute on an unchanged price — the opposite of what its comment claimed. Now compares the quote fields and keeps the old reference when nothing moved. - SPVClient.start() queried the tip three times (corruption check, genesis seed, initial height); one read now feeds all three. - coin-control's loader depended on `chainHeight`, which is written once per merkle block, rebuilding the entire gesture graph on each one during sync. - BuyHistoryList re-read the full order list even when no status changed. Simplification: - A test asserted `ALL_STATUSES.filter(s => f(s) || !f(s))` had the same length as ALL_STATUSES — `p || !p`, so it passed for any implementation including a constant `true`, while its comment claimed it caught unclassified statuses. - Dropped the dead `variant` field and `SyncVariant` type from the chain screen, the tone indirection in BuyHistoryList, and a stale docblock left above `sendToOne`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c55e256 commit 4c1f494

15 files changed

Lines changed: 109 additions & 147 deletions

app/(tabs)/index.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import { ArrowCircleDownIcon } from "../../src/ui/components/ArrowCircleDownIcon
3333
import { SendIcon } from "../../src/ui/components/SendIcon";
3434
import { WalletSwitcherSheet } from "../../src/ui/sheets/WalletSwitcherSheet";
3535
import { TransactionDetailSheet } from "../../src/ui/sheets/TransactionDetailSheet";
36-
import { RefreshRainbowBar } from "../../src/ui/components/RefreshRainbowBar";
3736
import { usePullToRefreshBand } from "../../src/hooks/usePullToRefreshBand";
3837
import { SendReceiveSheet } from "../../src/ui/sheets/SendReceiveSheet";
3938
import { SafeAreaView } from "../../src/ui/safe-area-view";
@@ -231,7 +230,7 @@ export default function HomeScreen() {
231230
const {
232231
gesture: composedGesture,
233232
scrollHandler,
234-
bandStyle,
233+
band,
235234
} = usePullToRefreshBand(startRefresh);
236235

237236
const activityGroups = useMemo(
@@ -387,9 +386,7 @@ export default function HomeScreen() {
387386
</View>
388387

389388
{/* ---- Pull-to-refresh rainbow band: below the tabs, grows as you drag ---- */}
390-
<Animated.View style={[bandStyle, { overflow: "hidden" }]}>
391-
<RefreshRainbowBar />
392-
</Animated.View>
389+
{band}
393390

394391
{/* ---- Tab content ---- */}
395392
{tab === "overview" ? (

app/buy/quote.tsx

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,18 @@ import { PaymentInstructions } from "../../src/components/buy/PaymentInstruction
3737
import {
3838
BuyApiError,
3939
getBuyStatus,
40-
type BuyOrderStatus,
4140
type BuyQuoteResponse,
4241
type BuyStatusResponse,
4342
} from "../../src/api/buy";
4443
import { getDatabase } from "../../src/wallet/wallet-store";
45-
import { updateBuyOrderStatus } from "../../src/wallet/buy-history";
44+
import {
45+
isTerminalBuyStatus,
46+
updateBuyOrderStatus,
47+
} from "../../src/wallet/buy-history";
4648
import { t } from "../../src/i18n";
4749

4850
const CONTENT_MAX_WIDTH = 600;
4951
const POLL_INTERVAL_MS = 5000;
50-
const TERMINAL_STATUSES: readonly BuyOrderStatus[] = [
51-
"DELIVERED",
52-
"FAILED",
53-
"EXPIRED",
54-
];
55-
56-
function isTerminal(status: BuyOrderStatus | null): boolean {
57-
return status !== null && TERMINAL_STATUSES.includes(status);
58-
}
59-
6052
/**
6153
* The status endpoint returns enough information to reconstruct the bare
6254
* minimum the PaymentInstructions component needs, so we synthesise a quote
@@ -166,7 +158,7 @@ export default function BuyQuoteScreen() {
166158
const fresh = await refreshStatus();
167159
if (cancelled) return;
168160
setLoading(false);
169-
if (fresh && isTerminal(fresh.status) && timer) {
161+
if (fresh && isTerminalBuyStatus(fresh.status) && timer) {
170162
clearInterval(timer);
171163
timer = null;
172164
}

app/chain.tsx

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
1818
import { useBloomTheme } from "@oxyhq/bloom/theme";
1919
import { useWalletStore, getDatabase } from "../src/wallet/wallet-store";
2020
import { ListItem, ScreenHeader } from "../src/ui/components";
21-
import { RefreshRainbowBar } from "../src/ui/components/RefreshRainbowBar";
2221
import { usePullToRefreshBand } from "../src/hooks/usePullToRefreshBand";
2322
import { GestureDetector } from "react-native-gesture-handler";
2423
import { t } from "../src/i18n";
@@ -27,11 +26,8 @@ import { t } from "../src/i18n";
2726
// Types
2827
// ---------------------------------------------------------------------------
2928

30-
type SyncVariant = "success" | "warning" | "error";
31-
3229
interface SyncState {
3330
label: string;
34-
variant: SyncVariant;
3531
/** Tailwind background class for the state dot. */
3632
dot: string;
3733
/** Tailwind text colour class for the state label. */
@@ -154,22 +150,19 @@ export default function ChainScreen() {
154150
if (connectedPeers === 0) {
155151
return {
156152
label: t("chain.sync.offline"),
157-
variant: "error",
158153
dot: "bg-red-400",
159154
text: "text-red-400",
160155
};
161156
}
162157
if (isSyncing) {
163158
return {
164159
label: t("chain.sync.syncing", { progress: Math.round(syncProgress) }),
165-
variant: "warning",
166160
dot: "bg-yellow-400",
167161
text: "text-yellow-400",
168162
};
169163
}
170164
return {
171165
label: t("chain.sync.synced"),
172-
variant: "success",
173166
dot: "bg-primary",
174167
text: "text-primary",
175168
};
@@ -212,7 +205,7 @@ export default function ChainScreen() {
212205

213206
// Both entry points — the header icon and a pull at the top of the list —
214207
// share one implementation with the Home screen.
215-
const { gesture, scrollHandler, bandStyle, trigger, refreshing } =
208+
const { gesture, scrollHandler, band, trigger, refreshing } =
216209
usePullToRefreshBand(handleRefresh);
217210

218211
return (
@@ -241,11 +234,7 @@ export default function ChainScreen() {
241234
}
242235
/>
243236

244-
{/* Refresh rainbow band — clipped to the animated height, exactly as the
245-
Home screen reveals it on pull. */}
246-
<Animated.View style={[bandStyle, { overflow: "hidden" }]}>
247-
<RefreshRainbowBar />
248-
</Animated.View>
237+
{band}
249238

250239
<GestureDetector gesture={gesture}>
251240
<Animated.ScrollView

app/coin-control.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
import { useTheme } from "@oxyhq/bloom/theme";
2121
import { GestureDetector } from "react-native-gesture-handler";
2222
import Animated from "react-native-reanimated";
23-
import { RefreshRainbowBar } from "../src/ui/components/RefreshRainbowBar";
2423
import { usePullToRefreshBand } from "../src/hooks/usePullToRefreshBand";
2524
import { t } from "../src/i18n";
2625

@@ -56,7 +55,6 @@ const SECTION_LABEL =
5655

5756
export default function CoinControlScreen() {
5857
const router = useRouter();
59-
const chainHeight = useWalletStore((s) => s.chainHeight);
6058
const existingSelection = useWalletStore((s) => s.selectedUTXOs);
6159
const setSelectedUTXOs = useWalletStore((s) => s.setSelectedUTXOs);
6260
const clearSelectedUTXOs = useWalletStore((s) => s.clearSelectedUTXOs);
@@ -75,6 +73,10 @@ export default function CoinControlScreen() {
7573
const loadUtxos = useCallback(() => {
7674
const db = getDatabase();
7775
if (!db) return;
76+
// Read the tip at call time instead of depending on it: `chainHeight` is
77+
// written once per merkle block, and a new `loadUtxos` identity rebuilds
78+
// the whole gesture graph through the refresh hook on every one of them.
79+
const tip = useWalletStore.getState().chainHeight;
7880

7981
db.getUnspentUTXOs().then((rows) => {
8082
const items: UTXOItem[] = rows.map((row) => ({
@@ -84,14 +86,14 @@ export default function CoinControlScreen() {
8486
value: BigInt(row.value),
8587
blockHeight: row.block_height,
8688
confirmations:
87-
chainHeight > 0 && row.block_height > 0
88-
? chainHeight - row.block_height + 1
89+
tip > 0 && row.block_height > 0
90+
? tip - row.block_height + 1
8991
: 0,
9092
}));
9193
setUtxos(items);
9294
setLoaded(true);
9395
});
94-
}, [chainHeight]);
96+
}, []);
9597

9698
// Load once on layout (no useEffect); a pull re-reads, so confirmations catch
9799
// up with the chain tip without leaving and re-entering the screen.
@@ -100,7 +102,7 @@ export default function CoinControlScreen() {
100102
loadUtxos();
101103
}, [loaded, loadUtxos]);
102104

103-
const { gesture, scrollHandler, bandStyle } = usePullToRefreshBand(loadUtxos);
105+
const { gesture, scrollHandler, band } = usePullToRefreshBand(loadUtxos);
104106

105107
const handleToggle = useCallback((txid: string, vout: number) => {
106108
const key = `${txid}:${vout}`;
@@ -174,9 +176,7 @@ export default function CoinControlScreen() {
174176
}
175177
onBack={() => router.back()}
176178
/>
177-
<Animated.View style={[bandStyle, { overflow: "hidden" }]}>
178-
<RefreshRainbowBar />
179-
</Animated.View>
179+
{band}
180180

181181
<GestureDetector gesture={gesture}>
182182
<Animated.ScrollView

app/masternode.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
} from "../src/ui/components";
2727
import { useTheme } from "@oxyhq/bloom/theme";
2828
import { Dialog, useDialogControl } from "@oxyhq/bloom/dialog";
29-
import { RefreshRainbowBar } from "../src/ui/components/RefreshRainbowBar";
3029
import { usePullToRefreshBand } from "../src/hooks/usePullToRefreshBand";
3130
import { t } from "../src/i18n";
3231

@@ -58,7 +57,7 @@ export default function MasternodeScreen() {
5857
);
5958

6059
// Pull down to re-check which UTXOs still meet the collateral requirement.
61-
const { gesture, scrollHandler, bandStyle } = usePullToRefreshBand(
60+
const { gesture, scrollHandler, band } = usePullToRefreshBand(
6261
refreshMasternodeUTXOs,
6362
);
6463

@@ -74,9 +73,7 @@ export default function MasternodeScreen() {
7473
edges={["top", "bottom", "left", "right"]}
7574
>
7675
<ScreenHeader title={t("masternode.title")} onBack={() => router.back()} />
77-
<Animated.View style={[bandStyle, { overflow: "hidden" }]}>
78-
<RefreshRainbowBar />
79-
</Animated.View>
76+
{band}
8077

8178
<GestureDetector gesture={gesture}>
8279
<Animated.ScrollView

app/peers/index.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import type { PeerRow } from "../../src/storage/database";
1818
import { EmptyState, ScreenHeader } from "../../src/ui/components";
1919
import { SafeAreaView } from "../../src/ui/safe-area-view";
2020
import { Button } from "../../src/ui/components/Button";
21-
import { RefreshRainbowBar } from "../../src/ui/components/RefreshRainbowBar";
2221
import { usePullToRefreshBand } from "../../src/hooks/usePullToRefreshBand";
2322
import { t } from "../../src/i18n";
2423

@@ -88,7 +87,7 @@ export default function PeersScreen() {
8887

8988
// Pull down to re-read the known-peer cache, which the SPV client writes to
9089
// as nodes complete their handshake.
91-
const { gesture, scrollHandler, bandStyle } =
90+
const { gesture, scrollHandler, band } =
9291
usePullToRefreshBand(loadPeers);
9392

9493
// Qualitative connection state: a colored dot + big status word carry the
@@ -130,9 +129,7 @@ export default function PeersScreen() {
130129
>
131130
{/* Pull-to-refresh rainbow band — same as Home: inside the scroll view,
132131
revealed on pull, never pinned. */}
133-
<Animated.View style={[bandStyle, { overflow: "hidden" }]}>
134-
<RefreshRainbowBar />
135-
</Animated.View>
132+
{band}
136133
{/* ---- Status hero: colored dot + status word + connected count ---- */}
137134
<View className="px-5 pt-4">
138135
<View className="flex-row items-center gap-2.5">
Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,16 @@
2626

2727
import { useCallback, useEffect, useMemo, useState } from "react";
2828
import { Gesture } from "react-native-gesture-handler";
29-
import {
29+
import Animated, {
3030
runOnJS,
31+
useAnimatedReaction,
3132
useAnimatedScrollHandler,
3233
useAnimatedStyle,
3334
useSharedValue,
3435
withTiming,
3536
} from "react-native-reanimated";
3637
import {
38+
RefreshRainbowBar,
3739
RAINBOW_BAND_HEIGHT,
3840
REFRESH_HOLD_MS,
3941
} from "../ui/components/RefreshRainbowBar";
@@ -141,13 +143,33 @@ export function usePullToRefreshBand(onRefresh: () => void | Promise<void>) {
141143

142144
const bandStyle = useAnimatedStyle(() => ({ height: pull.get() }));
143145

146+
// The band is only mounted while it is actually revealed. It renders 70
147+
// views and drives an infinite `withRepeat` transform, so leaving it mounted
148+
// behind a zero-height clip burns UI-thread work every frame, on every
149+
// screen that offers a refresh, forever.
150+
const [bandVisible, setBandVisible] = useState(false);
151+
useAnimatedReaction(
152+
() => pull.get() > 0,
153+
(revealed, previous) => {
154+
if (revealed !== previous) runOnJS(setBandVisible)(revealed);
155+
},
156+
);
157+
158+
// Returned as an element rather than as a style, so the clip, the overflow
159+
// and the mount condition cannot drift between the screens that use it.
160+
const band = (
161+
<Animated.View style={[bandStyle, { overflow: "hidden" }]}>
162+
{bandVisible ? <RefreshRainbowBar /> : null}
163+
</Animated.View>
164+
);
165+
144166
return {
145167
/** Attach to the `GestureDetector` wrapping the scroll view. */
146168
gesture,
147169
/** Pass to the `Animated.ScrollView`'s `onScroll`. */
148170
scrollHandler,
149-
/** Animated height for the `Animated.View` that clips the band. */
150-
bandStyle,
171+
/** The rainbow band, ready to render above the scroll view. */
172+
band,
151173
/** Start a refresh from a button instead of a pull. */
152174
trigger,
153175
/** True while the band is held open. */

src/p2p/header-validation.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
*/
4444

4545
import type { BlockHeader, NetworkType } from "@fairco.in/core";
46-
import { hashBlockHeader } from "@fairco.in/core";
46+
import { bytesEqual, hashBlockHeader } from "@fairco.in/core";
4747
import type { BlockHeaderMsg } from "./messages";
4848

4949
// ---------------------------------------------------------------------------
@@ -153,14 +153,6 @@ export function lastPowBlock(network: NetworkType): number {
153153
// Header chain validation
154154
// ---------------------------------------------------------------------------
155155

156-
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
157-
if (a.length !== b.length) return false;
158-
for (let i = 0; i < a.length; i++) {
159-
if (a[i] !== b[i]) return false;
160-
}
161-
return true;
162-
}
163-
164156
function toCoreHeader(header: BlockHeaderMsg): BlockHeader {
165157
return {
166158
version: header.version,

src/p2p/merkle-proof.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,13 @@
1313
*/
1414

1515
import { sha256 } from "@noble/hashes/sha256";
16+
import { bytesEqual } from "@fairco.in/core";
1617
import type { MerkleBlockMsg } from "./messages";
1718

1819
// ---------------------------------------------------------------------------
1920
// Helpers
2021
// ---------------------------------------------------------------------------
2122

22-
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
23-
if (a.length !== b.length) return false;
24-
for (let i = 0; i < a.length; i++) {
25-
if (a[i] !== b[i]) return false;
26-
}
27-
return true;
28-
}
29-
3023
function hashPair(left: Uint8Array, right: Uint8Array): Uint8Array {
3124
const combined = new Uint8Array(64);
3225
combined.set(left, 0);

src/p2p/peer-manager.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,6 @@ export class PeerManager {
184184
}
185185
}
186186

187-
/**
188-
* Send a message to a single ready peer.
189-
* Returns true if a peer was available and the message was sent.
190-
*/
191187
/**
192188
* Send a message to a single ready peer, rotating through them.
193189
*

0 commit comments

Comments
 (0)