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