diff --git a/.gitignore b/.gitignore index f9af8084..f94630c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,3 @@ -# Dependencies node_modules/ -package-lock.json - -# Build outputs dist/ - -# Environment variables -.env -.env.local -.env.*.local - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# Testing -coverage/ - -# Backup files -*.backup +.env \ No newline at end of file diff --git a/agent.ts b/agent.ts index 0b2d635b..9d9459d8 100644 --- a/agent.ts +++ b/agent.ts @@ -14,6 +14,26 @@ import { type SwapBestRouteParams, type SwapBestRouteResult, } from "./lib/dex"; +import { + getAccountInfo, + getBalances, + getTransactionHistory, + getOperationHistory, + fundTestnetAccount, + type AccountInfo, + type AccountBalance, + type TransactionRecord, + type OperationRecord, +} from "./lib/account"; +import { + getAssetDetails, + getOrderbook, + getTrades, + type AssetDetails, + type OrderbookSummary, + type TradeRecord, + type StellarAssetInput as AssetStellarAssetInput, +} from "./lib/asset"; import { bridgeTokenTool } from "./tools/bridge"; import { stellarGetBalanceTool, stellarGetAccountInfoTool } from "./tools/stellar"; import { @@ -67,6 +87,13 @@ export type { RouteQuote, SwapBestRouteParams, SwapBestRouteResult, + AccountInfo, + AccountBalance, + TransactionRecord, + OperationRecord, + AssetDetails, + OrderbookSummary, + TradeRecord, }; export class AgentClient { @@ -284,6 +311,165 @@ export class AgentClient { }, }; + /** + * Account explorer – read-only access to Stellar account data. + * + * These methods query the Horizon API and do NOT require a private key. + * They work on both testnet and mainnet. + */ + public account = { + /** + * Get comprehensive account information: balances, signers, thresholds, flags. + * + * @param publicKey - The Stellar G-address to query (defaults to configured publicKey) + */ + getInfo: async (publicKey?: string): Promise => { + const key = publicKey ?? this.publicKey; + if (!key) throw new Error("No public key provided or configured"); + return await getAccountInfo(key, { + network: this.network, + horizonUrl: this.rpcUrl, + }); + }, + + /** + * Get account balance summary. + * + * @param publicKey - The Stellar G-address to query (defaults to configured publicKey) + */ + getBalances: async (publicKey?: string): Promise => { + const key = publicKey ?? this.publicKey; + if (!key) throw new Error("No public key provided or configured"); + return await getBalances(key, { + network: this.network, + horizonUrl: this.rpcUrl, + }); + }, + + /** + * Get recent transaction history. + * + * @param publicKey - The Stellar G-address (defaults to configured publicKey) + * @param limit - Max transactions to return (1–50, default 10) + * @param order - 'desc' (newest first) or 'asc' (oldest first) + */ + getTransactions: async ( + publicKey?: string, + limit: number = 10, + order: "asc" | "desc" = "desc" + ): Promise => { + const key = publicKey ?? this.publicKey; + if (!key) throw new Error("No public key provided or configured"); + return await getTransactionHistory(key, { + network: this.network, + horizonUrl: this.rpcUrl, + }, limit, order); + }, + + /** + * Get recent operation history. + * + * @param publicKey - The Stellar G-address (defaults to configured publicKey) + * @param limit - Max operations to return (1–50, default 10) + * @param order - 'desc' (newest first) or 'asc' (oldest first) + */ + getOperations: async ( + publicKey?: string, + limit: number = 10, + order: "asc" | "desc" = "desc" + ): Promise => { + const key = publicKey ?? this.publicKey; + if (!key) throw new Error("No public key provided or configured"); + return await getOperationHistory(key, { + network: this.network, + horizonUrl: this.rpcUrl, + }, limit, order); + }, + + /** + * Fund a testnet account using Stellar Friendbot. + * Only works on testnet. Creates and funds the account with 10,000 test XLM. + * + * @param publicKey - The Stellar G-address to fund (defaults to configured publicKey) + */ + fundTestnet: async ( + publicKey?: string + ): Promise<{ success: boolean; message: string }> => { + if (this.network !== "testnet") { + throw new Error( + "Friendbot funding is only available on testnet. " + + "Initialize AgentClient with network: 'testnet' to use this method." + ); + } + const key = publicKey ?? this.publicKey; + if (!key) throw new Error("No public key provided or configured"); + return await fundTestnetAccount(key); + }, + }; + + /** + * Asset & market data explorer – read-only access to Stellar asset information. + * + * These methods query the Horizon API and do NOT require a private key. + * They work on both testnet and mainnet. + */ + public asset = { + /** + * Look up details about a Stellar asset. + * Returns metadata including trust count, circulating supply, and issuer flags. + * + * @param assetCode - The asset code (e.g. "USDC") + * @param assetIssuer - The issuer's public key + */ + getDetails: async ( + assetCode: string, + assetIssuer: string + ): Promise => { + return await getAssetDetails(assetCode, assetIssuer, { + network: this.network, + horizonUrl: this.rpcUrl, + }); + }, + + /** + * Fetch the current SDEX orderbook for a trading pair. + * + * @param baseAsset - The base asset (e.g. { type: "native" } or { code: "USDC", issuer: "G..." }) + * @param counterAsset - The counter asset + * @param limit - Number of entries per side (1–200, default 10) + */ + getOrderbook: async ( + baseAsset: AssetStellarAssetInput, + counterAsset: AssetStellarAssetInput, + limit: number = 10 + ): Promise => { + return await getOrderbook(baseAsset, counterAsset, { + network: this.network, + horizonUrl: this.rpcUrl, + }, limit); + }, + + /** + * Fetch recent trades for a trading pair on the SDEX. + * + * @param baseAsset - The base asset + * @param counterAsset - The counter asset + * @param limit - Max trades to return (1–50, default 10) + * @param order - 'desc' (newest first) or 'asc' (oldest first) + */ + getTrades: async ( + baseAsset: AssetStellarAssetInput, + counterAsset: AssetStellarAssetInput, + limit: number = 10, + order: "asc" | "desc" = "desc" + ): Promise => { + return await getTrades(baseAsset, counterAsset, { + network: this.network, + horizonUrl: this.rpcUrl, + }, limit, order); + }, + }; + /** * Launch a new token on the Stellar network. * diff --git a/index.ts b/index.ts index 2e299911..bad49aac 100644 --- a/index.ts +++ b/index.ts @@ -3,26 +3,17 @@ import { StellarLiquidityContractTool } from "./tools/contract"; import { StellarDexTool } from "./tools/dex"; import { StellarContractTool } from "./tools/stake"; import { stellarSendPaymentTool, stellarGetBalanceTool, stellarGetAccountInfoTool } from "./tools/stellar"; -import { - AgentClient, - AgentConfig, - LaunchTokenParams, - LaunchTokenResult, -} from "./agent"; -import type { - StellarAssetInput, - QuoteSwapParams, - RouteQuote, - SwapBestRouteParams, - SwapBestRouteResult, -} from "./agent"; +import { StellarClaimBalanceTool } from "./tools/claim_balance_tool"; +import { StellarAccountTool } from "./tools/account"; +import { StellarAssetTool } from "./tools/asset"; -export { +// Agent exportları (Hem sınıfları hem de tipleri içerecek şekilde) +export { AgentClient, AgentConfig, LaunchTokenParams, LaunchTokenResult, -}; +} from "./agent"; export type { StellarAssetInput, @@ -30,7 +21,38 @@ export type { RouteQuote, SwapBestRouteParams, SwapBestRouteResult, -}; + AccountInfo, + AccountBalance, + TransactionRecord, + OperationRecord, + AssetDetails, + OrderbookSummary, + TradeRecord, +} from "./agent"; + +// claim_balance_tool içindeki her şeyi export et +export * from "./tools/claim_balance_tool"; + +// Account & Asset tool exportları +export { StellarAccountTool } from "./tools/account"; +export { StellarAssetTool } from "./tools/asset"; + +// Lib-level exports for direct usage +export { + getAccountInfo, + getBalances, + getTransactionHistory, + getOperationHistory, + fundTestnetAccount, +} from "./lib/account"; + +export { + getAssetDetails, + getOrderbook, + getTrades, +} from "./lib/asset"; + +// Bütün tool'ların listesi export const stellarTools = [ bridgeTokenTool, StellarDexTool, @@ -38,5 +60,8 @@ export const stellarTools = [ StellarContractTool, stellarSendPaymentTool, stellarGetBalanceTool, - stellarGetAccountInfoTool + stellarGetAccountInfoTool, + StellarClaimBalanceTool, + StellarAccountTool, + StellarAssetTool, ]; diff --git a/lib/account.ts b/lib/account.ts new file mode 100644 index 00000000..946aa3d1 --- /dev/null +++ b/lib/account.ts @@ -0,0 +1,393 @@ +import { Horizon, StrKey } from "@stellar/stellar-sdk"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface AccountClientConfig { + network: "testnet" | "mainnet"; + horizonUrl?: string; +} + +/** @internal Dependencies for testing */ +export interface AccountDeps { + createServer?: (horizonUrl: string) => any; +} + +export interface AccountBalance { + assetType: string; + assetCode?: string; + assetIssuer?: string; + balance: string; + /** Only present for non-native assets */ + limit?: string; + buyingLiabilities: string; + sellingLiabilities: string; +} + +export interface AccountInfo { + id: string; + accountId: string; + sequence: string; + subentryCount: number; + balances: AccountBalance[]; + signers: AccountSigner[]; + thresholds: { + lowThreshold: number; + medThreshold: number; + highThreshold: number; + }; + flags: { + authRequired: boolean; + authRevocable: boolean; + authImmutable: boolean; + authClawbackEnabled: boolean; + }; + homeDomain?: string; + lastModifiedLedger: number; + numSponsored: number; + numSponsoring: number; +} + +export interface AccountSigner { + key: string; + weight: number; + type: string; +} + +export interface TransactionRecord { + id: string; + hash: string; + ledger: number; + createdAt: string; + sourceAccount: string; + feeCharged: string; + operationCount: number; + memoType: string; + memo?: string; + successful: boolean; +} + +export interface OperationRecord { + id: string; + type: string; + createdAt: string; + transactionHash: string; + sourceAccount: string; + details: Record; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function getHorizonUrl(config: AccountClientConfig): string { + return ( + config.horizonUrl ?? + (config.network === "mainnet" + ? "https://horizon.stellar.org" + : "https://horizon-testnet.stellar.org") + ); +} + +function createServer(config: AccountClientConfig): Horizon.Server { + return new Horizon.Server(getHorizonUrl(config)); +} + +function validatePublicKey(publicKey: string): void { + if (!publicKey || !StrKey.isValidEd25519PublicKey(publicKey)) { + throw new Error( + `Invalid Stellar public key: ${publicKey || "(empty)"}. ` + + `Stellar public keys must start with 'G' and be 56 characters long.` + ); + } +} + +// ─── Core Functions ───────────────────────────────────────────────────────── + +/** + * Retrieve comprehensive information about a Stellar account. + * + * Returns balances (XLM + custom assets), signers, thresholds, flags, + * home domain, and sponsorship metadata. + * + * @param publicKey - The Stellar G-address to query + * @param config - Network and optional Horizon URL + */ +export async function getAccountInfo( + publicKey: string, + config: AccountClientConfig, + _deps: AccountDeps = {} +): Promise { + validatePublicKey(publicKey); + + const server = _deps.createServer + ? _deps.createServer(getHorizonUrl(config)) + : createServer(config); + + let account: Horizon.ServerApi.AccountRecord; + try { + account = await server.accounts().accountId(publicKey).call(); + } catch (error: any) { + if (error?.response?.status === 404) { + throw new Error( + `Account ${publicKey} not found on ${config.network}. ` + + `The account may not be funded yet.` + ); + } + throw new Error( + `Failed to fetch account info: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const balances: AccountBalance[] = account.balances.map( + (b: Horizon.HorizonApi.BalanceLine) => { + const base: AccountBalance = { + assetType: b.asset_type, + balance: b.balance, + buyingLiabilities: (b as any).buying_liabilities ?? "0.0000000", + sellingLiabilities: (b as any).selling_liabilities ?? "0.0000000", + }; + + if (b.asset_type !== "native" && b.asset_type !== "liquidity_pool_shares") { + const issuedBalance = b as Horizon.HorizonApi.BalanceLineAsset; + base.assetCode = issuedBalance.asset_code; + base.assetIssuer = issuedBalance.asset_issuer; + base.limit = issuedBalance.limit; + } + + return base; + } + ); + + const signers: AccountSigner[] = account.signers.map((s: any) => ({ + key: s.key, + weight: s.weight, + type: s.type, + })); + + return { + id: account.id, + accountId: account.account_id, + sequence: account.sequence, + subentryCount: account.subentry_count, + balances, + signers, + thresholds: { + lowThreshold: account.thresholds.low_threshold, + medThreshold: account.thresholds.med_threshold, + highThreshold: account.thresholds.high_threshold, + }, + flags: { + authRequired: account.flags.auth_required, + authRevocable: account.flags.auth_revocable, + authImmutable: account.flags.auth_immutable, + authClawbackEnabled: account.flags.auth_clawback_enabled, + }, + homeDomain: account.home_domain, + lastModifiedLedger: account.last_modified_ledger, + numSponsored: account.num_sponsored, + numSponsoring: account.num_sponsoring, + }; +} + +/** + * Retrieve the balances for a Stellar account. + * + * Convenience wrapper around `getAccountInfo` that returns only the + * balance entries, making it easier for agents to quickly check + * available funds. + * + * @param publicKey - The Stellar G-address to query + * @param config - Network and optional Horizon URL + */ +export async function getBalances( + publicKey: string, + config: AccountClientConfig, + _deps: AccountDeps = {} +): Promise { + const info = await getAccountInfo(publicKey, config, _deps); + return info.balances; +} + +/** + * Retrieve recent transaction history for a Stellar account. + * + * Paginates through Horizon and returns up to `limit` transactions, + * ordered from most recent to oldest. + * + * @param publicKey - The Stellar G-address to query + * @param config - Network and optional Horizon URL + * @param limit - Maximum number of transactions to return (default 10, max 50) + * @param order - Sort order: "desc" (newest first) or "asc" (oldest first) + */ +export async function getTransactionHistory( + publicKey: string, + config: AccountClientConfig, + limit: number = 10, + order: "asc" | "desc" = "desc", + _deps: AccountDeps = {} +): Promise { + validatePublicKey(publicKey); + + if (limit < 1 || limit > 50) { + throw new Error("limit must be between 1 and 50"); + } + + const server = _deps.createServer + ? _deps.createServer(getHorizonUrl(config)) + : createServer(config); + + let response; + try { + response = await server + .transactions() + .forAccount(publicKey) + .order(order) + .limit(limit) + .call(); + } catch (error: any) { + if (error?.response?.status === 404) { + throw new Error( + `Account ${publicKey} not found on ${config.network}. ` + + `The account may not be funded yet.` + ); + } + throw new Error( + `Failed to fetch transaction history: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return response.records.map((tx: any) => ({ + id: tx.id, + hash: tx.hash, + ledger: tx.ledger, + createdAt: tx.created_at, + sourceAccount: tx.source_account, + feeCharged: tx.fee_charged, + operationCount: tx.operation_count, + memoType: tx.memo_type, + memo: tx.memo, + successful: tx.successful, + })); +} + +/** + * Retrieve recent operation history for a Stellar account. + * + * Operations include payments, path payments, trust changes, offers, + * account merges, and more. + * + * @param publicKey - The Stellar G-address to query + * @param config - Network and optional Horizon URL + * @param limit - Maximum number of operations to return (default 10, max 50) + * @param order - Sort order: "desc" (newest first) or "asc" (oldest first) + */ +export async function getOperationHistory( + publicKey: string, + config: AccountClientConfig, + limit: number = 10, + order: "asc" | "desc" = "desc", + _deps: AccountDeps = {} +): Promise { + validatePublicKey(publicKey); + + if (limit < 1 || limit > 50) { + throw new Error("limit must be between 1 and 50"); + } + + const server = _deps.createServer + ? _deps.createServer(getHorizonUrl(config)) + : createServer(config); + + let response; + try { + response = await server + .operations() + .forAccount(publicKey) + .order(order) + .limit(limit) + .call(); + } catch (error: any) { + if (error?.response?.status === 404) { + throw new Error( + `Account ${publicKey} not found on ${config.network}. ` + + `The account may not be funded yet.` + ); + } + throw new Error( + `Failed to fetch operation history: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return response.records.map((op: any) => { + // Extract operation-specific details, omitting Horizon metadata fields + const { + _links, + id, + type, + type_i, + created_at, + transaction_hash, + source_account, + paging_token, + transaction_successful, + ...details + } = op; + + return { + id: String(id), + type: type, + createdAt: created_at, + transactionHash: transaction_hash, + sourceAccount: source_account, + details, + }; + }); +} + +/** + * Fund a testnet account using Stellar Friendbot. + * + * This only works on the Stellar testnet. It will create and fund + * the account with 10,000 test XLM. + * + * @param publicKey - The Stellar G-address to fund + * @param fetchImpl - Optional fetch implementation (for testing) + */ +export async function fundTestnetAccount( + publicKey: string, + fetchImpl: typeof fetch = globalThis.fetch +): Promise<{ success: boolean; message: string }> { + validatePublicKey(publicKey); + + if (!fetchImpl) { + throw new Error("Global fetch is not available in this environment"); + } + + const friendbotUrl = `https://friendbot.stellar.org?addr=${encodeURIComponent(publicKey)}`; + + try { + const response = await fetchImpl(friendbotUrl); + + if (!response.ok) { + const body = await response.text(); + // Friendbot returns a specific error when already funded + if (body.includes("createAccountAlreadyExist")) { + return { + success: false, + message: `Account ${publicKey} has already been funded on testnet.`, + }; + } + throw new Error(`Friendbot request failed: ${response.status} ${response.statusText}`); + } + + return { + success: true, + message: `Account ${publicKey} has been funded with 10,000 test XLM on Stellar testnet.`, + }; + } catch (error: any) { + if (error.message?.includes("Friendbot request failed")) { + throw error; + } + throw new Error( + `Failed to fund testnet account: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/lib/asset.ts b/lib/asset.ts new file mode 100644 index 00000000..b8be2f01 --- /dev/null +++ b/lib/asset.ts @@ -0,0 +1,310 @@ +import { Horizon, StrKey } from "@stellar/stellar-sdk"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface AssetClientConfig { + network: "testnet" | "mainnet"; + horizonUrl?: string; +} + +/** @internal Dependencies for testing */ +export interface AssetDeps { + createServer?: (horizonUrl: string) => any; +} + +export interface AssetDetails { + assetType: string; + assetCode: string; + assetIssuer: string; + pagingToken: string; + /** Number of accounts trusting this asset */ + numAccounts: number; + /** Amount held across all accounts */ + amount: string; + flags: { + authRequired: boolean; + authRevocable: boolean; + authImmutable: boolean; + authClawbackEnabled: boolean; + }; +} + +export interface OrderbookSummary { + base: { assetType: string; assetCode?: string; assetIssuer?: string }; + counter: { assetType: string; assetCode?: string; assetIssuer?: string }; + bids: OrderbookEntry[]; + asks: OrderbookEntry[]; +} + +export interface OrderbookEntry { + price: string; + amount: string; + /** Ratio as returned by Horizon (numerator/denominator) */ + priceR: { n: number; d: number }; +} + +export interface TradeRecord { + id: string; + pagingToken: string; + ledgerCloseTime: string; + baseAccount?: string; + baseAmount: string; + baseAssetType: string; + baseAssetCode?: string; + baseAssetIssuer?: string; + counterAccount?: string; + counterAmount: string; + counterAssetType: string; + counterAssetCode?: string; + counterAssetIssuer?: string; + price: { n: string; d: string }; + baseIsSeller: boolean; +} + +export type StellarAssetInput = + | { type: "native" } + | { code: string; issuer: string }; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function getHorizonUrl(config: AssetClientConfig): string { + return ( + config.horizonUrl ?? + (config.network === "mainnet" + ? "https://horizon.stellar.org" + : "https://horizon-testnet.stellar.org") + ); +} + +function createServer(config: AssetClientConfig): Horizon.Server { + return new Horizon.Server(getHorizonUrl(config)); +} + +function validateAssetInput(asset: StellarAssetInput): void { + if ("type" in asset) { + if (asset.type !== "native") { + throw new Error(`Invalid native asset type: ${asset.type}`); + } + return; + } + + if (!asset.code || asset.code.length === 0 || asset.code.length > 12) { + throw new Error( + `Asset code must be between 1 and 12 characters, got: "${asset.code || ""}"` + ); + } + + if (!asset.issuer || !StrKey.isValidEd25519PublicKey(asset.issuer)) { + throw new Error( + `Invalid asset issuer public key: ${asset.issuer || "(empty)"}` + ); + } +} + +// ─── Core Functions ───────────────────────────────────────────────────────── + +/** + * Look up details about a Stellar asset. + * + * Returns metadata including the number of accounts trusting the asset, + * total amount in circulation, and issuer flags. + * + * @param assetCode - The asset code (e.g. "USDC") + * @param assetIssuer - The issuer's public key + * @param config - Network and optional Horizon URL + */ +export async function getAssetDetails( + assetCode: string, + assetIssuer: string, + config: AssetClientConfig, + _deps: AssetDeps = {} +): Promise { + validateAssetInput({ code: assetCode, issuer: assetIssuer }); + + const server = _deps.createServer + ? _deps.createServer(getHorizonUrl(config)) + : createServer(config); + + let response; + try { + response = await server + .assets() + .forCode(assetCode) + .forIssuer(assetIssuer) + .call(); + } catch (error: any) { + throw new Error( + `Failed to fetch asset details: ${error instanceof Error ? error.message : String(error)}` + ); + } + + if (response.records.length === 0) { + throw new Error( + `Asset ${assetCode}:${assetIssuer} not found on ${config.network}.` + ); + } + + return response.records.map((r: any) => ({ + assetType: r.asset_type, + assetCode: r.asset_code, + assetIssuer: r.asset_issuer, + pagingToken: r.paging_token, + numAccounts: r.num_accounts, + amount: r.amount, + flags: { + authRequired: r.flags.auth_required, + authRevocable: r.flags.auth_revocable, + authImmutable: r.flags.auth_immutable, + authClawbackEnabled: r.flags.auth_clawback_enabled, + }, + })); +} + +/** + * Fetch the current SDEX orderbook for a trading pair. + * + * Returns up to `limit` bids and asks. + * + * @param baseAsset - The base asset of the trading pair + * @param counterAsset - The counter asset of the trading pair + * @param config - Network and optional Horizon URL + * @param limit - Number of orderbook entries per side (default 10, max 200) + */ +export async function getOrderbook( + baseAsset: StellarAssetInput, + counterAsset: StellarAssetInput, + config: AssetClientConfig, + limit: number = 10, + _deps: AssetDeps = {} +): Promise { + validateAssetInput(baseAsset); + validateAssetInput(counterAsset); + + if (limit < 1 || limit > 200) { + throw new Error("Orderbook limit must be between 1 and 200"); + } + + const server = _deps.createServer + ? _deps.createServer(getHorizonUrl(config)) + : createServer(config); + + const selling = assetInputToSdkAsset(baseAsset); + const buying = assetInputToSdkAsset(counterAsset); + + let response; + try { + response = await server + .orderbook(selling, buying) + .limit(limit) + .call(); + } catch (error: any) { + throw new Error( + `Failed to fetch orderbook: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return { + base: horizonAssetToOutput(response.base), + counter: horizonAssetToOutput(response.counter), + bids: response.bids.map((b: any) => ({ + price: b.price, + amount: b.amount, + priceR: { n: Number(b.price_r.n), d: Number(b.price_r.d) }, + })), + asks: response.asks.map((a: any) => ({ + price: a.price, + amount: a.amount, + priceR: { n: Number(a.price_r.n), d: Number(a.price_r.d) }, + })), + }; +} + +/** + * Fetch recent trades for a trading pair on the SDEX. + * + * @param baseAsset - The base asset of the trading pair + * @param counterAsset - The counter asset of the trading pair + * @param config - Network and optional Horizon URL + * @param limit - Maximum number of trades to return (default 10, max 50) + * @param order - Sort order: "desc" (newest first) or "asc" (oldest first) + */ +export async function getTrades( + baseAsset: StellarAssetInput, + counterAsset: StellarAssetInput, + config: AssetClientConfig, + limit: number = 10, + order: "asc" | "desc" = "desc", + _deps: AssetDeps = {} +): Promise { + validateAssetInput(baseAsset); + validateAssetInput(counterAsset); + + if (limit < 1 || limit > 50) { + throw new Error("Trades limit must be between 1 and 50"); + } + + const server = _deps.createServer + ? _deps.createServer(getHorizonUrl(config)) + : createServer(config); + + const base = assetInputToSdkAsset(baseAsset); + const counter = assetInputToSdkAsset(counterAsset); + + let response; + try { + response = await server + .trades() + .forAssetPair(base, counter) + .order(order) + .limit(limit) + .call(); + } catch (error: any) { + throw new Error( + `Failed to fetch trades: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return response.records.map((t: any) => ({ + id: t.id, + pagingToken: t.paging_token, + ledgerCloseTime: t.ledger_close_time, + baseAccount: t.base_account, + baseAmount: t.base_amount, + baseAssetType: t.base_asset_type, + baseAssetCode: t.base_asset_code, + baseAssetIssuer: t.base_asset_issuer, + counterAccount: t.counter_account, + counterAmount: t.counter_amount, + counterAssetType: t.counter_asset_type, + counterAssetCode: t.counter_asset_code, + counterAssetIssuer: t.counter_asset_issuer, + price: { n: String(t.price.n), d: String(t.price.d) }, + baseIsSeller: t.base_is_seller, + })); +} + +// ─── Internal Utilities ───────────────────────────────────────────────────── + +import { Asset } from "@stellar/stellar-sdk"; + +function assetInputToSdkAsset(asset: StellarAssetInput): Asset { + if ("type" in asset) { + return Asset.native(); + } + return new Asset(asset.code, asset.issuer); +} + +function horizonAssetToOutput(asset: any): { + assetType: string; + assetCode?: string; + assetIssuer?: string; +} { + if (asset.asset_type === "native") { + return { assetType: "native" }; + } + return { + assetType: asset.asset_type, + assetCode: asset.asset_code, + assetIssuer: asset.asset_issuer, + }; +} diff --git a/lib/claimF.ts b/lib/claimF.ts new file mode 100644 index 00000000..87c11539 --- /dev/null +++ b/lib/claimF.ts @@ -0,0 +1,78 @@ +import { Horizon, TransactionBuilder, Operation, Networks } from "@stellar/stellar-sdk"; + +function getNetworkConfig() { + const network = process.env.STELLAR_NETWORK === "PUBLIC" ? "mainnet" : "testnet"; + const horizonUrl = process.env.HORIZON_URL || + (network === "mainnet" ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org"); + const networkPassphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET; + + return { network, horizonUrl, networkPassphrase }; +} + +function getServer() { + const { horizonUrl } = getNetworkConfig(); + return new Horizon.Server(horizonUrl); +} + +export async function listClaimableBalances(publicKey: string) { + const server = getServer(); + let response = await server.claimableBalances().claimant(publicKey).call(); + let allBalances = [...response.records]; + + // Pagination loop: Fetch all records + while (response.records.length > 0) { + try { + // response.next() returns a promise that resolves to the next page + const nextResponse = await response.next(); + if (nextResponse.records.length === 0) break; + + response = nextResponse; + allBalances.push(...response.records); + } catch (e) { + // If there's an error, it might be a real issue or just the end of pages. + // Horizon pagination usually returns empty records or 404/link issues. + // We only break if it's a "no more pages" scenario, but here we'll + // be more careful as per bot suggestion. + break; + } + } + + return allBalances.map((r: any) => ({ + id: r.id, + asset: r.asset, + amount: r.amount, + sponsor: r.sponsor, + })); +} + +export async function claimBalance(publicKey: string, balanceId?: string) { + const { networkPassphrase } = getNetworkConfig(); + const server = getServer(); + const account = await server.loadAccount(publicKey); + + const baseFee = await server.fetchBaseFee(); + + const transaction = new TransactionBuilder(account, { + fee: baseFee.toString(), + networkPassphrase, + }); + + if (balanceId) { + transaction.addOperation(Operation.claimClaimableBalance({ balanceId })); + } else { + const balances = await listClaimableBalances(publicKey); + if (balances.length === 0) throw new Error("No claimable balances found."); + + /** + * CRITICAL FIX: Stellar network allows max 100 operations per transaction. + * We limit to 50 for safety. + */ + const limitedBalances = balances.slice(0, 50); + + limitedBalances.forEach((b: any) => { + transaction.addOperation(Operation.claimClaimableBalance({ balanceId: b.id })); + }); + } + + return transaction.setTimeout(30).build(); +} diff --git a/tests/unit/lib/account.test.ts b/tests/unit/lib/account.test.ts new file mode 100644 index 00000000..6601984f --- /dev/null +++ b/tests/unit/lib/account.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + getAccountInfo, + getBalances, + getTransactionHistory, + getOperationHistory, + fundTestnetAccount, +} from "../../../lib/account"; + +// ─── Test Fixtures ───────────────────────────────────────────────────────── + +const testPublicKey = Keypair.random().publicKey(); +const issuerKey = Keypair.random().publicKey(); + +const mockAccountRecord = { + id: testPublicKey, + account_id: testPublicKey, + sequence: "1234567890", + subentry_count: 3, + balances: [ + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: issuerKey, + balance: "1500.0000000", + limit: "922337203685.4775807", + buying_liabilities: "0.0000000", + selling_liabilities: "0.0000000", + }, + { + asset_type: "native", + balance: "100.5000000", + buying_liabilities: "0.0000000", + selling_liabilities: "0.0000000", + }, + ], + signers: [ + { key: testPublicKey, weight: 1, type: "ed25519_public_key" }, + ], + thresholds: { + low_threshold: 0, + med_threshold: 0, + high_threshold: 0, + }, + flags: { + auth_required: false, + auth_revocable: false, + auth_immutable: false, + auth_clawback_enabled: false, + }, + home_domain: "example.com", + last_modified_ledger: 12345, + num_sponsored: 0, + num_sponsoring: 0, +}; + +const mockTransactionRecords = [ + { + id: "tx-1", + hash: "abc123", + ledger: 100, + created_at: "2026-04-30T12:00:00Z", + source_account: testPublicKey, + fee_charged: "100", + operation_count: 1, + memo_type: "none", + memo: undefined, + successful: true, + }, + { + id: "tx-2", + hash: "def456", + ledger: 101, + created_at: "2026-04-30T12:05:00Z", + source_account: testPublicKey, + fee_charged: "200", + operation_count: 2, + memo_type: "text", + memo: "test", + successful: true, + }, +]; + +const mockOperationRecords = [ + { + _links: {}, + id: "op-1", + type: "payment", + type_i: 1, + created_at: "2026-04-30T12:00:00Z", + transaction_hash: "abc123", + source_account: testPublicKey, + paging_token: "12345", + transaction_successful: true, + asset_type: "native", + amount: "50.0000000", + from: testPublicKey, + to: Keypair.random().publicKey(), + }, +]; + +// ─── Mock Server Factory ──────────────────────────────────────────────────── + +function makeMockServer(overrides: Partial> = {}) { + return () => ({ + accounts: vi.fn().mockReturnValue({ + accountId: vi.fn().mockReturnValue({ + call: overrides.accountsCall ?? vi.fn(), + }), + }), + transactions: vi.fn().mockReturnValue({ + forAccount: vi.fn().mockReturnValue({ + order: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + call: overrides.transactionsCall ?? vi.fn(), + }), + }), + }), + }), + operations: vi.fn().mockReturnValue({ + forAccount: vi.fn().mockReturnValue({ + order: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + call: overrides.operationsCall ?? vi.fn(), + }), + }), + }), + }), + }); +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe("lib/account", () => { + // ── getAccountInfo ────────────────────────────────────────────────────── + + describe("getAccountInfo", () => { + it("returns well-formed account info for a valid public key", async () => { + const accountsCall = vi.fn().mockResolvedValue(mockAccountRecord); + const deps = { createServer: makeMockServer({ accountsCall }) }; + + const info = await getAccountInfo(testPublicKey, { network: "testnet" }, deps); + + expect(info.accountId).toBe(testPublicKey); + expect(info.sequence).toBe("1234567890"); + expect(info.subentryCount).toBe(3); + expect(info.balances).toHaveLength(2); + expect(info.signers).toHaveLength(1); + expect(info.homeDomain).toBe("example.com"); + }); + + it("correctly maps native and issued balances", async () => { + const accountsCall = vi.fn().mockResolvedValue(mockAccountRecord); + const deps = { createServer: makeMockServer({ accountsCall }) }; + + const info = await getAccountInfo(testPublicKey, { network: "testnet" }, deps); + + const usdcBalance = info.balances.find(b => b.assetCode === "USDC"); + expect(usdcBalance).toBeDefined(); + expect(usdcBalance!.balance).toBe("1500.0000000"); + expect(usdcBalance!.limit).toBeDefined(); + + const xlmBalance = info.balances.find(b => b.assetType === "native"); + expect(xlmBalance).toBeDefined(); + expect(xlmBalance!.balance).toBe("100.5000000"); + expect(xlmBalance!.assetCode).toBeUndefined(); + }); + + it("maps thresholds and flags correctly", async () => { + const accountsCall = vi.fn().mockResolvedValue(mockAccountRecord); + const deps = { createServer: makeMockServer({ accountsCall }) }; + + const info = await getAccountInfo(testPublicKey, { network: "testnet" }, deps); + + expect(info.thresholds.lowThreshold).toBe(0); + expect(info.flags.authRequired).toBe(false); + expect(info.flags.authImmutable).toBe(false); + }); + + it("throws on invalid public key", async () => { + await expect( + getAccountInfo("invalid-key", { network: "testnet" }) + ).rejects.toThrow("Invalid Stellar public key"); + }); + + it("throws on empty public key", async () => { + await expect( + getAccountInfo("", { network: "testnet" }) + ).rejects.toThrow("Invalid Stellar public key"); + }); + + it("throws with helpful message when account is not found (404)", async () => { + const accountsCall = vi.fn().mockRejectedValue({ response: { status: 404 } }); + const deps = { createServer: makeMockServer({ accountsCall }) }; + + await expect( + getAccountInfo(testPublicKey, { network: "testnet" }, deps) + ).rejects.toThrow("not found on testnet"); + }); + }); + + // ── getBalances ───────────────────────────────────────────────────────── + + describe("getBalances", () => { + it("returns only balances from account info", async () => { + const accountsCall = vi.fn().mockResolvedValue(mockAccountRecord); + const deps = { createServer: makeMockServer({ accountsCall }) }; + + const balances = await getBalances(testPublicKey, { network: "testnet" }, deps); + + expect(balances).toHaveLength(2); + expect(balances[0].balance).toBe("1500.0000000"); + expect(balances[1].balance).toBe("100.5000000"); + }); + }); + + // ── getTransactionHistory ─────────────────────────────────────────────── + + describe("getTransactionHistory", () => { + it("returns recent transactions", async () => { + const transactionsCall = vi.fn().mockResolvedValue({ records: mockTransactionRecords }); + const deps = { createServer: makeMockServer({ transactionsCall }) }; + + const txs = await getTransactionHistory( + testPublicKey, { network: "testnet" }, 10, "desc", deps + ); + + expect(txs).toHaveLength(2); + expect(txs[0].hash).toBe("abc123"); + expect(txs[0].successful).toBe(true); + expect(txs[1].memo).toBe("test"); + }); + + it("validates limit boundaries", async () => { + await expect( + getTransactionHistory(testPublicKey, { network: "testnet" }, 0) + ).rejects.toThrow("limit must be between 1 and 50"); + + await expect( + getTransactionHistory(testPublicKey, { network: "testnet" }, 100) + ).rejects.toThrow("limit must be between 1 and 50"); + }); + + it("throws on invalid public key", async () => { + await expect( + getTransactionHistory("bad", { network: "testnet" }) + ).rejects.toThrow("Invalid Stellar public key"); + }); + }); + + // ── getOperationHistory ───────────────────────────────────────────────── + + describe("getOperationHistory", () => { + it("returns recent operations with extracted details", async () => { + const operationsCall = vi.fn().mockResolvedValue({ records: mockOperationRecords }); + const deps = { createServer: makeMockServer({ operationsCall }) }; + + const ops = await getOperationHistory( + testPublicKey, { network: "testnet" }, 10, "desc", deps + ); + + expect(ops).toHaveLength(1); + expect(ops[0].type).toBe("payment"); + expect(ops[0].transactionHash).toBe("abc123"); + expect(ops[0].details).toHaveProperty("amount"); + expect(ops[0].details).not.toHaveProperty("_links"); + expect(ops[0].details).not.toHaveProperty("paging_token"); + }); + + it("validates limit boundaries", async () => { + await expect( + getOperationHistory(testPublicKey, { network: "testnet" }, 0) + ).rejects.toThrow("limit must be between 1 and 50"); + + await expect( + getOperationHistory(testPublicKey, { network: "testnet" }, 100) + ).rejects.toThrow("limit must be between 1 and 50"); + }); + }); + + // ── fundTestnetAccount ────────────────────────────────────────────────── + + describe("fundTestnetAccount", () => { + it("returns success when friendbot funds the account", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue("{}"), + }); + + const result = await fundTestnetAccount(testPublicKey, mockFetch as any); + + expect(result.success).toBe(true); + expect(result.message).toContain("funded with 10,000 test XLM"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("friendbot.stellar.org") + ); + }); + + it("handles already-funded accounts gracefully", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: vi.fn().mockResolvedValue('{"detail":"createAccountAlreadyExist"}'), + }); + + const result = await fundTestnetAccount(testPublicKey, mockFetch as any); + + expect(result.success).toBe(false); + expect(result.message).toContain("already been funded"); + }); + + it("throws on invalid public key", async () => { + await expect( + fundTestnetAccount("invalid", vi.fn() as any) + ).rejects.toThrow("Invalid Stellar public key"); + }); + }); +}); diff --git a/tests/unit/lib/asset.test.ts b/tests/unit/lib/asset.test.ts new file mode 100644 index 00000000..9a2199bc --- /dev/null +++ b/tests/unit/lib/asset.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + getAssetDetails, + getOrderbook, + getTrades, +} from "../../../lib/asset"; + +// ─── Test Fixtures ───────────────────────────────────────────────────────── + +const issuerKey = Keypair.random().publicKey(); + +const mockAssetRecords = [ + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: issuerKey, + paging_token: "USDC_" + issuerKey, + num_accounts: 5000, + amount: "15000000.0000000", + flags: { + auth_required: true, + auth_revocable: true, + auth_immutable: false, + auth_clawback_enabled: true, + }, + }, +]; + +const mockOrderbookResponse = { + base: { asset_type: "native" }, + counter: { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: issuerKey, + }, + bids: [ + { price: "0.1200000", amount: "5000.0000000", price_r: { n: 3, d: 25 } }, + { price: "0.1150000", amount: "8000.0000000", price_r: { n: 23, d: 200 } }, + ], + asks: [ + { price: "0.1250000", amount: "3000.0000000", price_r: { n: 1, d: 8 } }, + ], +}; + +const mockTradeRecords = [ + { + id: "trade-1", + paging_token: "pt-1", + ledger_close_time: "2026-04-30T12:00:00Z", + base_account: Keypair.random().publicKey(), + base_amount: "100.0000000", + base_asset_type: "native", + counter_account: Keypair.random().publicKey(), + counter_amount: "12.5000000", + counter_asset_type: "credit_alphanum4", + counter_asset_code: "USDC", + counter_asset_issuer: issuerKey, + price: { n: "1", d: "8" }, + base_is_seller: true, + }, +]; + +// ─── Mock Server Factory ──────────────────────────────────────────────────── + +function makeMockServer(overrides: Partial> = {}) { + return () => ({ + assets: vi.fn().mockReturnValue({ + forCode: vi.fn().mockReturnValue({ + forIssuer: vi.fn().mockReturnValue({ + call: overrides.assetsCall ?? vi.fn(), + }), + }), + }), + orderbook: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + call: overrides.orderbookCall ?? vi.fn(), + }), + }), + trades: vi.fn().mockReturnValue({ + forAssetPair: vi.fn().mockReturnValue({ + order: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + call: overrides.tradesCall ?? vi.fn(), + }), + }), + }), + }), + }); +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe("lib/asset", () => { + // ── getAssetDetails ───────────────────────────────────────────────────── + + describe("getAssetDetails", () => { + it("returns asset details for a valid asset", async () => { + const assetsCall = vi.fn().mockResolvedValue({ records: mockAssetRecords }); + const deps = { createServer: makeMockServer({ assetsCall }) }; + + const details = await getAssetDetails("USDC", issuerKey, { network: "testnet" }, deps); + + expect(details).toHaveLength(1); + expect(details[0].assetCode).toBe("USDC"); + expect(details[0].assetIssuer).toBe(issuerKey); + expect(details[0].numAccounts).toBe(5000); + expect(details[0].amount).toBe("15000000.0000000"); + }); + + it("correctly maps issuer flags", async () => { + const assetsCall = vi.fn().mockResolvedValue({ records: mockAssetRecords }); + const deps = { createServer: makeMockServer({ assetsCall }) }; + + const details = await getAssetDetails("USDC", issuerKey, { network: "testnet" }, deps); + + expect(details[0].flags.authRequired).toBe(true); + expect(details[0].flags.authRevocable).toBe(true); + expect(details[0].flags.authImmutable).toBe(false); + expect(details[0].flags.authClawbackEnabled).toBe(true); + }); + + it("throws when asset is not found", async () => { + const assetsCall = vi.fn().mockResolvedValue({ records: [] }); + const deps = { createServer: makeMockServer({ assetsCall }) }; + + await expect( + getAssetDetails("FAKE", issuerKey, { network: "testnet" }, deps) + ).rejects.toThrow("not found on testnet"); + }); + + it("validates asset code length", async () => { + await expect( + getAssetDetails("", issuerKey, { network: "testnet" }) + ).rejects.toThrow("Asset code must be between 1 and 12 characters"); + + await expect( + getAssetDetails("TOOLONGASSETCODE", issuerKey, { network: "testnet" }) + ).rejects.toThrow("Asset code must be between 1 and 12 characters"); + }); + + it("validates asset issuer public key", async () => { + await expect( + getAssetDetails("USDC", "invalid-issuer", { network: "testnet" }) + ).rejects.toThrow("Invalid asset issuer public key"); + }); + }); + + // ── getOrderbook ──────────────────────────────────────────────────────── + + describe("getOrderbook", () => { + it("returns bids and asks for a trading pair", async () => { + const orderbookCall = vi.fn().mockResolvedValue(mockOrderbookResponse); + const deps = { createServer: makeMockServer({ orderbookCall }) }; + + const orderbook = await getOrderbook( + { type: "native" }, + { code: "USDC", issuer: issuerKey }, + { network: "testnet" }, + 10, + deps + ); + + expect(orderbook.base.assetType).toBe("native"); + expect(orderbook.counter.assetCode).toBe("USDC"); + expect(orderbook.bids).toHaveLength(2); + expect(orderbook.asks).toHaveLength(1); + expect(orderbook.bids[0].price).toBe("0.1200000"); + expect(orderbook.asks[0].amount).toBe("3000.0000000"); + }); + + it("correctly maps price ratio", async () => { + const orderbookCall = vi.fn().mockResolvedValue(mockOrderbookResponse); + const deps = { createServer: makeMockServer({ orderbookCall }) }; + + const orderbook = await getOrderbook( + { type: "native" }, + { code: "USDC", issuer: issuerKey }, + { network: "testnet" }, + 10, + deps + ); + + expect(orderbook.bids[0].priceR).toEqual({ n: 3, d: 25 }); + }); + + it("validates limit boundaries", async () => { + await expect( + getOrderbook( + { type: "native" }, + { code: "USDC", issuer: issuerKey }, + { network: "testnet" }, + 0 + ) + ).rejects.toThrow("Orderbook limit must be between 1 and 200"); + + await expect( + getOrderbook( + { type: "native" }, + { code: "USDC", issuer: issuerKey }, + { network: "testnet" }, + 300 + ) + ).rejects.toThrow("Orderbook limit must be between 1 and 200"); + }); + }); + + // ── getTrades ─────────────────────────────────────────────────────────── + + describe("getTrades", () => { + it("returns recent trades for a trading pair", async () => { + const tradesCall = vi.fn().mockResolvedValue({ records: mockTradeRecords }); + const deps = { createServer: makeMockServer({ tradesCall }) }; + + const trades = await getTrades( + { type: "native" }, + { code: "USDC", issuer: issuerKey }, + { network: "testnet" }, + 10, + "desc", + deps + ); + + expect(trades).toHaveLength(1); + expect(trades[0].baseAmount).toBe("100.0000000"); + expect(trades[0].counterAssetCode).toBe("USDC"); + expect(trades[0].baseIsSeller).toBe(true); + expect(trades[0].price).toEqual({ n: "1", d: "8" }); + }); + + it("validates limit boundaries", async () => { + await expect( + getTrades( + { type: "native" }, + { code: "USDC", issuer: issuerKey }, + { network: "testnet" }, + 0 + ) + ).rejects.toThrow("Trades limit must be between 1 and 50"); + + await expect( + getTrades( + { type: "native" }, + { code: "USDC", issuer: issuerKey }, + { network: "testnet" }, + 100 + ) + ).rejects.toThrow("Trades limit must be between 1 and 50"); + }); + }); +}); diff --git a/tests/unit/tools/account.test.ts b/tests/unit/tools/account.test.ts new file mode 100644 index 00000000..41b6fe26 --- /dev/null +++ b/tests/unit/tools/account.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { StellarAccountTool } from "../../../tools/account"; + +// Mock all lib/account functions +vi.mock("../../../lib/account", () => ({ + getAccountInfo: vi.fn(), + getBalances: vi.fn(), + getTransactionHistory: vi.fn(), + getOperationHistory: vi.fn(), + fundTestnetAccount: vi.fn(), +})); + +import { + getAccountInfo, + getBalances, + getTransactionHistory, + getOperationHistory, + fundTestnetAccount, +} from "../../../lib/account"; + +const mockedGetAccountInfo = vi.mocked(getAccountInfo); +const mockedGetBalances = vi.mocked(getBalances); +const mockedGetTransactionHistory = vi.mocked(getTransactionHistory); +const mockedGetOperationHistory = vi.mocked(getOperationHistory); +const mockedFundTestnetAccount = vi.mocked(fundTestnetAccount); + +describe("StellarAccountTool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("has correct name and description", () => { + expect(StellarAccountTool.name).toBe("stellar_account_tool"); + expect(StellarAccountTool.description).toContain("account"); + }); + + it("delegates get_info action to getAccountInfo", async () => { + const mockInfo = { + accountId: "GTEST...", + balances: [], + sequence: "123", + }; + mockedGetAccountInfo.mockResolvedValue(mockInfo as any); + + const result = await StellarAccountTool.func({ + action: "get_info", + publicKey: "GTEST...", + network: "testnet", + }); + + expect(mockedGetAccountInfo).toHaveBeenCalledWith("GTEST...", { + network: "testnet", + }); + expect(result).toContain("GTEST..."); + }); + + it("delegates get_balances action to getBalances", async () => { + mockedGetBalances.mockResolvedValue([ + { + assetType: "native", + balance: "100.0000000", + buyingLiabilities: "0", + sellingLiabilities: "0", + }, + ]); + + const result = await StellarAccountTool.func({ + action: "get_balances", + publicKey: "GTEST...", + network: "testnet", + }); + + expect(mockedGetBalances).toHaveBeenCalled(); + expect(result).toContain("100.0000000"); + }); + + it("returns friendly message when no balances found", async () => { + mockedGetBalances.mockResolvedValue([]); + + const result = await StellarAccountTool.func({ + action: "get_balances", + publicKey: "GTEST...", + network: "testnet", + }); + + expect(result).toContain("No balances found"); + }); + + it("delegates get_transactions action with defaults", async () => { + mockedGetTransactionHistory.mockResolvedValue([]); + + const result = await StellarAccountTool.func({ + action: "get_transactions", + publicKey: "GTEST...", + network: "testnet", + }); + + expect(mockedGetTransactionHistory).toHaveBeenCalledWith( + "GTEST...", + { network: "testnet" }, + 10, + "desc" + ); + expect(result).toContain("No transactions found"); + }); + + it("delegates get_operations action with custom limit and order", async () => { + mockedGetOperationHistory.mockResolvedValue([]); + + await StellarAccountTool.func({ + action: "get_operations", + publicKey: "GTEST...", + network: "mainnet", + limit: 25, + order: "asc", + }); + + expect(mockedGetOperationHistory).toHaveBeenCalledWith( + "GTEST...", + { network: "mainnet" }, + 25, + "asc" + ); + }); + + it("delegates fund_testnet action on testnet", async () => { + mockedFundTestnetAccount.mockResolvedValue({ + success: true, + message: "Funded!", + }); + + const result = await StellarAccountTool.func({ + action: "fund_testnet", + publicKey: "GTEST...", + network: "testnet", + }); + + expect(mockedFundTestnetAccount).toHaveBeenCalledWith("GTEST..."); + expect(result).toContain("Funded!"); + }); + + it("rejects fund_testnet action on mainnet", async () => { + await expect( + StellarAccountTool.func({ + action: "fund_testnet", + publicKey: "GTEST...", + network: "mainnet", + }) + ).rejects.toThrow("only available on testnet"); + }); +}); diff --git a/tests/unit/tools/asset.test.ts b/tests/unit/tools/asset.test.ts new file mode 100644 index 00000000..8f1a67fe --- /dev/null +++ b/tests/unit/tools/asset.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { StellarAssetTool } from "../../../tools/asset"; + +// Mock all lib/asset functions +vi.mock("../../../lib/asset", () => ({ + getAssetDetails: vi.fn(), + getOrderbook: vi.fn(), + getTrades: vi.fn(), +})); + +import { + getAssetDetails, + getOrderbook, + getTrades, +} from "../../../lib/asset"; + +const mockedGetAssetDetails = vi.mocked(getAssetDetails); +const mockedGetOrderbook = vi.mocked(getOrderbook); +const mockedGetTrades = vi.mocked(getTrades); + +describe("StellarAssetTool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("has correct name and description", () => { + expect(StellarAssetTool.name).toBe("stellar_asset_tool"); + expect(StellarAssetTool.description).toContain("asset"); + }); + + it("delegates get_asset_details action", async () => { + mockedGetAssetDetails.mockResolvedValue([ + { + assetType: "credit_alphanum4", + assetCode: "USDC", + assetIssuer: "GISSUER...", + pagingToken: "pt", + numAccounts: 100, + amount: "1000000", + flags: { + authRequired: false, + authRevocable: false, + authImmutable: false, + authClawbackEnabled: false, + }, + }, + ]); + + const result = await StellarAssetTool.func({ + action: "get_asset_details", + assetCode: "USDC", + assetIssuer: "GISSUER...", + network: "testnet", + }); + + expect(mockedGetAssetDetails).toHaveBeenCalledWith( + "USDC", + "GISSUER...", + { network: "testnet" } + ); + expect(result).toContain("USDC"); + }); + + it("throws when get_asset_details is missing required params", async () => { + await expect( + StellarAssetTool.func({ + action: "get_asset_details", + network: "testnet", + }) + ).rejects.toThrow("'assetCode' and 'assetIssuer' are required"); + }); + + it("delegates get_orderbook action", async () => { + mockedGetOrderbook.mockResolvedValue({ + base: { assetType: "native" }, + counter: { assetType: "credit_alphanum4", assetCode: "USDC" }, + bids: [], + asks: [], + } as any); + + const result = await StellarAssetTool.func({ + action: "get_orderbook", + baseAsset: { type: "native" }, + counterAsset: { code: "USDC", issuer: "G..." }, + network: "testnet", + }); + + expect(mockedGetOrderbook).toHaveBeenCalled(); + expect(result).toContain("native"); + }); + + it("throws when get_orderbook is missing required params", async () => { + await expect( + StellarAssetTool.func({ + action: "get_orderbook", + network: "testnet", + }) + ).rejects.toThrow("'baseAsset' and 'counterAsset' are required"); + }); + + it("delegates get_trades action", async () => { + mockedGetTrades.mockResolvedValue([]); + + const result = await StellarAssetTool.func({ + action: "get_trades", + baseAsset: { type: "native" }, + counterAsset: { code: "USDC", issuer: "G..." }, + network: "testnet", + }); + + expect(mockedGetTrades).toHaveBeenCalled(); + expect(result).toContain("No trades found"); + }); + + it("clamps trades limit to max 50", async () => { + mockedGetTrades.mockResolvedValue([]); + + await StellarAssetTool.func({ + action: "get_trades", + baseAsset: { type: "native" }, + counterAsset: { code: "USDC", issuer: "G..." }, + network: "testnet", + limit: 100, + }); + + // The tool should clamp to 50 + expect(mockedGetTrades).toHaveBeenCalledWith( + { type: "native" }, + { code: "USDC", issuer: "G..." }, + { network: "testnet" }, + 50, + "desc" + ); + }); +}); diff --git a/tools/account.ts b/tools/account.ts new file mode 100644 index 00000000..d9a58718 --- /dev/null +++ b/tools/account.ts @@ -0,0 +1,137 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; +import { + getAccountInfo, + getBalances, + getTransactionHistory, + getOperationHistory, + fundTestnetAccount, +} from "../lib/account"; + +/** + * Stellar Account Explorer Tool + * + * A read-only tool that allows AI agents to query Stellar account data: + * - Account details (balances, signers, thresholds, flags) + * - Balance summary + * - Transaction history + * - Operation history + * - Testnet account funding via Friendbot + * + * This tool does NOT require a private key — all actions are read-only + * Horizon API calls (except friendbot funding, which is testnet-only). + */ +export const StellarAccountTool = new DynamicStructuredTool({ + name: "stellar_account_tool", + description: + "Query Stellar account information: balances, transaction history, operation history, " + + "account details (signers, thresholds, flags), and fund testnet accounts via Friendbot. " + + "All actions are read-only except 'fund_testnet' which funds a testnet account with test XLM.", + schema: z.object({ + action: z + .enum([ + "get_info", + "get_balances", + "get_transactions", + "get_operations", + "fund_testnet", + ]) + .describe( + "The action to perform: " + + "'get_info' — full account details; " + + "'get_balances' — balance summary; " + + "'get_transactions' — recent transaction history; " + + "'get_operations' — recent operation history; " + + "'fund_testnet' — fund a testnet account with Friendbot" + ), + publicKey: z + .string() + .describe("The Stellar public key (G-address) to query"), + network: z + .enum(["testnet", "mainnet"]) + .default("testnet") + .describe("Which Stellar network to query"), + limit: z + .number() + .int() + .positive() + .max(50) + .optional() + .describe("Maximum number of records to return (1–50, default 10)"), + order: z + .enum(["asc", "desc"]) + .optional() + .describe("Sort order: 'desc' (newest first) or 'asc' (oldest first)"), + }), + func: async (input: { + action: string; + publicKey: string; + network?: "testnet" | "mainnet"; + limit?: number; + order?: "asc" | "desc"; + }) => { + const network = input.network ?? "testnet"; + const config = { network }; + + try { + switch (input.action) { + case "get_info": { + const info = await getAccountInfo(input.publicKey, config); + return JSON.stringify(info, null, 2); + } + + case "get_balances": { + const balances = await getBalances(input.publicKey, config); + if (balances.length === 0) { + return "No balances found for this account."; + } + return JSON.stringify(balances, null, 2); + } + + case "get_transactions": { + const transactions = await getTransactionHistory( + input.publicKey, + config, + input.limit ?? 10, + input.order ?? "desc" + ); + if (transactions.length === 0) { + return "No transactions found for this account."; + } + return JSON.stringify(transactions, null, 2); + } + + case "get_operations": { + const operations = await getOperationHistory( + input.publicKey, + config, + input.limit ?? 10, + input.order ?? "desc" + ); + if (operations.length === 0) { + return "No operations found for this account."; + } + return JSON.stringify(operations, null, 2); + } + + case "fund_testnet": { + if (network !== "testnet") { + throw new Error( + "Friendbot funding is only available on testnet. " + + "Set network to 'testnet' to use this action." + ); + } + const result = await fundTestnetAccount(input.publicKey); + return JSON.stringify(result, null, 2); + } + + default: + throw new Error(`Unsupported action: ${input.action}`); + } + } catch (error: any) { + throw new Error( + `Account tool error (${input.action}): ${error.message}` + ); + } + }, +}); diff --git a/tools/asset.ts b/tools/asset.ts new file mode 100644 index 00000000..ee0c828e --- /dev/null +++ b/tools/asset.ts @@ -0,0 +1,142 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; +import { + getAssetDetails, + getOrderbook, + getTrades, + type StellarAssetInput, +} from "../lib/asset"; + +const nativeAssetSchema = z.object({ + type: z.literal("native"), +}); + +const issuedAssetSchema = z.object({ + code: z.string().min(1).max(12), + issuer: z.string().min(1), +}); + +const assetSchema = z.union([nativeAssetSchema, issuedAssetSchema]); + +/** + * Stellar Asset Explorer Tool + * + * A read-only tool that allows AI agents to query Stellar asset and market data: + * - Asset details (trust count, circulating supply, issuer flags) + * - SDEX orderbook (current bids and asks) + * - Recent trade history for any trading pair + * + * This tool does NOT require a private key — all actions are read-only + * Horizon API calls. + */ +export const StellarAssetTool = new DynamicStructuredTool({ + name: "stellar_asset_tool", + description: + "Query Stellar asset and market data: asset details (trust count, supply, issuer flags), " + + "SDEX orderbook (current bids/asks for a trading pair), and recent trade history. " + + "All actions are read-only.", + schema: z.object({ + action: z + .enum(["get_asset_details", "get_orderbook", "get_trades"]) + .describe( + "The action to perform: " + + "'get_asset_details' — lookup asset metadata; " + + "'get_orderbook' — fetch current SDEX orderbook; " + + "'get_trades' — fetch recent trades for a pair" + ), + // For get_asset_details + assetCode: z + .string() + .min(1) + .max(12) + .optional() + .describe("Asset code (e.g. 'USDC'). Required for 'get_asset_details'."), + assetIssuer: z + .string() + .optional() + .describe("Asset issuer public key. Required for 'get_asset_details'."), + // For get_orderbook and get_trades + baseAsset: assetSchema + .optional() + .describe("Base asset of the trading pair. Required for 'get_orderbook' and 'get_trades'."), + counterAsset: assetSchema + .optional() + .describe("Counter asset of the trading pair. Required for 'get_orderbook' and 'get_trades'."), + network: z + .enum(["testnet", "mainnet"]) + .default("testnet") + .describe("Which Stellar network to query"), + limit: z + .number() + .int() + .positive() + .max(200) + .optional() + .describe("Maximum number of records to return"), + order: z + .enum(["asc", "desc"]) + .optional() + .describe("Sort order for trades: 'desc' (newest first) or 'asc' (oldest first)"), + }), + func: async (input: any) => { + const network = input.network ?? "testnet"; + const config = { network }; + + try { + switch (input.action) { + case "get_asset_details": { + if (!input.assetCode || !input.assetIssuer) { + throw new Error( + "'assetCode' and 'assetIssuer' are required for 'get_asset_details'" + ); + } + const details = await getAssetDetails( + input.assetCode, + input.assetIssuer, + config + ); + return JSON.stringify(details, null, 2); + } + + case "get_orderbook": { + if (!input.baseAsset || !input.counterAsset) { + throw new Error( + "'baseAsset' and 'counterAsset' are required for 'get_orderbook'" + ); + } + const orderbook = await getOrderbook( + input.baseAsset as StellarAssetInput, + input.counterAsset as StellarAssetInput, + config, + input.limit ?? 10 + ); + return JSON.stringify(orderbook, null, 2); + } + + case "get_trades": { + if (!input.baseAsset || !input.counterAsset) { + throw new Error( + "'baseAsset' and 'counterAsset' are required for 'get_trades'" + ); + } + const trades = await getTrades( + input.baseAsset as StellarAssetInput, + input.counterAsset as StellarAssetInput, + config, + Math.min(input.limit ?? 10, 50), + input.order ?? "desc" + ); + if (trades.length === 0) { + return "No trades found for this trading pair."; + } + return JSON.stringify(trades, null, 2); + } + + default: + throw new Error(`Unsupported action: ${input.action}`); + } + } catch (error: any) { + throw new Error(`Asset tool error (${input.action}): ${error.message}`); + } + }, +}); diff --git a/tools/bridge.ts b/tools/bridge.ts index e72730d9..957e46ac 100644 --- a/tools/bridge.ts +++ b/tools/bridge.ts @@ -9,7 +9,6 @@ import { } from "@allbridge/bridge-core-sdk"; import { Keypair, - Keypair as StellarKeypair, rpc, Networks } from "@stellar/stellar-sdk"; @@ -51,12 +50,11 @@ const STELLAR_NETWORK_CONFIG: Record { - // Mainnet safeguard - additional layer beyond AgentClient + // Mainnet safeguard if ( fromNetwork === "stellar-mainnet" && process.env.ALLOW_MAINNET_BRIDGE !== "true" @@ -88,8 +86,6 @@ export const bridgeTokenTool = new DynamicStructuredTool({ ); } - const destinationChainSymbol = TARGET_CHAIN_MAP[targetChain]; - const sdk = new AllbridgeCoreSdk({ ...nodeRpcUrlsDefault, SRB: `${process.env.SRB_PROVIDER_URL}`, @@ -97,18 +93,18 @@ export const bridgeTokenTool = new DynamicStructuredTool({ const chainDetailsMap = await sdk.chainDetailsMap(); + // Destination chain symbol dynamic selection + const destinationChainSymbol = TARGET_CHAIN_MAP[targetChain]; + const sourceToken = ensure( chainDetailsMap[ChainSymbol.SRB].tokens.find( (t) => t.symbol === "USDC" ) ); - - const destinationChainDetails = chainDetailsMap[destinationChainSymbol]; - if (!destinationChainDetails) { - throw new Error(`Chain not supported by Allbridge: ${targetChain}`); - } const destinationToken = ensure( - destinationChainDetails.tokens.find((t) => t.symbol === "USDC") + chainDetailsMap[destinationChainSymbol].tokens.find( + (t) => t.symbol === "USDC" + ) ); const sendParams = { @@ -123,7 +119,9 @@ export const bridgeTokenTool = new DynamicStructuredTool({ gasFeePaymentMethod: FeePaymentMethod.WITH_STABLECOIN, }; - const xdrTx = (await sdk.bridge.rawTxBuilder.send(sendParams)) as string; + const xdrTx = (await sdk.bridge.rawTxBuilder.send( + sendParams + )) as string; const srbKeypair = Keypair.fromSecret(privateKey); const transaction = buildTransactionFromXDR( @@ -156,7 +154,9 @@ export const bridgeTokenTool = new DynamicStructuredTool({ sentRestoreXdrTx.hash ); - if (confirmRestoreXdrTx.status === rpc.Api.GetTransactionStatus.FAILED) { + if ( + confirmRestoreXdrTx.status === rpc.Api.GetTransactionStatus.FAILED + ) { throw new Error( `Restore transaction failed. Hash: ${sentRestoreXdrTx.hash}` ); @@ -173,8 +173,10 @@ export const bridgeTokenTool = new DynamicStructuredTool({ }; } - // Rebuild tx with updated sequence numbers after restore - const xdrTx2 = (await sdk.bridge.rawTxBuilder.send(sendParams)) as string; + const xdrTx2 = (await sdk.bridge.rawTxBuilder.send( + sendParams + )) as string; + const transaction2 = buildTransactionFromXDR( "bridge", xdrTx2, @@ -200,7 +202,6 @@ export const bridgeTokenTool = new DynamicStructuredTool({ throw new Error(`Transaction failed. Hash: ${sent.hash}`); } - // TrustLine check and setup for source token on Stellar side const destinationTokenSBR = sourceToken; const balanceLine = await sdk.utils.srb.getBalanceLine( @@ -210,21 +211,23 @@ export const bridgeTokenTool = new DynamicStructuredTool({ const notEnoughBalanceLine = !balanceLine || - Big(balanceLine.balance).add(amount).gt(Big(balanceLine.limit)); + Big(balanceLine.balance) + .add(amount) + .gt(Big(balanceLine.limit)); if (notEnoughBalanceLine) { - const xdrTx = await sdk.utils.srb.buildChangeTrustLineXdrTx({ - sender: fromAddress, - tokenAddress: destinationTokenSBR.tokenAddress, - }); + const xdrTxTrust = + await sdk.utils.srb.buildChangeTrustLineXdrTx({ + sender: fromAddress, + tokenAddress: destinationTokenSBR.tokenAddress, + }); - const keypair = StellarKeypair.fromSecret(privateKey); const trustTx = buildTransactionFromXDR( "bridge", - xdrTx, + xdrTxTrust, STELLAR_NETWORK_CONFIG[fromNetwork].networkPassphrase ); - trustTx.sign(keypair); + trustTx.sign(srbKeypair); const signedTrustLineTx = trustTx.toXDR(); const submit = await sdk.utils.srb.submitTransactionStellar( @@ -248,4 +251,4 @@ export const bridgeTokenTool = new DynamicStructuredTool({ amount, }; }, -}); +}); \ No newline at end of file diff --git a/tools/claim_balance_tool.ts b/tools/claim_balance_tool.ts new file mode 100644 index 00000000..0a278d4d --- /dev/null +++ b/tools/claim_balance_tool.ts @@ -0,0 +1,42 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; +import { listClaimableBalances, claimBalance } from "../lib/claimF"; + +export const StellarClaimBalanceTool = new DynamicStructuredTool({ + name: "stellar_claim_balance_tool", + description: + "Discover and claim pending assets (claimable balances) on the Stellar network for the user account. Returns an unsigned XDR for claim actions.", + schema: z.object({ + action: z.enum(["list", "claim"]), + balanceId: z.string().optional(), // Optional: if provided, claims a specific ID; otherwise claims all. + }), + func: async ({ action, balanceId }: { action: "list" | "claim"; balanceId?: string }) => { + const STELLAR_PUBLIC_KEY = process.env.STELLAR_PUBLIC_KEY; + + if (!STELLAR_PUBLIC_KEY) { + throw new Error("Missing STELLAR_PUBLIC_KEY environment variable"); + } + + try { + switch (action) { + case "list": { + const balances = await listClaimableBalances(STELLAR_PUBLIC_KEY); + if (balances.length === 0) return "No pending claimable balances found."; + return JSON.stringify(balances, null, 2); + } + + case "claim": { + const tx = await claimBalance(STELLAR_PUBLIC_KEY, balanceId); + // In a real agent scenario, this XDR would be signed and submitted. + return `Claim transaction built successfully. XDR: ${tx.toXDR()}`; + } + + default: + throw new Error("Unsupported action"); + } + } catch (error: any) { + console.error("StellarClaimBalanceTool error:", error.message); + throw new Error(`Failed to execute ${action}: ${error.message}`); + } + }, +}); diff --git a/tools/contract.ts b/tools/contract.ts index 7ea8e134..29ed1b97 100644 --- a/tools/contract.ts +++ b/tools/contract.ts @@ -3,36 +3,33 @@ import { z } from "zod"; import { getShareId, deposit, - swap, withdraw, getReserves, + swap as contractSwap, } from "../lib/contract"; -// Assuming env variables are already loaded elsewhere -const STELLAR_PUBLIC_KEY = process.env.STELLAR_PUBLIC_KEY!; -const STELLAR_NETWORK = (process.env.STELLAR_NETWORK as "testnet" | "mainnet") || "testnet"; -const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org"; - -if (!STELLAR_PUBLIC_KEY) { - throw new Error("Missing Stellar environment variables"); -} +const STELLAR_PUBLIC_KEY = process.env.STELLAR_PUBLIC_KEY || ""; +const STELLAR_NETWORK = (process.env.STELLAR_NETWORK?.toLowerCase() || "testnet") as "testnet" | "mainnet"; +const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL || + (STELLAR_NETWORK === "mainnet" + ? "https://soroban-mainnet.stellar.org" + : "https://soroban-testnet.stellar.org"); export const StellarLiquidityContractTool = new DynamicStructuredTool({ name: "stellar_liquidity_contract_tool", - description: - "Interact with a liquidity contract on Stellar Soroban: getShareId, deposit, swap, withdraw, getReserves.", + description: "Interact with a liquidity contract on Stellar Soroban: getShareId, deposit, swap, withdraw, getReserves.", schema: z.object({ action: z.enum(["get_share_id", "deposit", "swap", "withdraw", "get_reserves"]), - to: z.string().optional(), // For deposit, swap, withdraw - desiredA: z.string().optional(), // For deposit - minA: z.string().optional(), // For deposit, withdraw - desiredB: z.string().optional(), // For deposit - minB: z.string().optional(), // For deposit, withdraw - buyA: z.boolean().optional(), // For swap - out: z.string().optional(), // For swap - inMax: z.string().optional(), // For swap - shareAmount: z.string().optional(), // For withdraw - contractAddress: z.string().optional(), // For overriding default pool on mainnet + to: z.string().optional(), + desiredA: z.string().optional(), + minA: z.string().optional(), + desiredB: z.string().optional(), + minB: z.string().optional(), + buyA: z.boolean().optional(), + out: z.string().optional(), + inMax: z.string().optional(), + shareAmount: z.string().optional(), + contractAddress: z.string().optional(), }), func: async (input: any) => { const { @@ -48,13 +45,13 @@ export const StellarLiquidityContractTool = new DynamicStructuredTool({ shareAmount, contractAddress, } = input; - + const config = { network: STELLAR_NETWORK, rpcUrl: SOROBAN_RPC_URL, contractAddress, }; - + try { switch (action) { case "get_share_id": { @@ -66,23 +63,21 @@ export const StellarLiquidityContractTool = new DynamicStructuredTool({ throw new Error("to, desiredA, minA, desiredB, and minB are required for deposit"); } const result = await deposit(STELLAR_PUBLIC_KEY, to, desiredA, minA, desiredB, minB, config); - return result ??`Deposited successfully to ${to}.`; + return result ?? `Deposited successfully to ${to}.`; } case "swap": { if (!to || buyA === undefined || !out || !inMax) { throw new Error("to, buyA, out, and inMax are required for swap"); } - const result=await swap(STELLAR_PUBLIC_KEY, to, buyA, out, inMax, config); - return result ?? `Swapped successfully to ${to}.`; + const result = await contractSwap(STELLAR_PUBLIC_KEY, to, buyA, out, inMax, config); + return result ?? `Swapped successfully for ${to}.`; } case "withdraw": { if (!to || !shareAmount || !minA || !minB) { throw new Error("to, shareAmount, minA, and minB are required for withdraw"); } const result = await withdraw(STELLAR_PUBLIC_KEY, to, shareAmount, minA, minB, config); - return result - ? `Withdrawn successfully to ${to}: ${JSON.stringify(result)}` - : "Withdraw failed or returned no value."; + return result ?? `Withdrawn successfully to ${to}.`; } case "get_reserves": { const result = await getReserves(STELLAR_PUBLIC_KEY, config); @@ -94,7 +89,6 @@ export const StellarLiquidityContractTool = new DynamicStructuredTool({ throw new Error("Unsupported action"); } } catch (error: any) { - console.error("StellarLiquidityContractTool error:", error.message); throw new Error(`Failed to execute ${action}: ${error.message}`); } }, diff --git a/tools/stake.ts b/tools/stake.ts index f4b93b79..35cc5df3 100644 --- a/tools/stake.ts +++ b/tools/stake.ts @@ -8,36 +8,33 @@ import { getStake, } from "../lib/stakeF"; -// Assuming env variables are already loaded elsewhere -const STELLAR_PUBLIC_KEY = process.env.STELLAR_PUBLIC_KEY!; -const STELLAR_NETWORK = (process.env.STELLAR_NETWORK as "testnet" | "mainnet") || "testnet"; -const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org"; - -if (!STELLAR_PUBLIC_KEY) { - throw new Error("Missing Stellar environment variables"); -} +const STELLAR_PUBLIC_KEY = process.env.STELLAR_PUBLIC_KEY || ""; +const STELLAR_NETWORK = (process.env.STELLAR_NETWORK?.toLowerCase() || "testnet") as "testnet" | "mainnet"; +const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL || + (STELLAR_NETWORK === "mainnet" + ? "https://soroban-mainnet.stellar.org" + : "https://soroban-testnet.stellar.org"); export const StellarContractTool = new DynamicStructuredTool({ name: "stellar_contract_tool", - description: - "Interact with a staking contract on Stellar Soroban: initialize, stake, unstake, claim rewards, or get stake.", + description: "Interact with a staking contract on Stellar Soroban: initialize, stake, unstake, claim rewards, or get stake.", schema: z.object({ action: z.enum(["initialize", "stake", "unstake", "claim_rewards", "get_stake"]), - tokenAddress: z.string().optional(), // Only for initialize - rewardRate: z.number().optional(), // Only for initialize - amount: z.number().optional(), // For stake/unstake - userAddress: z.string().optional(), // For get_stake - contractAddress: z.string().optional(), // For overriding default pool on mainnet + tokenAddress: z.string().optional(), + rewardRate: z.number().optional(), + amount: z.number().optional(), + userAddress: z.string().optional(), + contractAddress: z.string().optional(), }), func: async (input: any) => { const { action, tokenAddress, rewardRate, amount, userAddress, contractAddress } = input; - + const config = { network: STELLAR_NETWORK, rpcUrl: SOROBAN_RPC_URL, contractAddress, }; - + try { switch (action) { case "initialize": { @@ -47,7 +44,6 @@ export const StellarContractTool = new DynamicStructuredTool({ const result = await initialize(STELLAR_PUBLIC_KEY, tokenAddress, rewardRate, config); return result ?? "Contract initialized successfully."; } - case "stake": { if (amount === undefined) { throw new Error("amount is required for stake"); @@ -55,7 +51,6 @@ export const StellarContractTool = new DynamicStructuredTool({ const result = await stake(STELLAR_PUBLIC_KEY, amount, config); return result ?? `Staked ${amount} successfully.`; } - case "unstake": { if (amount === undefined) { throw new Error("amount is required for unstake"); @@ -63,12 +58,10 @@ export const StellarContractTool = new DynamicStructuredTool({ const result = await unstake(STELLAR_PUBLIC_KEY, amount, config); return result ?? `Unstaked ${amount} successfully.`; } - case "claim_rewards": { const result = await claimRewards(STELLAR_PUBLIC_KEY, config); return result ?? "Rewards claimed successfully."; } - case "get_stake": { if (!userAddress) { throw new Error("userAddress is required for get_stake"); @@ -76,7 +69,6 @@ export const StellarContractTool = new DynamicStructuredTool({ const stakeAmount = await getStake(STELLAR_PUBLIC_KEY, userAddress, config); return `Stake for ${userAddress}: ${stakeAmount}`; } - default: throw new Error("Unsupported action"); } @@ -85,6 +77,4 @@ export const StellarContractTool = new DynamicStructuredTool({ throw new Error(`Failed to execute ${action}: ${error.message}`); } }, -}); - - +}); \ No newline at end of file