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
45 changes: 21 additions & 24 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,17 @@
"ErrorEnvelope": {
"type": "object",
"required": [
"error",
"status",
"code",
"message",
"error"
"retryable"
],
"properties": {
"error": {
"type": "string",
"example": "Bad Request"
},
"status": {
"type": "integer",
"example": 400
Expand All @@ -98,30 +104,21 @@
"type": "string",
"example": "Some of the information you provided is invalid. Please check and try again."
},
"error": {
"code": {
"type": "string",
"example": "VALIDATION_ERROR"
},
"retryable": {
"type": "boolean",
"example": false
},
"correlationId": {
"type": "string",
"format": "uuid"
},
"details": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "string",
"example": "VALIDATION_ERROR"
},
"message": {
"type": "string",
"example": "Validation failed"
},
"correlationId": {
"type": "string",
"format": "uuid"
},
"details": {
"type": "object",
"nullable": true
}
}
"nullable": true
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
"prisma:schema-check": "prisma validate --schema prisma/schema.prisma && prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --exit-code",
"prisma:migrate": "prisma migrate dev --schema prisma/schema.prisma",
"prisma:deploy": "prisma migrate deploy --schema prisma/schema.prisma",
"prisma:deploy": "node scripts/backfill-webhook-secret-hashes.js && prisma migrate deploy --schema prisma/schema.prisma",
"prisma:status": "prisma migrate status --schema prisma/schema.prisma",
"prisma:rollback": "node scripts/prisma-rollback.js",
"seed": "tsx prisma/seed.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- migration-safety: allow-webhook-secret-drop
ALTER TABLE "WebhookEndpoint" DROP COLUMN "secret";
1 change: 0 additions & 1 deletion backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,6 @@ model WebhookEndpoint {
url String
eventTypes String // JSON array
enabled Boolean @default(true)
secret String?
secretHash String?
verificationStatus String @default("pending")
challengeTokenHash String?
Expand Down
34 changes: 34 additions & 0 deletions backend/scripts/backfill-webhook-secret-hashes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';

const crypto = require('crypto');
const { PrismaClient } = require('@prisma/client');

const prisma = new PrismaClient();

async function main() {
const columns = await prisma.$queryRawUnsafe('PRAGMA table_info("WebhookEndpoint")');
const columnNames = new Set(columns.map((column) => column.name));
if (!columnNames.has('secret') || !columnNames.has('secretHash')) return;

const endpoints = await prisma.$queryRawUnsafe(
'SELECT id, secret FROM "WebhookEndpoint" WHERE secret IS NOT NULL AND secretHash IS NULL',
);

for (const endpoint of endpoints) {
const secretHash = crypto.createHash('sha256').update(endpoint.secret).digest('hex');
await prisma.$executeRawUnsafe(
'UPDATE "WebhookEndpoint" SET secretHash = ? WHERE id = ?',
secretHash,
endpoint.id,
);
}
}

main()
.catch((error) => {
console.error('Webhook secret hash backfill failed:', error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
4 changes: 4 additions & 0 deletions backend/scripts/check-migrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ function checkFile(file) {
const allowsBootstrapIndexes = lowered.includes(
'migration-safety: allow-nonconcurrent-indexes'
);
const allowsWebhookSecretDrop =
lowered.includes('migration-safety: allow-webhook-secret-drop') &&
/alter\s+table\s+["`]?webhookendpoint["`]?\s+drop\s+column\s+["`]?secret["`]?/i.test(content);
const isCanarySafeOptIn = lowered.includes(
'migration-safety: canary-safe'
);
Expand All @@ -114,6 +117,7 @@ function checkFile(file) {
];

for (const rule of highRiskPatterns) {
if (allowsWebhookSecretDrop && rule.label === 'blocking alter/drop change') continue;
if (rule.pattern.test(content)) {
results.push({ file, severity: 'error', message: rule.label });
}
Expand Down
17 changes: 16 additions & 1 deletion backend/src/__tests__/feeCalculator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// src/__tests__/feeCalculator.test.ts
import { getDeterministicBaseFee } from '../feeCalculator';
import {
getDeterministicBaseFee,
moneyAmountToBaseUnits,
parseMoneyAmount,
} from '../feeCalculator';

describe('getDeterministicBaseFee', () => {
const originalEnv = process.env.FIXED_BASE_FEE;
Expand All @@ -26,4 +30,15 @@ describe('getDeterministicBaseFee', () => {
process.env.FIXED_BASE_FEE = 'abc';
expect(() => getDeterministicBaseFee()).toThrow('FIXED_BASE_FEE must be a positive integer string');
});

test('converts supported decimal precision to base units without rounding', () => {
expect(moneyAmountToBaseUnits('1.2345678')).toBe('12345678');
});

test('rejects amounts requiring rounding or exceeding i128 capacity', () => {
expect(() => parseMoneyAmount('1.23456789')).toThrow('7 decimal places');
expect(() => parseMoneyAmount('17014118346046923173168730371588.4105728')).toThrow(
'maximum supported value',
);
});
});
12 changes: 11 additions & 1 deletion backend/src/__tests__/sanitization.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import request from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { sanitizationMiddleware, isValidStellarAddress } from '../sanitization';
import { correlationIdMiddleware } from '../middleware/correlationId';

const app = express();

app.use(express.json({ limit: '1kb' }));
app.use(correlationIdMiddleware);
app.use(sanitizationMiddleware);

app.post('/test', (req: Request, res: Response) => {
Expand Down Expand Up @@ -51,9 +53,17 @@ describe('Sanitization Middleware', () => {

it('should return 400 for numbers out of safe range', async () => {
const payload = { num: Number.MAX_SAFE_INTEGER + 10 };
const response = await request(app).post('/test').send(payload);
const correlationId = '123e4567-e89b-12d3-a456-426614174000';
const response = await request(app).post('/test').set('X-Correlation-ID', correlationId).send(payload);
expect(response.status).toBe(400);
expect(response.body.message).toContain('Numeric parameter is out of acceptable range');
expect(response.body).toMatchObject({
error: 'Bad Request',
status: 400,
code: 'VALIDATION_ERROR',
correlationId,
});
expect(response.body.retryable).toBe(false);
});

it('should return 413 for oversized payloads', async () => {
Expand Down
19 changes: 19 additions & 0 deletions backend/src/__tests__/transferOrchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ describe('transferOrchestrator', () => {
expect(res.outcome).toBe('submitted');
});

test('preserves the smallest supported token amount exactly', async () => {
await orchestrateTransfer({ ...params, amount: '0.0000001' }, freshKey());

expect(sorobanClient.submitVaultOperation).toHaveBeenCalledWith(
'deposit',
VALID_TEST_WALLET,
'0.0000001',
'USDC',
);
});

test('a repeat with the same key replays without a second submission', async () => {
const key = freshKey();
const first = await orchestrateTransfer(params, key);
Expand Down Expand Up @@ -228,6 +239,14 @@ describe('transferOrchestrator', () => {
test.each([
['a negative amount', { amount: '-1' }, 'TRANSFER_AMOUNT_INVALID'],
['a zero amount', { amount: '0' }, 'TRANSFER_AMOUNT_INVALID'],
['an amount with excessive precision', { amount: '1.12345678' }, 'TRANSFER_AMOUNT_INVALID'],
[
'an amount above i128 base-unit capacity',
{ amount: '17014118346046923173168730371588.4105728' },
'TRANSFER_AMOUNT_INVALID',
],
['an amount with excessive precision', { amount: '1.12345678' }, 'TRANSFER_AMOUNT_INVALID'],
['an amount above i128 base-unit capacity', { amount: '17014118346046923173168730371.5884106' }, 'TRANSFER_AMOUNT_INVALID'],
['a NaN amount', { amount: 'NaN' }, 'TRANSFER_AMOUNT_INVALID'],
['an Infinity amount', { amount: 'Infinity' }, 'TRANSFER_AMOUNT_INVALID'],
['an exponent amount', { amount: '1e3' }, 'TRANSFER_AMOUNT_INVALID'],
Expand Down
35 changes: 35 additions & 0 deletions backend/src/__tests__/webhookVerification.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import crypto from 'crypto';
import {
registerWebhookEndpoint,
verifyWebhookEndpoint,
Expand All @@ -6,6 +7,7 @@ import {
buildWebhookSignedEnvelope,
createWebhookSignature,
markWebhookDeliverySeen,
verifyIncomingWebhookPayload,
} from '../webhookDelivery';

describe('Webhook endpoint verification handshake', () => {
Expand Down Expand Up @@ -93,4 +95,37 @@ describe('Webhook endpoint verification handshake', () => {
expect(markWebhookDeliverySeen(delivery.endpointId, delivery.id, envelope.sentAt)).toBe(true);
expect(markWebhookDeliverySeen(delivery.endpointId, delivery.id, envelope.sentAt)).toBe(false);
});

it('stores no plaintext secret and verifies signatures using the persisted hash', () => {
const secret = 'test-key-42';
const endpoint = registerWebhookEndpoint({
url: 'https://example.com/hook',
enabled: true,
secret,
});
const delivery = {
id: 'whd_hash_test',
endpointId: endpoint.id,
endpointUrl: endpoint.url,
eventType: 'transaction.deposit.created' as const,
status: 'pending' as const,
attempts: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const envelope = buildWebhookSignedEnvelope(delivery, {
transactionId: 'tx-hash-test',
amount: '100',
asset: 'USDC',
walletAddress: `G${'A'.repeat(55)}`,
transactionHash: '0xdef',
status: 'pending',
timestamp: new Date().toISOString(),
});
const secretHash = crypto.createHash('sha256').update(secret).digest('hex');
const signature = createWebhookSignature(secretHash, envelope);

expect(JSON.stringify(endpoint)).not.toContain(secret);
expect(verifyIncomingWebhookPayload(endpoint.id, envelope, signature).verified).toBe(true);
});
});
37 changes: 35 additions & 2 deletions backend/src/feeCalculator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,33 @@
// src/feeCalculator.ts
import Decimal from 'decimal.js';

const MoneyDecimal = Decimal.clone({ precision: 50 });
const TOKEN_SCALE = new MoneyDecimal(10).pow(7);
const MAX_I128 = new MoneyDecimal('170141183460469231731687303715884105727');
const AMOUNT_PATTERN = /^\d+(\.\d+)?$/;

export function parseMoneyAmount(value: unknown): Decimal {
if (typeof value !== 'string' || !AMOUNT_PATTERN.test(value.trim())) {
throw new Error('amount must be a plain decimal string');
}

const amount = new MoneyDecimal(value.trim());
if (!amount.isFinite() || amount.lte(0)) {
throw new Error('amount must be greater than zero');
}
if (amount.decimalPlaces() > 7) {
throw new Error('amount must not exceed 7 decimal places');
}
if (amount.times(TOKEN_SCALE).gt(MAX_I128)) {
throw new Error('amount exceeds the maximum supported value');
}
return amount;
}

export function moneyAmountToBaseUnits(value: string): string {
return parseMoneyAmount(value).times(TOKEN_SCALE).toFixed(0);
}

/**
* Deterministic fee calculator.
* Returns a base fee that is stable across runs.
Expand All @@ -8,8 +37,12 @@
export function getDeterministicBaseFee(): string {
const envFee = process.env.FIXED_BASE_FEE;
if (envFee) {
// Basic validation – ensure it's a positive integer string.
if (!/^[0-9]+$/.test(envFee)) {
// Base fees are integer stroops and must fit the contract's i128 amount.
if (!/^\d+$/.test(envFee)) {
throw new Error('FIXED_BASE_FEE must be a positive integer string');
}
const fee = new MoneyDecimal(envFee);
if (fee.lte(0) || fee.gt(MAX_I128)) {
throw new Error('FIXED_BASE_FEE must be a positive integer string');
}
return envFee;
Expand Down
9 changes: 5 additions & 4 deletions backend/src/sanitization.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import { StrKey } from '@stellar/stellar-base';
import { sendApiError } from './middleware/apiError';

const MAX_SAFE_NUMBER = Number.MAX_SAFE_INTEGER;
const MIN_SAFE_NUMBER = Number.MIN_SAFE_INTEGER;
Expand Down Expand Up @@ -32,11 +33,11 @@ export const sanitizationMiddleware = (req: Request, res: Response, next: NextFu
try {
sanitizeObject(req.body);
next();
} catch (error: any) {
res.status(400).json({
error: 'Bad Request',
} catch (error: unknown) {
sendApiError(req, res, {
status: 400,
message: error.message || 'Invalid input parameters detected',
code: 'VALIDATION_ERROR',
message: error instanceof Error ? error.message : 'Invalid input parameters detected',
});
}
};
Expand Down
6 changes: 3 additions & 3 deletions backend/src/sorobanClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
StrKey,
TransactionBuilder,
} from '@stellar/stellar-sdk';
import { getDeterministicBaseFee } from './feeCalculator';
import { getDeterministicBaseFee, moneyAmountToBaseUnits } from './feeCalculator';
import { logger } from './middleware/structuredLogging';
import { getCurrentTraceId } from './tracing';
import { sorobanRetryBudget } from './retryBudget';
Expand Down Expand Up @@ -201,10 +201,10 @@ export async function submitVaultOperation(
const sourceAccount = await rpcClient.getAccount(sourceKeypair.publicKey());
const contract = new Contract(contractId);

// Convert the amount string to BigInt so nativeToScVal produces a valid i128.
// Convert the decimal token amount to exact 7-decimal base units.
let amountBigInt: bigint;
try {
amountBigInt = BigInt(Math.round(Number(amount)));
amountBigInt = BigInt(moneyAmountToBaseUnits(amount));
} catch {
throw new SorobanSimulationError(
`Invalid amount value: ${amount}`,
Expand Down
Loading
Loading