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
42 changes: 0 additions & 42 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 31 additions & 2 deletions src/adapters/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,26 @@ import TransportWebHID from "@ledgerhq/hw-transport-webhid";
import type Transport from "@ledgerhq/hw-transport";
import Str from "@ledgerhq/hw-app-str";
import type { WalletAdapter } from "../types.js";
import { LedgerFirmwareTooOldError } from "../errors.js";

/** Minimum Ledger Stellar app version required for signing. */
export const MIN_LEDGER_FIRMWARE = "2.0.0";

/** Ledger hardware wallet adapter implementing WalletAdapter. */
export class LedgerAdapter implements WalletAdapter {
private readonly path: string;
private readonly skipFirmwareCheck: boolean;

constructor(path = "44'/148'/0'") {
this.path = path;
constructor(options?: { path?: string; skipFirmwareCheck?: boolean }) {
this.path = options?.path ?? "44'/148'/0'";
this.skipFirmwareCheck = options?.skipFirmwareCheck ?? false;
}

async getAddress(): Promise<string> {
const transport = await this.openTransport();
try {
const str = new Str(transport);
await this.checkFirmwareVersion(str);
const { publicKey } = await str.getPublicKey(this.path);
return publicKey;
} finally {
Expand All @@ -26,6 +33,7 @@ export class LedgerAdapter implements WalletAdapter {
const transport = await this.openTransport();
try {
const str = new Str(transport);
await this.checkFirmwareVersion(str);
const txBytes = Uint8Array.from(atob(xdr), (c) => c.charCodeAt(0));
const { signature } = await str.signTransaction(
this.path,
Expand All @@ -38,6 +46,27 @@ export class LedgerAdapter implements WalletAdapter {
}
}

private async checkFirmwareVersion(str: Str): Promise<void> {
if (this.skipFirmwareCheck) return;
const { version } = await str.getAppConfiguration();
if (this.versionCompare(version, MIN_LEDGER_FIRMWARE) < 0) {
throw new LedgerFirmwareTooOldError(MIN_LEDGER_FIRMWARE, version);
}
}

/** Semantic version comparison: returns <0 if a<b, 0 if equal, >0 if a>b. */
private versionCompare(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na < nb) return -1;
if (na > nb) return 1;
}
return 0;
}

private async openTransport(): Promise<Transport> {
try {
return await TransportWebHID.create();
Expand Down
131 changes: 122 additions & 9 deletions src/adapters/walletconnect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import type { WalletAdapter } from "./types.js";

/** Session data persisted to localStorage for recovery across reloads. */
export interface PersistedWalletConnectSession {
topic: string;
relayUrl: string;
chainId: string;
address: string;
/** Unix timestamp (ms) when the session expires. */
expiry: number;
}

/** Options for constructing a WalletConnectAdapter. */
export interface WalletConnectAdapterOptions {
/** WalletConnect Sign Client instance (from @walletconnect/sign-client). */
Expand All @@ -12,36 +22,139 @@ export interface WalletConnectAdapterOptions {
}): Promise<string>;
};
/** Active WalletConnect session topic. */
topic: string;
topic?: string;
/** Stellar chain ID (e.g. "stellar:testnet"). */
chainId: string;
chainId?: string;
/** The connected wallet's Stellar public key. */
address: string;
address?: string;
/** WalletConnect relay URL. */
relayUrl?: string;
/** Session expiry timestamp (ms). */
expiry?: number;
/** localStorage key override (default: stellar_split_walletconnect_session). */
storageKey?: string;
}

const DEFAULT_STORAGE_KEY = "stellar_split_walletconnect_session";

/**
* WalletConnect adapter — routes signing through a WalletConnect session
* instead of the Freighter browser extension.
*
* Automatically persists the active session to localStorage on connect
* and restores it on construction when it has not expired.
*/
export class WalletConnectAdapter implements WalletAdapter {
private readonly opts: WalletConnectAdapterOptions;
private readonly client: WalletConnectAdapterOptions["client"];
private topic: string | undefined;
private chainId: string | undefined;
private addressValue: string | undefined;
private relayUrl: string | undefined;
private readonly storageKey: string;

constructor(opts: WalletConnectAdapterOptions) {
this.opts = opts;
this.client = opts.client;
this.storageKey = opts.storageKey ?? DEFAULT_STORAGE_KEY;

if (opts.topic && opts.chainId && opts.address) {
// Fresh connection supplied directly.
this.topic = opts.topic;
this.chainId = opts.chainId;
this.addressValue = opts.address;
this.relayUrl = opts.relayUrl;
if (opts.expiry) {
this.persist({
topic: opts.topic,
relayUrl: opts.relayUrl ?? "",
chainId: opts.chainId,
address: opts.address,
expiry: opts.expiry,
});
}
} else {
// Attempt to restore a previously persisted session.
this.restore();
}
}

/** Returns true when a session (restored or explicitly set) is present. */
get isConnected(): boolean {
return this.topic !== undefined && this.addressValue !== undefined;
}

async getAddress(): Promise<string> {
return this.opts.address;
if (!this.addressValue) {
throw new Error(
"WalletConnect session not available. Connect or restore a session first."
);
}
return this.addressValue;
}

async signTransaction(xdr: string, network: string): Promise<string> {
return this.opts.client.request({
topic: this.opts.topic,
chainId: this.opts.chainId,
if (!this.topic || !this.chainId) {
throw new Error(
"WalletConnect session not available. Connect or restore a session first."
);
}
return this.client.request({
topic: this.topic,
chainId: this.chainId,
request: {
method: "stellar_signXDR",
params: { xdr, network },
},
});
}

/**
* Persists a successfully established session so it survives page reloads.
* Call this once the WalletConnect pairing / session creation has completed.
*/
persist(session: PersistedWalletConnectSession): void {
this.topic = session.topic;
this.chainId = session.chainId;
this.addressValue = session.address;
this.relayUrl = session.relayUrl;
if (typeof localStorage !== "undefined") {
localStorage.setItem(this.storageKey, JSON.stringify(session));
}
}

/**
* Clears the active session and removes persisted data from localStorage.
* The underlying WalletConnect client should also be disconnected by the
* caller (this adapter does not own the client lifecycle).
*/
disconnect(): void {
this.topic = undefined;
this.chainId = undefined;
this.addressValue = undefined;
this.relayUrl = undefined;
if (typeof localStorage !== "undefined") {
localStorage.removeItem(this.storageKey);
}
}

/** Attempts to restore a session from localStorage. */
private restore(): void {
if (typeof localStorage === "undefined") return;
const raw = localStorage.getItem(this.storageKey);
if (!raw) return;
try {
const session: PersistedWalletConnectSession = JSON.parse(raw);
if (Date.now() >= session.expiry) {
// Session has expired — clean it up.
localStorage.removeItem(this.storageKey);
return;
}
this.topic = session.topic;
this.chainId = session.chainId;
this.addressValue = session.address;
this.relayUrl = session.relayUrl;
} catch {
// Malformed storage entry — discard.
localStorage.removeItem(this.storageKey);
}
}
}
18 changes: 18 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,24 @@ export class NoSignerProvidedError extends StellarSplitError {
}
}

/** Thrown when the Ledger device firmware (or Stellar app) is too old for the requested operation. */
export class LedgerFirmwareTooOldError extends StellarSplitError {
readonly requiredVersion: string;
readonly actualVersion: string;

constructor(requiredVersion: string, actualVersion: string) {
super(
`Ledger firmware/app version ${actualVersion} is too old. Minimum required: ${requiredVersion}`,
"LEDGER_FIRMWARE_TOO_OLD",
{ requiredVersion, actualVersion }
);
this.name = "LedgerFirmwareTooOldError";
this.requiredVersion = requiredVersion;
this.actualVersion = actualVersion;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/** Thrown when connection pool is improperly configured. */
export class ConnectionPoolConfigError extends StellarSplitError {
readonly issue: string;
Expand Down
7 changes: 4 additions & 3 deletions src/wallets/adapters/FreighterAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { WalletAdapter } from "../../types.js";
import { FreighterNotInstalledError } from "../../errors.js";

type Unsubscribe = () => void;

Expand All @@ -24,7 +25,7 @@ export class FreighterAdapter implements WalletAdapter {

async connect(): Promise<string> {
if (!window.freighter) {
throw new Error("Freighter wallet not installed");
throw new FreighterNotInstalledError();
}

const address = await window.freighter.getPublicKey();
Expand All @@ -38,15 +39,15 @@ export class FreighterAdapter implements WalletAdapter {

async sign(xdr: string, network: string): Promise<string> {
if (!window.freighter) {
throw new Error("Freighter wallet not installed");
throw new FreighterNotInstalledError();
}

return await window.freighter.signTransaction(xdr, network);
}

async getAddress(): Promise<string> {
if (!window.freighter) {
throw new Error("Freighter wallet not installed");
throw new FreighterNotInstalledError();
}

return await window.freighter.getPublicKey();
Expand Down
Loading
Loading