Skip to content

Commit 4da41ea

Browse files
authored
Merge pull request AgesEmpire#637 from charityagbenu12-cmd/issue-fixes/webhook-reserve-retry
Issue fixes/webhook reserve retry
2 parents 87b66f7 + d121e8f commit 4da41ea

10 files changed

Lines changed: 229 additions & 0 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { ReserveMonitorService } from '../reserve-monitor.service';
3+
import { ConfigService } from '@nestjs/config';
4+
5+
@Injectable()
6+
export class MonitorReservesJob {
7+
private readonly logger = new Logger(MonitorReservesJob.name);
8+
constructor(private readonly service: ReserveMonitorService, private readonly config: ConfigService) {}
9+
10+
async run(): Promise<void> {
11+
const cfg = this.config.get<string>('RESERVE_ASSETS') || ''; // format: CODE:ISSUER:THRESHOLD,CSV
12+
if (!cfg) return this.logger.debug('No RESERVE_ASSETS configured');
13+
const entries = cfg.split(',').map((s) => s.trim()).filter(Boolean);
14+
for (const e of entries) {
15+
const parts = e.split(':');
16+
const code = parts[0];
17+
const issuer = parts[1];
18+
const threshold = parseFloat(parts[2] || '0');
19+
const res = await this.service.checkAssetReserve(code, issuer, threshold);
20+
if (res.below) {
21+
this.logger.warn(`Reserve alert for ${code}:${issuer} — current=${res.current} threshold=${threshold}`);
22+
} else {
23+
this.logger.log(`Reserve OK for ${code}:${issuer} — current=${res.current}`);
24+
}
25+
}
26+
}
27+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ReserveMonitorService } from './reserve-monitor.service';
2+
import { ConfigService } from '@nestjs/config';
3+
4+
describe('ReserveMonitorService', () => {
5+
let service: ReserveMonitorService;
6+
beforeEach(() => {
7+
const cfg = { get: (k: string) => 'https://horizon-testnet.stellar.org' } as unknown as ConfigService;
8+
service = new ReserveMonitorService(cfg);
9+
});
10+
11+
it('evaluates threshold correctly', () => {
12+
expect(service).toBeDefined();
13+
});
14+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Server } from 'stellar-sdk';
3+
import { ConfigService } from '@nestjs/config';
4+
import { isBelowThreshold } from './utils/threshold-evaluator';
5+
6+
@Injectable()
7+
export class ReserveMonitorService {
8+
private readonly logger = new Logger(ReserveMonitorService.name);
9+
private server: Server;
10+
11+
constructor(private readonly config: ConfigService) {
12+
this.server = new Server(this.config.get<string>('HORIZON_URL') || 'https://horizon-testnet.stellar.org');
13+
}
14+
15+
async checkAssetReserve(assetCode: string, issuer: string, threshold: number): Promise<{ below: boolean; current: number }> {
16+
try {
17+
const account = await this.server.loadAccount(issuer);
18+
const bal = account.balances || [];
19+
if (assetCode === 'XLM') {
20+
const native = bal.find((b: any) => b.asset_type === 'native');
21+
const current = parseFloat(native?.balance || '0');
22+
return { below: isBelowThreshold(current, threshold), current };
23+
}
24+
25+
const found = bal.find((b: any) => b.asset_code === assetCode && b.asset_issuer === issuer);
26+
const current = parseFloat(found?.balance || '0');
27+
return { below: isBelowThreshold(current, threshold), current };
28+
} catch (err) {
29+
this.logger.error(`Failed to fetch reserve for ${assetCode}:${issuer}`, err);
30+
// On error consider it below to trigger alerting downstream
31+
return { below: true, current: 0 };
32+
}
33+
}
34+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function isBelowThreshold(current: number, threshold: number): boolean {
2+
return current < threshold;
3+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as crypto from 'crypto';
2+
3+
export function verifyHmacSHA256(rawBody: string, signatureHeader: string, secret: string): boolean {
4+
if (!signatureHeader || !secret) return false;
5+
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
6+
// header may be like "sha256=..." or raw hex
7+
const received = signatureHeader.replace(/^sha256=/, '');
8+
const rb = Buffer.from(received, 'hex');
9+
const eb = Buffer.from(expected, 'hex');
10+
if (rb.length !== eb.length) return false;
11+
return crypto.timingSafeEqual(rb, eb);
12+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ConfigService } from '@nestjs/config';
2+
import { WebhookVerifierService } from './webhook-verifier.service';
3+
import * as crypto from 'crypto';
4+
5+
describe('WebhookVerifierService', () => {
6+
let service: WebhookVerifierService;
7+
const secret = 'test-secret';
8+
beforeEach(() => {
9+
const config = { get: (k: string) => secret } as unknown as ConfigService;
10+
service = new WebhookVerifierService(config);
11+
});
12+
13+
it('validates correct signature', () => {
14+
const body = JSON.stringify({ hello: 'world' });
15+
const sig = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
16+
expect(service.validate(body, sig)).toBe(true);
17+
});
18+
19+
it('rejects incorrect signature', () => {
20+
const body = 'x';
21+
const sig = 'sha256=' + crypto.createHmac('sha256', 'other').update(body).digest('hex');
22+
try {
23+
service.validate(body, sig);
24+
throw new Error('should have thrown');
25+
} catch (err) {
26+
expect(err.status).toBe(401);
27+
}
28+
});
29+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
2+
import { verifyHmacSHA256 } from './utils/signature-validator';
3+
import { ConfigService } from '@nestjs/config';
4+
5+
@Injectable()
6+
export class WebhookVerifierService {
7+
private readonly logger = new Logger(WebhookVerifierService.name);
8+
constructor(private readonly config: ConfigService) {}
9+
10+
validate(rawBody: string, signatureHeader?: string, providerKeyName = 'WEBHOOK_SIGNING_KEY'): boolean {
11+
const secret = this.config.get<string>(providerKeyName) || '';
12+
const ok = verifyHmacSHA256(rawBody, signatureHeader || '', secret);
13+
if (!ok) {
14+
this.logger.warn('Invalid webhook signature');
15+
throw new UnauthorizedException('Invalid webhook signature');
16+
}
17+
return true;
18+
}
19+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { StakeVerificationService } from './stake-verification.service';
2+
import { ConfigService } from '@nestjs/config';
3+
4+
const makeService = () => {
5+
const cfg = { get: (k: string) => 'https://example.org' } as unknown as ConfigService;
6+
const cache = { get: jest.fn(), set: jest.fn(), del: jest.fn() } as any;
7+
const svc = new StakeVerificationService(cfg, cache);
8+
// patch private query method
9+
return { svc, cache };
10+
};
11+
12+
describe('StakeVerificationService', () => {
13+
it('verifies eligible provider', async () => {
14+
const { svc } = makeService();
15+
// @ts-ignore
16+
svc.queryStakeFromSoroban = jest.fn(async () => '2000');
17+
const res = await svc.verifyProviderStake({ publicKey: 'GABC' } as any);
18+
expect(res.verified).toBe(true);
19+
expect(res.stakeAmount).toBe('2000');
20+
});
21+
22+
it('rejects ineligible provider', async () => {
23+
const { svc } = makeService();
24+
// @ts-ignore
25+
svc.queryStakeFromSoroban = jest.fn(async () => '10');
26+
const res = await svc.verifyProviderStake({ publicKey: 'GXYZ' } as any);
27+
expect(res.verified).toBe(false);
28+
});
29+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { TransactionRetryService } from './transaction-retry.service';
2+
3+
describe('TransactionRetryService', () => {
4+
let service: TransactionRetryService;
5+
beforeEach(() => (service = new TransactionRetryService()));
6+
7+
it('retries transient failures and succeeds', async () => {
8+
let calls = 0;
9+
const work = jest.fn(async () => {
10+
calls += 1;
11+
if (calls < 2) throw new Error('timeout');
12+
return 'tx123';
13+
});
14+
15+
const res = await service.runWithRetry(work, 3, 1);
16+
expect(res.success).toBe(true);
17+
expect(res.txId).toBe('tx123');
18+
expect(res.attempts).toBe(2);
19+
});
20+
21+
it('fails on permanent errors without retrying all attempts', async () => {
22+
const work = jest.fn(async () => {
23+
throw new Error('invalid signature');
24+
});
25+
const res = await service.runWithRetry(work, 3, 1);
26+
expect(res.success).toBe(false);
27+
expect(res.attempts).toBe(1);
28+
});
29+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
3+
export type TxWork = () => Promise<string>; // returns tx id on success
4+
5+
@Injectable()
6+
export class TransactionRetryService {
7+
private readonly logger = new Logger(TransactionRetryService.name);
8+
9+
async runWithRetry(work: TxWork, attempts = 3, baseBackoff = 500): Promise<{ success: boolean; txId?: string; error?: string; attempts: number }> {
10+
let attempt = 0;
11+
while (attempt < attempts) {
12+
attempt += 1;
13+
try {
14+
const txId = await work();
15+
this.logger.log(`Transaction succeeded on attempt ${attempt}: ${txId}`);
16+
return { success: true, txId, attempts: attempt };
17+
} catch (err) {
18+
const msg = err instanceof Error ? err.message : String(err);
19+
this.logger.warn(`Transaction attempt ${attempt} failed: ${msg}`);
20+
// Simple heuristic: treat network/timeouts as transient if message contains certain words
21+
const transient = /timeout|timed out|ECONNRESET|ETIMEDOUT|temporar/i.test(msg);
22+
if (attempt >= attempts || !transient) {
23+
this.logger.error(`Giving up after ${attempt} attempts: ${msg}`);
24+
return { success: false, error: msg, attempts: attempt };
25+
}
26+
const delay = baseBackoff * Math.pow(2, attempt - 1);
27+
this.logger.log(`Retrying in ${delay}ms...`);
28+
await new Promise((r) => setTimeout(r, delay));
29+
}
30+
}
31+
return { success: false, error: 'unknown', attempts };
32+
}
33+
}

0 commit comments

Comments
 (0)