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
110 changes: 110 additions & 0 deletions docs/hardware-wallets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Hardware Wallet Support (Ledger, Trezor)

OracleDesk-Frontend supports hardware wallets through the Stellar Wallets Kit, which provides integration with Ledger and Trezor devices.

## Supported Devices

- **Ledger Nano S/S Plus/X**: USB connection via Ledger Live or ledger.com browser extension
- **Ledger Stax**: Bluetooth connection via Ledger Live
- **Trezor Model One/T**: USB connection via Trezor browser extension

## Enabled Modules

The app includes all wallet modules via `@creit.tech/stellar-wallets-kit/modules/utils`:

```typescript
import { defaultModules } from "@creit.tech/stellar-wallets-kit/modules/utils";
// Includes: Freighter, WalletConnect, Ledger, Trezor, and others
```

## Browser Support

| Device | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Ledger (USB HID) | ✅ | ✅ | ✅ (11+) | ✅ |
| Trezor | ✅ | ✅ | ⚠️ Limited | ✅ |

USB HID (required for direct USB connection) is supported in all modern browsers except Safari (uses WebHID).

## Using Hardware Wallets

### 1. Ledger

**Via browser extension (recommended):**
- Install [Ledger Live](https://www.ledgerwallet.com/) browser extension
- Or use [Ledger Stellar App](https://ledger.com/pages/ledger-stellar)
- Connect your device
- Open OracleDesk

**Via USB:**
- Connect Ledger device
- Open OracleDesk in a browser that supports WebHID (all except Safari)
- Select Ledger from wallet picker

**Transaction signing:**
1. OracleDesk displays "Confirm on device"
2. Review transaction on Ledger screen
3. Press right button to approve (or left to reject)

### 2. Trezor

**Via browser extension:**
- Install [Trezor Bridge](https://wallet.trezor.io/) or use browser extension
- Connect your device
- Open OracleDesk

**Via USB:**
- Connect Trezor device
- Open OracleDesk in a browser that supports WebHID
- Select Trezor from wallet picker

**Transaction signing:**
1. A browser popup window appears
2. Review transaction details
3. Click "Confirm" on device (or reject)

## SEP-10 Challenge Signing

Hardware wallets sign SEP-10 challenge transactions before the user can trade:

1. User clicks "Connect Wallet" → selects hardware wallet
2. OracleDesk requests a SEP-10 challenge from the backend
3. Challenge is validated before signing (see `lib/stellar/sep10-validator.ts`)
4. User confirms signature on device
5. Backend verifies signature and returns JWT

This prevents the user from accidentally signing arbitrary transactions.

## Troubleshooting

### "Device not found"
- Ensure device is connected and unlocked
- Try refreshing the page
- Check browser console for errors
- For Ledger: open Ledger Live and switch to browser mode

### "User rejected transaction"
- User cancelled on the device
- Try again or use a different account

### "Signature verification failed"
- Device may have outdated firmware; update via Ledger Live or Trezor Wallet
- Try a different account if available

### Safari limitations
- Safari doesn't support WebHID (required for direct USB connection)
- Use browser extensions (Ledger Live) instead
- Or switch to Chrome/Firefox/Edge

## Development

To test hardware wallet support:

```bash
npm run dev
# Open http://localhost:3000
# Connect any supported hardware wallet
# Try signing a transaction (trades auto-redirect to /markets after login)
```

The `lib/stellar/WalletProvider.tsx` initializes the Stellar Wallets Kit and handles wallet connection, disconnection, and signing.
78 changes: 76 additions & 2 deletions lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { apiClient, clearAuthSession, getStoredSessionWallet, getStoredToken, storeAuthSession } from "./client";
import { apiClient, clearAuthSession, getStoredSessionWallet, getStoredToken, storeAuthSession, isAuthError } from "./client";
import { validateSEP10Challenge, type SEP10ValidationError } from "@/lib/stellar/sep10-validator";
import { isTokenExpired, getTokenTTL, getReAuthPromise, setReAuthPromise, isReAuthInFlight, waitForReAuth } from "./jwt-manager";
import { broadcastSessionChange } from "./cross-tab-sync";

export interface AuthSession {
token: string;
Expand All @@ -21,21 +24,92 @@ export async function requestChallenge(address: string) {
export async function verifyChallenge(address: string, signed: string) {
const { data } = await apiClient.post<AuthSession>("/auth/verify", { address, signed });
storeAuthSession(data);
broadcastSessionChange(data.token, data.walletAddress);
return data;
}

/**
* Validate SEP-10 challenge before signing.
* Throws if the challenge is invalid or expired.
*/
export function validateChallenge(challenge: AuthChallenge): SEP10ValidationError | null {
return validateSEP10Challenge(challenge, challenge.networkPassphrase);
}

/**
* SEP-10 style login: the backend issues a challenge transaction, the wallet
* signs it (signTransaction works in every Stellar wallet), the backend
* verifies the signature and returns a JWT.
*
* Validates the challenge before signing to prevent signing malformed transactions.
*/
export async function signInWithWallet(
address: string,
signTransaction: (xdr: string, networkPassphrase: string) => Promise<string>,
): Promise<AuthSession> {
const challenge = await requestChallenge(address);

// Validate challenge before signing
const validationError = validateChallenge(challenge);
if (validationError) {
throw new Error(`Invalid SEP-10 challenge: ${validationError.message}`);
}

const signed = await signTransaction(challenge.transaction, challenge.networkPassphrase);
return verifyChallenge(address, signed);
}

export { clearAuthSession, getStoredSessionWallet, getStoredToken };
/**
* Check if stored JWT is expired and needs refresh.
* Useful for preemptive re-auth before making API calls.
*/
export function isStoredTokenExpired(): boolean {
const token = getStoredToken();
return !token || isTokenExpired(token);
}

/**
* Get TTL of stored JWT (time until expiry in milliseconds).
* Returns 0 if token is invalid or expired.
*/
export function getStoredTokenTTL(): number {
const token = getStoredToken();
return token ? getTokenTTL(token) : 0;
}

/**
* Single-flight re-authentication: ensures only one re-auth request is in flight.
* Subsequent calls wait for the first one to complete instead of spawning new requests.
*
* This prevents the "thundering herd" problem when many API calls fail with 401
* simultaneously and all try to re-auth at once.
*/
export async function ensureAuthWithSingleFlight(
address: string,
signTransaction: (xdr: string, networkPassphrase: string) => Promise<string>,
): Promise<AuthSession> {
// Check if re-auth is already in flight
if (isReAuthInFlight()) {
await waitForReAuth();
// After waiting, return the current session (it may still fail if re-auth failed)
const token = getStoredToken();
const wallet = getStoredSessionWallet();
if (!token || !wallet) throw new Error("Re-authentication failed");
return { token, userId: "", walletAddress: wallet };
}

// Start new re-auth attempt
const reAuthPromise = signInWithWallet(address, signTransaction);
setReAuthPromise(reAuthPromise);

try {
const session = await reAuthPromise;
setReAuthPromise(null);
return session;
} catch (error) {
setReAuthPromise(null);
throw error;
}
}

export { clearAuthSession, getStoredSessionWallet, getStoredToken, isAuthError };
11 changes: 11 additions & 0 deletions lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ async function request<T>(
};
}

/**
* Enhanced error handling with JWT expiry detection.
* Returns true if the error indicates an expired or invalid token that needs re-auth.
*/
export function isAuthError(error: unknown): boolean {
if (error instanceof ApiError) {
return error.code === "TOKEN_EXPIRED" || error.code === "UNAUTHORIZED" || error.status === 401;
}
return false;
}

export const apiClient = {
get<T>(path: string, params?: QueryParams) {
return request<T>(path, { method: "GET" }, params);
Expand Down
113 changes: 113 additions & 0 deletions lib/api/cross-tab-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Cross-tab synchronization for wallet and session state.
* Uses BroadcastChannel for real-time sync and storage events as fallback.
*/

export interface SyncMessage {
type: "auth_session_changed" | "wallet_disconnected" | "wallet_changed";
address?: string | null;
token?: string | null;
timestamp: number;
}

/**
* Create a BroadcastChannel for cross-tab sync, with graceful fallback to storage events.
*/
export function createCrossTabSync(onMessage: (msg: SyncMessage) => void) {
const unsubscribe: Array<() => void> = [];

// Try to use BroadcastChannel (not supported in all browsers)
let channel: BroadcastChannel | null = null;
try {
channel = new BroadcastChannel("oracledesk-wallet-sync");
channel.addEventListener("message", (event) => {
onMessage(event.data as SyncMessage);
});
unsubscribe.push(() => {
channel?.close();
});
} catch {
// BroadcastChannel not available, fall back to storage events
}

// Fallback: listen for storage events from other tabs
const handleStorageChange = (event: StorageEvent) => {
if (event.key === "oracledesk_wallet") {
onMessage({
type: "wallet_changed",
address: event.newValue,
timestamp: Date.now(),
});
} else if (event.key === "oracledesk_token") {
onMessage({
type: "auth_session_changed",
token: event.newValue || undefined,
timestamp: Date.now(),
});
}
};

if (!channel) {
window.addEventListener("storage", handleStorageChange);
unsubscribe.push(() => {
window.removeEventListener("storage", handleStorageChange);
});
}

return {
/**
* Broadcast a message to other tabs.
*/
broadcast(msg: SyncMessage) {
if (channel) {
channel.postMessage(msg);
}
// Storage events are automatic when localStorage changes
},

/**
* Stop listening for sync messages.
*/
unsubscribe: () => {
unsubscribe.forEach((fn) => fn());
},
};
}

/**
* Broadcast session change to other tabs.
*/
export function broadcastSessionChange(token: string | null, address: string | null) {
if (typeof window === "undefined") return;

try {
const channel = new BroadcastChannel("oracledesk-wallet-sync");
channel.postMessage({
type: "auth_session_changed",
token,
address,
timestamp: Date.now(),
} as SyncMessage);
channel.close();
} catch {
// BroadcastChannel not available; storage events will handle it
}
}

/**
* Broadcast wallet disconnection to other tabs.
*/
export function broadcastWalletDisconnect() {
if (typeof window === "undefined") return;

try {
const channel = new BroadcastChannel("oracledesk-wallet-sync");
channel.postMessage({
type: "wallet_disconnected",
timestamp: Date.now(),
} as SyncMessage);
channel.close();
} catch {
// BroadcastChannel not available
}
}
Loading