From b38ab28730397353ad33f5d1be76a02d1594264d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20K=C4=B1rba=C5=9F?= Date: Tue, 24 Mar 2026 21:13:21 +0300 Subject: [PATCH 1/9] fix: total cleanup and sync with upstream --- .gitignore | 31 +------------------- index.ts | 20 +++++-------- lib/claimF.ts | 57 +++++++++++++++++++++++++++++++++++++ tools/bridge.ts | 19 ++++--------- tools/claim_balance_tool.ts | 42 +++++++++++++++++++++++++++ tools/contract.ts | 30 +++++++++---------- tools/stake.ts | 8 ++---- 7 files changed, 131 insertions(+), 76 deletions(-) create mode 100644 lib/claimF.ts create mode 100644 tools/claim_balance_tool.ts 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/index.ts b/index.ts index 45795ae6..2f716e60 100644 --- a/index.ts +++ b/index.ts @@ -2,22 +2,16 @@ import { bridgeTokenTool } from "./tools/bridge"; import { StellarLiquidityContractTool } from "./tools/contract"; import { StellarContractTool } from "./tools/stake"; import { stellarSendPaymentTool } from "./tools/stellar"; -import { - AgentClient, - AgentConfig, - LaunchTokenParams, - LaunchTokenResult -} from "./agent"; +import { StellarClaimBalanceTool } from "./tools/claim_balance_tool"; +import { AgentClient } from "./agent"; + +export { AgentClient }; +export * from "./tools/claim_balance_tool"; -export { - AgentClient, - AgentConfig, - LaunchTokenParams, - LaunchTokenResult -}; export const stellarTools = [ bridgeTokenTool, StellarLiquidityContractTool, StellarContractTool, - stellarSendPaymentTool + stellarSendPaymentTool, + StellarClaimBalanceTool ]; \ No newline at end of file diff --git a/lib/claimF.ts b/lib/claimF.ts new file mode 100644 index 00000000..1244ad32 --- /dev/null +++ b/lib/claimF.ts @@ -0,0 +1,57 @@ +import { Horizon, TransactionBuilder, Operation, Networks } from "@stellar/stellar-sdk"; + +function getServer() { + return new Horizon.Server(process.env.HORIZON_URL || "https://horizon-testnet.stellar.org"); +} + +export async function listClaimableBalances(publicKey: string) { + const server = getServer(); + 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) { + try { + response = await response.next(); + if (response.records.length > 0) { + allBalances.push(...response.records); + } + } catch (e) { + // Daha fazla sayfa yoksa döngüden çık + 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 server = getServer(); + 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: process.env.STELLAR_NETWORK === "PUBLIC" ? Networks.PUBLIC : Networks.TESTNET, + }); + + if (balanceId) { + transaction.addOperation(Operation.claimClaimableBalance({ balanceId })); + } else { + const balances = await listClaimableBalances(publicKey); + if (balances.length === 0) throw new Error("No claimable balances found."); + + balances.forEach((b: any) => { + transaction.addOperation(Operation.claimClaimableBalance({ balanceId: b.id })); + }); + } + + return transaction.setTimeout(30).build(); +} \ No newline at end of file diff --git a/tools/bridge.ts b/tools/bridge.ts index 252d6b30..ff233aef 100644 --- a/tools/bridge.ts +++ b/tools/bridge.ts @@ -9,10 +9,7 @@ import { } from "@allbridge/bridge-core-sdk"; import { Keypair, - Keypair as StellarKeypair, rpc, - TransactionBuilder as StellarTransactionBuilder, - TransactionBuilder, Networks } from "@stellar/stellar-sdk"; import { ensure } from "../utils/utils"; @@ -25,8 +22,10 @@ dotenv.config({ path: ".env" }); const fromAddress = process.env.STELLAR_PUBLIC_KEY as string; const privateKey = process.env.STELLAR_PRIVATE_KEY as string; + type StellarNetwork = "stellar-testnet" | "stellar-mainnet"; +// 34. Satır ve Sonrasındaki Hatalı Bölüm Düzeltildi: const STELLAR_NETWORK_CONFIG: Record = { "stellar-testnet": { networkPassphrase: Networks.TESTNET, @@ -59,7 +58,7 @@ export const bridgeTokenTool = new DynamicStructuredTool({ toAddress: string; fromNetwork: StellarNetwork; }) => { - // Mainnet safeguard - additional layer beyond AgentClient + // Mainnet safeguard if ( fromNetwork === "stellar-mainnet" && process.env.ALLOW_MAINNET_BRIDGE !== "true" @@ -103,7 +102,6 @@ export const bridgeTokenTool = new DynamicStructuredTool({ sendParams )) as string; - // Use unified transaction builder for XDR-based bridge operations const srbKeypair = Keypair.fromSecret(privateKey); const transaction = buildTransactionFromXDR( "bridge", @@ -135,7 +133,6 @@ export const bridgeTokenTool = new DynamicStructuredTool({ sentRestoreXdrTx.hash ); - // Handle FAILED restore explicitly if ( confirmRestoreXdrTx.status === rpc.Api.GetTransactionStatus.FAILED ) { @@ -154,7 +151,6 @@ export const bridgeTokenTool = new DynamicStructuredTool({ }; } - // Get new tx with updated sequences const xdrTx2 = (await sdk.bridge.rawTxBuilder.send( sendParams )) as string; @@ -183,7 +179,6 @@ export const bridgeTokenTool = new DynamicStructuredTool({ throw new Error(`Transaction failed. Hash: ${sent.hash}`); } - // TrustLine check and setup for destinationToken if it is SRB const destinationTokenSBR = sourceToken; const balanceLine = await sdk.utils.srb.getBalanceLine( @@ -198,20 +193,18 @@ export const bridgeTokenTool = new DynamicStructuredTool({ .gt(Big(balanceLine.limit)); if (notEnoughBalanceLine) { - const xdrTx = + const xdrTxTrust = await sdk.utils.srb.buildChangeTrustLineXdrTx({ sender: fromAddress, tokenAddress: destinationTokenSBR.tokenAddress, }); - // Use unified transaction builder for XDR-based bridge TrustLine operation - 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( 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 d742f8b2..f7635681 100644 --- a/tools/contract.ts +++ b/tools/contract.ts @@ -31,19 +31,19 @@ export const StellarLiquidityContractTool = new DynamicStructuredTool({ inMax: z.string().optional(), // For swap shareAmount: z.string().optional(), // For withdraw }), - func: async (input: any) => { - const { - action, - to, - desiredA, - minA, - desiredB, - minB, - buyA, - out, - inMax, - shareAmount, - } = input; + // Buradaki parametreye ': any' ekleyerek "unknown" hatasını çözdük + func: async ({ + action, + to, + desiredA, + minA, + desiredB, + minB, + buyA, + out, + inMax, + shareAmount, + }: any) => { try { switch (action) { case "get_share_id": { @@ -55,13 +55,13 @@ 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); - 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); + const result = await swap(STELLAR_PUBLIC_KEY, to, buyA, out, inMax); return result ?? `Swapped successfully to ${to}.`; } case "withdraw": { diff --git a/tools/stake.ts b/tools/stake.ts index 266458f3..a146328f 100644 --- a/tools/stake.ts +++ b/tools/stake.ts @@ -26,8 +26,8 @@ export const StellarContractTool = new DynamicStructuredTool({ amount: z.number().optional(), // For stake/unstake userAddress: z.string().optional(), // For get_stake }), - func: async (input: any) => { - const { action, tokenAddress, rewardRate, amount, userAddress } = input; + // func parametresine ': any' ekleyerek tip hatasını giderdik + func: async ({ action, tokenAddress, rewardRate, amount, userAddress }: any) => { try { switch (action) { case "initialize": { @@ -75,6 +75,4 @@ export const StellarContractTool = new DynamicStructuredTool({ throw new Error(`Failed to execute ${action}: ${error.message}`); } }, -}); - - +}); \ No newline at end of file From 8e43f589e6dbd7478e06fd65b235aa358379cdef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20K=C4=B1rba=C5=9F?= <161657597+efekrbas@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:47:24 +0300 Subject: [PATCH 2/9] Fix and enhance target chain handling in bridge.ts --- tools/bridge.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tools/bridge.ts b/tools/bridge.ts index ff233aef..c1e30b2b 100644 --- a/tools/bridge.ts +++ b/tools/bridge.ts @@ -25,7 +25,19 @@ const privateKey = process.env.STELLAR_PRIVATE_KEY as string; type StellarNetwork = "stellar-testnet" | "stellar-mainnet"; -// 34. Satır ve Sonrasındaki Hatalı Bölüm Düzeltildi: +/** + * Supported target EVM chains for bridging from Stellar. + * Maps user-facing chain names to Allbridge ChainSymbol values. + */ +export type TargetChain = "ethereum" | "polygon" | "arbitrum" | "base"; + +const TARGET_CHAIN_MAP: Record = { + ethereum: ChainSymbol.ETH, + polygon: ChainSymbol.POL, + arbitrum: ChainSymbol.ARB, + base: ChainSymbol.BAS, +}; + const STELLAR_NETWORK_CONFIG: Record = { "stellar-testnet": { networkPassphrase: Networks.TESTNET, @@ -47,16 +59,22 @@ export const bridgeTokenTool = new DynamicStructuredTool({ .enum(["stellar-testnet", "stellar-mainnet"]) .default("stellar-testnet") .describe("Source Stellar network"), + targetChain: z + .enum(["ethereum", "polygon", "arbitrum", "base"]) + .default("ethereum") + .describe("The destination EVM chain"), }), func: async ({ amount, toAddress, fromNetwork, + targetChain, }: { amount: string; toAddress: string; fromNetwork: StellarNetwork; + targetChain: TargetChain; }) => { // Mainnet safeguard if ( @@ -75,13 +93,16 @@ 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 destinationToken = ensure( - chainDetailsMap[ChainSymbol.ETH].tokens.find( + chainDetailsMap[destinationChainSymbol].tokens.find( (t) => t.symbol === "USDC" ) ); @@ -226,4 +247,4 @@ export const bridgeTokenTool = new DynamicStructuredTool({ amount, }; }, -}); \ No newline at end of file +}); From 167809bc78d476a1101263f919740e687100f966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20K=C4=B1rba=C5=9F?= <161657597+efekrbas@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:09:18 +0300 Subject: [PATCH 3/9] Update claimF.ts --- lib/claimF.ts | 92 ++++++++++++++++++++------------------------------- 1 file changed, 35 insertions(+), 57 deletions(-) diff --git a/lib/claimF.ts b/lib/claimF.ts index 1244ad32..dedc6736 100644 --- a/lib/claimF.ts +++ b/lib/claimF.ts @@ -1,57 +1,35 @@ -import { Horizon, TransactionBuilder, Operation, Networks } from "@stellar/stellar-sdk"; - -function getServer() { - return new Horizon.Server(process.env.HORIZON_URL || "https://horizon-testnet.stellar.org"); -} - -export async function listClaimableBalances(publicKey: string) { - const server = getServer(); - 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) { - try { - response = await response.next(); - if (response.records.length > 0) { - allBalances.push(...response.records); - } - } catch (e) { - // Daha fazla sayfa yoksa döngüden çık - 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 server = getServer(); - 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: process.env.STELLAR_NETWORK === "PUBLIC" ? Networks.PUBLIC : Networks.TESTNET, - }); - - if (balanceId) { - transaction.addOperation(Operation.claimClaimableBalance({ balanceId })); - } else { - const balances = await listClaimableBalances(publicKey); - if (balances.length === 0) throw new Error("No claimable balances found."); - - balances.forEach((b: any) => { - transaction.addOperation(Operation.claimClaimableBalance({ balanceId: b.id })); - }); - } - - return transaction.setTimeout(30).build(); -} \ No newline at end of file +import { bridgeTokenTool } from "./tools/bridge"; +import { StellarLiquidityContractTool } from "./tools/contract"; +import { StellarDexTool } from "./tools/dex"; +import { StellarContractTool } from "./tools/stake"; +import { stellarSendPaymentTool } from "./tools/stellar"; +import { StellarClaimBalanceTool } from "./tools/claim_balance_tool"; + +// Agent exportları (Hem sınıfları hem de tipleri içerecek şekilde) +export { + AgentClient, + AgentConfig, + LaunchTokenParams, + LaunchTokenResult, +} from "./agent"; + +export type { + StellarAssetInput, + QuoteSwapParams, + RouteQuote, + SwapBestRouteParams, + SwapBestRouteResult, +} from "./agent"; + +// claim_balance_tool içindeki her şeyi export et +export * from "./tools/claim_balance_tool"; + +// Bütün tool'ların listesi +export const stellarTools = [ + bridgeTokenTool, + StellarDexTool, + StellarLiquidityContractTool, + StellarContractTool, + stellarSendPaymentTool, + StellarClaimBalanceTool, +]; From a92042b25284816b24f443667e0cf20ee753ffd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20K=C4=B1rba=C5=9F?= <161657597+efekrbas@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:18:02 +0300 Subject: [PATCH 4/9] Implement claimable balances listing and claiming --- lib/claimF.ts | 92 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/lib/claimF.ts b/lib/claimF.ts index dedc6736..2fb0407e 100644 --- a/lib/claimF.ts +++ b/lib/claimF.ts @@ -1,35 +1,57 @@ -import { bridgeTokenTool } from "./tools/bridge"; -import { StellarLiquidityContractTool } from "./tools/contract"; -import { StellarDexTool } from "./tools/dex"; -import { StellarContractTool } from "./tools/stake"; -import { stellarSendPaymentTool } from "./tools/stellar"; -import { StellarClaimBalanceTool } from "./tools/claim_balance_tool"; - -// Agent exportları (Hem sınıfları hem de tipleri içerecek şekilde) -export { - AgentClient, - AgentConfig, - LaunchTokenParams, - LaunchTokenResult, -} from "./agent"; - -export type { - StellarAssetInput, - QuoteSwapParams, - RouteQuote, - SwapBestRouteParams, - SwapBestRouteResult, -} from "./agent"; - -// claim_balance_tool içindeki her şeyi export et -export * from "./tools/claim_balance_tool"; - -// Bütün tool'ların listesi -export const stellarTools = [ - bridgeTokenTool, - StellarDexTool, - StellarLiquidityContractTool, - StellarContractTool, - stellarSendPaymentTool, - StellarClaimBalanceTool, -]; +import { Horizon, TransactionBuilder, Operation, Networks } from "@stellar/stellar-sdk"; + +function getServer() { + return new Horizon.Server(process.env.HORIZON_URL || "https://horizon-testnet.stellar.org"); +} + +export async function listClaimableBalances(publicKey: string) { + const server = getServer(); + 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) { + try { + response = await response.next(); + if (response.records.length > 0) { + allBalances.push(...response.records); + } + } catch (e) { + // Daha fazla sayfa yoksa döngüden çık + 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 server = getServer(); + 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: process.env.STELLAR_NETWORK === "PUBLIC" ? Networks.PUBLIC : Networks.TESTNET, + }); + + if (balanceId) { + transaction.addOperation(Operation.claimClaimableBalance({ balanceId })); + } else { + const balances = await listClaimableBalances(publicKey); + if (balances.length === 0) throw new Error("No claimable balances found."); + + balances.forEach((b: any) => { + transaction.addOperation(Operation.claimClaimableBalance({ balanceId: b.id })); + }); + } + + return transaction.setTimeout(30).build(); +} From cf8a57f3a78d4a20cd60ceeddeea90514b52c788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20K=C4=B1rba=C5=9F?= <161657597+efekrbas@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:21:43 +0300 Subject: [PATCH 5/9] Update index.ts --- index.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index 2f716e60..dedc6736 100644 --- a/index.ts +++ b/index.ts @@ -1,17 +1,35 @@ import { bridgeTokenTool } from "./tools/bridge"; import { StellarLiquidityContractTool } from "./tools/contract"; +import { StellarDexTool } from "./tools/dex"; import { StellarContractTool } from "./tools/stake"; import { stellarSendPaymentTool } from "./tools/stellar"; import { StellarClaimBalanceTool } from "./tools/claim_balance_tool"; -import { AgentClient } from "./agent"; -export { AgentClient }; +// Agent exportları (Hem sınıfları hem de tipleri içerecek şekilde) +export { + AgentClient, + AgentConfig, + LaunchTokenParams, + LaunchTokenResult, +} from "./agent"; + +export type { + StellarAssetInput, + QuoteSwapParams, + RouteQuote, + SwapBestRouteParams, + SwapBestRouteResult, +} from "./agent"; + +// claim_balance_tool içindeki her şeyi export et export * from "./tools/claim_balance_tool"; +// Bütün tool'ların listesi export const stellarTools = [ bridgeTokenTool, + StellarDexTool, StellarLiquidityContractTool, StellarContractTool, stellarSendPaymentTool, - StellarClaimBalanceTool -]; \ No newline at end of file + StellarClaimBalanceTool, +]; From 191ef5fdf05e019511da25bdf265503cae711857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20K=C4=B1rba=C5=9F?= <161657597+efekrbas@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:29:10 +0300 Subject: [PATCH 6/9] Update claimF.ts --- lib/claimF.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/claimF.ts b/lib/claimF.ts index 2fb0407e..a4461d0f 100644 --- a/lib/claimF.ts +++ b/lib/claimF.ts @@ -48,7 +48,13 @@ export async function claimBalance(publicKey: string, balanceId?: string) { const balances = await listClaimableBalances(publicKey); if (balances.length === 0) throw new Error("No claimable balances found."); - balances.forEach((b: any) => { + /** + * KRİTİK DÜZELTME: Stellar ağı bir işlemde en fazla 100 operasyona izin verir. + * Güvenlik amacıyla tek seferde en fazla 50 bakiyeyi çekiyoruz (slice(0, 50)). + */ + const limitedBalances = balances.slice(0, 50); + + limitedBalances.forEach((b: any) => { transaction.addOperation(Operation.claimClaimableBalance({ balanceId: b.id })); }); } From 9467b837867f65ef578f06239dc284f2b40a998d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20K=C4=B1rba=C5=9F?= Date: Thu, 30 Apr 2026 23:40:40 +0300 Subject: [PATCH 7/9] feat: add Account and Asset Explorer tools with full test suite --- agent.ts | 186 +++++++++++++++ index.ts | 30 +++ lib/account.ts | 393 +++++++++++++++++++++++++++++++ lib/asset.ts | 310 ++++++++++++++++++++++++ package-lock.json | 43 ++-- tests/unit/lib/account.test.ts | 320 +++++++++++++++++++++++++ tests/unit/lib/asset.test.ts | 251 ++++++++++++++++++++ tests/unit/tools/account.test.ts | 151 ++++++++++++ tests/unit/tools/asset.test.ts | 135 +++++++++++ tools/account.ts | 137 +++++++++++ tools/asset.ts | 142 +++++++++++ 11 files changed, 2077 insertions(+), 21 deletions(-) create mode 100644 lib/account.ts create mode 100644 lib/asset.ts create mode 100644 tests/unit/lib/account.test.ts create mode 100644 tests/unit/lib/asset.test.ts create mode 100644 tests/unit/tools/account.test.ts create mode 100644 tests/unit/tools/asset.test.ts create mode 100644 tools/account.ts create mode 100644 tools/asset.ts diff --git a/agent.ts b/agent.ts index 36d4d8c3..3c3a16a1 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 { Horizon, @@ -66,6 +86,13 @@ export type { RouteQuote, SwapBestRouteParams, SwapBestRouteResult, + AccountInfo, + AccountBalance, + TransactionRecord, + OperationRecord, + AssetDetails, + OrderbookSummary, + TradeRecord, }; export class AgentClient { @@ -238,6 +265,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 ad4fc517..98a6d5dc 100644 --- a/index.ts +++ b/index.ts @@ -4,6 +4,8 @@ import { StellarDexTool } from "./tools/dex"; import { StellarContractTool } from "./tools/stake"; import { stellarSendPaymentTool } from "./tools/stellar"; import { StellarClaimBalanceTool } from "./tools/claim_balance_tool"; +import { StellarAccountTool } from "./tools/account"; +import { StellarAssetTool } from "./tools/asset"; // Agent exportları (Hem sınıfları hem de tipleri içerecek şekilde) export { @@ -19,11 +21,37 @@ 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, @@ -32,4 +60,6 @@ export const stellarTools = [ StellarContractTool, stellarSendPaymentTool, StellarClaimBalanceTool, + StellarAccountTool, + StellarAssetTool, ]; \ No newline at end of file diff --git a/lib/account.ts b/lib/account.ts new file mode 100644 index 00000000..6027c0d5 --- /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.buying_liabilities, + sellingLiabilities: b.selling_liabilities, + }; + + 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/package-lock.json b/package-lock.json index 184ce2b5..229b51fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1640,6 +1640,7 @@ "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.2.tgz", "integrity": "sha512-BqVwEG+TaG2yCkBMbD3C4hdpustR4FpuUFRPUmqRZYYlPI9Hg4XMWxHWOWRzHE9Lkc9NDjzXFX7lDXSgzC7R1A==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", @@ -2029,6 +2030,7 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -2237,16 +2239,6 @@ "license": "Apache-2.0", "optional": true }, - "node_modules/bare-url": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", - "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base-x": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", @@ -3447,6 +3439,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -4045,6 +4038,18 @@ } } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/noms": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", @@ -4284,6 +4289,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5097,6 +5103,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5192,6 +5199,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5267,6 +5275,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -5459,17 +5468,6 @@ } } }, - "node_modules/web3-eth-abi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/web3-eth-accounts": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/web3-eth-accounts/-/web3-eth-accounts-4.3.1.tgz", @@ -5674,6 +5672,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5933,6 +5932,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -6016,6 +6016,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } 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}`); + } + }, +}); From 858204c9e87e7a18fcc545aed332269a1d112f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20K=C4=B1rba=C5=9F?= Date: Fri, 1 May 2026 00:05:32 +0300 Subject: [PATCH 8/9] fix: resolve all build errors in stake and contract tools --- lib/account.ts | 4 ++-- tools/contract.ts | 30 ++++++++++++++++++++++++++++-- tools/stake.ts | 14 ++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/account.ts b/lib/account.ts index 6027c0d5..946aa3d1 100644 --- a/lib/account.ts +++ b/lib/account.ts @@ -141,8 +141,8 @@ export async function getAccountInfo( const base: AccountBalance = { assetType: b.asset_type, balance: b.balance, - buyingLiabilities: b.buying_liabilities, - sellingLiabilities: b.selling_liabilities, + 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") { diff --git a/tools/contract.ts b/tools/contract.ts index 31d6bcb6..9103be6c 100644 --- a/tools/contract.ts +++ b/tools/contract.ts @@ -1,3 +1,16 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; +import { + getShareId, + deposit, + withdraw, + getReserves, +} from "../lib/contract"; + +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 || "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.", @@ -52,8 +65,21 @@ export const StellarLiquidityContractTool = new DynamicStructuredTool({ if (!to || buyA === undefined || !out || !inMax) { throw new Error("to, buyA, out, and inMax are required for swap"); } - // Diğer case'ler buraya devam edecek... - return "Swap executed (Logic needs to be completed based on your lib)"; + // Note: Full swap logic should be implemented in lib/contract if needed. + return "Swap functionality is currently a placeholder in this tool."; + } + 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}.`; + } + case "get_reserves": { + const result = await getReserves(STELLAR_PUBLIC_KEY, config); + return result + ? `Reserves: ${JSON.stringify(result)}` + : "No reserves found."; } default: throw new Error("Unsupported action"); diff --git a/tools/stake.ts b/tools/stake.ts index 6025aa9a..a9e5ceeb 100644 --- a/tools/stake.ts +++ b/tools/stake.ts @@ -1,3 +1,17 @@ +import { DynamicStructuredTool } from "@langchain/core/tools"; +import { z } from "zod"; +import { + initialize, + stake, + unstake, + claimRewards, + getStake, +} from "../lib/stakeF"; + +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 || "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.", From e838dd35162bd1d75925b46be879b61ea2c987bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20K=C4=B1rba=C5=9F?= Date: Fri, 1 May 2026 02:52:19 +0300 Subject: [PATCH 9/9] fix: address AI reviewer issues in bridge, contract, and claim tools --- lib/claimF.ts | 41 +++++++++++++++++++++++++------------ tools/bridge.ts | 4 ++++ tools/claim_balance_tool.ts | 12 +++++------ tools/contract.ts | 10 ++++++--- tools/stake.ts | 5 ++++- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/lib/claimF.ts b/lib/claimF.ts index a4461d0f..87c11539 100644 --- a/lib/claimF.ts +++ b/lib/claimF.ts @@ -1,7 +1,17 @@ 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() { - return new Horizon.Server(process.env.HORIZON_URL || "https://horizon-testnet.stellar.org"); + const { horizonUrl } = getNetworkConfig(); + return new Horizon.Server(horizonUrl); } export async function listClaimableBalances(publicKey: string) { @@ -9,16 +19,21 @@ export async function listClaimableBalances(publicKey: string) { let response = await server.claimableBalances().claimant(publicKey).call(); let allBalances = [...response.records]; - // Sayfalama (Pagination) döngüsü: Tüm kayıtları çeker + // Pagination loop: Fetch all records while (response.records.length > 0) { try { - response = await response.next(); - if (response.records.length > 0) { - allBalances.push(...response.records); - } + // 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) { - // Daha fazla sayfa yoksa döngüden çık - break; + // 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; } } @@ -31,15 +46,15 @@ export async function listClaimableBalances(publicKey: string) { } export async function claimBalance(publicKey: string, balanceId?: string) { + const { networkPassphrase } = getNetworkConfig(); const server = getServer(); 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: process.env.STELLAR_NETWORK === "PUBLIC" ? Networks.PUBLIC : Networks.TESTNET, + fee: baseFee.toString(), + networkPassphrase, }); if (balanceId) { @@ -49,8 +64,8 @@ export async function claimBalance(publicKey: string, balanceId?: string) { if (balances.length === 0) throw new Error("No claimable balances found."); /** - * KRİTİK DÜZELTME: Stellar ağı bir işlemde en fazla 100 operasyona izin verir. - * Güvenlik amacıyla tek seferde en fazla 50 bakiyeyi çekiyoruz (slice(0, 50)). + * CRITICAL FIX: Stellar network allows max 100 operations per transaction. + * We limit to 50 for safety. */ const limitedBalances = balances.slice(0, 50); diff --git a/tools/bridge.ts b/tools/bridge.ts index f786e5d6..957e46ac 100644 --- a/tools/bridge.ts +++ b/tools/bridge.ts @@ -169,6 +169,7 @@ export const bridgeTokenTool = new DynamicStructuredTool({ status: "pending_restore", hash: sentRestoreXdrTx.hash, network: fromNetwork, + targetChain, }; } @@ -193,6 +194,7 @@ export const bridgeTokenTool = new DynamicStructuredTool({ status: "pending", hash: sent.hash, network: fromNetwork, + targetChain, }; } @@ -236,6 +238,7 @@ export const bridgeTokenTool = new DynamicStructuredTool({ status: "trustline_submitted", hash: submit.hash, network: fromNetwork, + targetChain, }; } @@ -243,6 +246,7 @@ export const bridgeTokenTool = new DynamicStructuredTool({ status: "confirmed", hash: sent.hash, network: fromNetwork, + targetChain, asset: sourceToken.symbol, amount, }; diff --git a/tools/claim_balance_tool.ts b/tools/claim_balance_tool.ts index 357f1db3..0a278d4d 100644 --- a/tools/claim_balance_tool.ts +++ b/tools/claim_balance_tool.ts @@ -2,12 +2,6 @@ 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: @@ -17,6 +11,12 @@ export const StellarClaimBalanceTool = new DynamicStructuredTool({ 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": { diff --git a/tools/contract.ts b/tools/contract.ts index 9103be6c..29ed1b97 100644 --- a/tools/contract.ts +++ b/tools/contract.ts @@ -5,11 +5,15 @@ import { deposit, withdraw, getReserves, + swap as contractSwap, } from "../lib/contract"; 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 || "https://soroban-testnet.stellar.org"; +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", @@ -65,8 +69,8 @@ export const StellarLiquidityContractTool = new DynamicStructuredTool({ if (!to || buyA === undefined || !out || !inMax) { throw new Error("to, buyA, out, and inMax are required for swap"); } - // Note: Full swap logic should be implemented in lib/contract if needed. - return "Swap functionality is currently a placeholder in this tool."; + 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) { diff --git a/tools/stake.ts b/tools/stake.ts index a9e5ceeb..35cc5df3 100644 --- a/tools/stake.ts +++ b/tools/stake.ts @@ -10,7 +10,10 @@ import { 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 || "https://soroban-testnet.stellar.org"; +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",