diff --git a/.gitignore b/.gitignore index f9af8084..869e9d40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,5 @@ -# 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 diff --git a/agent.ts b/agent.ts index 0b2d635b..d394f075 100644 --- a/agent.ts +++ b/agent.ts @@ -14,17 +14,41 @@ 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 { + initialize as stakingInitialize, + stake as stakingStake, + unstake as stakingUnstake, + claimRewards as stakingClaimRewards, + getStake as stakingGetStake, +} from "./lib/stakeF"; +import { + listClaimableBalances, + claimBalance, +} from "./lib/claimF"; import { bridgeTokenTool } from "./tools/bridge"; import { stellarGetBalanceTool, stellarGetAccountInfoTool } from "./tools/stellar"; -import { - Horizon, - Keypair, - Asset, - TransactionBuilder, - Operation, - Networks, - BASE_FEE -} from "@stellar/stellar-sdk"; +import { Keypair, StrKey, Horizon, Networks, TransactionBuilder, Operation, Asset, Memo } from "@stellar/stellar-sdk"; const { Server } = Horizon; @@ -67,6 +91,13 @@ export type { RouteQuote, SwapBestRouteParams, SwapBestRouteResult, + AccountInfo, + AccountBalance, + TransactionRecord, + OperationRecord, + AssetDetails, + OrderbookSummary, + TradeRecord, }; export class AgentClient { @@ -127,6 +158,120 @@ export class AgentClient { ); } + /** + * Send a payment on the Stellar network. + * + * Sends XLM or any Stellar asset from the configured account to a recipient. + * Requires STELLAR_PRIVATE_KEY to be set in the environment. + * + * @param params Payment parameters + * @returns Transaction hash of the payment + */ + async sendPayment(params: { + recipient: string; + amount: string; + asset?: { code: string; issuer: string }; + memo?: string; + }): Promise<{ hash: string }> { + if (!this.publicKey) { + throw new Error("Public key is required to send payments."); + } + + if (!StrKey.isValidEd25519PublicKey(params.recipient)) { + throw new Error("Invalid recipient address."); + } + + if (!params.amount || isNaN(Number(params.amount)) || Number(params.amount) <= 0) { + throw new Error("Amount must be a positive number."); + } + + const privateKey = process.env.STELLAR_PRIVATE_KEY; + if (!privateKey || !StrKey.isValidEd25519SecretSeed(privateKey)) { + throw new Error("Invalid or missing STELLAR_PRIVATE_KEY in environment."); + } + + const keypair = Keypair.fromSecret(privateKey); + if (keypair.publicKey() !== this.publicKey) { + throw new Error("STELLAR_PRIVATE_KEY does not match the configured publicKey."); + } + + const server = new Horizon.Server(this.rpcUrl); + const account = await server.loadAccount(this.publicKey); + const networkPassphrase = this.network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET; + + const paymentAsset = params.asset + ? new Asset(params.asset.code, params.asset.issuer) + : Asset.native(); + + const builder = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation( + Operation.payment({ + destination: params.recipient, + asset: paymentAsset, + amount: params.amount, + }) + ) + .setTimeout(300); + + if (params.memo) { + builder.addMemo(Memo.text(params.memo)); + } + + const transaction = builder.build(); + transaction.sign(keypair); + const response = await server.submitTransaction(transaction); + + return { hash: response.hash }; + } + + /** + * Get the balance of a Stellar account. + * @param publicKey Optional Stellar public key (defaults to client's publicKey) + */ + async getBalance(publicKey?: string) { + const targetPublicKey = publicKey || this.publicKey; + if (!targetPublicKey) { + throw new Error("Public key is required to fetch balance."); + } + + const result = await stellarGetBalanceTool.func({ + publicKey: targetPublicKey, + network: this.network, + }); + + try { + return JSON.parse(result); + } catch (e) { + // If it's not JSON (e.g. error message), return as is + return result; + } + } + + /** + * Get full details of a Stellar account. + * @param publicKey Optional Stellar public key (defaults to client's publicKey) + */ + async getAccountInfo(publicKey?: string) { + const targetPublicKey = publicKey || this.publicKey; + if (!targetPublicKey) { + throw new Error("Public key is required to fetch account info."); + } + + const result = await stellarGetAccountInfoTool.func({ + publicKey: targetPublicKey, + network: this.network, + }); + + try { + return JSON.parse(result); + } catch (e) { + return result; + } + } + /** * Bridge USDC from Stellar to an EVM-compatible chain. * @@ -284,6 +429,298 @@ 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); + }, + }; + + /** + * Staking operations on Stellar Soroban. + * + * These methods interact with a staking smart contract. + * They require STELLAR_PRIVATE_KEY to be set for transaction signing. + */ + public staking = { + /** + * Initialize a staking contract with a token address and reward rate. + * + * @param tokenAddress - The Soroban token contract address + * @param rewardRate - The reward rate for staking + * @param contractAddress - Optional custom staking contract address + */ + initialize: async (params: { + tokenAddress: string; + rewardRate: number; + contractAddress?: string; + }): Promise => { + if (!this.publicKey || !StrKey.isValidEd25519PublicKey(this.publicKey)) throw new Error("Valid public key is required for staking operations."); + return await stakingInitialize( + this.publicKey, + params.tokenAddress, + params.rewardRate, + { network: this.network, rpcUrl: this.getSorobanRpcUrl(), contractAddress: params.contractAddress } + ); + }, + + /** + * Stake tokens in the staking contract. + * + * @param amount - The amount to stake + * @param contractAddress - Optional custom staking contract address + */ + stake: async (params: { + amount: number; + contractAddress?: string; + }): Promise => { + if (!this.publicKey || !StrKey.isValidEd25519PublicKey(this.publicKey)) throw new Error("Valid public key is required for staking operations."); + return await stakingStake( + this.publicKey, + params.amount, + { network: this.network, rpcUrl: this.getSorobanRpcUrl(), contractAddress: params.contractAddress } + ); + }, + + /** + * Unstake tokens from the staking contract. + * + * @param amount - The amount to unstake + * @param contractAddress - Optional custom staking contract address + */ + unstake: async (params: { + amount: number; + contractAddress?: string; + }): Promise => { + if (!this.publicKey || !StrKey.isValidEd25519PublicKey(this.publicKey)) throw new Error("Valid public key is required for staking operations."); + return await stakingUnstake( + this.publicKey, + params.amount, + { network: this.network, rpcUrl: this.getSorobanRpcUrl(), contractAddress: params.contractAddress } + ); + }, + + /** + * Claim staking rewards. + * + * @param contractAddress - Optional custom staking contract address + */ + claimRewards: async (params?: { + contractAddress?: string; + }): Promise => { + if (!this.publicKey || !StrKey.isValidEd25519PublicKey(this.publicKey)) throw new Error("Valid public key is required for staking operations."); + return await stakingClaimRewards( + this.publicKey, + { network: this.network, rpcUrl: this.getSorobanRpcUrl(), contractAddress: params?.contractAddress } + ); + }, + + /** + * Get the staked amount for a specific user. + * + * @param userAddress - The Stellar address to check stake for + * @param contractAddress - Optional custom staking contract address + */ + getStake: async (params: { + userAddress: string; + contractAddress?: string; + }): Promise => { + if (!this.publicKey || !StrKey.isValidEd25519PublicKey(this.publicKey)) throw new Error("Valid public key is required for staking operations."); + return await stakingGetStake( + this.publicKey, + params.userAddress, + { network: this.network, rpcUrl: this.getSorobanRpcUrl(), contractAddress: params.contractAddress } + ); + }, + }; + + /** + * Claimable balance operations. + * + * Discover and claim pending assets (claimable balances) on the Stellar network. + */ + public claims = { + /** + * List all pending claimable balances for the configured account. + * + * @param publicKey - Optional public key to query (defaults to configured publicKey) + */ + list: async (publicKey?: string) => { + const targetPublicKey = publicKey || this.publicKey; + if (!targetPublicKey) { + throw new Error("No public key provided or configured"); + } + return await listClaimableBalances(targetPublicKey, { network: this.network, horizonUrl: this.rpcUrl }); + }, + + /** + * Build a claim transaction for one or all claimable balances. + * Returns an unsigned transaction that needs to be signed before submission. + * + * @param balanceId - Optional specific balance ID to claim. If omitted, claims all. + * @param publicKey - Optional public key (defaults to configured publicKey) + */ + claim: async (params: { balanceId?: string; publicKey?: string } = {}) => { + const targetPublicKey = params.publicKey || this.publicKey; + if (!targetPublicKey) { + throw new Error("No public key provided or configured"); + } + return await claimBalance(targetPublicKey, params.balanceId, { network: this.network, horizonUrl: this.rpcUrl }); + }, + }; + /** * Launch a new token on the Stellar network. * @@ -573,4 +1010,14 @@ export class AgentClient { throw new Error(`Failed to lock issuer account: ${error instanceof Error ? error.message : String(error)}`); } } + + /** + * Get the Soroban RPC URL for the current network. + * Soroban uses a different endpoint than Horizon. + */ + private getSorobanRpcUrl(): string { + return this.network === "mainnet" + ? "https://soroban-mainnet.stellar.org" + : "https://soroban-testnet.stellar.org"; + } } diff --git a/docs/api.md b/docs/api.md index 5900b7fa..49aafc7b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -309,6 +309,105 @@ console.log(`Share Token ID: ${shareId}`); --- +## 💸 sendPayment() + +Sends XLM or any Stellar asset from the configured account to a recipient. + +#### Parameters + +```typescript +{ + recipient: string; // Destination public key + amount: string; // Amount to send + asset?: { // Optional: Send custom asset (defaults to native XLM) + code: string; + issuer: string; + }; + memo?: string; // Optional: Transaction memo +} +``` + +#### Example + +```typescript +await agent.sendPayment({ + recipient: "GXXXX...", + amount: "10", + memo: "Payment for services" +}); +``` + +--- + +## 🔍 account Namespace + +Read-only access to Stellar account data. + +### `account.getInfo(publicKey?)` +Gets comprehensive account information (balances, signers, thresholds, flags). + +### `account.getBalances(publicKey?)` +Gets account balance summary. + +### `account.getTransactions(publicKey?, limit?, order?)` +Gets recent transaction history for an account. + +### `account.getOperations(publicKey?, limit?, order?)` +Gets recent operation history for an account. + +### `account.fundTestnet(publicKey?)` +Funds an account with 10,000 test XLM using Friendbot (testnet only). + +--- + +## 📈 asset Namespace + +Read-only access to Stellar asset and market data. + +### `asset.getDetails(assetCode, assetIssuer)` +Looks up details about a Stellar asset. + +### `asset.getOrderbook(baseAsset, counterAsset, limit?)` +Fetches the current SDEX orderbook for a trading pair. + +### `asset.getTrades(baseAsset, counterAsset, limit?, order?)` +Fetches recent trades for a trading pair on the SDEX. + +--- + +## 🥩 staking Namespace + +Operations for interacting with Soroban staking smart contracts. + +### `staking.initialize({ tokenAddress, rewardRate, contractAddress? })` +Initializes a staking contract. + +### `staking.stake({ amount, contractAddress? })` +Stakes tokens in the staking contract. + +### `staking.unstake({ amount, contractAddress? })` +Unstakes tokens from the staking contract. + +### `staking.claimRewards({ contractAddress? })` +Claims staking rewards. + +### `staking.getStake({ userAddress, contractAddress? })` +Gets the staked amount for a specific user. + +--- + +## 📥 claims Namespace + +Discover and claim pending assets (claimable balances) on the Stellar network. + +### `claims.list(publicKey?)` +Lists all pending claimable balances for the configured account. + +### `claims.claim({ balanceId?, publicKey? })` +Builds a claim transaction for one or all claimable balances. Returns an unsigned transaction. + +--- + ## 🚨 Error Handling All methods may throw errors in the following scenarios: diff --git a/index.ts b/index.ts index 2e299911..044fc74e 100644 --- a/index.ts +++ b/index.ts @@ -3,6 +3,9 @@ import { StellarLiquidityContractTool } from "./tools/contract"; import { StellarDexTool } from "./tools/dex"; import { StellarContractTool } from "./tools/stake"; import { stellarSendPaymentTool, stellarGetBalanceTool, stellarGetAccountInfoTool } from "./tools/stellar"; +import { StellarClaimBalanceTool } from "./tools/claim_balance_tool"; +import { StellarAccountTool } from "./tools/account"; +import { StellarAssetTool } from "./tools/asset"; import { AgentClient, AgentConfig, @@ -17,12 +20,13 @@ import type { SwapBestRouteResult, } from "./agent"; -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 +34,53 @@ 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"; + +// Staking lib-level exports +export { + initialize as stakingInitialize, + stake as stakingStake, + unstake as stakingUnstake, + claimRewards as stakingClaimRewards, + getStake as stakingGetStake, +} from "./lib/stakeF"; + +// Claim lib-level exports +export { + listClaimableBalances, + claimBalance, +} from "./lib/claimF"; + +// Bütün tool'ların listesi export const stellarTools = [ bridgeTokenTool, StellarDexTool, @@ -38,5 +88,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..82e96d95 --- /dev/null +++ b/lib/account.ts @@ -0,0 +1,387 @@ +import { Horizon, StrKey } from "@stellar/stellar-sdk"; +import { getHorizonUrl, createServer, HorizonConfig } from "../utils/horizon"; + +// ─── 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 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("op_already_exists") || + body.includes("createAccountAlreadyExist") || + body.includes("account already funded") + ) { + 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..f9c04134 --- /dev/null +++ b/lib/asset.ts @@ -0,0 +1,304 @@ +import { Horizon, StrKey } from "@stellar/stellar-sdk"; +import { getHorizonUrl, createServer, HorizonConfig } from "../utils/horizon"; + +// ─── 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 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 (!/^[a-zA-Z0-9]+$/.test(asset.code)) { + throw new Error(`Asset code must contain only alphanumeric 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..188b489c --- /dev/null +++ b/lib/claimF.ts @@ -0,0 +1,62 @@ +import { Horizon, TransactionBuilder, Operation, Networks } from "@stellar/stellar-sdk"; +import { createServer, HorizonConfig } from "../utils/horizon"; + +export async function listClaimableBalances(publicKey: string, config: HorizonConfig) { + const server = createServer(config); + let response = await server.claimableBalances().claimant(publicKey).call(); + let allBalances = [...response.records]; + + // Sayfalama (Pagination) döngüsü: Tüm kayıtları çeker + while (response.records.length > 0) { + if (!response.next) break; + try { + response = await response.next(); + if (response.records && response.records.length > 0) { + allBalances.push(...response.records); + } + } catch (e: any) { + // Sadece 404 veya bulanamadı hatalarını görmezden gel, diğerlerini fırlat + if (e?.response?.status === 404 || e?.message?.includes('not found')) { + break; + } + throw new Error(`Failed to fetch next page of claimable balances: ${e.message || e}`); + } + } + + 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 | undefined, config: HorizonConfig) { + const server = createServer(config); + const account = await server.loadAccount(publicKey); + + // Hata veren 'fee' kısmı string'e çevrildi ve yapı düzeltildi + const baseFee = await server.fetchBaseFee(); + + const transaction = new TransactionBuilder(account, { + fee: baseFee.toString(), // Sayı olan fee değerini string yaparak hatayı çözdük + networkPassphrase: config.network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }); + + if (balanceId) { + transaction.addOperation(Operation.claimClaimableBalance({ balanceId })); + } else { + const balances = await listClaimableBalances(publicKey, config); + if (balances.length === 0) throw new Error("No claimable balances found."); + + if (balances.length > 100) { + throw new Error("Too many claimable balances for a single transaction (max 100). Please provide a specific balanceId or claim them in batches."); + } + + balances.forEach((b: any) => { + transaction.addOperation(Operation.claimClaimableBalance({ balanceId: b.id })); + }); + } + + return transaction.setTimeout(30).build(); +} diff --git a/lib/stakeF.ts b/lib/stakeF.ts index 968a33ed..9612d7cc 100644 --- a/lib/stakeF.ts +++ b/lib/stakeF.ts @@ -86,75 +86,49 @@ import { } if (txResponse.status !== "SUCCESS") { - return `Transaction failed with status: ${txResponse.status}`; + throw new Error(`Transaction failed with status: ${txResponse.status}`); } return null; // No return value (e.g., for void functions) } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); - return `Error in contract interaction (${functName}): ${errorMessage}`; + throw new Error(`Error in contract interaction (${functName}): ${errorMessage}`); } }; // Contract interaction functions async function initialize(caller: string, tokenAddress: string, rewardRate: number, config?: SorobanContractConfig) { - try { - const tokenScVal = addressToScVal(tokenAddress); - const rewardRateScVal = numberToI128(rewardRate); - await contractInt(caller, "initialize", [tokenScVal, rewardRateScVal], config); - return "Contract initialized successfully"; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - return errorMessage; - } + const tokenScVal = addressToScVal(tokenAddress); + const rewardRateScVal = numberToI128(rewardRate); + await contractInt(caller, "initialize", [tokenScVal, rewardRateScVal], config); + return "Contract initialized successfully"; } async function stake(caller: string, amount: number, config?: SorobanContractConfig) { - try { - const userScVal = addressToScVal(caller); - const amountScVal = numberToI128(amount); - await contractInt(caller, "stake", [userScVal, amountScVal], config); - return `Staked ${amount} successfully`; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - return errorMessage; - } + const userScVal = addressToScVal(caller); + const amountScVal = numberToI128(amount); + await contractInt(caller, "stake", [userScVal, amountScVal], config); + return `Staked ${amount} successfully`; } async function unstake(caller: string, amount: number, config?: SorobanContractConfig) { - try { - const userScVal = addressToScVal(caller); - const amountScVal = numberToI128(amount); - await contractInt(caller, "unstake", [userScVal, amountScVal], config); - return `Unstaked ${amount} successfully`; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - return errorMessage; - } + const userScVal = addressToScVal(caller); + const amountScVal = numberToI128(amount); + await contractInt(caller, "unstake", [userScVal, amountScVal], config); + return `Unstaked ${amount} successfully`; } async function claimRewards(caller: string, config?: SorobanContractConfig) { - try { - const userScVal = addressToScVal(caller); - await contractInt(caller, "claim_rewards", userScVal, config); - return "Rewards claimed successfully"; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - return errorMessage; - } + const userScVal = addressToScVal(caller); + await contractInt(caller, "claim_rewards", userScVal, config); + return "Rewards claimed successfully"; } async function getStake(caller: string, userAddress: string, config?: SorobanContractConfig) { - try { - const userScVal = addressToScVal(userAddress); - const result = await contractInt(caller, "get_stake", userScVal, config); - return `Stake for ${userAddress}: ${result}`; - return result; // Returns i128 as a BigInt - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - return errorMessage; // Returns error message as a string - } + const userScVal = addressToScVal(userAddress); + const result = await contractInt(caller, "get_stake", userScVal, config); + return result; } export { initialize, stake, unstake, claimRewards, getStake }; \ No newline at end of file diff --git a/tests/unit/agent.test.ts b/tests/unit/agent.test.ts new file mode 100644 index 00000000..0b686547 --- /dev/null +++ b/tests/unit/agent.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; + +import { AgentClient } from "../../agent"; + +const testKeypair = Keypair.random(); +const testPublicKey = testKeypair.publicKey(); + +describe("AgentClient Base Configuration", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.STELLAR_PUBLIC_KEY = testPublicKey; + process.env.STELLAR_PRIVATE_KEY = testKeypair.secret(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe("constructor", () => { + it("creates a testnet client without requiring allowMainnet", () => { + const client = new AgentClient({ + network: "testnet", + publicKey: testPublicKey, + }); + expect(client).toBeDefined(); + }); + + it("blocks mainnet without allowMainnet flag", () => { + expect( + () => + new AgentClient({ + network: "mainnet", + publicKey: testPublicKey, + }) + ).toThrow("Mainnet execution blocked for safety"); + }); + + it("allows mainnet with allowMainnet: true", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = new AgentClient({ + network: "mainnet", + allowMainnet: true, + publicKey: testPublicKey, + }); + expect(client).toBeDefined(); + warnSpy.mockRestore(); + }); + + it("falls back to env var for public key", () => { + process.env.STELLAR_PUBLIC_KEY = testPublicKey; + const client = new AgentClient({ network: "testnet" }); + expect(client).toBeDefined(); + }); + }); + + describe("launchToken (mainnet blocker)", () => { + it("blocks token launch on mainnet", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = new AgentClient({ + network: "mainnet", + allowMainnet: true, + publicKey: testPublicKey, + }); + + await expect( + client.launchToken({ + code: "TEST", + issuerSecret: Keypair.random().secret(), + distributorSecret: Keypair.random().secret(), + initialSupply: "1000", + }) + ).rejects.toThrow("Token launches on mainnet are disabled"); + warnSpy.mockRestore(); + }); + }); +}); diff --git a/tools/bridge.ts b/tools/bridge.ts index e72730d9..f786e5d6 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}` ); @@ -169,12 +169,13 @@ export const bridgeTokenTool = new DynamicStructuredTool({ status: "pending_restore", hash: sentRestoreXdrTx.hash, network: fromNetwork, - targetChain, }; } - // 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, @@ -192,7 +193,6 @@ export const bridgeTokenTool = new DynamicStructuredTool({ status: "pending", hash: sent.hash, network: fromNetwork, - targetChain, }; } @@ -200,7 +200,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 +209,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( @@ -235,7 +236,6 @@ export const bridgeTokenTool = new DynamicStructuredTool({ status: "trustline_submitted", hash: submit.hash, network: fromNetwork, - targetChain, }; } @@ -243,9 +243,8 @@ export const bridgeTokenTool = new DynamicStructuredTool({ status: "confirmed", hash: sent.hash, network: fromNetwork, - targetChain, asset: sourceToken.symbol, 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..357f1db3 --- /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"; + +const STELLAR_PUBLIC_KEY = process.env.STELLAR_PUBLIC_KEY!; + +if (!STELLAR_PUBLIC_KEY) { + throw new Error("Missing Stellar environment variables"); +} + +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 }) => { + 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..e5f4f166 100644 --- a/tools/contract.ts +++ b/tools/contract.ts @@ -1,38 +1,30 @@ import { DynamicStructuredTool } from "@langchain/core/tools"; import { z } from "zod"; -import { - getShareId, - deposit, - swap, - withdraw, - getReserves, -} from "../lib/contract"; +import { getShareId, deposit, swap, withdraw, getReserves } 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"; +const STELLAR_NETWORK = process.env.STELLAR_NETWORK || "testnet"; +const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL || (STELLAR_NETWORK === "mainnet" ? "https://soroban.stellar.org" : "https://soroban-testnet.stellar.org"); +const STELLAR_PUBLIC_KEY = process.env.STELLAR_PUBLIC_KEY || ""; if (!STELLAR_PUBLIC_KEY) { - throw new Error("Missing Stellar environment variables"); + throw new Error("STELLAR_PUBLIC_KEY is not configured. Please set it in environment variables."); } 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 +40,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,35 +58,30 @@ 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 swap(STELLAR_PUBLIC_KEY, to, buyA, out, inMax, config); + return result ?? `Swap executed successfully.`; } 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); - return result - ? `Reserves: ${JSON.stringify(result)}` - : "No reserves found."; + const result = await getReserves(config); + return result ? `Reserves: ${JSON.stringify(result, (_, value) => typeof value === "bigint" ? value.toString() : value)}` : "No reserves found."; } default: 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..6025aa9a 100644 --- a/tools/stake.ts +++ b/tools/stake.ts @@ -1,43 +1,23 @@ -import { DynamicStructuredTool } from "@langchain/core/tools"; -import { z } from "zod"; -import { - initialize, - stake, - unstake, - claimRewards, - 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"); -} - 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 +27,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 +34,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 +41,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 +52,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 +60,4 @@ export const StellarContractTool = new DynamicStructuredTool({ throw new Error(`Failed to execute ${action}: ${error.message}`); } }, -}); - - +}); \ No newline at end of file diff --git a/utils/horizon.ts b/utils/horizon.ts new file mode 100644 index 00000000..4fce9cd4 --- /dev/null +++ b/utils/horizon.ts @@ -0,0 +1,19 @@ +import { Horizon } from "@stellar/stellar-sdk"; + +export interface HorizonConfig { + network: "testnet" | "mainnet"; + horizonUrl?: string; +} + +export function getHorizonUrl(config: HorizonConfig): string { + return ( + config.horizonUrl ?? + (config.network === "mainnet" + ? "https://horizon.stellar.org" + : "https://horizon-testnet.stellar.org") + ); +} + +export function createServer(config: HorizonConfig): Horizon.Server { + return new Horizon.Server(getHorizonUrl(config)); +} diff --git a/vitest.config.mts b/vitest.config.mts index 1f09b03f..0eb0de16 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -9,7 +9,7 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], - include: ['tools/**/*.ts', 'lib/**/*.ts', 'utils/**/*.ts'], + include: ['agent.ts', 'tools/**/*.ts', 'lib/**/*.ts', 'utils/**/*.ts'], exclude: [ 'node_modules', 'dist',