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
21 changes: 21 additions & 0 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const DURATION_PRESETS = [
] as const;

const CREATE_STREAM_TIMEOUT_MS = 60_000;
const DRAFT_KEY = 'conduit:create-stream-draft';

/**
* Timeout message for the create pipeline. It is rendered inline in the form,
Expand Down Expand Up @@ -134,6 +135,25 @@ export default function CreatePage() {
},
});

useEffect(() => {
try {
const saved = localStorage.getItem(DRAFT_KEY);
if (saved) {
const draft = JSON.parse(saved) as Partial<FormValues>;
for (const key of ['recipient', 'token', 'depositAmount', 'durationSeconds', 'clawback'] as const) {
if (draft[key] !== undefined) setValue(key, draft[key] as never);
}
}
} catch { /* stale or unavailable storage is non-fatal */ }
}, [setValue]);

useEffect(() => {
const subscription = watch((values) => {
try { localStorage.setItem(DRAFT_KEY, JSON.stringify(values)); } catch { /* storage is optional */ }
});
return () => subscription.unsubscribe();
}, [watch]);

const deposit = watch('depositAmount');
const duration = watch('durationSeconds');
const token = watch('token');
Expand Down Expand Up @@ -389,6 +409,7 @@ export default function CreatePage() {
await refreshStreamData();

setTxHash(hash);
try { localStorage.removeItem(DRAFT_KEY); } catch { /* storage is optional */ }
// #362 — createStream now decodes the confirmed transaction's return
// value, so we can deep-link straight to the new stream instead of
// redirecting to /streams and hoping the user finds it. Fall back to
Expand Down
33 changes: 30 additions & 3 deletions app/streams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Plus, AlertCircle, Columns } from "lucide-react";
import { Plus, AlertCircle, Columns, Pause, Wallet, X } from "lucide-react";
import { useWallet } from "@/contexts/WalletContext";
import { StreamCard } from "@/components/stream/StreamCard";
import { StreamCardSkeleton } from "@/components/stream/StreamCardSkeleton";
import { streamsBySender, streamsByRecipient } from "@/lib/factory";
import { getStreamAddress, getStreamInfo, type StreamInfo } from '@/lib/stream';
import { getStreamAddress, getStreamInfo, getWithdrawable, pause, cancel, withdraw, type StreamInfo } from '@/lib/stream';
import { readSnapshot, saveSnapshot } from "@/lib/offline-cache";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { deriveStatus, MAX_COMPARE, MIN_COMPARE, type StreamStatus } from "@/lib/stream-compare";
Expand All @@ -20,6 +20,7 @@ type Tab = "receiving" | "sending";

interface StreamRow {
id: string;
address: string;
info: StreamInfo;
status: StreamStatus;
}
Expand Down Expand Up @@ -52,6 +53,7 @@ async function loadRows(
const info = await getStreamInfo(publicKey, addr);
rows.push({
id: id.toString(),
address: addr,
info,
status: deriveStatus(info, now),
});
Expand All @@ -77,7 +79,7 @@ function statusFromParam(value: string | null): "ALL" | StreamStatus {
}

function StreamsView() {
const { publicKey, connected } = useWallet();
const { publicKey, connected, signTx } = useWallet();
// The global NetworkTroubleBanner already covers RPC-down / fetch-failure
// cases, so suppress this page's own error row when it's showing.
const { status: networkStatus } = useNetworkStatus();
Expand Down Expand Up @@ -106,6 +108,8 @@ function StreamsView() {
});
// Stream ids ticked for the side-by-side /streams/compare view.
const [selected, setSelected] = useState<string[]>([]);
const [batchPending, setBatchPending] = useState(false);
const [batchMessage, setBatchMessage] = useState<string | null>(null);

useEffect(() => {
const params = new URLSearchParams();
Expand Down Expand Up @@ -206,6 +210,25 @@ function StreamsView() {
sort,
);

const selectedRows = displayed.filter(row => selected.includes(row.id));
async function runBatch(action: 'pause' | 'withdraw' | 'cancel') {
if (!publicKey || !signTx || selectedRows.length === 0) return;
setBatchPending(true); setBatchMessage(null);
try {
for (const row of selectedRows) {
if (action === 'pause') await pause(publicKey, row.address, signTx);
else if (action === 'cancel') await cancel(publicKey, row.address, signTx);
else {
const amount = await getWithdrawable(publicKey, row.address);
if (amount > 0n) await withdraw(publicKey, row.address, amount, signTx);
}
}
setBatchMessage(`${action[0].toUpperCase() + action.slice(1)} completed for ${selectedRows.length} stream${selectedRows.length === 1 ? '' : 's'}.`);
setSelected([]);
} catch (error) { setBatchMessage(error instanceof Error ? error.message : 'Batch action failed'); }
finally { setBatchPending(false); }
}

return (
<div className="max-w-3xl mx-auto px-4 py-10">
{/* Header */}
Expand Down Expand Up @@ -297,6 +320,9 @@ function StreamsView() {
: ""}
</span>
<div className="flex items-center gap-3">
{tab === 'sending' && <button disabled={batchPending} onClick={() => void runBatch('pause')} className="btn-secondary text-xs"><Pause className="w-3 h-3" /> Bulk Pause</button>}
{tab === 'receiving' && <button disabled={batchPending} onClick={() => void runBatch('withdraw')} className="btn-secondary text-xs"><Wallet className="w-3 h-3" /> Bulk Withdraw</button>}
{tab === 'sending' && <button disabled={batchPending} onClick={() => void runBatch('cancel')} className="btn-secondary text-xs"><X className="w-3 h-3" /> Bulk Cancel</button>}
<button
onClick={() => setSelected([])}
className="text-xs underline text-gray-400 hover:text-black dark:hover:text-white"
Expand All @@ -318,6 +344,7 @@ function StreamsView() {
</div>
</div>
)}
{batchMessage && <p role="status" className="text-sm text-gray-600 dark:text-gray-400 mb-4">{batchMessage}</p>}

{/* Content */}
{cachedAt && (
Expand Down
17 changes: 17 additions & 0 deletions lib/stellar-address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,20 @@ export function isValidStellarContract(address: string): boolean {
export function isValidStellarAddress(address: string): boolean {
return isValidStellarPublicKey(address) || isValidStellarContract(address);
}

export interface FederationResolveOptions { signal?: AbortSignal; endpoint?: string; }

/** Resolve a Stellar federation address such as `user*domain.com`. */
export async function resolveFederationAddress(federationAddress: string, options: FederationResolveOptions = {}): Promise<string> {
const match = federationAddress.trim().match(/^([^*]+)\*([^*]+)$/);
if (!match) throw new Error('Invalid federation address (expected user*domain.com)');
const endpoint = options.endpoint ?? `https://${match[2]}/federation`;
const url = new URL(endpoint);
url.searchParams.set('q', federationAddress.trim());
url.searchParams.set('type', 'name');
const response = await fetch(url, { signal: options.signal, headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`Federation lookup failed (${response.status})`);
const payload = await response.json() as { account_id?: string };
if (!payload.account_id || !isValidStellarPublicKey(payload.account_id)) throw new Error('Federation response did not contain a valid account');
return payload.account_id;
}
16 changes: 16 additions & 0 deletions lib/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ export interface TokenMeta {
logoUrl?: string;
}

export interface StellarExpertVerification { verified: boolean; token?: TokenMeta; error?: string; }

/** Verify an asset against Stellar Expert's public token directory. */
export async function verifyTokenAgainstStellarExpert(code: string, issuer: string, network: 'mainnet' | 'testnet' = 'mainnet', options: { signal?: AbortSignal } = {}): Promise<StellarExpertVerification> {
if (!code || !issuer) return { verified: false, error: 'Token code and issuer are required' };
const url = `https://api.stellar.expert/explorer/${network}/asset/${encodeURIComponent(code)}-${encodeURIComponent(issuer)}`;
try {
const response = await fetch(url, { signal: options.signal, headers: { Accept: 'application/json' } });
if (!response.ok) return { verified: false, error: `Stellar Expert returned ${response.status}` };
const data = await response.json() as { asset?: { code?: string; issuer?: string; name?: string; decimals?: number; image?: string } };
const asset = data.asset;
if (!asset?.code || asset.issuer !== issuer) return { verified: false, error: 'Asset was not found in Stellar Expert' };
return { verified: true, token: { symbol: asset.code, name: asset.name ?? asset.code, decimals: asset.decimals ?? 7, address: issuer, logoUrl: asset.image } };
} catch (error) { return { verified: false, error: error instanceof Error ? error.message : 'Token verification failed' }; }
}

export const TOKENS_TESTNET: TokenMeta[] = [
{
symbol: 'XLM',
Expand Down
Loading