Skip to content
Open
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
32 changes: 28 additions & 4 deletions src/routes/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import type { NextFunction, Request, Response } from 'express';
import type { Pool } from 'pg';

import {
BadGatewayError,
BadRequestError,
GatewayTimeoutError,
InternalServerError,
NotFoundError,
PaymentRequiredError,
Expand All @@ -12,7 +14,7 @@ import {
import { requireAuth, type AuthenticatedLocals } from '../middleware/requireAuth.js';
import { idempotencyMiddleware } from '../middleware/idempotency.js';
import { BillingService } from '../services/billing.js';
import { createSorobanRpcBillingClient } from '../services/sorobanBilling.js';
import { createSorobanRpcBillingClient, SorobanRpcError } from '../services/sorobanBilling.js';

const router = Router();

Expand Down Expand Up @@ -115,11 +117,19 @@ router.post(

if (!result.success) {
const message = result.error ?? 'Billing deduction failed';
if (message.toLowerCase().includes('insufficient balance')) {
next(new PaymentRequiredError('Billing deduction failed', 'BILLING_DEDUCTION_FAILED'));
const lower = message.toLowerCase();
if (lower.includes('insufficient balance') || lower.includes('insufficient funds')) {
next(new PaymentRequiredError(message, 'INSUFFICIENT_BALANCE'));
return;
}
if (lower.includes('timeout') || lower.includes('timed out')) {
next(new GatewayTimeoutError(message, 'SOROBAN_RPC_TIMEOUT'));
return;
}
if (lower.includes('balance check failed') || lower.includes('contract') || lower.includes('network')) {
next(new BadGatewayError(message, 'SOROBAN_RPC_ERROR'));
return;
}

next(new InternalServerError('Billing deduction failed', 'BILLING_DEDUCTION_FAILED'));
return;
}
Expand All @@ -131,6 +141,20 @@ router.post(
alreadyProcessed: result.alreadyProcessed,
});
} catch (error) {
if (error instanceof SorobanRpcError) {
switch (error.category) {
case 'INSUFFICIENT_BALANCE':
next(new PaymentRequiredError(error.message, 'INSUFFICIENT_BALANCE'));
return;
case 'TIMEOUT':
next(new GatewayTimeoutError(error.message, 'SOROBAN_RPC_TIMEOUT'));
return;
case 'CONTRACT_ERROR':
case 'NETWORK_ERROR':
next(new BadGatewayError(error.message, 'SOROBAN_RPC_ERROR'));
return;
}
}
next(error);
}
}
Expand Down
104 changes: 104 additions & 0 deletions src/services/sorobanBilling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildSorobanBalanceInvocation,
buildSorobanDeductInvocation,
createSorobanRpcBillingClient,
SorobanRpcError,
} from './sorobanBilling.js';

describe('buildSorobanBalanceInvocation', () => {
Expand Down Expand Up @@ -133,3 +134,106 @@ describe('SorobanRpcBillingClient', () => {
);
});
});

describe('SorobanRpcError categories', () => {
function makeClient(simulationErrorMessage: string) {
const fetchImpl = (async () => ({
ok: true,
status: 200,
json: async () => ({
result: { error: { message: simulationErrorMessage } },
}),
})) as unknown as typeof fetch;

return createSorobanRpcBillingClient({
rpcUrl: 'http://soroban-rpc.internal',
contractId: 'contract_abc',
fetchImpl,
});
}

test('classifies insufficient balance as INSUFFICIENT_BALANCE', async () => {
const client = makeClient('insufficient balance for deduction');
const err = await client.deductBalance('user_1', '1000').catch((e) => e);
assert.ok(err instanceof SorobanRpcError);
assert.equal(err.category, 'INSUFFICIENT_BALANCE');
});

test('classifies insufficient funds as INSUFFICIENT_BALANCE', async () => {
const client = makeClient('insufficient funds');
const err = await client.deductBalance('user_1', '1000').catch((e) => e);
assert.ok(err instanceof SorobanRpcError);
assert.equal(err.category, 'INSUFFICIENT_BALANCE');
});

test('classifies contract error as CONTRACT_ERROR', async () => {
const client = makeClient('contract execution failed');
const err = await client.deductBalance('user_1', '1000').catch((e) => e);
assert.ok(err instanceof SorobanRpcError);
assert.equal(err.category, 'CONTRACT_ERROR');
});

test('classifies simulation failed as CONTRACT_ERROR', async () => {
const client = makeClient('Simulation failed: wasm trap');
const err = await client.deductBalance('user_1', '1000').catch((e) => e);
assert.ok(err instanceof SorobanRpcError);
assert.equal(err.category, 'CONTRACT_ERROR');
});

test('classifies HTTP failure as NETWORK_ERROR', async () => {
const fetchImpl = (async () => ({
ok: false,
status: 503,
json: async () => ({}),
})) as unknown as typeof fetch;

const client = createSorobanRpcBillingClient({
rpcUrl: 'http://soroban-rpc.internal',
contractId: 'contract_abc',
fetchImpl,
});

const err = await client.deductBalance('user_1', '1000').catch((e) => e);
assert.ok(err instanceof SorobanRpcError);
assert.equal(err.category, 'NETWORK_ERROR');
});

test('classifies timeout/abort as TIMEOUT', async () => {
const fetchImpl = jest.fn(async (_url: string, init?: RequestInit) => {
// Simulate the AbortController firing
if (init?.signal) {
throw Object.assign(new Error('The operation was aborted'), { name: 'AbortError' });
}
return { ok: true, status: 200, json: async () => ({}) };
}) as unknown as typeof fetch;

const client = createSorobanRpcBillingClient({
rpcUrl: 'http://soroban-rpc.internal',
contractId: 'contract_abc',
fetchImpl,
requestTimeoutMs: 1,
});

const err = await client.deductBalance('user_1', '1000').catch((e) => e);
assert.ok(err instanceof SorobanRpcError);
assert.equal(err.category, 'TIMEOUT');
});

test('missing result in RPC response is classified as NETWORK_ERROR', async () => {
const fetchImpl = (async () => ({
ok: true,
status: 200,
json: async () => ({ id: '1', jsonrpc: '2.0' }), // no result field
})) as unknown as typeof fetch;

const client = createSorobanRpcBillingClient({
rpcUrl: 'http://soroban-rpc.internal',
contractId: 'contract_abc',
fetchImpl,
});

const err = await client.deductBalance('user_1', '1000').catch((e) => e);
assert.ok(err instanceof SorobanRpcError);
assert.equal(err.category, 'NETWORK_ERROR');
});
});
53 changes: 49 additions & 4 deletions src/services/sorobanBilling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,44 @@ export interface SorobanDeductResponse {

const DEFAULT_TIMEOUT_MS = 5_000;

/**
* Stable error categories for Soroban RPC failures.
* - INSUFFICIENT_BALANCE: on-chain balance too low → 402
* - TIMEOUT: request timed out → 504
* - CONTRACT_ERROR: contract rejected the call → 502
* - NETWORK_ERROR: transport / HTTP failure → 502
*/
export type SorobanRpcErrorCategory =
| 'INSUFFICIENT_BALANCE'
| 'TIMEOUT'
| 'CONTRACT_ERROR'
| 'NETWORK_ERROR';

export class SorobanRpcError extends Error {
public readonly category: SorobanRpcErrorCategory;

constructor(message: string, category: SorobanRpcErrorCategory) {
super(message);
this.name = 'SorobanRpcError';
this.category = category;
Object.setPrototypeOf(this, SorobanRpcError.prototype);
}
}

function classifyError(message: string): SorobanRpcErrorCategory {
const lower = message.toLowerCase();
if (lower.includes('insufficient balance') || lower.includes('insufficient funds')) {
return 'INSUFFICIENT_BALANCE';
}
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('aborted')) {
return 'TIMEOUT';
}
if (lower.includes('contract') || lower.includes('simulation failed') || lower.includes('wasm')) {
return 'CONTRACT_ERROR';
}
return 'NETWORK_ERROR';
}

function extractErrorMessage(error: unknown, depth = 0): string | undefined {
if (depth > 4 || error === null || error === undefined) {
return undefined;
Expand Down Expand Up @@ -274,23 +312,30 @@ export class SorobanRpcBillingClient {
});

if (!response.ok) {
throw new Error(`Soroban RPC request failed: HTTP ${response.status}`);
const message = `Soroban RPC request failed: HTTP ${response.status}`;
throw new SorobanRpcError(message, 'NETWORK_ERROR');
}

const payload = await response.json() as Record<string, unknown>;
const simulationError = extractSimulationError(payload);
if (simulationError) {
throw new Error(normalizeSorobanBillingError(simulationError, 'Simulation failed'));
const message = normalizeSorobanBillingError(simulationError, 'Simulation failed');
throw new SorobanRpcError(message, classifyError(message));
}

const result = extractRpcResult(payload);
if (!result) {
throw new Error('Missing result in Soroban RPC response');
throw new SorobanRpcError('Missing result in Soroban RPC response', 'NETWORK_ERROR');
}

return result;
} catch (error) {
throw new Error(normalizeSorobanBillingError(error, 'Soroban RPC request failed'));
// Re-throw SorobanRpcError as-is so the category is preserved
if (error instanceof SorobanRpcError) {
throw error;
}
const message = normalizeSorobanBillingError(error, 'Soroban RPC request failed');
throw new SorobanRpcError(message, classifyError(message));
} finally {
clearTimeout(timeout);
}
Expand Down