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
2 changes: 2 additions & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import socialRoutes from './routes/social.routes';
import notificationRoutes from './routes/notification.routes';
import disputeRoutes from './routes/dispute.routes';
import creditRoutes from './routes/credit.routes';
import nonceRoutes from './routes/nonce.routes';

import { errorHandler } from './middleware/errorHandler';
import { idempotencyMiddleware } from './middleware/idempotency';
Expand Down Expand Up @@ -193,6 +194,7 @@ app.use('/api/social', socialRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/disputes', disputeRoutes);
app.use('/api/credit', creditRoutes);
app.use('/api/nonce', nonceRoutes);

app.use(errorHandler);

Expand Down
158 changes: 158 additions & 0 deletions api/src/controllers/nonce.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { Router, Request, Response } from 'express';
import { nonceManager } from '../services/nonce-manager/nonce.service';
import { ValidationError } from '../utils/errors';
import logger from '../utils/logger';
import type {
NonceAllocationRequest,
NonceRecoveryRequest,
NonceStateResponse,
} from '../types/nonce';

const router = Router();

function validateAddress(address: string): void {
if (!address || !/^G[A-Z0-9]{56}$/.test(address)) {
throw new ValidationError('Invalid Stellar address format');
}
}

router.get('/:address', async (req: Request, res: Response) => {
try {
const { address } = req.params;
validateAddress(address);

const state = await nonceManager.getNonceState(address);
const response: NonceStateResponse = {
address: state.address,
currentNonce: state.currentNonce,
nextNonce: state.nextNonce,
pendingNonceCount: state.pendingNonces.length,
failedNonceCount: state.failedNonces.length,
gapCount: state.gaps.length,
};

res.json(response);
} catch (error) {
logger.error('Error fetching nonce state:', error);
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
} else {
res.status(500).json({ error: 'Failed to fetch nonce state' });
}
}
});

router.post('/next', async (req: Request, res: Response) => {
try {
const { address } = req.body as NonceAllocationRequest;
validateAddress(address);

const result = await nonceManager.allocateNonce(address);
res.json({
nonce: result.nonce,
allocatedAt: result.allocatedAt,
});
} catch (error) {
logger.error('Error allocating nonce:', error);
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
} else {
res.status(500).json({ error: 'Failed to allocate nonce' });
}
}
});

router.post('/recover', async (req: Request, res: Response) => {
try {
const { address, nonce } = req.body as NonceRecoveryRequest;
validateAddress(address);

if (!nonce) {
throw new ValidationError('Nonce is required');
}

const result = await nonceManager.recoverNonce(address, nonce);
res.json(result);
} catch (error) {
logger.error('Error recovering nonce:', error);
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
} else {
res.status(500).json({ error: 'Failed to recover nonce' });
}
}
});

router.post('/confirm', async (req: Request, res: Response) => {
try {
const { address, nonce, txHash } = req.body;
validateAddress(address);

if (!nonce || !txHash) {
throw new ValidationError('Nonce and transaction hash are required');
}

await nonceManager.confirmNonce(address, nonce, txHash);
res.json({ confirmed: true });
} catch (error) {
logger.error('Error confirming nonce:', error);
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
} else {
res.status(500).json({ error: 'Failed to confirm nonce' });
}
}
});

router.post('/fill-gaps', async (req: Request, res: Response) => {
try {
const { address } = req.body as { address: string };
validateAddress(address);

const result = await nonceManager.fillGaps(address);
res.json(result);
} catch (error) {
logger.error('Error filling gaps:', error);
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
} else {
res.status(500).json({ error: 'Failed to fill gaps' });
}
}
});

router.get('/:address/pending', async (req: Request, res: Response) => {
try {
const { address } = req.params;
validateAddress(address);

const pending = await nonceManager.getPendingNonces(address);
res.json({ pending });
} catch (error) {
logger.error('Error fetching pending nonces:', error);
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
} else {
res.status(500).json({ error: 'Failed to fetch pending nonces' });
}
}
});

router.get('/:address/next-available', async (req: Request, res: Response) => {
try {
const { address } = req.params;
validateAddress(address);

const nextNonce = await nonceManager.getNextNonce(address);
res.json({ nextNonce });
} catch (error) {
logger.error('Error fetching next nonce:', error);
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
} else {
res.status(500).json({ error: 'Failed to fetch next nonce' });
}
}
});

export const nonceRouter = router;
8 changes: 8 additions & 0 deletions api/src/routes/nonce.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Router } from 'express';
import { nonceRouter } from '../controllers/nonce.controller';

const router: Router = Router();

router.use('/', nonceRouter);

export default router;
201 changes: 201 additions & 0 deletions api/src/services/nonce-manager/nonce.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { redisCacheService } from '../redisCache.service';
import logger from '../../utils/logger';
import type { NonceState, PendingNonce } from '../../types/nonce';

interface NonceAllocation {
address: string;
nonce: string;
allocatedAt: number;
status: 'pending' | 'confirmed' | 'failed';
txHash?: string;
}

const NONCE_KEY_PREFIX = 'nonce';
const PENDING_NONCES_KEY_PREFIX = 'pending_nonces';
const ALLOCATION_LOCK_TTL = 30; // seconds

class NonceManager {
private locks = new Map<string, Promise<void>>();

async getNonceState(address: string): Promise<NonceState> {
const cacheKey = this.buildKey(address);
const cached = await redisCacheService.get<NonceState>(cacheKey);
if (cached) return cached;

const state: NonceState = {
address,
currentNonce: '0',
nextNonce: '0',
pendingNonces: [],
failedNonces: [],
gaps: [],
lastUpdated: Date.now(),
};

await redisCacheService.set(cacheKey, state, 3600);
return state;
}

async allocateNonce(address: string): Promise<{ nonce: string; allocatedAt: number }> {
await this.acquireLock(address);
try {
const state = await this.getNonceState(address);
const nextNonce = BigInt(state.nextNonce) + 1n;
const nonceStr = nextNonce.toString();

const allocation: NonceAllocation = {
address,
nonce: nonceStr,
allocatedAt: Date.now(),
status: 'pending',
};

const pendingKey = this.buildPendingKey(address);
const pending = await redisCacheService.get<PendingNonce[]>(pendingKey) || [];
pending.push({
nonce: nonceStr,
allocatedAt: allocation.allocatedAt,
status: 'pending',
});

state.nextNonce = nonceStr;
state.pendingNonces.push({
nonce: nonceStr,
allocatedAt: allocation.allocatedAt,
status: 'pending',
});
state.lastUpdated = Date.now();

await redisCacheService.set(this.buildKey(address), state, 3600);
await redisCacheService.set(pendingKey, pending, 3600);

logger.info('Nonce allocated', { address, nonce: nonceStr });
return { nonce: nonceStr, allocatedAt: allocation.allocatedAt };
} finally {
this.releaseLock(address);
}
}

async confirmNonce(
address: string,
nonce: string,
txHash: string
): Promise<void> {
await this.acquireLock(address);
try {
const state = await this.getNonceState(address);
const pending = state.pendingNonces.find((p) => p.nonce === nonce);

if (pending) {
pending.status = 'confirmed';
pending.txHash = txHash;
}

state.currentNonce = nonce;
state.lastUpdated = Date.now();

await redisCacheService.set(this.buildKey(address), state, 3600);
logger.info('Nonce confirmed', { address, nonce, txHash });
} finally {
this.releaseLock(address);
}
}

async recoverNonce(address: string, failedNonce: string): Promise<{ recovered: boolean }> {
await this.acquireLock(address);
try {
const state = await this.getNonceState(address);
const pendingIdx = state.pendingNonces.findIndex((p) => p.nonce === failedNonce);

if (pendingIdx >= 0) {
state.pendingNonces[pendingIdx].status = 'failed';
}

const currentBig = BigInt(state.currentNonce);
const failedBig = BigInt(failedNonce);

if (failedBig > currentBig) {
state.gaps.push({
start: currentBig.toString(),
end: (failedBig - 1n).toString(),
reason: 'transaction_failed',
filledAt: undefined,
});
}

state.lastUpdated = Date.now();
await redisCacheService.set(this.buildKey(address), state, 3600);

logger.info('Nonce recovered', { address, nonce: failedNonce });
return { recovered: true };
} finally {
this.releaseLock(address);
}
}

async fillGaps(address: string): Promise<{ filled: number }> {
await this.acquireLock(address);
try {
const state = await this.getNonceState(address);
let filled = 0;

for (const gap of state.gaps) {
if (!gap.filledAt) {
gap.filledAt = Date.now();
filled++;
}
}

state.gaps = state.gaps.filter((g) => g.filledAt === undefined || Date.now() - g.filledAt > 300000); // Keep 5 min history

state.lastUpdated = Date.now();
await redisCacheService.set(this.buildKey(address), state, 3600);

if (filled > 0) {
logger.info('Gaps filled', { address, count: filled });
}
return { filled };
} finally {
this.releaseLock(address);
}
}

async getPendingNonces(address: string): Promise<PendingNonce[]> {
const state = await this.getNonceState(address);
return state.pendingNonces;
}

async getNextNonce(address: string): Promise<string> {
const state = await this.getNonceState(address);
return (BigInt(state.nextNonce) + 1n).toString();
}

private buildKey(address: string): string {
return `${NONCE_KEY_PREFIX}:${address}`;
}

private buildPendingKey(address: string): string {
return `${PENDING_NONCES_KEY_PREFIX}:${address}`;
}

private async acquireLock(address: string): Promise<void> {
const lockKey = `${address}_lock`;
if (this.locks.has(lockKey)) {
await this.locks.get(lockKey);
}

const lockPromise = new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
this.locks.set(lockKey, lockPromise);

await lockPromise;
}

private releaseLock(address: string): void {
const lockKey = `${address}_lock`;
this.locks.delete(lockKey);
}
}

export const nonceManager = new NonceManager();
Loading