Skip to content

Commit 3d9da59

Browse files
authored
Merge pull request #56 from Dnreikronos/security/onchain-tx-verification
security: verify transaction signatures on-chain before trusting them
2 parents c6b23ef + ec8bbd9 commit 3d9da59

22 files changed

Lines changed: 279 additions & 197 deletions

backend/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ model RelayerJob {
141141
}
142142

143143
enum PaymentExecutionStatus {
144+
PENDING
144145
SUCCESS
145146
FAILED
146147
}

backend/src/config.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import dotenv from "dotenv";
22
import { z } from "zod";
33

4-
// Load environment variables
54
dotenv.config();
65

7-
// Define the schema for environment variables
86
const envSchema = z.object({
97
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
108
JWT_SECRET: z
@@ -18,7 +16,6 @@ const envSchema = z.object({
1816
SOLANA_NETWORK: z.enum(["mainnet", "devnet"]).default("mainnet"),
1917
});
2018

21-
// Validate and parse environment variables
2219
const parseEnv = () => {
2320
try {
2421
return envSchema.parse(process.env);
@@ -36,7 +33,6 @@ const parseEnv = () => {
3633

3734
const rawConfig = parseEnv();
3835

39-
// Parse CORS origins from comma-separated FRONTEND_URL
4036
const corsOrigins = rawConfig.FRONTEND_URL.split(",")
4137
.map((url) => url.trim())
4238
.filter(Boolean);

backend/src/constants/tokens.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ export interface TokenConfig {
55
decimals: number;
66
}
77

8-
// Get network from environment (mainnet or devnet)
98
const NETWORK = process.env.SOLANA_NETWORK || "mainnet";
109

1110
const TOKEN_CONFIGS: Record<string, Record<string, TokenConfig>> = {

backend/src/controllers/payer.controller.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ export const createPayer = async (
2020
try {
2121
const validatedData = createPayerSchema.parse(request.body);
2222

23-
// Check if payer with this wallet address AND name combination already exists
2423
const existing = await prisma.payer.findUnique({
2524
where: {
2625
walletAddress_name: {

backend/src/controllers/payment-execution.controller.ts

Lines changed: 29 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,15 @@ import {
1010
type PaymentExecutionIdParam,
1111
paymentExecutionIdParamSchema,
1212
} from "../schemas/payment-execution.schema.js";
13+
import { verifyOneTimePayment } from "../services/solana.service.js";
1314

14-
/**
15-
* Create a payment execution record for a one-time payment
16-
*
17-
* This endpoint is called by the frontend AFTER the user has successfully
18-
* made a payment via their wallet. It records the transaction in the database.
19-
*/
2015
export const createPaymentExecution = async (
2116
request: FastifyRequest<{ Body: CreatePaymentExecutionBody }>,
2217
reply: FastifyReply
2318
) => {
2419
try {
2520
const validatedData = createPaymentExecutionSchema.parse(request.body);
2621

27-
// Verify the plan exists (no authentication needed - public endpoint)
2822
const plan = await prisma.plan.findUnique({
2923
where: { id: validatedData.planId },
3024
include: { planTokens: true, receiver: true },
@@ -38,7 +32,6 @@ export const createPaymentExecution = async (
3832
});
3933
}
4034

41-
// Verify this is NOT a recurring plan
4235
if (plan.isRecurring) {
4336
return reply.code(400).send({
4437
statusCode: 400,
@@ -48,7 +41,6 @@ export const createPaymentExecution = async (
4841
});
4942
}
5043

51-
// Verify the token is supported by the plan
5244
const planToken = plan.planTokens.find(
5345
(t) => t.tokenMint === validatedData.tokenMint
5446
);
@@ -61,7 +53,6 @@ export const createPaymentExecution = async (
6153
});
6254
}
6355

64-
// Check if this transaction signature already exists (prevent duplicates)
6556
const existingPayment = await prisma.paymentExecution.findFirst({
6657
where: { txSignature: validatedData.txSignature },
6758
});
@@ -76,26 +67,27 @@ export const createPaymentExecution = async (
7667
});
7768
}
7869

79-
// TODO: Verify the transaction on-chain
80-
// 1. Fetch transaction from Solana using txSignature
81-
// 2. Verify transaction is confirmed
82-
// 3. Verify amount matches
83-
// 4. Verify recipient is the plan's receiver
84-
// 5. Verify token mint matches
85-
// For now, we'll trust the frontend and mark as SUCCESS
70+
const verification = await verifyOneTimePayment({
71+
txSignature: validatedData.txSignature,
72+
expectedTokenMint: validatedData.tokenMint,
73+
expectedAmount: validatedData.amount,
74+
expectedReceiverWallet: plan.receiver.walletAddress,
75+
tokenDecimals: planToken.tokenDecimals,
76+
});
77+
78+
const status = verification.valid ? "SUCCESS" : "FAILED";
8679

87-
// Create the payment execution record
8880
const paymentExecution = await prisma.paymentExecution.create({
8981
data: {
9082
planId: validatedData.planId,
91-
subscriptionId: null, // One-time payment, no subscription
83+
subscriptionId: null,
9284
txSignature: validatedData.txSignature,
9385
executedBy: validatedData.executedBy ?? null,
94-
status: "SUCCESS", // TODO: Verify on-chain before marking as SUCCESS
86+
status,
9587
executedAt: new Date(),
9688
tokenMint: validatedData.tokenMint,
9789
amount: validatedData.amount,
98-
errorMessage: null,
90+
errorMessage: verification.valid ? null : verification.reason ?? null,
9991
},
10092
include: {
10193
plan: {
@@ -107,12 +99,25 @@ export const createPaymentExecution = async (
10799
},
108100
});
109101

102+
if (!verification.valid) {
103+
request.log.warn(
104+
`Payment verification failed for plan ${plan.id} (tx: ${validatedData.txSignature}): ${verification.reason}`
105+
);
106+
107+
return reply.code(400).send({
108+
statusCode: 400,
109+
error: "Bad Request",
110+
message: `Transaction verification failed: ${verification.reason}`,
111+
paymentExecutionId: paymentExecution.id,
112+
});
113+
}
114+
110115
request.log.info(
111-
`Payment execution created: ${paymentExecution.id} for plan ${plan.id} (tx: ${validatedData.txSignature})`
116+
`Payment verified and recorded: ${paymentExecution.id} for plan ${plan.id} (tx: ${validatedData.txSignature})`
112117
);
113118

114119
return reply.code(201).send({
115-
message: "Payment execution recorded successfully",
120+
message: "Payment verified and recorded successfully",
116121
paymentExecution,
117122
});
118123
} catch (error) {
@@ -134,9 +139,6 @@ export const createPaymentExecution = async (
134139
}
135140
};
136141

137-
/**
138-
* Get all payment executions for the authenticated receiver
139-
*/
140142
export const getPaymentExecutions = async (
141143
request: FastifyRequest<{ Querystring: GetPaymentExecutionsQuery }>,
142144
reply: FastifyReply
@@ -145,29 +147,24 @@ export const getPaymentExecutions = async (
145147
const validatedQuery = getPaymentExecutionsQuerySchema.parse(request.query);
146148
const userId = request.user.userId;
147149

148-
// Build query filters
149150
const where: Prisma.PaymentExecutionWhereInput = {
150151
plan: {
151-
receiverId: userId, // Only show payments for this receiver's plans
152+
receiverId: userId,
152153
},
153154
};
154155

155-
// Status filter
156156
if (validatedQuery.status !== "all") {
157157
where.status = validatedQuery.status;
158158
}
159159

160-
// Plan filter
161160
if (validatedQuery.planId) {
162161
where.planId = validatedQuery.planId;
163162
}
164163

165-
// Token filter
166164
if (validatedQuery.tokenMint) {
167165
where.tokenMint = validatedQuery.tokenMint;
168166
}
169167

170-
// Date range filter
171168
if (validatedQuery.dateFrom || validatedQuery.dateTo) {
172169
where.executedAt = {};
173170
if (validatedQuery.dateFrom) {
@@ -178,7 +175,6 @@ export const getPaymentExecutions = async (
178175
}
179176
}
180177

181-
// Search by transaction signature
182178
if (validatedQuery.search) {
183179
where.txSignature = {
184180
contains: validatedQuery.search,
@@ -235,9 +231,6 @@ export const getPaymentExecutions = async (
235231
}
236232
};
237233

238-
/**
239-
* Get a specific payment execution by ID
240-
*/
241234
export const getPaymentExecution = async (
242235
request: FastifyRequest<{ Params: PaymentExecutionIdParam }>,
243236
reply: FastifyReply
@@ -271,7 +264,6 @@ export const getPaymentExecution = async (
271264
});
272265
}
273266

274-
// Verify the payment belongs to this receiver
275267
if (paymentExecution.plan?.receiverId !== userId) {
276268
return reply.code(403).send({
277269
statusCode: 403,

0 commit comments

Comments
 (0)