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
149 changes: 95 additions & 54 deletions src/models/Escrow.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import mongoose, { Schema, Document, Types, Model } from 'mongoose';
// src/models/Escrow.ts
import mongoose, { Schema, Document, Model, Types } from 'mongoose';

/**
* Lifecycle of funds held in a Soroban escrow contract for a delivery.
*/
/** Lifecycle of funds held in a Soroban escrow contract for a delivery. */
export enum EscrowStatus {
PENDING = 'pending',
LOCKED = 'locked',
RELEASED = 'released',
REFUNDED = 'refunded',
DISPUTED = 'disputed',
}

/** Alias for lock status – kept for backward compatibility. */
export enum EscrowLockStatus {
PENDING = 'pending',
LOCKED = 'locked',
Expand All @@ -11,7 +19,19 @@ export enum EscrowLockStatus {
DISPUTED = 'disputed',
}

/** The kind of on-chain operation a recorded transaction hash represents. */
/** Escrow states in which funds are actually held by the contract. */
const FUNDS_HELD_STATUSES: ReadonlySet<EscrowStatus> = new Set([
EscrowStatus.LOCKED,
EscrowStatus.DISPUTED,
]);

/** Escrow states that can no longer change. */
const TERMINAL_STATUSES: ReadonlySet<EscrowStatus> = new Set([
EscrowStatus.RELEASED,
EscrowStatus.REFUNDED,
]);

/** The kind of on‑chain operation a recorded transaction hash represents. */
export type EscrowTransactionType = 'fund' | 'release' | 'refund';

export interface IEscrowTransaction {
Expand All @@ -22,82 +42,103 @@ export interface IEscrowTransaction {
}

export interface IEscrow extends Document {
/** Reference to the delivery this escrow secures. */
delivery: Types.ObjectId;
contractId: string;
/** Current escrow lifecycle state. */
status: EscrowStatus;
/** Escrowed amount, denominated in `assetCode` units (not stroops). */
amount: number;
asset: string;
lockStatus: EscrowLockStatus;
fundedBy?: string;
transactions: IEscrowTransaction[];
/** Asset code of the escrowed funds (e.g. `XLM`, `USDC`). */
assetCode: string;
/** Issuer account for non‑native assets. */
assetIssuer?: string;
/** Soroban contract id (`C...`) holding the funds. */
contractId?: string;
/** Stellar account funding the escrow. */
payerAddress?: string;
/** Stellar account entitled to the funds on release. */
payeeAddress?: string;
/** Transaction hash of the successful lock invocation. */
lockTransactionHash?: string;
/** Transaction hash of the successful release invocation. */
releaseTransactionHash?: string;
/** Transaction hash of the successful refund invocation. */
refundTransactionHash?: string;
lockedAt?: Date;
releasedAt?: Date;
refundedAt?: Date;
/** Ledger sequence of the last on‑chain event applied to this record. */
lastSyncedLedger?: number;
/** Reason recorded when the escrow moved to `disputed`. */
disputeReason?: string;
/** Timestamp fields provided by Mongoose. */
createdAt: Date;
updatedAt: Date;
/** Collection of on‑chain transaction hashes. */
transactions: IEscrowTransaction[];
/** Virtuals */
readonly isFundsLocked: boolean;
readonly isSettled: boolean;
}

// Schema definitions
const EscrowTransactionSchema = new Schema<IEscrowTransaction>(
{
hash: { type: String, required: true, trim: true },
type: {
type: String,
enum: ['fund', 'release', 'refund'],
required: true,
},
type: { type: String, enum: ['fund', 'release', 'refund'], required: true },
ledger: { type: Number },
recordedAt: { type: Date, default: Date.now },
},
{ _id: false },
{ _id: false }
);

const EscrowSchema = new Schema<IEscrow>(
{
delivery: {
type: Schema.Types.ObjectId,
ref: 'Delivery',
required: true,
index: true,
},
contractId: {
type: String,
required: true,
unique: true,
trim: true,
},
amount: {
type: Number,
required: true,
min: 0,
},
asset: {
type: String,
required: true,
trim: true,
},
lockStatus: {
type: String,
enum: Object.values(EscrowLockStatus),
default: EscrowLockStatus.PENDING,
index: true,
},
fundedBy: { type: String, trim: true },
transactions: {
type: [EscrowTransactionSchema],
default: [],
},
delivery: { type: Schema.Types.ObjectId, ref: 'Delivery', required: true, unique: true, index: true },
status: { type: String, enum: Object.values(EscrowStatus), default: EscrowStatus.PENDING, required: true, index: true },
amount: { type: Number, required: true, min: 0 },
assetCode: { type: String, required: true, trim: true, uppercase: true, maxlength: 12 },
assetIssuer: { type: String, trim: true },
contractId: { type: String, trim: true },
payerAddress: { type: String, trim: true },
payeeAddress: { type: String, trim: true },
lockTransactionHash: { type: String, trim: true },
releaseTransactionHash: { type: String, trim: true },
refundTransactionHash: { type: String, trim: true },
lockedAt: { type: Date },
releasedAt: { type: Date },
refundedAt: { type: Date },
lastSyncedLedger: { type: Number, min: 0 },
disputeReason: { type: String, trim: true },
transactions: { type: [EscrowTransactionSchema], default: [] },
},
{ timestamps: true },
{
timestamps: true,
toJSON: {
virtuals: true,
transform(_doc, ret: Record<string, unknown>) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
toObject: { virtuals: true },
}
);

// A given on-chain transaction hash must only ever be recorded once across
// all escrows, preventing duplicate ingestion by the indexer.
// Virtuals
EscrowSchema.virtual('isFundsLocked').get(function (this: IEscrow) {
return FUNDS_HELD_STATUSES.has(this.status);
});
EscrowSchema.virtual('isSettled').get(function (this: IEscrow) {
return TERMINAL_STATUSES.has(this.status);
});

// Ensure transaction hash uniqueness across escrows
EscrowSchema.index({ 'transactions.hash': 1 }, { unique: true, sparse: true });

const Escrow: Model<IEscrow> =
(mongoose.models.Escrow as Model<IEscrow>) || mongoose.model<IEscrow>('Escrow', EscrowSchema);
const Escrow: Model<IEscrow> = (mongoose.models.Escrow as Model<IEscrow>) || mongoose.model<IEscrow>('Escrow', EscrowSchema);

export default Escrow;
export { Escrow };
64 changes: 47 additions & 17 deletions src/sockets/connectionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
SocketData,
TypedSocket,
} from './socket.types';
import jwt from 'jsonwebtoken';

/**
* Typed Socket.IO server alias used throughout the sockets layer.
Expand Down Expand Up @@ -53,24 +54,48 @@ export function initializeSocketServer(httpServer: HttpServer): TypedServer {

// ─── Per-connection setup ──────────────────────────────────────────────────
io.on('connection', (socket: TypedSocket) => {
// Extract userId and JWT token from auth handshake data
const userId = extractUserId(socket);
const token = extractToken(socket);
// Extract authentication info from handshake
const { userId, tokenExp } = extractAuthInfo(socket);

// Store metadata on the socket data for easy access later
// Store auth data on socket data
socket.data.connectedAt = Date.now();
socket.data.userId = userId;
socket.data.token = token;
if (userId) socket.data.userId = userId;
if (tokenExp) (socket.data as any).tokenExp = tokenExp;

// Register the connection in the service layer
socketService.registerConnection(socket, userId);
// Register the connection in the service layer with token expiration
socketService.registerConnection(socket, userId, tokenExp);

// ── offline sync handler ─────────────────────────────────────────────────
registerSyncHandler(socket);

// ── real-time location broadcast handler ─────────────────────────────────
registerLocationHandler(io, socket);

// ── token refresh handler ─────────────────────────────────────────────────────
socket.on('refresh_token', (payload: { token: string }) => {
const token = payload?.token;
if (!token) {
logger.warn(`[Socket] refresh_token missing token – socketId=${socket.id}`);
socket.emit('auth_expired');
socket.disconnect(true);
return;
}
try {
const rawToken = token.startsWith('Bearer ') ? token.slice(7) : token;
const decoded = jwt.verify(rawToken, process.env.JWT_SECRET as string) as { userId?: string; exp?: number };
const newExp = typeof decoded.exp === 'number' ? decoded.exp * 1000 : undefined;
if (newExp) {
(socket.data as any).tokenExp = newExp;
socketService.updateTokenExpiration(socket.id, newExp);
logger.info(`[Socket] Token refreshed for socketId=${socket.id}`);
}
} catch (err) {
logger.warn(`Token refresh verification failed for socket ${socket.id}: ${(err as Error).message}`);
socket.emit('auth_expired');
socket.disconnect(true);
}
});

// ── pong handler ────────────────────────────────────────────────────────
socket.on('pong', (payload: PongPayload) => {
socketService.handlePong(socket, payload);
Expand Down Expand Up @@ -167,20 +192,25 @@ export async function shutdownSocketServer(io: TypedServer): Promise<void> {
* @param socket - The connecting socket.
* @returns The userId string, or undefined if absent.
*/
function extractUserId(socket: TypedSocket): string | undefined {
function extractAuthInfo(socket: TypedSocket): { userId?: string; tokenExp?: number } {
const auth = socket.handshake.auth as Record<string, unknown>;
const token = typeof auth?.token === 'string' ? auth.token : undefined;

if (typeof auth?.userId === 'string' && auth.userId.trim()) {
return auth.userId.trim();
if (!token) {
return {};
}

// Fallback: check query params (useful for testing with Postman)
const queryUserId = socket.handshake.query?.userId;
if (typeof queryUserId === 'string' && queryUserId.trim()) {
return queryUserId.trim();
try {
const rawToken = token.startsWith('Bearer ') ? token.slice(7) : token;
const decoded = jwt.verify(rawToken, process.env.JWT_SECRET as string) as { userId?: string; exp?: number };
return {
userId: typeof decoded.userId === 'string' ? decoded.userId : undefined,
tokenExp: typeof decoded.exp === 'number' ? decoded.exp * 1000 : undefined,
};
} catch (err) {
logger.warn(`JWT verification failed for socket ${socket.id}: ${(err as Error).message}`);
return {};
}

return undefined;
}

/**
Expand Down
19 changes: 15 additions & 4 deletions src/sockets/socket.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ export class SocketService {
* @param socket - The incoming socket instance.
* @param userId - Optional authenticated user ID extracted from auth token.
*/
public registerConnection(socket: TypedSocket, userId?: string): void {
public registerConnection(socket: TypedSocket, userId?: string, tokenExp?: number): void {
const meta: SocketConnectionMeta = {
tokenExp: tokenExp,
socketId: socket.id,
userId,
connectedAt: Date.now(),
Expand Down Expand Up @@ -230,12 +231,22 @@ export class SocketService {
staleConnectionsEvicted += 1;
} else {
// Send ping and wait for pong response
const pingPayload: PingPayload = { timestamp: Date.now() };
const socket = io.sockets.sockets.get(socketId);
if (socket) {
// Send ping and wait for pong response
const pingPayload: PingPayload = { timestamp: Date.now() };
const socket = io.sockets.sockets.get(socketId);
if (socket) {
// Check JWT expiration before sending ping
const exp = (socket.data as any).tokenExp as number | undefined;
if (exp && exp < Date.now()) {
// Token has expired – notify client and disconnect
logger.warn(`[Socket] JWT expired for socket id=${socketId}`);
socket.emit('auth_expired');
socket.disconnect(true);
} else {
socket.emit('ping', pingPayload);
}
}
}
}

const result: HealthCheckResult = {
Expand Down
10 changes: 6 additions & 4 deletions src/sockets/socket.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface SocketConnectionMeta {
missedPongs: number;
/** Rooms the socket is currently a member of */
rooms: string[];
/** Optional JWT expiration timestamp (ms since epoch) */
tokenExp?: number;
}

/**
Expand Down Expand Up @@ -188,10 +190,8 @@ export interface ServerToClientEvents {
'location:update': (payload: LocationBroadcastPayload) => void;
/** Ack sent back to the driver after a live location update is processed. */
location_update_ack: (payload: LocationUpdateAck) => void;
/** Emitted when the server detects an expired or invalid JWT during an active session. */
auth_expired: (payload: AuthExpiredPayload) => void;
/** Ack sent back after the client submits a refreshed JWT via `auth_refresh`. */
auth_refresh_ack: (payload: AuthRefreshAckPayload) => void;
/** Notify client that authentication token has expired */
auth_expired: () => void;
}

/**
Expand Down Expand Up @@ -225,6 +225,8 @@ export interface SocketData {
userId?: string;
token?: string;
connectedAt: number;
/** JWT expiration timestamp in ms */
tokenExp?: number;
}

/**
Expand Down
Loading