Skip to content

Commit 8346d40

Browse files
feat(transaction-panel): send assetIssuer and show a balance hint (#624)
TransactionPanel's asset selector already let users pick a non-native asset from their balances, but TransactionParams had no assetIssuer field and the payload never included one, so a payment in USDC (or any other issued asset) would submit with only the asset code and no way for the API to disambiguate which issuer's USDC to pay with. - lib/client.ts: add an optional assetIssuer field to TransactionParams. - TransactionPanel.tsx: pass the selected balance's assetIssuer through to transaction.submit (undefined for native XLM), and show the selected asset's balance as a hint under the Amount input via the existing Input hint prop. Closes #565 Closes #566
1 parent 6a59a6b commit 8346d40

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

src/components/TransactionPanel.test.tsx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,4 +291,167 @@ describe("TransactionPanel", () => {
291291

292292
expect(await screen.findByText("Transaction submitted")).toBeInTheDocument();
293293
});
294+
295+
// ── Asset selector (#178) ─────────────────────────────────────────────────
296+
describe("asset selector", () => {
297+
const balances = [
298+
{ asset: "XLM", balance: "100.0000000", assetType: "native" as const },
299+
{
300+
asset: "USDC",
301+
balance: "50.0000000",
302+
assetType: "credit_alphanum4" as const,
303+
assetCode: "USDC",
304+
assetIssuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
305+
},
306+
];
307+
308+
it("populates the asset selector with the correct asset codes from context balances", () => {
309+
vi.mocked(useSorokit).mockReturnValue({
310+
address: "GABC",
311+
isConnected: true,
312+
balances,
313+
} as unknown as ReturnType<typeof useSorokit>);
314+
315+
render(<TransactionPanel />);
316+
317+
const select = screen.getByLabelText("Asset") as HTMLSelectElement;
318+
const optionValues = Array.from(select.options).map((o) => o.value);
319+
expect(optionValues).toEqual(["XLM", "USDC"]);
320+
});
321+
322+
it("updates the submitted asset when USDC is selected", async () => {
323+
const mockSubmit = vi
324+
.fn()
325+
.mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null });
326+
mockGetClient(mockSubmit);
327+
vi.mocked(useSorokit).mockReturnValue({
328+
address: "GABC",
329+
isConnected: true,
330+
balances,
331+
} as unknown as ReturnType<typeof useSorokit>);
332+
333+
render(<TransactionPanel />);
334+
335+
const select = screen.getByLabelText("Asset");
336+
fireEvent.change(select, { target: { value: "USDC" } });
337+
expect(select).toHaveValue("USDC");
338+
expect(screen.getByLabelText("Amount (USDC)")).toBeInTheDocument();
339+
340+
const validDest = VALID_DEST;
341+
fireEvent.change(screen.getByLabelText("Destination Address"), {
342+
target: { value: validDest },
343+
});
344+
fireEvent.change(screen.getByLabelText("Amount (USDC)"), {
345+
target: { value: "10" },
346+
});
347+
348+
await reviewAndConfirm();
349+
350+
await screen.findByText("Transaction submitted");
351+
expect(mockSubmit).toHaveBeenCalledWith(
352+
expect.objectContaining({ asset: "USDC" }),
353+
);
354+
});
355+
356+
it("disables the asset selector when no balances are loaded", () => {
357+
vi.mocked(useSorokit).mockReturnValue({
358+
address: "GABC",
359+
isConnected: true,
360+
balances: [],
361+
} as unknown as ReturnType<typeof useSorokit>);
362+
363+
render(<TransactionPanel />);
364+
365+
const select = screen.getByLabelText("Asset");
366+
expect(select).toBeDisabled();
367+
expect(select).toHaveValue("XLM");
368+
});
369+
370+
it("includes the asset's issuer in the submitted payload for a non-native asset (#565)", async () => {
371+
const mockSubmit = vi
372+
.fn()
373+
.mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null });
374+
mockGetClient(mockSubmit);
375+
vi.mocked(useSorokit).mockReturnValue({
376+
address: "GABC",
377+
isConnected: true,
378+
balances,
379+
// getClient() here returns the object mockGetClient() just wired up
380+
// above; TransactionPanel reads `client` from context, not from a
381+
// direct getClient() call, so the mocked client has to be threaded
382+
// through explicitly for the submission to actually run.
383+
client: getClient(),
384+
} as unknown as ReturnType<typeof useSorokit>);
385+
386+
render(<TransactionPanel />);
387+
388+
fireEvent.change(screen.getByLabelText("Asset"), {
389+
target: { value: "USDC" },
390+
});
391+
const validDest = VALID_DEST;
392+
fireEvent.change(screen.getByLabelText("Destination Address"), {
393+
target: { value: validDest },
394+
});
395+
fireEvent.change(screen.getByLabelText("Amount (USDC)"), {
396+
target: { value: "10" },
397+
});
398+
399+
await reviewAndConfirm();
400+
401+
await screen.findByText("Transaction submitted");
402+
expect(mockSubmit).toHaveBeenCalledWith(
403+
expect.objectContaining({
404+
asset: "USDC",
405+
assetIssuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
406+
}),
407+
);
408+
});
409+
410+
it("omits assetIssuer for the native XLM asset (#565)", async () => {
411+
const mockSubmit = vi
412+
.fn()
413+
.mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null });
414+
mockGetClient(mockSubmit);
415+
vi.mocked(useSorokit).mockReturnValue({
416+
address: "GABC",
417+
isConnected: true,
418+
balances,
419+
client: getClient(),
420+
} as unknown as ReturnType<typeof useSorokit>);
421+
422+
render(<TransactionPanel />);
423+
424+
const validDest = VALID_DEST;
425+
fireEvent.change(screen.getByLabelText("Destination Address"), {
426+
target: { value: validDest },
427+
});
428+
fireEvent.change(screen.getByLabelText("Amount (XLM)"), {
429+
target: { value: "10" },
430+
});
431+
432+
await reviewAndConfirm();
433+
434+
await screen.findByText("Transaction submitted");
435+
expect(mockSubmit).toHaveBeenCalledWith(
436+
expect.objectContaining({ asset: "XLM", assetIssuer: undefined }),
437+
);
438+
});
439+
440+
it("shows the selected asset's balance as a hint near the amount input (#565)", () => {
441+
vi.mocked(useSorokit).mockReturnValue({
442+
address: "GABC",
443+
isConnected: true,
444+
balances,
445+
} as unknown as ReturnType<typeof useSorokit>);
446+
447+
render(<TransactionPanel />);
448+
449+
expect(screen.getByText("Balance: 100.0000000 XLM")).toBeInTheDocument();
450+
451+
fireEvent.change(screen.getByLabelText("Asset"), {
452+
target: { value: "USDC" },
453+
});
454+
expect(screen.getByText("Balance: 50.0000000 USDC")).toBeInTheDocument();
455+
});
456+
});
294457
});

src/components/TransactionPanel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export function TransactionPanel({
9191
const xlmBalance = balances.find((b) => b.asset === "XLM")?.balance || "0";
9292
const xlmBalanceNumber = parseFloat(xlmBalance);
9393
const hasSufficientBalance = !isNaN(parsedAmount) && parsedAmount <= xlmBalanceNumber;
94+
const selectedAssetBalance = assetOptions.find((b) => b.asset === selectedAsset);
9495

9596
const canSubmit =
9697
isConnected &&
@@ -122,6 +123,7 @@ export function TransactionPanel({
122123
destination: dest.trim(),
123124
amount: amount.trim(),
124125
asset: selectedAsset,
126+
assetIssuer: selectedAssetBalance?.assetIssuer,
125127
memoType,
126128
memo:
127129
memoType !== "none" && memo.trim() !== "" ? memo.trim() : undefined,
@@ -356,6 +358,11 @@ export function TransactionPanel({
356358
setAmount(e.target.value);
357359
setAmountDirty(true);
358360
}}
361+
hint={
362+
selectedAssetBalance
363+
? `Balance: ${selectedAssetBalance.balance} ${selectedAsset}`
364+
: undefined
365+
}
359366
error={
360367
amountDirty
361368
? amount.trim() === ""

src/lib/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface TransactionParams {
1111
destination: string;
1212
amount: string;
1313
asset: string;
14+
/** Issuer account for a non-native asset. Omitted (or undefined) for XLM. */
15+
assetIssuer?: string;
1416
memoType: "none" | "text" | "id";
1517
memo?: string;
1618
}

0 commit comments

Comments
 (0)