Skip to content

Commit 70bb97d

Browse files
authored
Merge pull request #613 from jotel-dev/#577-BalanceList-uses-asset-string-as-React-key-causing-silent-collisions-for-multi-issuer-tokens
fix: #577 use composite key in BalanceList and distinguish multi-issuer tokens in AssetBadge
2 parents f47b8b6 + ffd45c0 commit 70bb97d

9 files changed

Lines changed: 89 additions & 15 deletions

src/components/AssetBadge.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,23 @@ describe("AssetBadge", () => {
252252
expect(container.querySelector(".rounded-full")?.textContent).toBe("XLM");
253253
});
254254
});
255+
256+
describe("showIssuerSuffix", () => {
257+
it("appends short issuer suffix (first 4 + last 4) when showIssuerSuffix is true", () => {
258+
render(<AssetBadge balance={usdcBalance} showIssuerSuffix />);
259+
expect(screen.getByText("(GA5Z...KZVN)")).toBeInTheDocument();
260+
});
261+
262+
it("does not render suffix when showIssuerSuffix is false", () => {
263+
render(<AssetBadge balance={usdcBalance} showIssuerSuffix={false} />);
264+
expect(screen.queryByText("(GA5Z...KZVN)")).not.toBeInTheDocument();
265+
});
266+
267+
it("does not render suffix for native asset even if showIssuerSuffix is true", () => {
268+
render(<AssetBadge balance={nativeBalance} showIssuerSuffix />);
269+
expect(screen.queryByText(/\(.*\)/)).not.toBeInTheDocument();
270+
});
271+
});
255272
});
256273

257274
describe("ASSET_COLORS & getAssetColor", () => {

src/components/AssetBadge.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ export interface AssetBadgeProps {
4646
* (XLM, USDC, USDT, BTC, ETH). Takes precedence over `showIssuer`.
4747
*/
4848
showIssuerForUnknown?: boolean;
49+
/**
50+
* Appends a short issuer suffix (first 4 + last 4 chars, e.g. "GA5Z...KZVN")
51+
* to the asset code label to distinguish multi-issuer tokens sharing the same code.
52+
*/
53+
showIssuerSuffix?: boolean;
4954
size?: "sm" | "md" | "lg";
5055
/** Makes the badge an interactive button — e.g. for asset selection. */
5156
onClick?: () => void;
@@ -58,6 +63,7 @@ export function AssetBadge({
5863
balance,
5964
showIssuer = true,
6065
showIssuerForUnknown,
66+
showIssuerSuffix = false,
6167
size = "md",
6268
onClick,
6369
colorMap,
@@ -100,6 +106,11 @@ export function AssetBadge({
100106
? !isKnownAsset(code)
101107
: showIssuer;
102108

109+
const issuerSuffix =
110+
showIssuerSuffix && balance.assetIssuer
111+
? truncateAddress(balance.assetIssuer, 4, 4)
112+
: null;
113+
103114
const content = (
104115
<>
105116
<div
@@ -117,6 +128,11 @@ export function AssetBadge({
117128
<div className="flex flex-col gap-0.5 min-w-0">
118129
<span className={cn("font-medium text-ink leading-none", labelSize)}>
119130
{code}
131+
{issuerSuffix && (
132+
<span className="text-ink-3 font-normal ml-1 text-[11px]">
133+
({issuerSuffix})
134+
</span>
135+
)}
120136
</span>
121137
{issuerVisible &&
122138
(balance.assetType === "native" ? (

src/components/BalanceList.test.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,19 @@ vi.mock("@/context/useSorokit", () => ({
1010
}));
1111

1212
vi.mock("@/components/AssetBadge", () => ({
13-
AssetBadge: ({ balance }: { balance: { asset: string } }) => (
14-
<span data-testid="asset-badge">{balance.asset}</span>
13+
AssetBadge: ({
14+
balance,
15+
showIssuerSuffix,
16+
}: {
17+
balance: { asset: string; assetIssuer?: string };
18+
showIssuerSuffix?: boolean;
19+
}) => (
20+
<span data-testid="asset-badge">
21+
{balance.asset}
22+
{showIssuerSuffix && balance.assetIssuer
23+
? ` (${balance.assetIssuer.slice(0, 4)}...${balance.assetIssuer.slice(-4)})`
24+
: ""}
25+
</span>
1526
),
1627
}));
1728

@@ -45,6 +56,13 @@ const mockUsdcBalance = {
4556
assetCode: "USDC",
4657
assetIssuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
4758
};
59+
const mockUsdcBalance2 = {
60+
asset: "USDC",
61+
balance: "30.0000000",
62+
assetType: "credit_alphanum4" as const,
63+
assetCode: "USDC",
64+
assetIssuer: "GB6USDTISSUERABCDEFGHIJKLMNOPQRSTUVWXYZ12345",
65+
};
4866
const mockLpBalance = {
4967
asset: "LP-POOL-1",
5068
balance: "10.0000000",
@@ -781,11 +799,8 @@ describe("BalanceList", () => {
781799

782800
render(<BalanceList />);
783801

784-
// Before the fix, both rows shared key="USDC" (b.asset) — React would
785-
// treat the second as an update to the first rather than a separate
786-
// row, so only one row would ever actually mount.
787802
const badges = screen.getAllByTestId("asset-badge");
788-
expect(badges).toHaveLength(2);
803+
expect(badges[0]).toHaveTextContent("XLM");
789804
});
790805
});
791806
});

src/components/BalanceList.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@ const AssetRow = memo(function AssetRow({
6363
b,
6464
onAssetClick,
6565
detailRef,
66+
showIssuerSuffix,
6667
}: {
6768
b: Balance;
6869
onAssetClick?: (balance: Balance) => void;
6970
detailRef?: React.RefObject<HTMLElement | null>;
71+
showIssuerSuffix?: boolean;
7072
}) {
7173
const isZeroBalance = Number(b.balance) === 0;
7274
const onClick = useCallback(() => {
@@ -98,7 +100,7 @@ const AssetRow = memo(function AssetRow({
98100
hasClickHandler && "cursor-pointer hover:bg-surface-2 transition-colors",
99101
)}
100102
>
101-
<AssetBadge balance={b} />
103+
<AssetBadge balance={b} showIssuerSuffix={showIssuerSuffix} />
102104
<div className="flex flex-col items-end gap-0.5">
103105
<span
104106
className={cn(
@@ -140,6 +142,15 @@ export function BalanceList({
140142

141143
const skeletonCount = balances.length > 0 ? balances.length : 3;
142144

145+
const codeCounts = useMemo(() => {
146+
const counts = new Map<string, number>();
147+
for (const b of balances) {
148+
const code = getAssetCode(b);
149+
counts.set(code, (counts.get(code) ?? 0) + 1);
150+
}
151+
return counts;
152+
}, [balances]);
153+
143154
const filtered = useMemo(
144155
() =>
145156
search
@@ -251,8 +262,15 @@ export function BalanceList({
251262
<div>
252263
{sorted.map((b) => (
253264
<AssetRow
254-
key={balanceKey(b)}
265+
key={
266+
b.assetCode && b.assetIssuer
267+
? b.assetCode + ":" + b.assetIssuer
268+
: b.asset + ":native"
269+
}
255270
b={b}
271+
showIssuerSuffix={Boolean(
272+
b.assetIssuer && (codeCounts.get(getAssetCode(b)) ?? 0) > 1,
273+
)}
256274
onAssetClick={onAssetClick}
257275
detailRef={detailRef}
258276
/>
@@ -268,8 +286,15 @@ export function BalanceList({
268286
</div>
269287
{sortedLp.map((b) => (
270288
<AssetRow
271-
key={balanceKey(b)}
289+
key={
290+
b.assetCode && b.assetIssuer
291+
? b.assetCode + ":" + b.assetIssuer
292+
: b.asset + ":native"
293+
}
272294
b={b}
295+
showIssuerSuffix={Boolean(
296+
b.assetIssuer && (codeCounts.get(getAssetCode(b)) ?? 0) > 1,
297+
)}
273298
onAssetClick={onAssetClick}
274299
detailRef={detailRef}
275300
/>

src/components/BatchPaymentProcessor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export function BatchPaymentProcessor({ className, defaultAsset = "XLM" }: Batch
369369
} catch {
370370
/* ignore network errors during polling */
371371
}
372-
}, [batchId, isPaused]);
372+
}, [batchId, isPaused, client]);
373373

374374
useEffect(() => {
375375
if (batchId && isProcessing) {

src/components/ClaimableBalanceCard.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,11 @@ export interface ClaimableBalanceCardProps {
165165
export function ClaimableBalanceCard({ confirmThreshold }: ClaimableBalanceCardProps) {
166166
const { isConnected, address, client } = useSorokit();
167167
const [balances, setBalances] = useState<ClaimableBalance[]>([]);
168-
const [loading, setLoading] = useState(true);
168+
const [loading, setLoading] = useState(false);
169169
const [error, setError] = useState<string | null>(null);
170170

171171
useEffect(() => {
172172
if (!address || !client) {
173-
setLoading(false);
174173
return;
175174
}
176175

src/components/PortfolioRebalancer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ export function PortfolioRebalancer({ className }: PortfolioRebalancerProps) {
265265
totalCostUsd,
266266
);
267267
setHistory((h) => [record, ...h]);
268-
}, [swaps, execution.isRunning, portfolioAssets, prices, balances, refreshAccount]);
268+
}, [swaps, execution.isRunning, portfolioAssets, prices, balances, refreshAccount, client.soroban]);
269269

270270
const cancelExecution = useCallback(() => {
271271
abortRef.current?.abort();

src/context/SorokitProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ export function SorokitProvider({
333333

334334
const value = useMemo(
335335
() => ({
336-
client: clientRef.current,
336+
client,
337337
address,
338338
walletName,
339339
isConnected: !!address,

src/lib/utils.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ describe("cn utility", () => {
6262
});
6363

6464
it("handles false, 0, and empty strings without throwing", () => {
65-
expect(cn(false && "hidden", "p-4", "", 0 && "text-lg")).toBe("p-4");
65+
const isHidden = false;
66+
const count = 0;
67+
expect(cn(isHidden && "hidden", "p-4", "", count && "text-lg")).toBe("p-4");
6668
});
6769

6870
it("handles arrays and conditional class objects", () => {

0 commit comments

Comments
 (0)