diff --git a/src/features/transactions/useXdrSigner.ts b/src/features/transactions/useXdrSigner.ts index 3f3badf..b52ac04 100644 --- a/src/features/transactions/useXdrSigner.ts +++ b/src/features/transactions/useXdrSigner.ts @@ -1,5 +1,23 @@ import { useState, useCallback, useEffect } from 'react'; import * as freighter from '@stellar/freighter-api'; +import { Account, Asset, Networks, Operation, TransactionBuilder } from '@stellar/stellar-sdk'; + +export const STELLAR_HORIZON_URL = + process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org'; +export const STELLAR_NETWORK_PASSPHRASE = + process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE || Networks.TESTNET; + +export interface Trustline { + assetCode: string; + issuer: string; + balance: string; + limit?: string; +} + +export interface AddTrustlineResult { + signedXdr: string; + hash: string; +} export interface UseXdrSignerResult { activeKey: string | null; @@ -11,6 +29,17 @@ export interface UseXdrSignerResult { signXdr: (xdr: string, networkPassphrase?: string) => Promise; disconnectWallet: () => void; setActiveKey: (key: string | null) => void; + getAccountBalances: (publicKey?: string) => Promise; + buildTrustlineXdr: (assetCode: string, issuer: string, limit?: string, publicKey?: string) => Promise; + addTrustline: (assetCode: string, issuer: string, limit?: string, publicKey?: string) => Promise; +} + +async function fetchAccount(publicKey: string): Promise { + const res = await fetch(`${STELLAR_HORIZON_URL}/accounts/${publicKey}`); + if (!res.ok) { + throw new Error(`Failed to fetch account from Horizon: ${res.statusText}`); + } + return res.json(); } export function useXdrSigner(): UseXdrSignerResult { @@ -24,13 +53,14 @@ export function useXdrSigner(): UseXdrSignerResult { const checkAvailability = async () => { try { if (typeof freighter.isConnected === 'function') { - const res = await freighter.isConnected(); - const available = typeof res === 'boolean' ? res : Boolean(res?.isConnected); + const res = await freighter.isConnected() as { isConnected?: boolean } | boolean; + const available = typeof res === 'boolean' ? res : Boolean(res); setIsFreighterAvailable(available); if (available && typeof freighter.getAddress === 'function') { - const info = await freighter.getAddress(); - if (info?.address) { - setActiveKey(info.address); + const info = await freighter.getAddress() as { address?: string; publicKey?: string }; + const pubKey = info?.address || info?.publicKey; + if (pubKey) { + setActiveKey(pubKey); setIsConnected(true); } } @@ -47,24 +77,27 @@ export function useXdrSigner(): UseXdrSignerResult { setError(null); try { if (typeof freighter.requestAccess === 'function') { - const res = await freighter.requestAccess(); + const res = await freighter.requestAccess() as string | { address?: string; publicKey?: string }; + let pubKey: string | null = null; if (typeof res === 'string' && res) { - setActiveKey(res); - setIsConnected(true); - return res; - } else if (res && typeof res === 'object' && 'address' in res && res.address) { - const addr = (res as { address: string }).address; - setActiveKey(addr); + pubKey = res; + } else if (res && typeof res === 'object') { + const obj = res as { address?: string; publicKey?: string }; + pubKey = obj.address || obj.publicKey || null; + } + if (pubKey) { + setActiveKey(pubKey); setIsConnected(true); - return addr; + return pubKey; } } if (typeof freighter.getAddress === 'function') { - const info = await freighter.getAddress(); - if (info?.address) { - setActiveKey(info.address); + const info = await freighter.getAddress() as { address?: string; publicKey?: string }; + const pubKey = info?.address || info?.publicKey; + if (pubKey) { + setActiveKey(pubKey); setIsConnected(true); - return info.address; + return pubKey; } } setError('Freighter browser extension was not detected. Please install Freighter or select a key manually.'); @@ -85,12 +118,12 @@ export function useXdrSigner(): UseXdrSignerResult { try { if (typeof freighter.signTransaction === 'function') { const res = await freighter.signTransaction(xdr, { - networkPassphrase: networkPassphrase || 'Test SDF Network ; September 2015', - }); + networkPassphrase: networkPassphrase || STELLAR_NETWORK_PASSPHRASE, + }) as string | { signedTxXdr?: string }; if (typeof res === 'string') { return res; } else if (res && typeof res === 'object' && 'signedTxXdr' in res) { - return res.signedTxXdr; + return res.signedTxXdr ?? null; } } throw new Error('Freighter signing operation failed or extension not connected.'); @@ -105,6 +138,96 @@ export function useXdrSigner(): UseXdrSignerResult { [] ); + const getAccountBalances = useCallback(async (publicKey?: string): Promise => { + const key = publicKey || activeKey; + if (!key) { + setError('Wallet is not connected. Cannot fetch balances.'); + return []; + } + try { + const account = await fetchAccount(key); + const balances = account.balances || []; + return balances.map((balance: any) => ({ + assetCode: balance.asset_code || 'XLM', + issuer: balance.asset_issuer || '', + balance: balance.balance, + limit: balance.limit, + })); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to fetch account balances.'; + setError(msg); + return []; + } + }, [activeKey]); + + const buildTrustlineXdr = useCallback( + async (assetCode: string, issuer: string, limit?: string, publicKey?: string): Promise => { + const key = publicKey || activeKey; + if (!key) { + setError('Wallet is not connected. Cannot build trustline transaction.'); + return null; + } + try { + const account = await fetchAccount(key); + const asset = new Asset(assetCode, issuer); + const accountObj = new Account(key, account.sequence); + const transaction = new TransactionBuilder(accountObj, { + fee: '100', + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation(Operation.changeTrust({ asset, limit: limit || undefined })) + .setTimeout(180) + .build(); + return transaction.toXDR(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to build trustline transaction.'; + setError(msg); + return null; + } + }, + [activeKey] + ); + + const addTrustline = useCallback( + async (assetCode: string, issuer: string, limit?: string, publicKey?: string): Promise => { + const key = publicKey || activeKey; + if (!key) { + setError('Wallet is not connected. Cannot add trustline.'); + return null; + } + try { + const xdr = await buildTrustlineXdr(assetCode, issuer, limit, key); + if (!xdr) { + return null; + } + const signedXdr = await signXdr(xdr); + if (!signedXdr) { + return null; + } + + const body = new URLSearchParams({ tx: signedXdr }); + const res = await fetch(`${STELLAR_HORIZON_URL}/transactions`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Transaction submission failed (${res.status}): ${errorText}`); + } + + const data = await res.json(); + return { signedXdr, hash: data.hash }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to add trustline.'; + setError(msg); + return null; + } + }, + [activeKey, buildTrustlineXdr, signXdr] + ); + const disconnectWallet = useCallback(() => { setActiveKey(null); setIsConnected(false); @@ -121,5 +244,8 @@ export function useXdrSigner(): UseXdrSignerResult { signXdr, disconnectWallet, setActiveKey, + getAccountBalances, + buildTrustlineXdr, + addTrustline, }; } diff --git a/src/features/wallet/trustline-schema.ts b/src/features/wallet/trustline-schema.ts new file mode 100644 index 0000000..3673864 --- /dev/null +++ b/src/features/wallet/trustline-schema.ts @@ -0,0 +1 @@ +import { z } from 'zod';export const stellarPublicKeySchema = z.string().regex(/^G[2-9A-J-NP-Z]{55}$/);export const assetCodeSchema = z.string().min(1).max(12).regex(/^[a-zA-Z0-9]{1,12}$/);export const trustlineSchema = z.object({assetCode: assetCodeSchema,assetIssuer: stellarPublicKeySchema.optional(),balance: z.string().optional(),limit: z.string().optional()});export type StellarPublicKey = z.infer;export type AssetCode = z.infer;export type Trustline = z.infer; \ No newline at end of file diff --git a/src/features/wallet/useTrustlineManager.ts b/src/features/wallet/useTrustlineManager.ts new file mode 100644 index 0000000..d65dc4b --- /dev/null +++ b/src/features/wallet/useTrustlineManager.ts @@ -0,0 +1,85 @@ +import { useState, useEffect, useCallback } from 'react'; +import { toast } from 'sonner'; +import { useWalletStore } from '/stores/wallet'; +import { + getSupportedAssetStatuses, + buildTrustlineTransaction, + signAndSubmitTransaction, + SupportedAssetStatus, +} from '../../services/stellar'; + +export function useTrustlineManager() { + const publicKey = useWalletStore((state: any) => state.publicKey); + const isConnected = useWalletStore((state: any) => state.isConnected); + + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const fetchAssets = useCallback(async () => { + if (!publicKey) { + setAssets([]); + return; + } + + setLoading(true); + setError(null); + + try { + const statuses = await getSupportedAssetStatuses(publicKey); + setAssets(statuses); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch asset balances'; + setError(message); + toast.error(message); + } finally { + setLoading(false); + } + }, [publicKey]); + + useEffect(() { + fetchAssets(); + }, [fetchAssets]); + + const addTrustline = useCallback( + async (assetCode: string, assetIssuer: string) => { + if (!publicKey) { + toast.error('Wallet not connected'); + return; + } + + const existing = assets.find( + (a) => a.assetCode === assetCode && a.issuer === assetIssuer + ); + if (existing?.trustlineActive) { + toast.info('Trustline already exists'); + return; + } + + setIsSubmitting(true); + try { + const xdr = await buildTrustlineTransaction(publicKey, assetCode, assetIssuer); + await signAndSubmitTransaction(xdr); + toast.success('Trustline added successfully'); + await fetchAssets(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to add trustline'; + toast.error(message); + } finally { + setIsSubmitting(false); + } + }, + [publicKey, assets, fetchAssets] + ); + + return { + assets, + loading, + error, + isSubmitting, + isConnected, + addTrustline, + refresh: fetchAssets, + }; +} \ No newline at end of file diff --git a/src/hooks/useStellarWallet.ts b/src/hooks/useStellarWallet.ts index 3d316f2..8fc7fac 100644 --- a/src/hooks/useStellarWallet.ts +++ b/src/hooks/useStellarWallet.ts @@ -1,6 +1,8 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import { z } from 'zod'; export type StellarWalletStatus = 'idle' | 'checking' | 'connected' | 'missing' | 'error'; @@ -36,11 +38,25 @@ async function loadFreighter(): Promise { } } +const trustlineSchema = z.object({ + assetCode: z.string().min(1, 'Asset code is required.').max(12).regex(/^[a-zA-Z0-9]{1,12}$/, 'Invalid asset code.'), + issuer: z.string().regex(/^G[2-7A-H-J-NP-Z]{55}$/, 'Invalid Stellar public key.'), +}); + +export type AccountBalance = { + assetCode: string; + assetIssuer: string | null; + balance: string; + limit?: string; +}; + export function useStellarWallet() { const [status, setStatus] = useState('checking'); const [publicKey, setPublicKey] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [balances, setBalances] = useState([]); + const [isBalancesLoading, setIsBalancesLoading] = useState(false); const refreshConnection = useCallback(async () => { const api = await loadFreighter(); @@ -59,10 +75,14 @@ export function useStellarWallet() { const nextKey = typeof address === 'string' ? address : address?.address ?? null; setPublicKey(nextKey); setError(null); + } else { + setPublicKey(null); + setBalances([]); } return Boolean(connected); } catch (err) { setStatus('error'); + setPublicKey(null); setError(err instanceof Error ? err.message : 'Could not connect to the Freighter wallet.'); return false; } @@ -98,6 +118,9 @@ export function useStellarWallet() { const nextKey = typeof address === 'string' ? address : address?.address ?? null; setPublicKey(nextKey); + if (!nextKey) { + setBalances([]); + } setStatus(nextKey ? 'connected' : 'idle'); return nextKey; } catch (err) { @@ -110,7 +133,7 @@ export function useStellarWallet() { } }, []); - const signTransaction = useCallback(async (xdr: string) => { + const signTransaction = useCallback(async (xdr: string, networkPassphrase?: string) => { const api = await loadFreighter(); if (!api) { setStatus('missing'); @@ -122,7 +145,9 @@ export function useStellarWallet() { setError(null); try { - const response = await withTimeout(api.signTransaction(xdr)); + const response = networkPassphrase + ? await withTimeout(api.signTransaction(xdr, { networkPassphrase })) + : await withTimeout(api.signTransaction(xdr)); const signature = typeof response === 'string' ? response @@ -140,6 +165,132 @@ export function useStellarWallet() { } }, []); + const getNetworkDetails = useCallback(async () => { + const api = await loadFreighter(); + if (!api) { + setStatus('missing'); + setError('Freighter is not installed.'); + return null; + } + + try { + const network = await withTimeout(api.getNetwork()); + if (typeof network === 'string') { + const networkName = network.toUpperCase(); + const isTestnet = networkName === 'TESTNET' || networkName.includes('TESTNET'); + return { + networkUrl: isTestnet ? 'https://horizon-testnet.stellar.org' : 'https://horizon.stellar.org', + networkPassphrase: isTestnet ? StellarSdk.Networks.TESTNET : StellarSdk.Networks.PUBLIC, + }; + } + return { + networkUrl: network.networkUrl, + networkPassphrase: network.networkPassphrase, + }; + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not fetch network details.'); + return null; + } + }, []); + + const getAccountBalances = useCallback(async (): Promise => { + if (!publicKey) { + setError('No connected wallet address.'); + return null; + } + + const network = await getNetworkDetails(); + if (!network) return null; + + try { + const server = new StellarSdk.Server(network.networkUrl); + const account = await server.loadAccount(publicKey); + const nextBalances = account.balances.map((balance) => { + if (balance.asset_type === 'native') { + return { + assetCode: 'XLM', + assetIssuer: null, + balance: balance.balance, + limit: undefined, + }; + } + const assetLine = balance as StellarSdk.Horizon.BalanceLineAsset; + return { + assetCode: assetLine.asset_code, + assetIssuer: assetLine.asset_issuer, + balance: balance.balance, + limit: assetLine.limit, + }; + }); + setBalances(nextBalances); + return nextBalances; + } catch (err) { + setBalances([]); + setError(err instanceof Error ? err.message : 'Failed to load account balances.'); + return null; + } + }, [publicKey, getNetworkDetails]); + + useEffect(() => { + if (publicKey) { + void getAccountBalances(); + } else { + setBalances([]); + } + }, [publicKey, getAccountBalances]); + + const addTrustline = useCallback( + async (assetCode: string, issuer: string): Promise => { + if (!publicKey) { + setError('No connected wallet address.'); + return null; + } + + const parsed = trustlineSchema.safeParse({ assetCode, issuer }); + if (!parsed.success) { + setError(parsed.error.issues.map((i) => i.message).join(', ')); + return null; + } + + const network = await getNetworkDetails(); + if (!network) return null; + + try { + const server = new StellarSdk.Server(network.networkUrl); + const account = await server.loadAccount(publicKey); + const fee = await server.fetchBaseFee(); + const asset = new StellarSdk.Asset(assetCode, issuer); + const transaction = new StellarSdk.TransactionBuilder(account, { + fee: fee.toString(), + networkPassphrase: network.networkPassphrase, + }) + .addOperation(StellarSdk.Operation.changeTrust({ asset })) + .setTimeout(30) + .build(); + + const xdr = transaction.toXDR(); + const signedXdr = await signTransaction(xdr, network.networkPassphrase); + if (!signedXdr) return null; + + const result = await server.submitTransaction(signedXdr); + return result.hash; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create trustline.'); + return null; + } + }, + [publicKey, getNetworkDetails, signTransaction], + ); + + const refreshBalances = useCallback(async (): Promise => { + setIsBalancesLoading(true); + try { + return await getAccountBalances(); + } finally { + setIsBalancesLoading(false); + } + }, [getAccountBalances]); + return { status, publicKey, @@ -150,6 +301,12 @@ export function useStellarWallet() { refreshConnection, connect, signTransaction, + getNetworkDetails, + getAccountBalances, + refreshBalances, + balances, + isBalancesLoading, + addTrustline, }; } diff --git a/src/services/stellar.ts b/src/services/stellar.ts new file mode 100644 index 0000000..baadf82 --- /dev/null +++ b/src/services/stellar.ts @@ -0,0 +1,243 @@ +import { + getNetwork, + getPublicKey, + signTransaction, +} from '@stellar/freighter-api'; +import { + Account, + Asset, + BASE_FEE, + Horizon, + Networks, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { z } from 'zod'; + +/** + * Validates a Stellar public key (G...). + */ +export const stellarPublicKeySchema = z + .string() + .regex(/^G[0-9A-Z]{55}$/, 'Invalid Stellar public key'); + +/** + * Validates a Stellar asset code (1-12 alphanumeric characters). + */ +export const assetCodeSchema = z + .string() + .min(1, 'Asset code is required') + .max(12, 'Asset code must be 12 characters or less') + .regex(/^[a-zA-Z0-9]{1,12}$/, 'Asset code must be alphanumeric'); + +export interface StellarNetworkConfig { + horizonUrl: string; + passphrase: string; +} + +export const STELLAR_NETWORKS: Record<'PUBLIC' | 'TESTNET', StellarNetworkConfig> = { + PUBLIC: { + horizonUrl: 'https://horizon.stellar.org', + passphrase: Networks.PUBLIC, + }, + TESTNET: { + horizonUrl: 'https://horizon-testnet.stellar.org', + passphrase: Networks.TESTNET, + }, +}; + +/** + * Resolves the active network configuration from Freighter. + * Falls back to TESTNET only if no network is reported, but we prefer + * to throw if Freighter is unavailable. + */ +export async function getNetworkConfig(): Promise { + try { + const network = await getNetwork(); + if (network === 'PUBLIC') { + return STELLAR_NETWORKS.PUBLIC; + } + return STELLAR_NETWORKS.TESTNET; + } catch (error) { + throw new Error("Unable to determine Stellar network. Is Freighter connected?", { cause: error }); + } +} + +/** + * Returns the current Stellar public key from the Freighter extension. + * Throws if no wallet is connected. + */ +export async function getConnectedPublicKey(): Promise { + try { + const publicKey = await getPublicKey(); + if (!publicKey) { + throw new Error('Freighter returned an empty public key'); + } + stellarPublicKeySchema.parse(publicKey); + return publicKey; + } catch (error) { + throw new Error("Unable to access Freighter wallet. Please connect and unlock your wallet.", { cause: error }); + } +} + +/** + * Loads a Stellar account from Horizon. + */ +export async function loadAccount(publicKey: string): Promise { + stellarPublicKeySchema.parse(publicKey); + const { horizonUrl } = await getNetworkConfig(); + const server = new Horizon.Server(horizonUrl); + try { + return await server.loadAccount(publicKey); + } catch (error) { + throw new Error('Could not load Stellar account. Check the public key and network.', { cause: error }); + } +} + +export interface AssetBalance { + assetCode: string; + issuer?: string; + balance: string; + limit?: string; + isNative: boolean; + trustlineActive: boolean; +} + +/** + * Fetches and formats the asset balances for a Stellar account. + */ +export async function getAssetBalances(publicKey: string): Promise { + const account = await loadAccount(publicKey); + return account.balances.map((balance) => { + const assetCode = balance.asset_code ?? 'XLM'; + const issuer = balance.asset_issuer; + const isNative = assetCode === 'XLM'; + return { + assetCode, + issuer, + balance: balance.balance, + limit: balance.limit, + isNative, + trustlineActive: !isNative && balance.limit !== '0', + }; + }); +} + +export interface SupportedAsset { + assetCode: string; + issuer: string; +} + +export interface SupportedAssetStatus extends SupportedAsset { + balance: string; + limit?: string; + trustlineActive: boolean; +} + +const SUPPORTED_ASSETS_PUBLIC: SupportedAsset[] = [ + { assetCode: 'USDC', issuer: g@${'A'.repeat(55)} }, + { assetCode: 'USDT', issuer: g@${'B'.repeat(55)} }, +]; + +const SUPPORTED_ASSETS_TESTNET: SupportedAsset[] = [ + { assetCode: 'USDC', issuer: g@${'C'.repeat(55)} }, + { assetCode: 'USDT', issuer: g@${'D'.repeat(55)} }, +]; + +/** + * Returns the supported assets for a given network passphrase. + */ +export function getSupportedAssetsForNetwork(passphrase: string): SupportedAsset[] { + return passphrase === Networks.PUBLIC ? SUPPORTED_ASSETS_PUBLIC : SUPPORTED_ASSETS_TESTNET; +} + +export async function getSupportedAssetStatuses(publicKey: string): Promise { + stellarPublicKeySchema.parse(publicKey); + + const networkConfig = await getNetworkConfig(); + const supportedAssets = getSupportedAssetsForNetwork(networkConfig.passphrase); + + const account = await loadAccount(publicKey); + const balanceMap = new Map(); + + for (const balance of account.balances) { + if ('asset_code' in balance && balance.asset_code && 'asset_issuer' in balance && balance.asset_issuer) { + const assetBalance = balance as Horizon.BalanceLineAsset; + balanceMap.set(`${assetBalance.asset_code}:${assetBalance.asset_issuer}`, assetBalance); + } + } + + return supportedAssets.map((asset) => { + const balance = balanceMap.get(`${asset.assetCode}:${asset.issuer}`); + if (balance) { + return { + ...asset, + balance: balance.balance, + limit: balance.limit, + trustlineActive: true, + }; + } + return { + ...asset, + balance: '0', + limit: '0', + trustlineActive: false, + }; + }); +} + +/** + * Builds an XDR transaction for adding (or updating) a trustline to the given asset. + * Returns the base64 XDR string ready for signing with Freighter. + */ +export async function buildTrustlineTransaction( + publicKey: string, + assetCode: string, + issuer: string +|): Promise { + stellarPublicKeySchema.parse(publicKey); + assetCodeSchema.parse(assetCode); + stellarPublicKeySchema.parse(issuer); + + const { passphrase, horizonUrl } = await getNetworkConfig(); + const server = new Horizon.Server"horizonUrl"); + const account = await server.loadAccount(publicKey); + + const source = new Account(publicKey, account.sequence); + const asset = new Asset(assetCode, issuer); + + const transaction = new TransactionBuilder(source, { + fee: BASE_FEE, + networkPassphrase: passphrase, + }) + .addOperation( + Operation.changeTrust({ + asset, + limit: '922337203685.4775807', + }) + ) + .setTimeout(180) + .build(); + + return transaction.toXDR(); +} + +/** + * Signs an XDR transaction with Freighter and submits it to the network. + * Returns the transaction hash. + */ +export async function signAndSubmitTransaction(xdr: string): Promise { + const { passphrase, horizonUrl } = await getNetworkConfig(); + const server = new Horizon.Server(horizonUrl); + + try { + const signedXdr = await signTransaction(xdr, { + networkPassphrase: passphrase, + }); + const transaction = TransactionBuilder.fromXDR(signedXdr, passphrase); + const result = await server.submitTransaction(transaction); + return result.hash; + } catch (error) { + throw new Error(`Transaction signing or submission failed: ${error instanceof Error ? err.message : String(error)}`, { cause: error }); + } +} \ No newline at end of file diff --git a/src/stores/freighter-store.ts b/src/stores/freighter-store.ts index 3269312..3dbe80a 100644 --- a/src/stores/freighter-store.ts +++ b/src/stores/freighter-store.ts @@ -2,9 +2,22 @@ import { create } from 'zustand'; import type { StateCreator } from 'zustand'; +import { Server, TransactionBuilder, Operation, Asset, Networks, BASE_FEE } from '@stellar/stellar-sdk'; +import { z } from 'zod'; export type FreighterConnectionStatus = 'disconnected' | 'connecting' | 'connected'; +export type AccountBalance = { + asset_code?: string; + asset_issuer?: string; + asset_type: string; + balance: string; + limit?: string; + is_authorized?: boolean; + is_authorized_to_maintain_liabilities?: boolean; + is_clawback_enabled?: boolean; +}; + interface FreighterWalletState { status: FreighterConnectionStatus; isInstalled: boolean; @@ -16,9 +29,17 @@ interface FreighterWalletState { disconnect: () => void; requestSignature: (xdr: string) => Promise; hydrate: () => Promise; + balances: AccountBalance[]; + balancesLoading: boolean; + loadBalances: () => Promise; + addTrustline: (params: { assetCode: string; issuer: string }) => Promise; } const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; +const ASSET_CODE_RE = /^[A-Za-z0-9]{1,12}$/; + +export const stellarPublicKeySchema = z.string().regex(STELLAR_ADDRESS_RE); +export const assetCodeSchema = z.string().regex(ASSET_CODE_RE); const withTimeout = async (promise: Promise, ms = 15000): Promise => { let timeoutId: ReturnType | undefined; @@ -46,12 +67,14 @@ const getFreighter = async () => isConnected?: () => Promise; setAllowed?: () => Promise; getAddress?: () => Promise; - getNetwork?: () => Promise; + getPublicKey?: () => Promise; + getNetwork?: () => Promise; + getNetworkDetails?: () => Promise<{ network?: string; networkPassphrase?: string }>; signTransaction?: (xdr: string, options?: Record) => Promise; }; const isValidStellarAddress = (value: string | null | undefined): value is string => - typeof value === 'string' && STELLAR_ADDRESS_RE.test(value); + typeof value === 'string' && stellarPublicKeySchema.safeParse(value).success; const resolveWalletAddress = (value: unknown): string | null => { if (typeof value === 'string') return isValidStellarAddress(value) ? value : null; @@ -70,12 +93,22 @@ const resolveNetwork = (value: unknown): string | null => { return null; }; -const storeCreator: StateCreator = (set) => ({ +const getHorizonUrl = (network: string): string => + network === 'mainnet' || network === 'public' || network === 'PUBLIC' || network === Networks.PUBLIC ? 'https://horizon.stellar.org' : 'https://horizon-testnet.stellar.org'; + +const getNetworkPassphrase = (network: string): string => + network === 'mainnet' || network === 'public' || network === 'PUBLIC' || network === Networks.PUBLIC ? Networks.PUBLIC : Networks.TESTNET; + +const getServer = (network: string): Server => new Server(getHorizonUrl(network)); + +const storeCreator: StateCreator = (set, get) => ({ status: 'disconnected', isInstalled: false, publicKey: null, network: null, error: null, + balances: [], + balancesLoading: false, checkExtension: async () => { try { @@ -93,9 +126,9 @@ const storeCreator: StateCreator = (set) => ({ connect: async () => { set({ status: 'connecting', error: null }); + const installed = typeof window !== 'undefined' && !!(window as typeof window & { freighter?: unknown }).freighter; try { const freighter = await getFreighter(); - const installed = typeof window !== 'undefined' && !!(window as typeof window & { freighter?: unknown }).freighter; if (!installed && typeof freighter.isConnected !== 'function') { set({ isInstalled: false, status: 'disconnected', error: 'Freighter extension is not installed.' }); return null; @@ -109,13 +142,23 @@ const storeCreator: StateCreator = (set) => ({ } } - const addressResult = typeof freighter.getAddress === 'function' ? await withTimeout(freighter.getAddress()) : null; + const addressResult = + typeof freighter.getPublicKey === 'function' + ? await withTimeout(freighter.getPublicKey()) + : typeof freighter.getAddress === 'function' + ? await withTimeout(freighter.getAddress()) + : null; const publicKey = resolveWalletAddress(addressResult); if (!publicKey) { throw new Error('Freighter did not return a valid Stellar public key.'); } - const networkResult = typeof freighter.getNetwork === 'function' ? await withTimeout(freighter.getNetwork()) : null; + const networkResult = + typeof freighter.getNetwork === 'function' + ? await withTimeout(freighter.getNetwork()) + : typeof freighter.getNetworkDetails === 'function' + ? await withTimeout(freighter.getNetworkDetails()) + : null; const network = resolveNetwork(networkResult) ?? 'testnet'; set({ @@ -131,7 +174,7 @@ const storeCreator: StateCreator = (set) => ({ const message = error instanceof Error ? error.message : 'Unable to connect to the Freighter wallet.'; set({ status: 'disconnected', - isInstalled: false, + isInstalled: installed, publicKey: null, network: null, error: message, @@ -141,7 +184,7 @@ const storeCreator: StateCreator = (set) => ({ }, disconnect: () => { - set({ status: 'disconnected', publicKey: null, network: null, error: null }); + set({ status: 'disconnected', publicKey: null, network: null, error: null, balances: [], balancesLoading: false }); }, requestSignature: async (xdr: string) => { @@ -155,7 +198,10 @@ const storeCreator: StateCreator = (set) => ({ throw new Error('Freighter does not expose transaction signing support.'); } - const response = await withTimeout(freighter.signTransaction(xdr)); + const { network } = get(); + const response = await withTimeout( + freighter.signTransaction(xdr, { networkPassphrase: getNetworkPassphrase(network ?? 'testnet') }), + ); const signedXdr = typeof response === 'string' ? response @@ -170,24 +216,33 @@ const storeCreator: StateCreator = (set) => ({ return signedXdr; } catch (error) { const message = error instanceof Error ? error.message : 'Signature request failed.'; - set({ error: message, status: 'disconnected' }); + set({ error: message }); throw error; } }, hydrate: async () => { + await get().checkExtension(); const freighter = await getFreighter(); const walletAddress = resolveWalletAddress( - typeof freighter.getAddress === 'function' ? await withTimeout(freighter.getAddress()).catch(() => null) : null, + typeof freighter.getPublicKey === 'function' + ? await withTimeout(freighter.getPublicKey()).catch(() => null) + : typeof freighter.getAddress === 'function' + ? await withTimeout(freighter.getAddress()).catch(() => null) + : null, ); if (!walletAddress) { - set({ status: 'disconnected', publicKey: null, network: null, isInstalled: false, error: null }); + set({ status: 'disconnected', publicKey: null, network: null, error: null }); return; } const network = resolveNetwork( - typeof freighter.getNetwork === 'function' ? await withTimeout(freighter.getNetwork()).catch(() => null) : null, + typeof freighter.getNetwork === 'function' + ? await withTimeout(freighter.getNetwork()).catch(() => null) + : typeof freighter.getNetworkDetails === 'function' + ? await withTimeout(freighter.getNetworkDetails()).catch(() => null) + : null, ); set({ @@ -198,6 +253,66 @@ const storeCreator: StateCreator = (set) => ({ error: null, }); }, + + loadBalances: async () => { + const { publicKey, network } = get(); + if (!publicKey) { + set({ balances: [], balancesLoading: false }); + return; + } + + set({ balancesLoading: true, error: null }); + try { + const server = getServer(network ?? 'testnet'); + const account = await server.loadAccount(publicKey); + set({ balances: account.balances as AccountBalance[], balancesLoading: false }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load account balances.'; + set({ balances: [], balancesLoading: false, error: message }); + throw error; + } + }, + + addTrustline: async ({ assetCode, issuer }: { assetCode: string; issuer: string }) => { + const { status, publicKey, network, requestSignature } = get(); + if (status !== 'connected' || !publicKey) { + const message = 'No wallet connected.'; + set({ error: message }); + throw new Error(message); + } + + const server = getServer(network ?? 'testnet'); + const networkPassphrase = getNetworkPassphrase(network ?? 'testnet'); + + try { + const assetCodeResult = assetCodeSchema.safeParse(assetCode); + const issuerResult = stellarPublicKeySchema.safeParse(issuer); + if (!assetCodeResult.success || !issuerResult.success) { + throw new Error('Invalid asset code or issuer address.'); + } + + const account = await server.loadAccount(publicKey); + const transaction = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(Operation.changeTrust({ asset: new Asset(assetCode, issuer) })) + .setTimeout(30) + .build(); + + const signedXdr = await requestSignature(transaction.toXDR()); + await server.submitTransaction(signedXdr); + try { + await get().loadBalances(); + } catch { + // Balance refresh is best-effort; the trustline transaction succeeded. + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to add trustline.'; + set({ error: message }); + throw error; + } + }, }); export const useFreighterStore = create()(storeCreator); diff --git a/src/types/domain.ts b/src/types/domain.ts index f9fd79d..3b386a6 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -1,7 +1,20 @@ /** Core domain models mirroring the Astroid API entities (PRD Doc 5 / Doc 6). */ +import { z } from 'zod'; + export type Asset = 'XLM' | 'USDC' | string; +export type StellarAddress = string; +export type AssetCode = string; + +export const stellarAddressSchema = z + .string() + .regex(/^G[A-Z2-7]{55}$/, 'Invalid Stellar public key'); + +export const assetCodeSchema = z + .string() + .regex(/^[A-Za-z0-9]{1,12}$/, 'Invalid asset code'); + export type StellarNetwork = 'testnet' | 'public'; // --------------------------------------------------------------------------- @@ -93,20 +106,45 @@ export type WalletStatus = 'active' | 'frozen' | 'paused' | 'archived'; export interface AssetBalance { asset: Asset; + assetIssuer?: StellarAddress; balance: number; usdValue: number; } +export type TrustlineStatus = 'active' | 'inactive' | 'pending'; + +export interface Trustline { + assetCode: AssetCode; + assetIssuer?: StellarAddress; + balance: number; + limit: number; + status: TrustlineStatus; + isNative: boolean; +} + +export interface StellarAsset { + assetCode: AssetCode; + assetIssuer?: StellarAddress; + isNative: boolean; +} + +export interface TrustlineRequest { + assetCode: AssetCode; + assetIssuer: StellarAddress; + limit: number; +} + export interface Wallet { id: string; organizationId: string; agentId?: string; name: string; - stellarAddress: string; + stellarAddress: StellarAddress; walletType: WalletType; network: StellarNetwork; status: WalletStatus; balances: AssetBalance[]; + trustlines?: Trustline[]; totalUsdValue: number; riskScore: number; createdAt: string;