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
93 changes: 80 additions & 13 deletions pr.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,82 @@
## Description

This PR hardens backend services, routes, escrow contract guards, and updates repository configuration.

### Addressed Issues
- closes #303
- closes #302
- closes #301
- closes #300

### Key Changes
- **Auth Routes (`src/routes/auth.routes.ts`)**: Optimized and hardened request validation schemas and error handling wrapper for auth routes.
- **Invoice Service (`src/services/invoice.service.ts`)**: Added `rejectInvoice` functionality with status transition validation, rejection reason persistence, and seller notification.
- **Contract Guard Service (`src/services/stellar/contract-guard.service.ts`)**: Fixed Soroban contract address parsing and ledger key building to prevent cache poisoning on RPC errors.
- **Git Ignore (`.gitignore`)**: Added `mimo` build and configuration artifacts ignore patterns.
Builds secondary market listing and purchase endpoints for fractional invoice shares, allowing investors to list their fractional holdings for resale and other investors to purchase from those listings.

### Scope & Key Implementation:
- **`POST /secondary-market/listings`**: Creates a resale listing with price and quantity.
- Validates that the seller owns sufficient confirmed shares in the invoice, accounting for existing active listings to prevent double-listing.
- Validates invoice status (only funded invoices can be listed).
- Rejects listing shares on invoices that are past maturity with HTTP 422 (`INVOICE_MATURED`).
- **`GET /secondary-market/listings`**: Returns active listings per invoice with support for filtering (`invoiceId`, `sellerWallet`, `minPrice`, `maxPrice`) and pagination (`page`, `limit`).
- **`POST /secondary-market/listings/:id/buy`**: Executes purchases of fractional invoice shares from active listings.
- Rejects over-purchases exceeding available listing quantity with HTTP 409 (`OVER_PURCHASE`).
- Rejects self-purchases and purchases on expired or non-active listings with HTTP 400.
- Atomically transfers ownership with payment in a single database transaction:
- Deducts purchased quantity from seller's confirmed holdings (`Investment`).
- Credits purchased quantity to buyer's confirmed holdings (`Investment`).
- Updates listing quantity/status (`SOLD` if fully bought).
- Records completed payment transactions (`Transaction`).
- **Cancel Listing Endpoints**:
- `DELETE /secondary-market/listings/:id` and `POST /secondary-market/listings/:id/cancel`
- Restricts cancellation exclusively to the listing owner (`sellerWallet` or `sellerId`), returning HTTP 403 (`FORBIDDEN`) for non-owners.
- **Route Mounting**:
- Mounted on `/secondary-market`, `/api/v1/secondary-market`, and `/api/v1/secondary` for complete URL compatibility.

Closes #474

## Type of Change

- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [x] ✨ New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] 📝 Documentation update
- [ ] 🎨 UI/UX improvement
- [ ] ♻️ Code refactoring
- [x] ✅ Test addition or update
- [ ] 🔧 Configuration change

## Checklist

- [x] **All GitHub Actions workflows are green** on this PR (required for merge)
- [x] Commit messages follow **Conventional Commits** (`feat:`, `fix:`, `chore:`, etc.) — enforced by CI
- [x] No secrets, API keys, `.env`, or credentials committed (see `CONTRIBUTING.md`)
- [x] My code follows the code style of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published

## Testing

### How to Test

1. Run secondary market test suite:
```bash
npx jest tests/unit/secondary-market.test.ts tests/secondary-market-notifications.test.ts
```
2. Run TypeScript type check:
```bash
npx tsc --noEmit
```
3. Run linting & formatting checks:
```bash
npm run lint
```

### Test Coverage

- [x] Unit tests added/updated
- [x] Integration tests added/updated
- [ ] E2E tests added/updated (if applicable)
- [x] Manual testing completed

## For Reviewers

- [x] Code quality and readability
- [x] Test coverage
- [x] Security implications
- [ ] Performance impact
- [ ] Breaking changes
13 changes: 8 additions & 5 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,10 +502,13 @@ export function createApp({
}

if (secondaryMarketService && authService) {
app.use(
"/api/v1/secondary",
createSecondaryMarketRouter({ secondaryMarketService, authService })
);
const secondaryMarketRouter = createSecondaryMarketRouter({
secondaryMarketService,
authService,
});
app.use("/api/v1/secondary-market", secondaryMarketRouter);
app.use("/secondary-market", secondaryMarketRouter);
app.use("/api/v1/secondary", secondaryMarketRouter);
}

if (watchlistService && authService) {
Expand Down Expand Up @@ -588,7 +591,7 @@ export function createApp({
adminSettlementService,
adminWallets: config.admin?.wallets || [],
invoiceEscrowContractService,
feeScheduleService
feeScheduleService,
})
);
}
Expand Down
24 changes: 14 additions & 10 deletions src/controllers/secondary-market.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ export class SecondaryMarketController {
return res.status(401).json({ error: "Unauthorized" });
}

const { invoiceId, quantity, pricePerFraction, expiresAt } = req.body;
const { invoiceId, quantity, pricePerFraction, price, expiresAt } = req.body || {};
const effectivePrice = pricePerFraction ?? price;

if (!invoiceId || !quantity || !pricePerFraction || !expiresAt) {
if (!invoiceId || !quantity || !effectivePrice) {
return res.status(400).json({
error: {
code: "MISSING_FIELDS",
message: "invoiceId, quantity, pricePerFraction, and expiresAt are required",
message: "invoiceId, quantity, and pricePerFraction (or price) are required",
},
});
}
Expand All @@ -27,8 +28,10 @@ export class SecondaryMarketController {
sellerWallet: req.user.stellarAddress,
sellerId: req.user.id,
quantity: Array.isArray(quantity) ? quantity[0] : quantity,
pricePerFraction: Array.isArray(pricePerFraction) ? pricePerFraction[0] : pricePerFraction,
expiresAt: new Date(Array.isArray(expiresAt) ? expiresAt[0] : expiresAt),
pricePerFraction: Array.isArray(effectivePrice) ? effectivePrice[0] : effectivePrice,
expiresAt: expiresAt
? new Date(Array.isArray(expiresAt) ? expiresAt[0] : expiresAt)
: undefined,
});

return res.status(201).json({
Expand Down Expand Up @@ -65,8 +68,8 @@ export class SecondaryMarketController {
sellerWallet: sellerWallet as string | undefined,
minPrice: minPrice ? Number(Array.isArray(minPrice) ? minPrice[0] : minPrice) : undefined,
maxPrice: maxPrice ? Number(Array.isArray(maxPrice) ? maxPrice[0] : maxPrice) : undefined,
sortBy: (sortBy as string) as "price" | "expires_at" | "created_at",
sortOrder: (sortOrder as string) as "ASC" | "DESC",
sortBy: sortBy as string as "price" | "expires_at" | "created_at",
sortOrder: sortOrder as string as "ASC" | "DESC",
};

const pagination = {
Expand Down Expand Up @@ -123,7 +126,7 @@ export class SecondaryMarketController {
}

const { id } = req.params;
const { quantity } = req.body;
const { quantity } = req.body || {};

const result = await this.secondaryMarketService.buyListing({
listingId: Array.isArray(id) ? id[0] : id,
Expand Down Expand Up @@ -155,12 +158,13 @@ export class SecondaryMarketController {
}

const { id } = req.params;
const { reason } = req.body;
const { reason } = req.body || {};

const listing = await this.secondaryMarketService.cancelListing(
Array.isArray(id) ? id[0] : id,
req.user.stellarAddress,
reason
reason,
req.user.id
);

return res.status(200).json({
Expand Down
27 changes: 19 additions & 8 deletions src/routes/secondary-market.routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Router } from "express";
import { Router, Request, Response, NextFunction } from "express";
import { SecondaryMarketController } from "../controllers/secondary-market.controller";
import { SecondaryMarketService } from "../services/secondary-market.service";
import { createAuthMiddleware } from "../middleware/auth.middleware";
import type { AuthService } from "../services/auth.service";
import type { AuthenticatedRequest } from "../types/auth";

export interface SecondaryMarketRouterDependencies {
secondaryMarketService: SecondaryMarketService;
Expand All @@ -17,20 +18,30 @@ export function createSecondaryMarketRouter({
const controller = new SecondaryMarketController(secondaryMarketService);
const authMiddleware = createAuthMiddleware(authService);

// POST /api/v1/secondary/listings - Create a new listing
const optionalAuth = (req: Request, res: Response, next: NextFunction) => {
if (req.headers && req.headers.authorization) {
return authMiddleware(req as AuthenticatedRequest, res, next);
}
next();
};

// POST /secondary-market/listings - Create a new listing
router.post("/listings", authMiddleware, controller.createListing);

// GET /api/v1/secondary/listings - Get active listings with filters
router.get("/listings", authMiddleware, controller.getListings);
// GET /secondary-market/listings - Get active listings with filters
router.get("/listings", optionalAuth, controller.getListings);

// GET /api/v1/secondary/listings/:id - Get listing details
router.get("/listings/:id", authMiddleware, controller.getListingById);
// GET /secondary-market/listings/:id - Get listing details
router.get("/listings/:id", optionalAuth, controller.getListingById);

// POST /api/v1/secondary/listings/:id/buy - Buy a listing
// POST /secondary-market/listings/:id/buy - Buy a listing
router.post("/listings/:id/buy", authMiddleware, controller.buyListing);

// DELETE /api/v1/secondary/listings/:id - Cancel a listing
// DELETE /secondary-market/listings/:id - Cancel a listing
router.delete("/listings/:id", authMiddleware, controller.cancelListing);

// POST /secondary-market/listings/:id/cancel - Cancel a listing (POST alias)
router.post("/listings/:id/cancel", authMiddleware, controller.cancelListing);

return router;
}
Loading