Skip to content

Commit a53e448

Browse files
committed
feat: added code updates UI and functionality improvement
1 parent d28244d commit a53e448

9 files changed

Lines changed: 457 additions & 83 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,6 @@ scripts/*.py
3333
task.md
3434
issueTmeplate.md
3535
vercelerror.md
36-
betterui.md
36+
betterui.md
37+
hackathon.md
38+
resources.md
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Add a partial unique index on payments.tx_id (excluding NULLs).
3+
* This prevents two payments from ever being confirmed with the same
4+
* on-chain transaction hash at the database level.
5+
*/
6+
export async function up(knex) {
7+
await knex.raw(`
8+
CREATE UNIQUE INDEX IF NOT EXISTS payments_tx_id_unique
9+
ON payments (tx_id)
10+
WHERE tx_id IS NOT NULL
11+
`);
12+
}
13+
14+
export async function down(knex) {
15+
await knex.raw(`DROP INDEX IF EXISTS payments_tx_id_unique`);
16+
}

backend/src/lib/horizon-poller.js

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import { supabase } from "./supabase.js";
13-
import { findMatchingPayment } from "./stellar.js";
13+
import { findMatchingPayment, findAnyRecentPayment } from "./stellar.js";
1414
import { sendWebhook, isEventSubscribed } from "./webhooks.js";
1515
import { sendReceiptEmail } from "./email.js";
1616
import { renderReceiptEmail } from "./email-templates.js";
@@ -68,7 +68,24 @@ async function pollPendingPayments() {
6868

6969
logger.info({ count: pending.length }, "Horizon poller: checking pending payments");
7070

71-
await Promise.allSettled(pending.map(p => checkPayment(p)));
71+
// Group by recipient+asset to process same-address payments sequentially
72+
// This prevents two payments with identical recipient+amount from both
73+
// claiming the same on-chain transaction in the same cycle.
74+
const groups = new Map();
75+
for (const p of pending) {
76+
const key = `${p.recipient}:${p.asset}`;
77+
if (!groups.has(key)) groups.set(key, []);
78+
groups.get(key).push(p);
79+
}
80+
81+
// Process each group sequentially, different groups in parallel
82+
await Promise.allSettled(
83+
Array.from(groups.values()).map(async (group) => {
84+
for (const p of group) {
85+
await checkPayment(p);
86+
}
87+
})
88+
);
7289

7390
} catch (err) {
7491
logger.warn({ err }, "Horizon poller: unexpected error");
@@ -97,7 +114,65 @@ async function checkPayment(payment) {
97114

98115
if (!match) {
99116
logger.info({ paymentId: payment.id }, "Horizon poller: no match yet");
100-
return; // not confirmed yet
117+
118+
// Check for wrong-amount payment
119+
const anyPayment = await findAnyRecentPayment({
120+
recipient: payment.recipient,
121+
assetCode: payment.asset,
122+
assetIssuer: payment.asset_issuer,
123+
createdAt: payment.created_at,
124+
});
125+
126+
if (anyPayment) {
127+
const received = Number(anyPayment.received_amount);
128+
const expected = Number(payment.amount);
129+
const diff = received - expected;
130+
131+
if (diff < -0.0000001) {
132+
// Underpayment — mark failed
133+
await supabase.from("payments").update({
134+
status: "failed",
135+
tx_id: anyPayment.transaction_hash,
136+
metadata: {
137+
...(payment.metadata || {}),
138+
failure_reason: "underpayment",
139+
expected_amount: expected,
140+
received_amount: received,
141+
shortfall: Number((expected - received).toFixed(7)),
142+
},
143+
}).eq("id", payment.id).eq("status", "pending");
144+
145+
const redis = await connectRedisClient();
146+
await invalidatePaymentCache(redis, payment.id);
147+
logger.info({ paymentId: payment.id, expected, received }, "Horizon poller: underpayment detected — marked failed");
148+
149+
// Notify via SSE and Socket.io
150+
streamManager.notify(payment.id, "payment.failed", { status: "failed", reason: "underpayment", expected_amount: expected, received_amount: received });
151+
if (_io && payment.merchant_id) {
152+
_io.to(`merchant:${payment.merchant_id}`).emit("payment:failed", { id: payment.id, reason: "underpayment", expected_amount: expected, received_amount: received });
153+
}
154+
} else if (diff > 0.0000001) {
155+
// Overpayment — confirm but flag
156+
const latencySeconds = (Date.now() - new Date(payment.created_at).getTime()) / 1000;
157+
const { data: updated } = await supabase.from("payments").update({
158+
status: "confirmed",
159+
tx_id: anyPayment.transaction_hash,
160+
completion_duration_seconds: Math.floor(latencySeconds),
161+
metadata: { ...(payment.metadata || {}), overpayment: true, expected_amount: expected, received_amount: received, excess: Number((received - expected).toFixed(7)) },
162+
}).eq("id", payment.id).eq("status", "pending").is("tx_id", null).select("id").maybeSingle();
163+
164+
if (!updated) return; // already claimed
165+
166+
const redis = await connectRedisClient();
167+
await invalidatePaymentCache(redis, payment.id);
168+
logger.info({ paymentId: payment.id, expected, received }, "Horizon poller: overpayment — confirmed with flag");
169+
streamManager.notify(payment.id, "payment.confirmed", { status: "confirmed", tx_id: anyPayment.transaction_hash, overpayment: true });
170+
if (_io && payment.merchant_id) {
171+
_io.to(`merchant:${payment.merchant_id}`).emit("payment:confirmed", { id: payment.id, tx_id: anyPayment.transaction_hash, overpayment: true });
172+
}
173+
}
174+
}
175+
return;
101176
}
102177

103178
// Guard: ensure this tx_hash hasn't already confirmed a different payment
@@ -116,22 +191,37 @@ async function checkPayment(payment) {
116191
const createdAt = new Date(payment.created_at);
117192
const latencySeconds = (Date.now() - createdAt.getTime()) / 1000;
118193

119-
// Update DB
120-
const { error: updateError } = await supabase
194+
// Atomic update: only succeeds if tx_id is still NULL (not claimed by another payment).
195+
// The unique index on tx_id provides the final database-level guarantee.
196+
const { data: updated, error: updateError } = await supabase
121197
.from("payments")
122198
.update({
123199
status: "confirmed",
124200
tx_id: match.transaction_hash,
125201
completion_duration_seconds: Math.floor(latencySeconds),
126202
})
127203
.eq("id", payment.id)
128-
.eq("status", "pending"); // guard against double-confirm
204+
.eq("status", "pending")
205+
.is("tx_id", null) // ← only claim if not already taken
206+
.select("id")
207+
.maybeSingle();
129208

130209
if (updateError) {
210+
// Unique constraint violation — another payment already claimed this tx
211+
if (updateError.code === "23505") {
212+
logger.warn({ paymentId: payment.id, txHash: match.transaction_hash }, "Horizon poller: tx_hash already claimed by another payment (unique constraint)");
213+
return;
214+
}
131215
logger.warn({ err: updateError, paymentId: payment.id }, "Horizon poller: DB update failed");
132216
return;
133217
}
134218

219+
// If updated is null, the row was already confirmed or claimed — skip
220+
if (!updated) {
221+
logger.info({ paymentId: payment.id }, "Horizon poller: payment already processed, skipping");
222+
return;
223+
}
224+
135225
// Invalidate Redis cache
136226
const redis = await connectRedisClient();
137227
await invalidatePaymentCache(redis, payment.id);

backend/src/lib/logger.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export const logger = pino({
1919
options: { colorize: true, translateTime: "SYS:standard" },
2020
},
2121
}),
22+
23+
2224
redact: {
2325
paths: REDACTED_PATHS,
2426
censor: "[REDACTED]",

backend/src/lib/stellar.js

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,26 @@ function amountsMatch(expected, received) {
158158
return false;
159159
}
160160

161+
// Exact match within 1 stroop (0.0000001 XLM)
161162
return Math.abs(expectedNum - receivedNum) <= 0.0000001;
162163
}
163164

165+
/**
166+
* Classify how a received amount compares to the expected amount.
167+
* Returns: "exact" | "underpaid" | "overpaid"
168+
*/
169+
export function classifyAmount(expected, received) {
170+
const expectedNum = Number(expected);
171+
const receivedNum = Number(received);
172+
173+
if (Number.isNaN(expectedNum) || Number.isNaN(receivedNum)) return "exact";
174+
175+
const diff = receivedNum - expectedNum;
176+
if (Math.abs(diff) <= 0.0000001) return "exact";
177+
if (diff < 0) return "underpaid";
178+
return "overpaid";
179+
}
180+
164181
function paymentMatchesAsset(payment, asset) {
165182
if (asset.isNative()) {
166183
return payment.asset_type === "native";
@@ -318,7 +335,7 @@ export async function findMatchingPayment({
318335
createdAt, // ISO string — only match transactions after this time
319336
}) {
320337
const asset = resolveAsset(assetCode, assetIssuer);
321-
const createdAtMs = createdAt ? new Date(createdAt).getTime() : 0;
338+
const createdAtMs = createdAt ? new Date(createdAt).getTime() - 60_000 : 0;
322339

323340
let page;
324341
try {
@@ -383,14 +400,58 @@ export async function findMatchingPayment({
383400
id: payment.id,
384401
transaction_hash: payment.transaction_hash,
385402
is_multisig: isMultiSig,
403+
received_amount: payment.amount,
386404
};
387405
}
388406

389407
return null;
390408
}
391409

392410
/**
393-
* Create a refund transaction XDR for a merchant to sign
411+
* Find any recent payment to the recipient regardless of amount.
412+
* Used to detect underpayments/overpayments.
413+
* Returns { transaction_hash, received_amount } or null.
414+
*
415+
* Note: we intentionally use a loose time window (no strict createdAt filter)
416+
* because Horizon ledger close times can have slight clock skew vs our DB.
417+
* The tx_id uniqueness constraint prevents false matches from older transactions.
418+
*/
419+
export async function findAnyRecentPayment({
420+
recipient,
421+
assetCode,
422+
assetIssuer,
423+
createdAt,
424+
}) {
425+
const asset = resolveAsset(assetCode, assetIssuer);
426+
// Allow 60s of clock skew — reject anything more than 60s before intent creation
427+
const cutoffMs = createdAt ? new Date(createdAt).getTime() - 60_000 : 0;
428+
429+
let page;
430+
try {
431+
page = await server.payments().forAccount(recipient).order("desc").limit(100).call();
432+
} catch {
433+
return null;
434+
}
435+
436+
for (const payment of page.records) {
437+
if (payment.type !== "payment" && payment.type !== "path_payment_strict_receive") continue;
438+
if (payment.to !== recipient) continue;
439+
if (!paymentMatchesAsset(payment, asset)) continue;
440+
441+
// Skip payments that are clearly older than the intent (with 60s slack)
442+
if (cutoffMs > 0 && payment.created_at) {
443+
if (new Date(payment.created_at).getTime() < cutoffMs) continue;
444+
}
445+
446+
return {
447+
transaction_hash: payment.transaction_hash,
448+
received_amount: payment.amount,
449+
};
450+
}
451+
return null;
452+
}
453+
454+
/*
394455
* Issue #150: Implement a Refund API Transaction Helper
395456
*/
396457
export async function createRefundTransaction({

0 commit comments

Comments
 (0)