diff --git a/tools/contract.ts b/tools/contract.ts index 7ea8e134..9c41b27e 100644 --- a/tools/contract.ts +++ b/tools/contract.ts @@ -8,33 +8,45 @@ import { getReserves, } from "../lib/contract"; -// Assuming env variables are already loaded elsewhere -const STELLAR_PUBLIC_KEY = process.env.STELLAR_PUBLIC_KEY!; -const STELLAR_NETWORK = (process.env.STELLAR_NETWORK as "testnet" | "mainnet") || "testnet"; -const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org"; - -if (!STELLAR_PUBLIC_KEY) { - throw new Error("Missing Stellar environment variables"); +// Lazy getters — defer env-var checks to invocation time so that importing +// this module doesn't crash consumers without a fully configured environment. +function getStellarPublicKey(): string { + const key = process.env.STELLAR_PUBLIC_KEY; + if (!key) throw new Error("Missing STELLAR_PUBLIC_KEY environment variable"); + return key; +} +function getStellarNetwork(): "testnet" | "mainnet" { + const net = process.env.STELLAR_NETWORK; + return net === "mainnet" ? "mainnet" : "testnet"; +} +function getSorobanRpcUrl(): string { + return process.env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org"; } +const schema = z.object({ + action: z.enum(["get_share_id", "deposit", "swap", "withdraw", "get_reserves"]), + to: z.string().optional(), + desiredA: z.string().optional(), + minA: z.string().optional(), + desiredB: z.string().optional(), + minB: z.string().optional(), + buyA: z.boolean().optional(), + out: z.string().optional(), + inMax: z.string().optional(), + shareAmount: z.string().optional(), + contractAddress: z.string().optional(), +}); + export const StellarLiquidityContractTool = new DynamicStructuredTool({ name: "stellar_liquidity_contract_tool", description: "Interact with a liquidity contract on Stellar Soroban: getShareId, deposit, swap, withdraw, getReserves.", - schema: z.object({ - action: z.enum(["get_share_id", "deposit", "swap", "withdraw", "get_reserves"]), - to: z.string().optional(), // For deposit, swap, withdraw - desiredA: z.string().optional(), // For deposit - minA: z.string().optional(), // For deposit, withdraw - desiredB: z.string().optional(), // For deposit - minB: z.string().optional(), // For deposit, withdraw - buyA: z.boolean().optional(), // For swap - out: z.string().optional(), // For swap - inMax: z.string().optional(), // For swap - shareAmount: z.string().optional(), // For withdraw - contractAddress: z.string().optional(), // For overriding default pool on mainnet - }), - func: async (input: any) => { + schema, + func: async (input: z.infer) => { + const STELLAR_PUBLIC_KEY = getStellarPublicKey(); + const STELLAR_NETWORK = getStellarNetwork(); + const SOROBAN_RPC_URL = getSorobanRpcUrl(); + const { action, to, @@ -48,11 +60,11 @@ export const StellarLiquidityContractTool = new DynamicStructuredTool({ shareAmount, contractAddress, } = input; - + const config = { network: STELLAR_NETWORK, rpcUrl: SOROBAN_RPC_URL, - contractAddress, + ...(contractAddress && { contractAddress }), }; try { @@ -66,14 +78,18 @@ export const StellarLiquidityContractTool = new DynamicStructuredTool({ throw new Error("to, desiredA, minA, desiredB, and minB are required for deposit"); } const result = await deposit(STELLAR_PUBLIC_KEY, to, desiredA, minA, desiredB, minB, config); - return result ??`Deposited successfully to ${to}.`; + return result + ? `Deposited to ${to}: ${JSON.stringify(result)}` + : "Deposit returned no value."; } case "swap": { - if (!to || buyA === undefined || !out || !inMax) { + if (!to || buyA == null || !out || !inMax) { throw new Error("to, buyA, out, and inMax are required for swap"); } - const result=await swap(STELLAR_PUBLIC_KEY, to, buyA, out, inMax, config); - return result ?? `Swapped successfully to ${to}.`; + const result = await swap(STELLAR_PUBLIC_KEY, to, buyA, out, inMax, config); + return result + ? `Swapped to ${to}: ${JSON.stringify(result)}` + : "Swap returned no value."; } case "withdraw": { if (!to || !shareAmount || !minA || !minB) { @@ -98,4 +114,4 @@ export const StellarLiquidityContractTool = new DynamicStructuredTool({ throw new Error(`Failed to execute ${action}: ${error.message}`); } }, -}); \ No newline at end of file +}); diff --git a/utils/buildTransaction.ts b/utils/buildTransaction.ts index 245ab499..a6fa6393 100644 --- a/utils/buildTransaction.ts +++ b/utils/buildTransaction.ts @@ -1,15 +1,15 @@ -import { - Contract, - rpc, - TransactionBuilder, - Account, - Asset, - BASE_FEE, - Networks, - Transaction, - Memo, - Operation, -} from "@stellar/stellar-sdk"; +import { + Contract, + FeeBumpTransaction, + TransactionBuilder, + Account, + Asset, + BASE_FEE, + Networks, + Transaction, + Memo, + Operation, +} from "@stellar/stellar-sdk"; /** * Configuration for transaction building @@ -18,6 +18,13 @@ interface BuildTransactionConfig { fee?: string; timeout?: number; memo?: string; + /** + * Network passphrase for the transaction envelope. + * Required — must be Networks.TESTNET or Networks.PUBLIC. + * Providing the wrong value causes the Soroban RPC to reject + * the transaction immediately (signature mismatch on the envelope). + */ + networkPassphrase: string; } /** @@ -28,61 +35,62 @@ type OperationType = "swap" | "lp" | "bridge" | "stake"; /** * Parameters for building a Soroban contract operation */ -interface SorobanOperationParams { - contract: Contract; - functionName: string; - args?: any[]; -} - -interface PathPaymentOperationParams { - mode: "strict-send" | "strict-receive"; - sendAsset: Asset; - destAsset: Asset; - sendAmount: string; - destAmount: string; - destination: string; - path: Asset[]; - sendMax?: string; - destMin?: string; -} +interface SorobanOperationParams { + contract: Contract; + functionName: string; + args?: any[]; +} + +interface PathPaymentOperationParams { + mode: "strict-send" | "strict-receive"; + sendAsset: Asset; + destAsset: Asset; + sendAmount: string; + destAmount: string; + destination: string; + path: Asset[]; + sendMax?: string; + destMin?: string; +} /** - * Unified transaction builder for Stellar operations + * Unified transaction builder for Stellar operations. * * This function provides a single entry point for building transactions across - * different operation types (swap, LP, bridge), normalizing fee, timeout, and memo logic. + * different operation types (swap, LP, bridge, stake), normalising fee, + * timeout, memo, and — critically — the network passphrase. * - * @param operationType - The type of operation: "swap" | "lp" | "bridge" - * @param sourceAccount - The source account for the transaction + * @param operationType - The type of operation: "swap" | "lp" | "bridge" | "stake" + * @param sourceAccount - The source account for the transaction * @param sorobanOperation - Parameters for the Soroban contract operation - * @param config - Optional configuration for fee, timeout, and memo + * @param config - Configuration; networkPassphrase is required * @returns A built transaction ready for simulation or signing + * + * @example + * // Mainnet usage — always pass the correct passphrase: + * const tx = buildTransaction("lp", account, op, { + * networkPassphrase: Networks.PUBLIC, + * }); */ export function buildTransaction( operationType: OperationType, sourceAccount: Account, sorobanOperation: SorobanOperationParams, - config: BuildTransactionConfig = {} -): any { - // Normalize configuration with sensible defaults per operation type - const fee = config.fee || BASE_FEE; - const timeout = config.timeout !== undefined ? config.timeout : getDefaultTimeout(operationType); - const memo = config.memo; - - // Build transaction parameters - const networkPassphrase = Networks.TESTNET; - const memoValue = memo ? Memo.text(memo) : undefined; - const params = { + config: BuildTransactionConfig +): Transaction { + const fee = config.fee ?? BASE_FEE; + const timeout = config.timeout !== undefined ? config.timeout : getDefaultTimeout(operationType); + const memo = config.memo; + const networkPassphrase = config.networkPassphrase; + const memoValue = memo ? Memo.text(memo) : undefined; + + const builder = new TransactionBuilder(sourceAccount, { fee, networkPassphrase, memo: memoValue, - }; - - // Build the transaction - const builder = new TransactionBuilder(sourceAccount, params); + }); - // Add the Soroban contract operation - if (sorobanOperation.args) { + if (sorobanOperation.args && sorobanOperation.args.length > 0) { builder.addOperation( sorobanOperation.contract.call( sorobanOperation.functionName, @@ -95,12 +103,8 @@ export function buildTransaction( ); } - // Set timeout builder.setTimeout(timeout); - - // Build and return the transaction - const transaction = builder.build(); - return transaction; + return builder.build(); } /** @@ -116,75 +120,74 @@ export function buildTransaction( * @param config - Optional configuration for memo (fee and timeout are already in XDR) * @returns A transaction object reconstructed from XDR */ -export function buildTransactionFromXDR( - operationType: OperationType, - xdrTx: string, +export function buildTransactionFromXDR( + _operationType: OperationType, + xdrTx: string, networkPassphrase: string, - config: BuildTransactionConfig = {} -): any { - // Reconstruct the transaction from XDR + config: Pick = {} +): Transaction | FeeBumpTransaction { const transaction = TransactionBuilder.fromXDR(xdrTx, networkPassphrase); - - // Note: Fee and timeout are already set in the XDR by external SDKs - // We only apply memo if provided and not already in the transaction - if (config.memo) { - (transaction as Transaction).memo = Memo.text(config.memo); + + // Only mutate memo on a regular Transaction — FeeBumpTransaction wraps an + // inner transaction and does not expose .memo directly, so mutating it is + // either a silent no-op or a runtime error depending on SDK version. + if (config.memo && transaction instanceof Transaction) { + transaction.memo = Memo.text(config.memo); } return transaction; - -} - -export function buildPathPaymentTransaction( - sourceAccount: Account, - operation: PathPaymentOperationParams, - config: BuildTransactionConfig & { networkPassphrase: string } -): Transaction { - const fee = config.fee || BASE_FEE; - const timeout = config.timeout !== undefined ? config.timeout : 300; - const memo = config.memo ? Memo.text(config.memo) : undefined; - - const builder = new TransactionBuilder(sourceAccount, { - fee, - networkPassphrase: config.networkPassphrase, - memo, - }); - - if (operation.mode === "strict-send") { - if (!operation.destMin) { - throw new Error("destMin is required for strict-send path payments"); - } - - builder.addOperation( - Operation.pathPaymentStrictSend({ - sendAsset: operation.sendAsset, - sendAmount: operation.sendAmount, - destination: operation.destination, - destAsset: operation.destAsset, - destMin: operation.destMin, - path: operation.path, - }) - ); - } else { - if (!operation.sendMax) { - throw new Error("sendMax is required for strict-receive path payments"); - } - - builder.addOperation( - Operation.pathPaymentStrictReceive({ - sendAsset: operation.sendAsset, - sendMax: operation.sendMax, - destination: operation.destination, - destAsset: operation.destAsset, - destAmount: operation.destAmount, - path: operation.path, - }) - ); - } - - builder.setTimeout(timeout); - return builder.build(); -} +} + +export function buildPathPaymentTransaction( + sourceAccount: Account, + operation: PathPaymentOperationParams, + config: BuildTransactionConfig +): Transaction { + const fee = config.fee ?? BASE_FEE; + const timeout = config.timeout !== undefined ? config.timeout : 300; + const memo = config.memo ? Memo.text(config.memo) : undefined; + + const builder = new TransactionBuilder(sourceAccount, { + fee, + networkPassphrase: config.networkPassphrase, + memo, + }); + + if (operation.mode === "strict-send") { + if (!operation.destMin) { + throw new Error("destMin is required for strict-send path payments"); + } + + builder.addOperation( + Operation.pathPaymentStrictSend({ + sendAsset: operation.sendAsset, + sendAmount: operation.sendAmount, + destination: operation.destination, + destAsset: operation.destAsset, + destMin: operation.destMin, + path: operation.path, + }) + ); + } else { + if (!operation.sendMax) { + throw new Error("sendMax is required for strict-receive path payments"); + } + + builder.addOperation( + Operation.pathPaymentStrictReceive({ + sendAsset: operation.sendAsset, + sendMax: operation.sendMax, + destination: operation.destination, + destAsset: operation.destAsset, + destAmount: operation.destAmount, + path: operation.path, + }) + ); + } + + builder.setTimeout(timeout); + return builder.build(); +} /** * Get the default timeout for a given operation type @@ -193,6 +196,7 @@ export function buildPathPaymentTransaction( * - swap: 300 seconds (5 minutes) * - lp (LP operations): 300 seconds (5 minutes) * - bridge: 300 seconds (5 minutes) + * - stake: 300 seconds (5 minutes) * * @param operationType - The type of operation * @returns The timeout in seconds @@ -209,8 +213,8 @@ function getDefaultTimeout(operationType: OperationType): number { return 300; default: const _exhaustive: never = operationType; - return _exhaustive; + throw new Error(`Unhandled operation type: ${_exhaustive}`); } } -export type { OperationType, BuildTransactionConfig, SorobanOperationParams }; +export type { OperationType, BuildTransactionConfig, SorobanOperationParams };