Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/web/components/bridge/evm-bridge-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -607,6 +610,13 @@ export function EvmBridgeForm({
onSelect={setAmountInput}
/>
))}
{maxInput !== null && (
<MaxAmount
value={maxInput}
active={amountInput.trim() === maxInput}
onSelect={setAmountInput}
/>
)}
</div>
}
>
Expand Down
36 changes: 33 additions & 3 deletions apps/web/components/bridge/quick-amount.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -31,3 +29,35 @@ export function QuickAmount({
</button>
);
}

/**
* 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 (
<button
type="button"
onClick={() => onSelect(value)}
aria-label={`Send the most this wallet can, ${value} USDC`}
className={cn(
"rounded-full px-2.5 py-1 text-[0.6875rem] font-medium",
"transition-colors duration-400 ease-haptic",
active
? "bg-bone/[0.12] text-bone"
: "text-bone/35 hover:bg-bone/[0.06] hover:text-bone/70",
)}
>
Max
</button>
);
}
14 changes: 13 additions & 1 deletion apps/web/components/bridge/stellar-bridge-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -677,6 +682,13 @@ export function StellarBridgeForm({
onSelect={setAmountInput}
/>
))}
{maxInput !== null && (
<MaxAmount
value={maxInput}
active={amountInput.trim() === maxInput}
onSelect={setAmountInput}
/>
)}
</div>
}
>
Expand Down
20 changes: 12 additions & 8 deletions apps/web/components/wallet/wallet-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -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 */
}
Expand All @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions apps/web/lib/max-amount.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
15 changes: 15 additions & 0 deletions apps/web/lib/max-amount.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
37 changes: 36 additions & 1 deletion apps/web/lib/stellar-wallet.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
FreighterModule,
LobstrModule,
type ModuleInterface,
StellarWalletsKit,
WalletNetwork,
} from "@creit.tech/stellar-wallets-kit";
Expand All @@ -27,6 +28,40 @@ const NETWORK_BY_PASSPHRASE: Record<string, WalletNetwork> = {
[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<boolean | null> {
const mod = stellarModules().find((m) => m.productId === id);
if (!mod) return false;
let timer: ReturnType<typeof setTimeout> | undefined;
const silent = new Promise<null>((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) {
Expand All @@ -38,6 +73,6 @@ export function createStellarWalletKit(): StellarWalletsKit {
return new StellarWalletsKit({
network,
selectedWalletId: "freighter",
modules: [new FreighterModule(), new LobstrModule()],
modules: stellarModules(),
});
}
36 changes: 30 additions & 6 deletions apps/web/lib/wallets/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WalletEntry[]> {
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<boolean | null>,
): Promise<WalletEntry[]> {
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,
Expand Down
47 changes: 46 additions & 1 deletion apps/web/lib/wallets/wallets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof stellarEntries>[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);
});
});
Loading