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
213 changes: 107 additions & 106 deletions packages/app/server/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,34 +23,32 @@ import {
} from 'services/facilitator/x402-types';
import { Decimal } from '@prisma/client/runtime/library';
import logger from 'logger';

export async function handleX402Request({
req,
res,
headers,
maxCost,
isPassthroughProxyRoute,
provider,
isStream,
}: X402HandlerInput) {
if (isPassthroughProxyRoute) {
return await makeProxyPassthroughRequest(req, res, provider, headers);
}

// Apply x402 payment middleware with the calculated maxCost
import { Request, Response } from 'express';

export async function settle(
req: Request,
res: Response,
headers: Record<string, string>,
maxCost: Decimal
): Promise<
{ payload: ExactEvmPayload; paymentAmountDecimal: Decimal } | undefined
> {
const network = process.env.NETWORK as Network;

let recipient: string;
try {
recipient = (await getSmartAccount()).smartAccount.address;
} catch (error) {
return buildX402Response(req, res, maxCost);
buildX402Response(req, res, maxCost);
return undefined;
Comment on lines +42 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont get this change. we are no longer returning the 402 response?

}

let xPaymentData: PaymentPayload;
try {
xPaymentData = validateXPaymentHeader(headers, req);
} catch (error) {
return buildX402Response(req, res, maxCost);
buildX402Response(req, res, maxCost);
return undefined;
}

const payload = xPaymentData.payload as ExactEvmPayload;
Expand All @@ -62,100 +60,103 @@ export async function handleX402Request({
// Note(shafu, alvaro): Edge case where client sends the x402-challenge
// but the payment amount is less than what we returned in the first response
if (BigInt(paymentAmount) < decimalToUsdcBigInt(maxCost)) {
return buildX402Response(req, res, maxCost);
buildX402Response(req, res, maxCost);
return undefined;
}

const facilitatorClient = new FacilitatorClient();
const paymentRequirements = PaymentRequirementsSchema.parse({
scheme: 'exact',
network,
maxAmountRequired: paymentAmount,
resource: `${req.protocol}://${req.get('host')}${req.url}`,
description: 'Echo x402',
mimeType: 'application/json',
payTo: recipient,
maxTimeoutSeconds: 60,
asset: USDC_ADDRESS,
extra: {
name: 'USD Coin',
version: '2',
},
});

const settleRequest = SettleRequestSchema.parse({
paymentPayload: xPaymentData,
paymentRequirements,
});

const settleResult = await facilitatorClient.settle(settleRequest);

if (!settleResult.success || !settleResult.transaction) {
buildX402Response(req, res, maxCost);
return undefined;
}

return { payload, paymentAmountDecimal };
}

export async function finalize(
paymentAmountDecimal: Decimal,
transaction: Transaction,
payload: ExactEvmPayload
) {
const refundAmount = calculateRefundAmount(
paymentAmountDecimal,
transaction.rawTransactionCost
);

if (!refundAmount.equals(0) && refundAmount.greaterThan(0)) {
const refundAmountUsdcBigInt = decimalToUsdcBigInt(refundAmount);
const authPayload = payload.authorization;
await transfer(authPayload.from as `0x${string}`, refundAmountUsdcBigInt);
}
}

export async function handleX402Request({
req,
res,
headers,
maxCost,
isPassthroughProxyRoute,
provider,
isStream,
}: X402HandlerInput) {
if (isPassthroughProxyRoute) {
return await makeProxyPassthroughRequest(req, res, provider, headers);
}

const settleResult = await settle(req, res, headers, maxCost);
if (!settleResult) {
return;
}

const { payload, paymentAmountDecimal } = settleResult;

try {
// Default to no refund
let refundAmount = new Decimal(0);
let transaction: Transaction | null = null;
let data: unknown = null;

// Construct and validate PaymentRequirements using Zod schema
const paymentRequirements = PaymentRequirementsSchema.parse({
scheme: 'exact',
network,
maxAmountRequired: paymentAmount,
resource: `${req.protocol}://${req.get('host')}${req.url}`,
description: 'Echo x402',
mimeType: 'application/json',
payTo: recipient,
maxTimeoutSeconds: 60,
asset: USDC_ADDRESS,
extra: {
name: 'USD Coin',
version: '2',
},
});
// Validate and execute settle request
const settleRequest = SettleRequestSchema.parse({
paymentPayload: xPaymentData,
paymentRequirements,
});

const settleResult = await facilitatorClient.settle(settleRequest);

if (!settleResult.success || !settleResult.transaction) {
return buildX402Response(req, res, maxCost);
}

try {
const transactionResult = await modelRequestService.executeModelRequest(
req,
res,
headers,
provider,
isStream
);
transaction = transactionResult.transaction;
data = transactionResult.data;

// Send the response - the middleware has intercepted res.end()/res.json()
// and will actually send it after settlement completes
modelRequestService.handleResolveResponse(res, isStream, data);

refundAmount = calculateRefundAmount(
paymentAmountDecimal,
transaction.rawTransactionCost
);

// Process refund if needed
if (!refundAmount.equals(0) && refundAmount.greaterThan(0)) {
const refundAmountUsdcBigInt = decimalToUsdcBigInt(refundAmount);
const authPayload = payload.authorization;
await transfer(
authPayload.from as `0x${string}`,
refundAmountUsdcBigInt
).catch(transferError => {
logger.error('Failed to process refund', {
error: transferError,
refundAmount: refundAmount.toString(),
});
});
}
} catch (error) {
// In case of error, do full refund
refundAmount = paymentAmountDecimal;

if (!refundAmount.equals(0) && refundAmount.greaterThan(0)) {
const refundAmountUsdcBigInt = decimalToUsdcBigInt(refundAmount);
const authPayload = payload.authorization;
await transfer(
authPayload.from as `0x${string}`,
refundAmountUsdcBigInt
).catch(transferError => {
logger.error('Failed to process full refund after error', {
error: transferError,
originalError: error,
refundAmount: refundAmount.toString(),
});
});
}
}
const transactionResult = await modelRequestService.executeModelRequest(
req,
res,
headers,
provider,
isStream
);

modelRequestService.handleResolveResponse(
res,
isStream,
transactionResult.data
);

await finalize(
paymentAmountDecimal,
transactionResult.transaction,
payload
);
} catch (error) {
logger.error('Error in handleX402Request', { error });
throw error;
const refundAmountUsdcBigInt = decimalToUsdcBigInt(paymentAmountDecimal);
const authPayload = payload.authorization;
await transfer(authPayload.from as `0x${string}`, refundAmountUsdcBigInt);
}
}

Expand Down
71 changes: 71 additions & 0 deletions packages/app/server/src/resources/tavily/prices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
export const CREDIT_PRICE = 0.008; // $0.008 per credit

// Tavily Search pricing
export const TAVILY_SEARCH_PRICING = {
basic: 1, // 1 credit per request
advanced: 2, // 2 credits per request
} as const;

// Tavily Extract pricing
export const TAVILY_EXTRACT_PRICING = {
basic: {
creditsPerUnit: 1,
urlsPerCredit: 5, // Every 5 successful URL extractions cost 1 credit
},
advanced: {
creditsPerUnit: 2,
urlsPerCredit: 5, // Every 5 successful URL extractions cost 2 credits
},
} as const;

// Tavily Map pricing
export const TAVILY_MAP_PRICING = {
regular: {
creditsPerUnit: 1,
pagesPerCredit: 10, // Every 10 successful pages cost 1 credit
},
withInstructions: {
creditsPerUnit: 2,
pagesPerCredit: 10, // Every 10 successful pages with instructions cost 2 credits
},
} as const;

// Calculate costs
export function calculateSearchCost(
searchDepth: 'basic' | 'advanced' = 'basic'
): number {
return TAVILY_SEARCH_PRICING[searchDepth] * CREDIT_PRICE;
}

export function calculateExtractCost(
successfulUrls: number,
extractionDepth: 'basic' | 'advanced' = 'basic'
): number {
const { creditsPerUnit, urlsPerCredit } =
TAVILY_EXTRACT_PRICING[extractionDepth];
const credits = Math.ceil(successfulUrls / urlsPerCredit) * creditsPerUnit;
return credits * CREDIT_PRICE;
}

export function calculateMapCost(
successfulPages: number,
withInstructions: boolean = false
): number {
const pricing = withInstructions
? TAVILY_MAP_PRICING.withInstructions
: TAVILY_MAP_PRICING.regular;
const credits =
Math.ceil(successfulPages / pricing.pagesPerCredit) *
pricing.creditsPerUnit;
return credits * CREDIT_PRICE;
}

export function calculateCrawlCost(
successfulPages: number,
extractionDepth: 'basic' | 'advanced' = 'basic'
): number {
// Crawl cost = Mapping cost + Extraction cost
const mappingCost = calculateMapCost(successfulPages, false);
const extractionCost = calculateExtractCost(successfulPages, extractionDepth);
return mappingCost + extractionCost;
}
55 changes: 55 additions & 0 deletions packages/app/server/src/resources/tavily/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { buildX402Response, isApiRequest, isX402Request } from 'utils';
import { TavilySearchInputSchema } from './types';
import { calculateTavilySearchCost, tavilySearch } from './tavily';
import { authenticateRequest } from 'auth';
import { prisma } from 'server';
import { settle } from 'handlers';
import { finalize } from 'handlers';
import { createTavilyTransaction } from './tavily';
import logger from 'logger';
import { Request, Response } from 'express';

export async function tavilySearchRoute(req: Request, res: Response) {
try {
const headers = req.headers as Record<string, string>;

const inputBody = TavilySearchInputSchema.parse(req.body);

const maxCost = calculateTavilySearchCost(inputBody);

if (!isApiRequest(headers) && !isX402Request(headers)) {
return buildX402Response(req, res, maxCost);
}

if (isApiRequest(headers)) {
const { echoControlService } = await authenticateRequest(headers, prisma);

const output = await tavilySearch(inputBody);

const transaction = createTavilyTransaction(inputBody, output, maxCost);

await echoControlService.createTransaction(transaction, maxCost);

return res.status(200).json(output);
} else if (isX402Request(headers)) {
const settleResult = await settle(req, res, headers, maxCost);
if (!settleResult) {
return buildX402Response(req, res, maxCost);
}
const { payload, paymentAmountDecimal } = settleResult;

const output = await tavilySearch(inputBody);

const transaction = createTavilyTransaction(inputBody, output, maxCost);

await finalize(paymentAmountDecimal, transaction, payload);

return res.status(200).json(output);
Comment on lines +34 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for X402 payments - if the Tavily API call fails after payment settlement, users won't receive refunds.

View Details
📝 Patch Details
diff --git a/packages/app/server/src/resources/tavily/route.ts b/packages/app/server/src/resources/tavily/route.ts
index b8f9c685..cb322777 100644
--- a/packages/app/server/src/resources/tavily/route.ts
+++ b/packages/app/server/src/resources/tavily/route.ts
@@ -1,10 +1,10 @@
-import { buildX402Response, isApiRequest, isX402Request } from 'utils';
+import { buildX402Response, isApiRequest, isX402Request, decimalToUsdcBigInt } from 'utils';
 import { TavilySearchInputSchema } from './types';
 import { calculateTavilySearchCost, tavilySearch } from './tavily';
 import { authenticateRequest } from 'auth';
 import { prisma } from 'server';
-import { settle } from 'handlers';
-import { finalize } from 'handlers';
+import { settle, finalize } from 'handlers';
+import { transfer } from 'transferWithAuth';
 import { createTavilyTransaction } from './tavily';
 import logger from 'logger';
 import { Request, Response } from 'express';
@@ -38,13 +38,21 @@ export async function tavilySearchRoute(req: Request, res: Response) {
       }
       const { payload, paymentAmountDecimal } = settleResult;
 
-      const output = await tavilySearch(inputBody);
+      try {
+        const output = await tavilySearch(inputBody);
 
-      const transaction = createTavilyTransaction(inputBody, output, maxCost);
+        const transaction = createTavilyTransaction(inputBody, output, maxCost);
 
-      await finalize(paymentAmountDecimal, transaction, payload);
+        await finalize(paymentAmountDecimal, transaction, payload);
 
-      return res.status(200).json(output);
+        return res.status(200).json(output);
+      } catch (error) {
+        // Full refund on error, like the main handleX402Request handler does
+        const refundAmountUsdcBigInt = decimalToUsdcBigInt(paymentAmountDecimal);
+        const authPayload = payload.authorization;
+        await transfer(authPayload.from as `0x${string}`, refundAmountUsdcBigInt);
+        throw error;
+      }
     } else {
       return buildX402Response(req, res, maxCost);
     }

Analysis

Missing error handling in tavily X402 route causes payment loss on API failures

What fails: tavilySearchRoute() in X402 payment path lacks error handling after settle() - if tavilySearch() throws an error, user loses payment without receiving service

How to reproduce:

# Send X402 payment request to tavily endpoint with invalid/expired API key
curl -X POST /tavily/search \
  -H "Content-Type: application/json" \
  -H "X-Payment: <valid_payment_payload>" \
  -d '{"query": "test", "search_depth": "basic"}'

Result: Payment gets settled successfully, then tavilySearch() throws "Tavily API request failed: 401 Unauthorized" but user receives no refund - payment is lost

Expected: Should perform full refund like handleX402Request() does at handlers.ts:156-160 when errors occur after settlement

Root cause: Tavily route calls settle(), then tavilySearch() without try-catch protection, unlike main X402 handler which wraps post-settlement operations in try-catch with transfer() refund on errors

} else {
return buildX402Response(req, res, maxCost);
}
} catch (error) {
logger.error('Error searching tavily', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
Loading
Loading