Skip to content
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ import { MAINNET_LIMITS, NETWORK_LIMITS } from "@/constants/networkLimits";

To manually fetch limits: `pnpm fetch-limits`.

### WalletConnect

WalletConnect is offered on mainnet and testnet only, since those are the only
Stellar chains it exposes (`stellar:pubnet` and `stellar:testnet`). It uses a
[Reown Cloud](https://cloud.reown.com/) project id committed in
`src/components/WalletKit/walletConnect.ts`.

**Testing it locally needs an https origin.** From `http://localhost`, Freighter
mobile approves the session and returns the account, but then refuses to sign,
reporting the domain as not connected. Use a tunnel (see Hardware Wallets below)
or a deployed preview, and add that origin to the Reown project's allowed
domains — the relay rejects unlisted origins outright with
`3000 (Unauthorized: origin not allowed)`. Allowlist changes can take a few
hours to propagate.

### Hardware Wallets

Testing hardware wallets requires an HTTPS connection to enable U2F. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export const InvokeContractForm = ({
const responseErrorEl = useRef<HTMLDivElement | null>(null);

const signTx = async (xdr: string): Promise<string | null> => {
if (!walletKitInstance?.isInitialized || !walletKit?.publicKey) {
if (!walletKitInstance.isInitialized || !walletKit?.publicKey) {
return null;
}

Expand Down
14 changes: 8 additions & 6 deletions src/components/SignMessage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const SignMessage = ({
};

const onSignExtension = async (): Promise<SignActionResult> => {
if (!walletKitInstance?.isInitialized) {
if (!walletKitInstance.isInitialized) {
return { errorMessage: "Wallet is not initialized, please try again" };
}

Expand All @@ -74,6 +74,10 @@ export const SignMessage = ({

// Not connected via the main nav — open the kit's auth modal to pick one.
if (!address) {
// Register WalletConnect first — the kit snapshots its wallet list when
// the modal opens, so a module added later wouldn't appear in it.
await walletKitInstance.ensureWalletConnect();

const auth = await StellarWalletsKit.authModal();
address = auth.address;

Expand All @@ -87,13 +91,11 @@ export const SignMessage = ({
return {};
}

const { signedMessage, signerAddress } = await StellarWalletsKit.signMessage(
message,
{
const { signedMessage, signerAddress } =
await StellarWalletsKit.signMessage(message, {
address,
networkPassphrase,
},
);
});

if (!signedMessage) {
onSigned?.(null);
Expand Down
140 changes: 127 additions & 13 deletions src/components/WalletKit/ConnectWallet.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"use client";

import { useContext, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { Button, Modal, Text } from "@stellar/design-system";
import { StellarWalletsKit } from "@creit.tech/stellar-wallets-kit";
import {
KitEventType,
StellarWalletsKit,
} from "@creit.tech/stellar-wallets-kit";
import { useStore } from "@/store/useStore";

import { useAccountInfo } from "@/query/useAccountInfo";
Expand All @@ -13,6 +16,10 @@ import { localStorageSavedWallet } from "@/helpers/localStorageSavedWallet";

import { ConnectedModal } from "@/components/WalletKit/ConnectedModal";
import { WalletKitContext } from "@/components/WalletKit/WalletKitContextProvider";
import {
hasLiveWalletConnectSession,
WALLET_CONNECT_ID,
} from "@/components/WalletKit/walletConnect";

import { trackEvent, TrackingEvent } from "@/metrics/tracking";

Expand All @@ -21,18 +28,20 @@ export const ConnectWallet = () => {
const [connected, setConnected] = useState<boolean>(false);
const [isModalVisible, setShowModal] = useState<boolean>(false);
const [errorMessageOnConnect, setErrorMessageOnConnect] = useState("");
const [isPreparingWallets, setIsPreparingWallets] = useState<boolean>(false);
const [hasAttemptedAutoConnect, setHasAttemptedAutoConnect] =
useState<boolean>(false);
const walletKitInstance = useContext(WalletKitContext);
const savedWallet = localStorageSavedWallet.get();
const isSavedWalletConnect = savedWallet?.id === WALLET_CONNECT_ID;

const { data: accountInfo, refetch: fetchAccountInfo } = useAccountInfo({
publicKey: walletKit?.publicKey || "",
horizonUrl: network?.horizonUrl || "",
headers: network ? getNetworkHeaders(network, "horizon") : {},
});

const disconnect = () => {
const clearWalletState = useCallback(() => {
updateWalletKit({
publicKey: undefined,
walletType: undefined,
Expand All @@ -42,20 +51,53 @@ export const ConnectWallet = () => {
setConnected(false);
setHasAttemptedAutoConnect(false);
localStorageSavedWallet.remove();
}, [updateWalletKit]);

// Let the kit tear down its own state too. For WalletConnect this closes the
// session with the wallet; other wallets have nothing to close.
const disconnectKit = async () => {
try {
await StellarWalletsKit.disconnect();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// Clearing Lab's state matters more than a clean wallet-side teardown
}
};

const disconnect = async () => {
await disconnectKit();
clearWalletState();
};

// The kit can end a session on its own — a WalletConnect session the wallet
// dropped while Lab was closed surfaces here once the relay reconnects — so
// mirror that into Lab's state instead of showing a stale connected address.
useEffect(() => {
return StellarWalletsKit.on(KitEventType.DISCONNECT, clearWalletState);
}, [clearWalletState]);

useEffect(() => {
let t: NodeJS.Timeout;

if (
!connected &&
!hasAttemptedAutoConnect &&
!!savedWallet?.id &&
![undefined, "false", "wallet_connect"].includes(savedWallet?.id) &&
![undefined, "false"].includes(savedWallet?.id) &&
savedWallet.network.id === network.id
) {
t = setTimeout(async () => {
if (!walletKitInstance?.isInitialized) {
if (!walletKitInstance.isInitialized) {
return;
}

// WalletConnect isn't registered at startup, so restoring a saved
// session has to pull in its chunk first.
if (
isSavedWalletConnect &&
!(await walletKitInstance.ensureWalletConnect())
) {
setHasAttemptedAutoConnect(true);
return;
}

Expand Down Expand Up @@ -83,29 +125,82 @@ export const ConnectWallet = () => {
};
// Not including savedWallet.network.id
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [savedWallet?.id, connected, hasAttemptedAutoConnect, walletKitInstance]);
}, [
savedWallet?.id,
isSavedWalletConnect,
connected,
hasAttemptedAutoConnect,
walletKitInstance,
]);

// Reset auto-connect attempt when network changes
useEffect(() => {
setHasAttemptedAutoConnect(false);
}, [network.id]);

// A WalletConnect session is approved for a single chain, and updating
// `allowedChains` only affects the next pairing — it can't renegotiate a
// session the wallet already approved. After an in-app mainnet/testnet
// switch the kit would keep signing over the same topic with the new chain
// id, which the session never authorized, so signing fails with no
// explanation. End the session instead and let the user pair again.
//
// The ref guard means this only runs on an actual switch, never on mount,
// where it would tear down a session that was just restored.
const previousNetworkId = useRef(network.id);

useEffect(() => {
if (previousNetworkId.current === network.id) {
return;
}

previousNetworkId.current = network.id;

if (walletKit?.walletType === WALLET_CONNECT_ID) {
disconnect();
}
// `disconnect` is recreated every render; including it would re-run this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [network.id, walletKit?.walletType]);

const handleSetWalletAddress = async ({
skipRequestAccess,
}: {
skipRequestAccess: boolean;
}): Promise<boolean> => {
try {
const addressResult = await StellarWalletsKit.selectedModule.getAddress({
skipRequestAccess,
});
// The WalletConnect module ignores `skipRequestAccess` and always starts
// a fresh pairing, which would pop a QR code modal on every page load.
// Its session outlives the page though — the kit rehydrates the address
// and the session topic from localStorage — so read the restored address
// from the kit rather than asking the module for it.
const addressResult = isSavedWalletConnect
? await StellarWalletsKit.getAddress()
Comment thread
jeesunikim marked this conversation as resolved.
: await StellarWalletsKit.selectedModule.getAddress({
skipRequestAccess,
});

const publicKey = addressResult?.address;

if (!addressResult?.address) {
if (!publicKey) {
return false;
}
const publicKey = addressResult.address;

if (!publicKey) {
// The cached address survives independently of the session, so confirm
// the sign client actually restored one that still authorizes this
// address on this chain. Otherwise Lab would show a connected wallet
// whose every signing request fails, and the user would have no way to
// tell why. Clearing the stale state sends them back to a fresh pairing.
if (
isSavedWalletConnect &&
!(await hasLiveWalletConnectSession({
address: publicKey,
networkId: network.id,
}))
) {
await disconnectKit();
clearWalletState();

return false;
}

Expand All @@ -124,6 +219,19 @@ export const ConnectWallet = () => {

const connectWallet = async () => {
try {
// Register WalletConnect before the modal opens: the kit snapshots its
// wallet list on open, so a module added later wouldn't appear. This
// fetches a chunk and waits for the sign client, roughly a second, so the
// button shows a loading state until the modal is ready to open. Only this
// step is covered — `authModal` then waits on the user scanning a QR code.
setIsPreparingWallets(true);

try {
await walletKitInstance.ensureWalletConnect();
} finally {
setIsPreparingWallets(false);
}

const { address } = await StellarWalletsKit.authModal();

if (!address) {
Expand Down Expand Up @@ -215,7 +323,13 @@ export const ConnectWallet = () => {
{renderModal()}
</>
) : (
<Button size="md" variant="secondary" onClick={connectWallet}>
<Button
size="md"
variant="secondary"
isLoading={isPreparingWallets}
disabled={isPreparingWallets}
onClick={connectWallet}
>
Connect wallet
{renderErrorModal()}
</Button>
Expand Down
Loading
Loading