From c5bf8252ac61edc5fbfccfc86d271f6c36331abf Mon Sep 17 00:00:00 2001 From: Ezedike-Evan Date: Thu, 15 Oct 2026 12:51:22 +0100 Subject: [PATCH 1/2] Add a Max button to the send amount Fills in the whole wallet balance once it has been read, and on Arc the balance less the USDC kept back for gas. Written as a plain decimal so the amount parsers read back exactly the same units. --- .../web/components/bridge/evm-bridge-form.tsx | 12 ++++++- apps/web/components/bridge/quick-amount.tsx | 36 +++++++++++++++++-- .../components/bridge/stellar-bridge-form.tsx | 14 +++++++- apps/web/lib/max-amount.test.ts | 35 ++++++++++++++++++ apps/web/lib/max-amount.ts | 15 ++++++++ 5 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 apps/web/lib/max-amount.test.ts create mode 100644 apps/web/lib/max-amount.ts diff --git a/apps/web/components/bridge/evm-bridge-form.tsx b/apps/web/components/bridge/evm-bridge-form.tsx index b04dd45..438d343 100644 --- a/apps/web/components/bridge/evm-bridge-form.tsx +++ b/apps/web/components/bridge/evm-bridge-form.tsx @@ -34,6 +34,7 @@ import { import { ensureChain } from "../../lib/evm-rpc"; import { type WrapperSettings, getWrapperSettings, termsFor } from "../../lib/evm-wrapper"; import { formatError, reportError } from "../../lib/format-error"; +import { unitsToInput } from "../../lib/max-amount"; import { type RouteDestination, feeDestination, @@ -53,7 +54,7 @@ import { StripeRule } from "../ui/stripe-rule"; import { useWallets } from "../wallet/wallet-provider"; import { ChainPicker, PickerLabel } from "./chain-picker"; import { EvmQuotePanel } from "./evm-quote-panel"; -import { QUICK_AMOUNTS, QuickAmount } from "./quick-amount"; +import { MaxAmount, QUICK_AMOUNTS, QuickAmount } from "./quick-amount"; import { type ReceiptRoute, SubmittedNotice } from "./receipt"; import { ArcRecipientPanel, RecipientPanel, UseMyWallet } from "./recipient-panels"; import { RouteTrack } from "./route-track"; @@ -339,6 +340,8 @@ export function EvmBridgeForm({ // Only a *known* shortfall blocks. An unread balance is not a zero balance. const spendable = balance === null ? null : spendableUsdc(source, balance); const short = spendable !== null && amount !== null && amount > spendable; + // Null until the balance has been read, so Max is never offered on a guess. + const maxInput = spendable !== null && spendable > 0n ? unitsToInput(spendable, 6) : null; // Poll until Circle reports an outcome. As on the Stellar receipt, except that "claimable" is // not final here until Circle has had time to deliver (`CLAIM_GRACE_MS`). @@ -607,6 +610,13 @@ export function EvmBridgeForm({ onSelect={setAmountInput} /> ))} + {maxInput !== null && ( + + )} } > diff --git a/apps/web/components/bridge/quick-amount.tsx b/apps/web/components/bridge/quick-amount.tsx index 70e0bdb..db50ee4 100644 --- a/apps/web/components/bridge/quick-amount.tsx +++ b/apps/web/components/bridge/quick-amount.tsx @@ -1,8 +1,6 @@ import { cn } from "../ui/cn"; -/** Offered as one tap each, on both forms. There is no "max" button: it was left out when the - * screen read no balance, because a max that guesses is a max that overdraws. Both forms read - * one now (on Arc, less the USDC kept back for gas: `spendableUsdc`), so a max need not guess. */ +/** Offered as one tap each, on both forms. */ export const QUICK_AMOUNTS = ["5", "10", "100"]; /** A preset amount. Sits on the label line, so it never competes with the primary action. */ @@ -31,3 +29,35 @@ export function QuickAmount({ ); } + +/** + * Fills in everything the wallet can spend. It is only rendered once the balance has been read + * (on Arc, less the USDC kept back for gas: `spendableUsdc`), because a max that guesses is a max + * that overdraws. `value` is the exact amount as the field would hold it, never a rounded figure. + */ +export function MaxAmount({ + value, + active, + onSelect, +}: { + value: string; + active: boolean; + onSelect: (value: string) => void; +}) { + return ( + + ); +} diff --git a/apps/web/components/bridge/stellar-bridge-form.tsx b/apps/web/components/bridge/stellar-bridge-form.tsx index a71186c..3db0c7a 100644 --- a/apps/web/components/bridge/stellar-bridge-form.tsx +++ b/apps/web/components/bridge/stellar-bridge-form.tsx @@ -31,6 +31,7 @@ import { suggestedMinimum, withTableCeiling, } from "../../lib/forward-fee"; +import { unitsToInput } from "../../lib/max-amount"; import { type RecipientResolution, resolveRecipient } from "../../lib/solana-address"; import { Button } from "../ui/button"; import { Field, Input } from "../ui/field"; @@ -39,7 +40,7 @@ import { Notice } from "../ui/notice"; import { StripeRule } from "../ui/stripe-rule"; import { useWallets } from "../wallet/wallet-provider"; import { ChainPicker, PickerLabel } from "./chain-picker"; -import { QUICK_AMOUNTS, QuickAmount } from "./quick-amount"; +import { MaxAmount, QUICK_AMOUNTS, QuickAmount } from "./quick-amount"; import { SubmittedNotice } from "./receipt"; import { ArcRecipientPanel, RecipientPanel, UseMyWallet } from "./recipient-panels"; import { RouteTrack } from "./route-track"; @@ -512,6 +513,10 @@ export function StellarBridgeForm({ const shortfall = balance !== null && quote !== null && balance < quote.amount ? quote.amount - balance : null; + // The whole balance: the fee comes out of the amount sent, and gas on Stellar is paid in XLM. + // Null until the balance has been read, so Max is never offered on a guess. + const maxInput = balance !== null && balance > 0n ? unitsToInput(balance, 7) : null; + const ready = Boolean(kit && address && quote && destinationCheck.ok && isCctpRailConfigured) && quote?.forwarded === true && @@ -677,6 +682,13 @@ export function StellarBridgeForm({ onSelect={setAmountInput} /> ))} + {maxInput !== null && ( + + )} } > diff --git a/apps/web/lib/max-amount.test.ts b/apps/web/lib/max-amount.test.ts new file mode 100644 index 0000000..99ed179 --- /dev/null +++ b/apps/web/lib/max-amount.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { parseUsdc } from "./cctp-bridge"; +import { unitsToInput } from "./max-amount"; +import { parseUsdc6 } from "./usdc6"; + +describe("unitsToInput", () => { + it("writes whole amounts without a decimal point", () => { + expect(unitsToInput(25_0000000n, 7)).toBe("25"); + expect(unitsToInput(25_000000n, 6)).toBe("25"); + }); + + it("keeps every digit of a fractional balance and drops only trailing zeros", () => { + expect(unitsToInput(1_2345670n, 7)).toBe("1.234567"); + expect(unitsToInput(1n, 7)).toBe("0.0000001"); + expect(unitsToInput(999_999n, 6)).toBe("0.999999"); + }); + + it("adds no thousands separators, which the parsers reject", () => { + expect(unitsToInput(1_234_567_8901234n, 7)).toBe("1234567.8901234"); + }); + + it("reads zero and negative as zero", () => { + expect(unitsToInput(0n, 7)).toBe("0"); + expect(unitsToInput(-5n, 6)).toBe("0"); + }); + + it("round-trips through the amount parsers", () => { + for (const units of [1n, 99n, 1_0000000n, 123_4567891n, 9_999_999_9999999n]) { + expect(parseUsdc(unitsToInput(units, 7))).toBe(units); + } + for (const units of [1n, 99n, 1_000000n, 123_456789n, 9_999_999_999999n]) { + expect(parseUsdc6(unitsToInput(units, 6))).toBe(units); + } + }); +}); diff --git a/apps/web/lib/max-amount.ts b/apps/web/lib/max-amount.ts new file mode 100644 index 0000000..2b96fca --- /dev/null +++ b/apps/web/lib/max-amount.ts @@ -0,0 +1,15 @@ +/** + * A base-unit balance as text for the amount field: a plain decimal that `parseUsdc` and + * `parseUsdc6` read back to exactly the same units. + * + * Not `formatUsdc`. That one is for display: it adds thousands separators, which the parsers + * reject, and it pads to two decimals. This one keeps every digit the balance has, so the Max + * button fills in precisely what the wallet holds and never a rounded-up figure that overdraws. + */ +export function unitsToInput(units: bigint, decimals: number): string { + if (units <= 0n) return "0"; + const scale = 10n ** BigInt(decimals); + const whole = units / scale; + const fraction = (units % scale).toString().padStart(decimals, "0").replace(/0+$/, ""); + return fraction === "" ? whole.toString() : `${whole}.${fraction}`; +} From 6cdcfa89d6e83f3694ccdfe99090363096547ff1 Mon Sep 17 00:00:00 2001 From: Ezedike-Evan Date: Thu, 15 Oct 2026 12:55:41 +0100 Subject: [PATCH 2/2] Stop showing installed Stellar wallets as not detected The kit answers "not available" if a wallet takes more than 500 ms, and an idle extension is asleep and takes longer. Ask each wallet directly with a 5 s timeout, keep a wallet detected once it has answered, and retry for longer after page load. --- .../web/components/wallet/wallet-provider.tsx | 20 ++++---- apps/web/lib/stellar-wallet.ts | 37 ++++++++++++++- apps/web/lib/wallets/stellar.ts | 36 +++++++++++--- apps/web/lib/wallets/wallets.test.ts | 47 ++++++++++++++++++- 4 files changed, 124 insertions(+), 16 deletions(-) diff --git a/apps/web/components/wallet/wallet-provider.tsx b/apps/web/components/wallet/wallet-provider.tsx index fbb4649..20314e4 100644 --- a/apps/web/components/wallet/wallet-provider.tsx +++ b/apps/web/components/wallet/wallet-provider.tsx @@ -12,7 +12,7 @@ import { useState, } from "react"; import { formatError } from "../../lib/format-error"; -import { createStellarWalletKit } from "../../lib/stellar-wallet"; +import { createStellarWalletKit, probeStellarWallet } from "../../lib/stellar-wallet"; import { type Eip6963Detail, type EvmEvent, @@ -24,7 +24,7 @@ import { watchEvmWallets, } from "../../lib/wallets/evm"; import { connectSolana, solanaEntries, watchSolanaWallets } from "../../lib/wallets/solana"; -import { connectStellar, stellarEntries } from "../../lib/wallets/stellar"; +import { connectStellar, keepDetected, stellarEntries } from "../../lib/wallets/stellar"; import type { Connection, Eip1193Provider, Family, WalletEntry } from "../../lib/wallets/types"; import { WalletModal } from "./wallet-modal"; @@ -74,14 +74,16 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { setKit(createStellarWalletKit()); }, []); - // Freighter's "is it there" check is a round trip to the extension. Asked once at page load it often - // answers "no" (the content script is not ready, or the wallet was unlocked afterwards), and a stale - // "not installed" is the worst answer to keep. So it is asked again shortly after load, whenever the - // tab regains focus, and whenever the modal opens. + // "Is the wallet there" is a round trip to the extension, and an idle extension is asleep: the first + // question wakes it and may go unanswered, which used to read as "not detected" for a wallet that was + // installed (see `probeStellarWallet`). A stale "not installed" is the worst answer to keep, so it is + // asked again for the first few seconds after load, whenever the tab regains focus, and whenever the + // modal opens, and a "detected" is never taken back. const refreshStellar = useCallback(async () => { if (!kit) return; try { - setStellar(await stellarEntries(kit)); + const next = await stellarEntries(kit, probeStellarWallet); + setStellar((previous) => keepDetected(previous, next)); } catch { /* keep the last answer */ } @@ -90,7 +92,9 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (!kit) return; void refreshStellar(); - const timers = [600, 1800, 4000].map((ms) => setTimeout(() => void refreshStellar(), ms)); + const timers = [600, 1800, 4000, 9000, 16000].map((ms) => + setTimeout(() => void refreshStellar(), ms), + ); const onFocus = () => void refreshStellar(); window.addEventListener("focus", onFocus); document.addEventListener("visibilitychange", onFocus); diff --git a/apps/web/lib/stellar-wallet.ts b/apps/web/lib/stellar-wallet.ts index b073f0f..06f400b 100644 --- a/apps/web/lib/stellar-wallet.ts +++ b/apps/web/lib/stellar-wallet.ts @@ -1,6 +1,7 @@ import { FreighterModule, LobstrModule, + type ModuleInterface, StellarWalletsKit, WalletNetwork, } from "@creit.tech/stellar-wallets-kit"; @@ -27,6 +28,40 @@ const NETWORK_BY_PASSPHRASE: Record = { [WalletNetwork.TESTNET]: WalletNetwork.TESTNET, }; +/** + * One instance of each module, shared by the kit and by `probeStellarWallet`. Built on first use + * rather than at import, so nothing here runs during server rendering. + */ +let modules: ModuleInterface[] | null = null; +function stellarModules(): ModuleInterface[] { + modules ??= [new FreighterModule(), new LobstrModule()]; + return modules; +} + +/** + * Whether a wallet extension is there: true or false when it answered, null when it did not answer + * in time. + * + * The kit's own `getSupportedWallets` cannot be used for this. It races each wallet against a fixed + * 500 ms timer that answers "not available", and a browser extension that has been idle goes to + * sleep and needs longer than that to wake. So the screen said "not detected" for a wallet that was + * installed and would connect fine, and only said "detected" for one opened a moment ago. The + * question is also what wakes the extension, so asking again soon after gets a real answer. + */ +export async function probeStellarWallet(id: string, timeoutMs = 5_000): Promise { + const mod = stellarModules().find((m) => m.productId === id); + if (!mod) return false; + let timer: ReturnType | undefined; + const silent = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }); + try { + return await Promise.race([mod.isAvailable().catch(() => false), silent]); + } finally { + clearTimeout(timer); + } +} + export function createStellarWalletKit(): StellarWalletsKit { const network = NETWORK_BY_PASSPHRASE[env.stellarNetworkPassphrase]; if (!network) { @@ -38,6 +73,6 @@ export function createStellarWalletKit(): StellarWalletsKit { return new StellarWalletsKit({ network, selectedWalletId: "freighter", - modules: [new FreighterModule(), new LobstrModule()], + modules: stellarModules(), }); } diff --git a/apps/web/lib/wallets/stellar.ts b/apps/web/lib/wallets/stellar.ts index 9854260..b3bc28f 100644 --- a/apps/web/lib/wallets/stellar.ts +++ b/apps/web/lib/wallets/stellar.ts @@ -21,21 +21,45 @@ export const VERIFIED_STELLAR_WALLETS: ReadonlyArray<{ { id: "lobstr", name: "LOBSTR", installUrl: "https://lobstr.co/signer-extension" }, ]; -export async function stellarEntries(kit: StellarWalletsKit): Promise { - const supported = await kit.getSupportedWallets(); - return VERIFIED_STELLAR_WALLETS.map((w) => { - const found = supported.find((s) => s.id === w.id); +/** + * Asks each wallet whether it is there. `probe` is the page's own check (`probeStellarWallet`), + * with a timeout long enough for a sleeping extension to wake; the kit is asked only for icons, + * because its own availability answer gives up after 500 ms. No answer is reported as "not found". + */ +export async function stellarEntries( + kit: StellarWalletsKit, + probe: (id: string) => Promise, +): Promise { + const [supported, found] = await Promise.all([ + kit.getSupportedWallets().catch(() => []), + Promise.all(VERIFIED_STELLAR_WALLETS.map((w) => probe(w.id))), + ]); + return VERIFIED_STELLAR_WALLETS.map((w, i) => { + const info = supported.find((s) => s.id === w.id); return { key: `stellar:${w.id}`, family: "stellar" as const, name: w.name, - icon: found?.icon && /^(data:image\/|https:\/\/)/.test(found.icon) ? found.icon : null, - installed: found?.isAvailable ?? false, + icon: info?.icon && /^(data:image\/|https:\/\/)/.test(info.icon) ? info.icon : null, + installed: found[i] === true, installUrl: w.installUrl, }; }); } +/** + * A wallet that answered "here" stays detected for the rest of the visit. An extension does not + * uninstall itself mid-session, but it does stop answering while it sleeps, and a later "no + * answer" must not take back a "detected" the screen already showed. + */ +export function keepDetected(previous: WalletEntry[], next: WalletEntry[]): WalletEntry[] { + return next.map((entry) => + !entry.installed && previous.some((p) => p.key === entry.key && p.installed) + ? { ...entry, installed: true } + : entry, + ); +} + export async function connectStellar( kit: StellarWalletsKit, entry: WalletEntry, diff --git a/apps/web/lib/wallets/wallets.test.ts b/apps/web/lib/wallets/wallets.test.ts index 7ad0345..13fe996 100644 --- a/apps/web/lib/wallets/wallets.test.ts +++ b/apps/web/lib/wallets/wallets.test.ts @@ -2,7 +2,8 @@ import type { Wallet, WalletAccount } from "@wallet-standard/base"; import { describe, expect, it } from "vitest"; import { applyEvmEvent, connectEvm, evmEntries, parseChainId, reduceAnnouncements } from "./evm"; import { isSolanaWallet, pickSolanaAccount, solanaEntries } from "./solana"; -import type { Connection, Eip1193Provider } from "./types"; +import { keepDetected, stellarEntries } from "./stellar"; +import type { Connection, Eip1193Provider, WalletEntry } from "./types"; const provider = (accounts: unknown): Eip1193Provider => ({ request: async () => accounts, @@ -130,3 +131,47 @@ describe("Wallet Standard (Solana)", () => { expect(pickSolanaAccount([acct("0xabc", ["eip155:1"])])).toBeNull(); }); }); + +describe("Stellar detection", () => { + const entry = (id: string, installed: boolean): WalletEntry => ({ + key: `stellar:${id}`, + family: "stellar", + name: id, + icon: null, + installed, + }); + // The kit is asked for icons only; its own availability answer must play no part. + const kit = { + getSupportedWallets: async () => [ + { id: "freighter", icon: "https://x/f.png", isAvailable: false }, + { id: "lobstr", icon: "https://x/l.png", isAvailable: false }, + ], + } as unknown as Parameters[0]; + + it("takes availability from the page's own probe, not the kit's 500 ms one", async () => { + const out = await stellarEntries(kit, async (id) => id === "lobstr"); + expect(out.map((e) => [e.name, e.installed])).toEqual([ + ["Freighter", false], + ["LOBSTR", true], + ]); + expect(out[1]?.icon).toBe("https://x/l.png"); + }); + + it("reads no answer as not found", async () => { + const out = await stellarEntries(kit, async () => null); + expect(out.every((e) => !e.installed)).toBe(true); + }); + + it("does not take back a detected wallet when a later check gets no answer", () => { + const out = keepDetected( + [entry("freighter", true), entry("lobstr", false)], + [entry("freighter", false), entry("lobstr", false)], + ); + expect(out.map((e) => e.installed)).toEqual([true, false]); + }); + + it("marks a newly detected wallet", () => { + const out = keepDetected([entry("lobstr", false)], [entry("lobstr", true)]); + expect(out[0]?.installed).toBe(true); + }); +});