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
157 changes: 157 additions & 0 deletions src/adapters/walletconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,55 @@ export interface WalletConnectAdapterOptions {
chainId: string;
/** The connected wallet's Stellar public key. */
address: string;
/**
* Relay URL used by the WalletConnect session.
* Persisted alongside the topic so the session can be restored on reload.
*/
relayUrl?: string;
/**
* Unix timestamp (seconds) at which the WalletConnect session expires.
* When provided the adapter validates the expiry before restoring a stored
* session and persists it so future restores can make the same check.
*/
expiry?: number;
}

/** Shape of the data written to / read from localStorage. */
interface PersistedSession {
topic: string;
relayUrl: string;
chainId: string;
address: string;
/** Unix timestamp in seconds when this session expires. */
expiry: number;
}

/** localStorage key used to store the active WalletConnect session. */
const STORAGE_KEY = "stellarsplit:wc:session";

/**
* WalletConnect adapter — routes signing through a WalletConnect session
* instead of the Freighter browser extension.
*
* ### Session persistence
* On construction the adapter persists the session to `localStorage` so it
* survives page reloads. Call the static `restore()` factory to hydrate an
* adapter from a previously-persisted session without requiring the user to
* scan a QR code again. `disconnect()` clears the stored data.
*/
export class WalletConnectAdapter implements WalletAdapter {
private readonly opts: WalletConnectAdapterOptions;

constructor(opts: WalletConnectAdapterOptions) {
this.opts = opts;
// Persist the session immediately on construction.
this.persistSession();
}

// ---------------------------------------------------------------------------
// WalletAdapter interface
// ---------------------------------------------------------------------------

async getAddress(): Promise<string> {
return this.opts.address;
}
Expand All @@ -44,4 +80,125 @@ export class WalletConnectAdapter implements WalletAdapter {
},
});
}

/**
* Clear the persisted session data from localStorage and reset internal
* state so the user must reconnect after the next page load.
*/
disconnect(): void {
WalletConnectAdapter.clearStoredSession();
}

// ---------------------------------------------------------------------------
// Static helpers for session persistence
// ---------------------------------------------------------------------------

/**
* Restore a previously-persisted WalletConnect session.
*
* Returns `null` when:
* - no session has been stored, or
* - the stored session has expired.
*
* The caller is responsible for providing the live WalletConnect `client`
* instance; only the session metadata is read from localStorage.
*
* @example
* ```ts
* const adapter = WalletConnectAdapter.restore(signClient);
* if (adapter) {
* // Session is still valid — no QR scan needed.
* } else {
* // Show QR code and create a new adapter on successful pairing.
* }
* ```
*/
static restore(
client: WalletConnectAdapterOptions["client"]
): WalletConnectAdapter | null {
const raw = WalletConnectAdapter.readRawSession();
if (!raw) return null;

// Validate expiry before restoring.
const nowSeconds = Math.floor(Date.now() / 1000);
if (raw.expiry <= nowSeconds) {
// Stale session — clean up so the user is not stuck.
WalletConnectAdapter.clearStoredSession();
return null;
}

return new WalletConnectAdapter({
client,
topic: raw.topic,
chainId: raw.chainId,
address: raw.address,
relayUrl: raw.relayUrl,
expiry: raw.expiry,
});
}

/**
* Remove the persisted session entry from localStorage.
* Called automatically by `disconnect()`.
*/
static clearStoredSession(): void {
try {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
// localStorage may be unavailable in some environments (e.g. SSR).
}
}

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------

/** Write current session data to localStorage. */
private persistSession(): void {
try {
if (typeof localStorage === "undefined") return;

const data: PersistedSession = {
topic: this.opts.topic,
relayUrl: this.opts.relayUrl ?? "",
chainId: this.opts.chainId,
address: this.opts.address,
// Default to 7 days from now if no expiry provided.
expiry:
this.opts.expiry ?? Math.floor(Date.now() / 1000) + 7 * 24 * 3600,
};

localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// Silently ignore write failures (storage quota, SSR, etc.).
}
}

/** Parse raw stored session data without validation. Returns `null` on any error. */
private static readRawSession(): PersistedSession | null {
try {
if (typeof localStorage === "undefined") return null;

const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;

const parsed = JSON.parse(raw) as Partial<PersistedSession>;

// Basic shape validation.
if (
typeof parsed.topic !== "string" ||
typeof parsed.chainId !== "string" ||
typeof parsed.address !== "string" ||
typeof parsed.expiry !== "number"
) {
return null;
}

return parsed as PersistedSession;
} catch {
return null;
}
}
}
71 changes: 71 additions & 0 deletions src/graph/PaymentGraphChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,52 @@ export class UnreachableRecipientError extends Error {
}
}

/** Represents a single directed edge in a payment graph. */
export interface PaymentGraphEdge {
/** Source node (account or asset identifier). */
from: string;
/** Destination node. */
to: string;
/**
* Numeric weight for this edge (e.g. an amount or fee multiplier).
* Zero-weight edges are allowed (pass-through hops); negative weights are not.
*/
weight: number;
}

/** A payment graph expressed as a list of directed, weighted edges. */
export interface PaymentGraph {
edges: PaymentGraphEdge[];
}

/** Result returned by `checkGraph()`. */
export interface GraphValidationResult {
/** `true` when all edges pass validation. */
valid: boolean;
/**
* Human-readable description of the first violation found, or `undefined`
* when the graph is valid.
*/
reason?: string;
}

/**
* Validate the structural integrity of a payment graph.
*
* Currently enforced rules:
* - No edge may have a negative weight. Zero-weight edges (pass-through hops)
* are permitted. A negative-weight edge can cause unbounded fund extraction
* when the graph is traversed greedily.
*
* @example
* ```ts
* const result = checker.checkGraph(graph);
* if (!result.valid) {
* throw new Error(result.reason);
* }
* ```
*/

interface CacheKey {
sourceAsset: string;
sourceAccount: string;
Expand Down Expand Up @@ -127,6 +173,31 @@ export class PaymentGraphChecker {
return result;
}

/**
* Validate a payment graph for structural correctness.
*
* Returns a failure result when any edge has a negative weight, naming the
* offending edge (`source → target`) in the reason message.
* Zero-weight edges are permitted — they represent pass-through hops where
* no fee or amount is exchanged.
*
* Valid graphs (all weights ≥ 0) return `{ valid: true }`.
*
* @param graph - The directed payment graph to validate.
*/
checkGraph(graph: PaymentGraph): GraphValidationResult {
for (const edge of graph.edges) {
if (edge.weight < 0) {
return {
valid: false,
reason: `Negative-weight edge detected: ${edge.from} → ${edge.to} (weight: ${edge.weight})`,
};
}
}

return { valid: true };
}

/**
* Find a payment path from source to destination using Stellar path-finding.
*/
Expand Down
57 changes: 45 additions & 12 deletions src/wallets/adapters/FreighterAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/**
* FreighterAdapter — Adapter for the Freighter wallet extension.
*
* Before any Freighter API call the adapter checks whether the extension is
* installed (`window.freighter`). If it is absent a `FreighterNotInstalledError`
* is thrown with the install URL so callers can surface an actionable message
* to the user instead of a cryptic TypeError.
*/

import type { WalletAdapter } from "../../types.js";
Expand All @@ -16,18 +21,36 @@ declare global {
}
}

/** Install URL shown to users when the Freighter extension is not found. */
const FREIGHTER_INSTALL_URL = "https://www.freighter.app";

/**
* Thrown when a Freighter API call is attempted but the browser extension is
* not installed. The `message` includes the install URL so it can be shown
* directly to the user.
*/
export class FreighterNotInstalledError extends Error {
constructor() {
super(
`Freighter wallet extension is not installed. ` +
`Install it from ${FREIGHTER_INSTALL_URL}`
);
this.name = "FreighterNotInstalledError";
// Maintain correct instanceof checks in transpiled environments.
Object.setPrototypeOf(this, new.target.prototype);
}
}

export class FreighterAdapter implements WalletAdapter {
readonly name = "Freighter";
private accountChangeHandlers: Array<(address: string) => void> = [];
private pollInterval: NodeJS.Timeout | null = null;
private lastKnownAddress: string | null = null;

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

const address = await window.freighter.getPublicKey();
const address = await window.freighter!.getPublicKey();
this.lastKnownAddress = address;

// Start polling for account changes (Freighter doesn't have a native event)
Expand All @@ -37,19 +60,15 @@ export class FreighterAdapter implements WalletAdapter {
}

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

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

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

return await window.freighter.getPublicKey();
return await window.freighter!.getPublicKey();
}

async signTransaction(xdr: string, network: string): Promise<string> {
Expand All @@ -76,6 +95,20 @@ export class FreighterAdapter implements WalletAdapter {
};
}

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------

/**
* Assert that the Freighter extension is available in the current window.
* Throws `FreighterNotInstalledError` when it is not.
*/
private assertInstalled(): void {
if (!window.freighter) {
throw new FreighterNotInstalledError();
}
}

private startAccountChangePolling(): void {
if (this.pollInterval) return;

Expand Down
Loading