Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ INTERNAL_SERVICE_TOKEN=
# Comma-separated keywords also supported: loopback,linklocal,uniquelocal
TRUST_PROXY=1

# HTTP Client (shared timeouts/retry/circuit-breaker for external services)
HTTP_CLIENT_TIMEOUT_MS=10000
HTTP_CLIENT_MAX_RETRIES=3
HTTP_CLIENT_BASE_DELAY_MS=200
HTTP_CLIENT_MAX_DELAY_MS=10000
HTTP_CLIENT_CIRCUIT_BREAKER_THRESHOLD=5
HTTP_CLIENT_CIRCUIT_BREAKER_RESET_MS=30000

# Dead Letter Queue
DLQ_ALERT_THRESHOLD=50
DLQ_ALERT_COOLDOWN_MS=900000
Expand Down
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,23 @@
"lint:style": "eslint \"src/**/*.ts\" \"prisma/**/*.ts\"",
"format": "prettier --write .github/workflows/node-ci.yml package.json .prettierrc.json eslint.config.mjs src/nlp/parser.ts src/stellar/dlq.ts src/whatsapp/handler.ts src/whatsapp/userManager.ts tests/unit/stellar/dlq-alerts.test.ts",
"format:check": "prettier --check .github/workflows/node-ci.yml package.json .prettierrc.json eslint.config.mjs src/nlp/parser.ts src/stellar/dlq.ts src/whatsapp/handler.ts src/whatsapp/userManager.ts tests/unit/stellar/dlq-alerts.test.ts",
"test": "jest",
"test:unit": "jest tests/unit",
"test:integration": "jest tests/integration",
"prisma:generate": "npx prisma generate",
"prisma:generate": "npx prisma generate"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"roots": ["<rootDir>/tests"],
"moduleFileExtensions": ["ts", "js", "json"],
"transform": {
"^.+\\.ts$": ["ts-jest", {
"tsconfig": "tsconfig.json"
}]
}
},
"prisma": {
"schema": "prisma/schema.prisma",
"seed": "ts-node prisma/seed.ts"
Expand Down
8 changes: 8 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,12 @@ export const config = {
alertThreshold: parseInt(process.env.DLQ_ALERT_THRESHOLD || '50'),
alertCooldownMs: parseInt(process.env.DLQ_ALERT_COOLDOWN_MS || '900000'), // 15 minutes default
},
httpClient: {
timeoutMs: parseInt(process.env.HTTP_CLIENT_TIMEOUT_MS || '10000'),
maxRetries: parseInt(process.env.HTTP_CLIENT_MAX_RETRIES || '3'),
baseDelayMs: parseInt(process.env.HTTP_CLIENT_BASE_DELAY_MS || '200'),
maxDelayMs: parseInt(process.env.HTTP_CLIENT_MAX_DELAY_MS || '10000'),
circuitBreakerThreshold: parseInt(process.env.HTTP_CLIENT_CIRCUIT_BREAKER_THRESHOLD || '5'),
circuitBreakerResetMs: parseInt(process.env.HTTP_CLIENT_CIRCUIT_BREAKER_RESET_MS || '30000'),
},
}
25 changes: 19 additions & 6 deletions src/nlp/parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Anthropic from '@anthropic-ai/sdk'
import { HttpClientAdapter } from '../utils/http-client'
import { config } from '../config'

export interface Intent {
action: 'deposit' | 'withdraw' | 'balance' | 'earnings' | 'help' | 'unknown'
Expand All @@ -11,6 +13,15 @@ const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY || 'dummy_key',
})

const anthropicHttpClient = new HttpClientAdapter({
timeoutMs: config.httpClient.timeoutMs,
maxRetries: config.httpClient.maxRetries,
baseDelayMs: config.httpClient.baseDelayMs,
maxDelayMs: config.httpClient.maxDelayMs,
circuitBreakerThreshold: config.httpClient.circuitBreakerThreshold,
circuitBreakerResetMs: config.httpClient.circuitBreakerResetMs,
})

// Regex fallback
export function parseWithRegex(message: string): Intent | null {
const lowerMsg = message.toLowerCase().trim()
Expand Down Expand Up @@ -57,19 +68,21 @@ export function parseWithRegex(message: string): Intent | null {
// Claude fallback
export async function parseWithClaude(message: string): Promise<Intent> {
try {
const response = await anthropic.messages.create({
model: 'claude-3-haiku-20240307',
max_tokens: 150,
system: `You are an intent parser for a financial bot. Determine if the user wants to deposit, withdraw, check balance, view earnings/performance, or needs help.
const response = await anthropicHttpClient.execute(async () => {
return anthropic.messages.create({
model: 'claude-3-haiku-20240307',
max_tokens: 150,
system: `You are an intent parser for a financial bot. Determine if the user wants to deposit, withdraw, check balance, view earnings/performance, or needs help.
Return ONLY a JSON object representing the intent, matching this TypeScript interface exactly without any wrapper text or markdown:
{
"action": "deposit" | "withdraw" | "balance" | "earnings" | "help" | "unknown",
"amount": number, // optional
"currency": string, // optional
"all": boolean // for "withdraw everything"
}`,
messages: [{ role: 'user', content: message }],
})
messages: [{ role: 'user', content: message }],
})
}, 'anthropic.parseIntent')

const contentBlock = response.content.find((c) => c.type === 'text')
if (contentBlock && contentBlock.type === 'text') {
Expand Down
126 changes: 75 additions & 51 deletions src/stellar/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,22 @@ import {
Networks,
Transaction,
TransactionBuilder,
Account,
} from '@stellar/stellar-sdk';
import { config } from '../config';
import { HttpClientAdapter, TimeoutError } from '../utils/http-client';
import { logger } from '../utils/logger';
import { TransactionResult } from './types';

export const stellarHttpClient = new HttpClientAdapter({
timeoutMs: config.httpClient.timeoutMs,
maxRetries: config.httpClient.maxRetries,
baseDelayMs: config.httpClient.baseDelayMs,
maxDelayMs: config.httpClient.maxDelayMs,
circuitBreakerThreshold: config.httpClient.circuitBreakerThreshold,
circuitBreakerResetMs: config.httpClient.circuitBreakerResetMs,
})

export function resolveNetworkPassphrase(network: string | undefined): string {
switch (network?.toLowerCase()) {
case 'mainnet':
Expand All @@ -29,26 +41,17 @@ const NETWORK_PASSPHRASE = resolveNetworkPassphrase(config.stellar.network);
let agentKeypair: Keypair | null = null;
let rpcServer: rpc.Server | null = null;

/**
* Initialize RPC server connection
*/
export function getRpcServer(): rpc.Server {
if (!rpcServer) {
rpcServer = new rpc.Server(RPC_URL);
}
return rpcServer;
}

/**
* Get network passphrase
*/
export function getNetworkPassphrase(): string {
return NETWORK_PASSPHRASE;
}

/**
* Load agent keypair from environment
*/
export function getAgentKeypair(): Keypair {
if (!agentKeypair) {
const secret = process.env.STELLAR_AGENT_SECRET_KEY;
Expand All @@ -60,60 +63,81 @@ export function getAgentKeypair(): Keypair {
return agentKeypair;
}

/**
* Submit transaction to Stellar network
*/
export async function submitTransaction(tx: Transaction): Promise<string> {
const server = getRpcServer();

try {
const response = await server.sendTransaction(tx);

if (response.status === 'ERROR') {
throw new Error(`Transaction failed: ${response.errorResult?.toXDR('base64')}`);

return stellarHttpClient.execute(async () => {
try {
const response = await server.sendTransaction(tx);

if (response.status === 'ERROR') {
throw new Error(`Transaction failed: ${response.errorResult?.toXDR('base64')}`);
}

return response.hash;
} catch (error) {
if (error instanceof TimeoutError) throw error
throw new Error(`Failed to submit transaction: ${error instanceof Error ? error.message : 'Unknown error'}`);
}

return response.hash;
} catch (error) {
throw new Error(`Failed to submit transaction: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}, 'stellar.submitTransaction')
}

/**
* Simulate a transaction against the Stellar RPC with retry/timeout/circuit-breaker.
*/
export async function simulateTransaction(tx: Transaction): Promise<rpc.Api.SimulateTransactionResponse> {
const server = getRpcServer()
return stellarHttpClient.execute(() => server.simulateTransaction(tx), 'stellar.simulateTransaction')
}

/**
* Prepare a transaction (add fee-bump etc.) with retry/timeout/circuit-breaker.
*/
export async function prepareTransaction(tx: Transaction): Promise<Transaction> {
const server = getRpcServer()
return stellarHttpClient.execute(() => server.prepareTransaction(tx), 'stellar.prepareTransaction')
}

/**
* Wait for transaction confirmation
* Get account details from the Stellar RPC with retry/timeout/circuit-breaker.
*/
export async function getAccount(publicKey: string): Promise<Account> {
const server = getRpcServer()
return stellarHttpClient.execute(() => server.getAccount(publicKey), 'stellar.getAccount')
}

export async function waitForConfirmation(
txHash: string,
timeoutMs: number = 30000
): Promise<TransactionResult> {
const server = getRpcServer();
const startTime = Date.now();

while (Date.now() - startTime < timeoutMs) {
try {
const response = await server.getTransaction(txHash);

if (response.status === 'SUCCESS') {
return {
hash: txHash,
status: 'success',
ledger: response.ledger,
};
}

if (response.status === 'FAILED') {
return {
hash: txHash,
status: 'failed',
};
}

// Still pending, wait before polling again
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
throw new Error(`Error polling transaction: ${error instanceof Error ? error.message : 'Unknown error'}`);
const pollDeadline = Date.now() + timeoutMs;

const poll = async (): Promise<TransactionResult> => {
const response = await server.getTransaction(txHash);

if (response.status === 'SUCCESS') {
return {
hash: txHash,
status: 'success',
ledger: response.ledger,
};
}

if (response.status === 'FAILED') {
return {
hash: txHash,
status: 'failed',
};
}

if (Date.now() >= pollDeadline) {
throw new Error(`Transaction confirmation timeout after ${timeoutMs}ms`);
}

await new Promise(resolve => setTimeout(resolve, 1000));
return poll()
}
throw new Error(`Transaction confirmation timeout after ${timeoutMs}ms`);

return stellarHttpClient.execute(poll, 'stellar.waitForConfirmation')
}
17 changes: 7 additions & 10 deletions src/stellar/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
scValToNative,
nativeToScVal,
} from '@stellar/stellar-sdk';
import { getRpcServer, getNetworkPassphrase, getAgentKeypair, submitTransaction, waitForConfirmation } from './client';
import { getRpcServer, getNetworkPassphrase, getAgentKeypair, submitTransaction, waitForConfirmation, simulateTransaction, prepareTransaction, getAccount } from './client';
import { getKeypairForUser } from './wallet';
import { config } from '../config';
import { OnChainBalance, TransactionResult } from './types';
Expand Down Expand Up @@ -39,7 +39,7 @@ async function buildContractCall(
): Promise<Transaction> {
const server = getRpcServer();
const contract = getVaultContract();
const account = await server.getAccount(sourcePublicKey);
const account = await getAccount(sourcePublicKey);

const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
Expand All @@ -64,19 +64,18 @@ async function executeWriteContractCall(
args: xdr.ScVal[],
signer: Keypair,
): Promise<TransactionResult> {
const server = getRpcServer();
const tx = await buildContractCall(method, args, signer.publicKey());

// Pre-Transaction Simulation & Validation (Issue #58)
const simulation = await server.simulateTransaction(tx);
const simulation = await simulateTransaction(tx);
if (rpc.Api.isSimulationError(simulation)) {
throw new Error(`Transaction simulation failed for ${method}: ${simulation.error}`);
}
if (!simulation.result) {
throw new Error(`Transaction simulation failed for ${method}: No result returned from simulation`);
}

const prepared = await server.prepareTransaction(tx);
const prepared = await prepareTransaction(tx);
prepared.sign(signer);

const txHash = await submitTransaction(prepared);
Expand Down Expand Up @@ -116,10 +115,9 @@ async function executeCustodialVaultOperation(
* Simulate and parse contract read call
*/
async function simulateRead(method: string, args: xdr.ScVal[] = []): Promise<any> {
const server = getRpcServer();
const tx = await buildContractCall(method, args);

const simulation = await server.simulateTransaction(tx);
const simulation = await simulateTransaction(tx);

if (rpc.Api.isSimulationError(simulation)) {
throw new Error(`Simulation failed: ${simulation.error}`);
Expand Down Expand Up @@ -237,23 +235,22 @@ export async function buildUnsignedVaultTransaction(
amount: number,
assetSymbol: string,
): Promise<string> {
const server = getRpcServer();
const userScVal = nativeToScVal(userAddress, { type: 'address' });
const amountScVal = nativeToScVal(toContractAmount(amount), { type: 'i128' });
const assetScVal = nativeToScVal(assetSymbol, { type: 'string' });

const tx = await buildContractCall(method, [userScVal, amountScVal, assetScVal], userAddress);

// Pre-Transaction Simulation & Validation (Issue #58)
const simulation = await server.simulateTransaction(tx);
const simulation = await simulateTransaction(tx);
if (rpc.Api.isSimulationError(simulation)) {
throw new Error(`Transaction simulation failed for ${method}: ${simulation.error}`);
}
if (!simulation.result) {
throw new Error(`Transaction simulation failed for ${method}: No result returned from simulation`);
}

const prepared = await server.prepareTransaction(tx);
const prepared = await prepareTransaction(tx);

return prepared.toXDR();
}
Loading
Loading