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
82 changes: 82 additions & 0 deletions backend/migrations/20260530000000_transaction_signer_indexes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Add performance indexes for Transaction Signer and payment query optimization.
* Issue #914 — Optimize SQL queries in Transaction Signer
*
* Promotes the raw SQL from backend/sql/migrations/20260529_transaction_signer_performance_indexes.sql
* into a tracked knex migration so indexes are applied automatically on deployment.
*/

export async function up(knex) {
// Composite index for merchant payments queries
// Covers: getMerchantPayments, getRollingMetrics in paymentService.js
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS payments_merchant_deleted_created_idx
ON payments(merchant_id, deleted_at, created_at DESC)
`);

// Partial index for pending payment polling in Ledger Monitor
// Covers: pollPendingPayments in horizon-poller.js
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS payments_status_deleted_created_idx
ON payments(status, deleted_at, created_at ASC)
WHERE status = 'pending'
`);

// Composite index for payment lookups with soft delete
// Covers: getPaymentStatus, verifyPayment in paymentService.js
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS payments_id_deleted_idx
ON payments(id, deleted_at)
`);

// Partial index for confirmation updates — optimistic locking on unclaimed rows
// Covers: checkPayment atomic update in horizon-poller.js
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS payments_status_txid_idx
ON payments(status, tx_id)
WHERE status = 'pending' AND tx_id IS NULL
`);

// Composite index for merchant status queries
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS payments_merchant_status_created_idx
ON payments(merchant_id, status, created_at DESC)
`);

// Composite index for recipient-based payment matching
// Covers: findMatchingPayment, findAnyRecentPayment in stellar.js
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS payments_recipient_asset_created_idx
ON payments(recipient, asset, created_at DESC)
WHERE deleted_at IS NULL
`);

// Unique index on tx_id — database-level guarantee against duplicate confirmations
await knex.raw(`
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS payments_tx_id_unique_idx
ON payments(tx_id)
WHERE tx_id IS NOT NULL
`);

await knex.raw("ANALYZE payments");

console.log("✓ Added transaction signer and payment query optimization indexes");
}

export async function down(knex) {
const indexes = [
"payments_merchant_deleted_created_idx",
"payments_status_deleted_created_idx",
"payments_id_deleted_idx",
"payments_status_txid_idx",
"payments_merchant_status_created_idx",
"payments_recipient_asset_created_idx",
"payments_tx_id_unique_idx",
];

for (const idx of indexes) {
await knex.raw(`DROP INDEX CONCURRENTLY IF EXISTS ${idx}`);
}

console.log("✓ Removed transaction signer optimization indexes");
}
10 changes: 10 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import {
createSep10VerifyRateLimit,
createDashboardMetricsRateLimit,
} from "./lib/rate-limit.js";
import {
createTransactionSignerMiddlewares,
handleVerifySignature,
} from "./lib/transaction-signer.js";
import { versionDeprecationMiddleware } from "./lib/version-deprecation.js";

export async function createApp({ redisClient }) {
Expand Down Expand Up @@ -312,6 +316,12 @@ export async function createApp({ redisClient }) {
app.use("/api", webhooksRouter);
app.use("/api/payments", paymentDetailsRouter); // NEW — GET /api/payments/:id

// Transaction Signer — rate-limited signature verification endpoint (#912)
const transactionSignerMiddlewares = createTransactionSignerMiddlewares({
redisClient: redisAvailable ? redisClient : undefined,
});
app.post("/api/verify-signature", ...transactionSignerMiddlewares, handleVerifySignature);

// SEP-0001 stellar.toml endpoint (public, no auth required)
app.use("/", sep0001Router);

Expand Down
36 changes: 28 additions & 8 deletions backend/src/lib/horizon-poller.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
findAnyRecentPayment,
verifyTransactionSignature,
} from "./stellar.js";
import {
validatePaymentRecord,
sanitizePaymentMetadata,
auditPaymentAnomaly,
isValidTransactionHash,
} from "./ledger-monitor-security.js";
import { sendWebhook, isEventSubscribed } from "./webhooks.js";
import { sendReceiptEmail } from "./email.js";
import { renderReceiptEmail } from "./email-templates.js";
Expand Down Expand Up @@ -295,7 +301,7 @@
logger.warn({ err }, "Horizon poller: unexpected error in poll cycle");
} finally {
const cycleDuration = (Date.now() - cycleStartTime) / 1000;
ledgerMonitorCycleDuration.observe(cycleDuration);

Check failure on line 304 in backend/src/lib/horizon-poller.js

View workflow job for this annotation

GitHub Actions / Backend — Lint & Test

src/lib/horizon-poller.test.js > Ledger Monitor — error recovery (Issue #627) > successful payment confirmation > confirms a matching payment and emits events

Error: [vitest] No "ledgerMonitorCycleDuration" export is defined on the "./metrics.js" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("./metrics.js"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ pollPendingPayments src/lib/horizon-poller.js:304:5 ❯ src/lib/horizon-poller.test.js:255:7
_running = false;
}
}
Expand All @@ -304,16 +310,20 @@

async function checkPayment(payment, { merchantConfigCache = new Map() } = {}) {
try {
// Guard: skip if essential fields are missing
if (!payment.asset || !payment.recipient) {
// Guard: full security validation on the DB record before touching Horizon
const validation = validatePaymentRecord(payment);
if (!validation.valid) {
logger.warn(
{ paymentId: payment.id },
"Horizon poller: skipping payment with missing asset or recipient",
{ paymentId: payment?.id, reason: validation.reason },
"Horizon poller: payment record failed security validation — skipping",
);
ledgerMonitorPaymentsChecked.inc({ result: "skipped" });
return;
}

// Emit audit event for any anomalous field values
auditPaymentAnomaly(payment);

let match;
try {
match = await withLedgerMonitorRateLimit(
Expand Down Expand Up @@ -384,13 +394,13 @@
() => supabase.from("payments").update({
status: "failed",
tx_id: anyPayment.transaction_hash,
metadata: {
metadata: sanitizePaymentMetadata({
...(payment.metadata || {}),
failure_reason: "underpayment",
expected_amount: expected,
received_amount: received,
shortfall: Number((expected - received).toFixed(7)),
},
}),
}).eq("id", payment.id).eq("status", "pending"),
{ paymentId: payment.id, operation: "markUnderpaymentFailed" },
);
Expand Down Expand Up @@ -424,13 +434,13 @@
status: "confirmed",
tx_id: anyPayment.transaction_hash,
completion_duration_seconds: Math.floor(latencySeconds),
metadata: {
metadata: sanitizePaymentMetadata({
...(payment.metadata || {}),
overpayment: true,
expected_amount: expected,
received_amount: received,
excess: Number((received - expected).toFixed(7)),
},
}),
}).eq("id", payment.id).eq("status", "pending").is("tx_id", null).select("id").maybeSingle(),
{ paymentId: payment.id, operation: "confirmOverpayment" },
);
Expand Down Expand Up @@ -460,6 +470,16 @@
return;
}

// Guard: validate the transaction hash returned from Horizon before using it
if (!isValidTransactionHash(match.transaction_hash)) {
logger.warn(
{ paymentId: payment.id, txHash: match.transaction_hash },
"Horizon poller: Horizon returned an invalid transaction hash — skipping",
);
ledgerMonitorPaymentsChecked.inc({ result: "skipped" });
return;
}

// Guard: ensure this tx_hash hasn't already confirmed a different payment
const { data: existing } = await supabase
.from("payments")
Expand Down
Loading
Loading