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/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/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}`; +} 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); + }); +});