Skip to content

Commit 87d736c

Browse files
Merge pull request #197 from dimka90/feat/issue-22-soroban-billing
feat: implement Soroban-backed billing service
2 parents 98fef33 + 909ac4f commit 87d736c

17 files changed

Lines changed: 1075 additions & 453 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173
5555
SOROBAN_RPC_ENABLED=false
5656
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
5757
SOROBAN_RPC_TIMEOUT=2000
58+
SOROBAN_BILLING_RPC_URL=https://soroban-testnet.stellar.org
59+
SOROBAN_BILLING_CONTRACT_ID=your-vault-contract-id
60+
SOROBAN_BILLING_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
61+
SOROBAN_BILLING_SOURCE_ACCOUNT=your-backend-source-account
62+
SOROBAN_BILLING_BACKEND_SECRET_KEY=your-backend-secret-key
63+
SOROBAN_BILLING_BALANCE_FN=balance
64+
SOROBAN_BILLING_DEDUCT_FN=deduct
65+
SOROBAN_BILLING_RPC_TIMEOUT_MS=5000
5866

5967
# -----------------------------------------------------------------------------
6068
# Horizon (optional — set HORIZON_ENABLED=true to activate)

package-lock.json

Lines changed: 5 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/config/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,9 @@ export const config = {
5858
idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
5959
connectionTimeoutMillis: env.DB_CONN_TIMEOUT_MS,
6060
},
61-
6261
jwt: {
6362
secret: env.JWT_SECRET,
6463
},
65-
6664
metrics: {
6765
apiKey: env.METRICS_API_KEY,
6866
},

src/controllers/vaultController.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ describe('VaultController - getBalance', () => {
394394
expect(response.status).toBe(200);
395395

396396
// Validate response structure
397-
expect(response.body).toBeObject();
397+
expect(response.body).toEqual(expect.any(Object));
398398
expect(response.body).toHaveProperty('balance_usdc');
399399
expect(response.body).toHaveProperty('contractId');
400400
expect(response.body).toHaveProperty('network');

src/index.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,60 @@ import { config } from './config/index.js';
2525
// Helper for Jest/CommonJS compat
2626
const isDirectExecution = process.argv[1] && (process.argv[1].endsWith('index.ts') || process.argv[1].endsWith('index.js'));
2727

28+
interface GracefulShutdownOptions {
29+
server: Server;
30+
activeConnections: Set<Socket>;
31+
closeDatabase: () => Promise<void>;
32+
logger?: Pick<typeof console, 'log' | 'warn' | 'error'>;
33+
timeoutMs?: number;
34+
}
35+
36+
export function createGracefulShutdownHandler({
37+
server,
38+
activeConnections,
39+
closeDatabase,
40+
logger = console,
41+
timeoutMs = 10_000,
42+
}: GracefulShutdownOptions) {
43+
let inFlight: Promise<number> | null = null;
44+
45+
return (signal: NodeJS.Signals): Promise<number> => {
46+
if (inFlight) {
47+
return inFlight;
48+
}
49+
50+
inFlight = new Promise<number>((resolve) => {
51+
logger.log(`Received ${signal}, shutting down gracefully`);
52+
53+
const timeout = setTimeout(() => {
54+
for (const socket of activeConnections) {
55+
socket.destroy();
56+
}
57+
}, timeoutMs);
58+
59+
server.close(async (error?: Error) => {
60+
clearTimeout(timeout);
61+
62+
if (error) {
63+
logger.error('Error while closing HTTP server', error);
64+
resolve(1);
65+
return;
66+
}
67+
68+
try {
69+
await closeDatabase();
70+
resolve(0);
71+
} catch (closeError) {
72+
logger.error('Error while closing data resources', closeError);
73+
resolve(1);
74+
}
75+
});
76+
});
77+
78+
return inFlight;
79+
};
80+
}
81+
2882
export const app = express();
2983

3084
app.get('/api/health', (_req, res) => {
@@ -133,7 +187,7 @@ if (isDirectExecution) {
133187
});
134188

135189
const onSignal = (signal: NodeJS.Signals) => {
136-
void gracefulShutdown(signal).then((exitCode) => {
190+
void gracefulShutdown(signal).then((exitCode: number) => {
137191
process.exit(exitCode);
138192
});
139193
};

src/lib/prisma.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
import { PrismaClient } from '../generated/prisma/client.js';
21
import { PrismaPg } from '@prisma/adapter-pg';
32

4-
let prisma: PrismaClient;
3+
type PrismaClientLike = {
4+
$disconnect: () => Promise<void>;
5+
[key: string]: unknown;
6+
};
57

6-
function getPrismaClient(): PrismaClient {
8+
let prisma: PrismaClientLike | undefined;
9+
10+
function getPrismaClient(): PrismaClientLike {
711
if (!prisma) {
812
const connectionString = process.env.DATABASE_URL;
913
if (!connectionString) {
1014
throw new Error('DATABASE_URL environment variable is required');
1115
}
1216
const adapter = new PrismaPg({ connectionString });
13-
prisma = new PrismaClient({ adapter });
17+
const { PrismaClient } = require('@prisma/client');
18+
prisma = new PrismaClient({ adapter }) as PrismaClientLike;
1419
}
1520
return prisma;
1621
}
@@ -22,7 +27,7 @@ export async function disconnectPrisma(): Promise<void> {
2227
await prisma.$disconnect();
2328
}
2429

25-
export default new Proxy({} as PrismaClient, {
30+
export default new Proxy({} as PrismaClientLike, {
2631
get(_target, prop, receiver) {
2732
const client = getPrismaClient();
2833
const value = Reflect.get(client, prop, receiver);

src/middleware/ipAllowlist.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,14 @@ export function createIpAllowlist(config: IpAllowlistConfig) {
9696
}
9797

9898
// Log configuration for security audit
99-
logger.info('IP allowlist middleware configured', {
100-
allowedRangesCount: allowedRanges.length,
101-
trustProxy,
102-
proxyHeaders,
103-
enabled,
104-
});
99+
logger.info(
100+
`IP allowlist middleware configured ${JSON.stringify({
101+
allowedRangesCount: allowedRanges.length,
102+
trustProxy,
103+
proxyHeaders,
104+
enabled,
105+
})}`
106+
);
105107

106108
return (req: Request, res: Response, next: NextFunction): void => {
107109
// Skip IP checking if allowlist is disabled
@@ -114,11 +116,13 @@ export function createIpAllowlist(config: IpAllowlistConfig) {
114116

115117
// Validate extracted IP format
116118
if (!isValidIp(clientIp)) {
117-
logger.warn('Invalid IP format detected', {
118-
ip: clientIp,
119-
userAgent: req.get('User-Agent'),
120-
path: req.path,
121-
});
119+
logger.warn(
120+
`Invalid IP format detected ${JSON.stringify({
121+
ip: clientIp,
122+
userAgent: req.get('User-Agent'),
123+
path: req.path,
124+
})}`
125+
);
122126

123127
res.status(400).json({
124128
error: 'Bad Request: invalid client IP format',
@@ -132,13 +136,15 @@ export function createIpAllowlist(config: IpAllowlistConfig) {
132136

133137
if (!isAllowed) {
134138
// Log blocked attempt for security monitoring
135-
logger.warn('IP allowlist blocked request', {
136-
clientIp,
137-
path: req.path,
138-
method: req.method,
139-
userAgent: req.get('User-Agent'),
140-
timestamp: new Date().toISOString(),
141-
});
139+
logger.warn(
140+
`IP allowlist blocked request ${JSON.stringify({
141+
clientIp,
142+
path: req.path,
143+
method: req.method,
144+
userAgent: req.get('User-Agent'),
145+
timestamp: new Date().toISOString(),
146+
})}`
147+
);
142148

143149
res.status(403).json({
144150
error: 'Forbidden: IP address not allowed',
@@ -148,11 +154,13 @@ export function createIpAllowlist(config: IpAllowlistConfig) {
148154
}
149155

150156
// Log successful allowlist check for audit trail
151-
logger.debug('IP allowlist check passed', {
152-
clientIp,
153-
path: req.path,
154-
method: req.method,
155-
});
157+
logger.info(
158+
`IP allowlist check passed ${JSON.stringify({
159+
clientIp,
160+
path: req.path,
161+
method: req.method,
162+
})}`
163+
);
156164

157165
next();
158166
};

0 commit comments

Comments
 (0)