From c5b345666239a19bf23f63704fe1ccd61f664288 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Thu, 18 Jun 2026 20:48:37 +0530 Subject: [PATCH 1/4] fix: prevent relayer wallet from depositing own ETH into escrow (#595) --- backend/api/src/routes/orderRoutes.js | 107 ++++++++++++++------------ backend/api/src/services/escrow.js | 87 ++++++++++++++++----- blockchain/contracts/Escrow.sol | 1 + blockchain/test/escrow.test.cjs | 17 ++++ 4 files changed, 144 insertions(+), 68 deletions(-) diff --git a/backend/api/src/routes/orderRoutes.js b/backend/api/src/routes/orderRoutes.js index 869a4d57..970d68ca 100644 --- a/backend/api/src/routes/orderRoutes.js +++ b/backend/api/src/routes/orderRoutes.js @@ -19,7 +19,7 @@ import { } from '../validation/requestSchemas.js'; import { changeDropSchema, cancelOrderSchema } from '../validation/requestSchemas.js'; import { awardReputationPoints } from '../services/reputation.js'; -import { escrowDeposit, escrowRelease, escrowRefund } from '../services/escrow.js'; +import { buildDepositTx, recordDepositTx, escrowRelease, escrowRefund } from '../services/escrow.js'; import { sendDeliveryOtpNotification } from '../services/notificationService.js'; import { predictDemand, predictPrice } from '../services/ml.js'; import rateLimit from 'express-rate-limit'; @@ -666,26 +666,19 @@ router.post('/:id/bids/:bidId/accept', authenticate, requireRole(['customer']), truckInfo = data; } - // Phase 1: Escrow deposit BEFORE accepting the bid - let escrowTxHash = null; + // Phase 1: Build unsigned deposit tx for customer to sign + let depositTxData = null; if (driverWallet && customerWallet) { const amountWei = ethers.parseEther((bid.bid_amount / 100).toFixed(2).toString()); - try { - const { txHash } = await escrowDeposit(order.order_display_id, customerWallet, driverWallet, amountWei); - if (txHash) { - escrowTxHash = txHash; - } else { - return res.status(500).json({ - error: 'Escrow deposit failed. Bid was not accepted.', - recovery: 'Please try again or contact support if the issue persists.' - }); - } - } catch (depositErr) { - return res.status(500).json({ - error: 'Escrow deposit failed. Bid was not accepted.', - details: depositErr.message, - recovery: 'Check that the customer wallet has sufficient MATIC balance for the deposit and that the Polygon RPC endpoint is reachable.' - }); + const { txData } = await buildDepositTx( + order.order_display_id, customerWallet, driverWallet, amountWei, + ); + if (txData) { + depositTxData = txData; + await supabase.from('orders').update({ + escrow_booking_id: `escrow:${order.order_display_id}`, + escrow_status: 'funding', + }).eq('id', orderId); } } @@ -698,42 +691,17 @@ router.post('/:id/bids/:bidId/accept', authenticate, requireRole(['customer']), }); if (rpcErr) { - // Compensating transaction: escrow deposit succeeded but DB update failed - if (escrowTxHash) { - try { - await escrowRefund(order.order_display_id); - logger.warn(`[escrow] Compensating refund issued for order ${order.order_display_id} after RPC failure.`); - } catch (refundErr) { - logger.error(`[escrow] CRITICAL: Escrow refund also failed for order ${order.order_display_id}:`, refundErr.message); - } - } return res.status(500).json({ error: 'Failed to accept bid atomically.', details: rpcErr.message, - recovery: 'The escrow deposit has been refunded. Please try again.' + recovery: 'The pending escrow deposit has been voided. Please try again.' }); } - // Record escrow booking reference and deposit info - const escrowUpdate = { - escrow_booking_id: `escrow:${order.order_display_id}`, - escrow_status: escrowTxHash ? 'funded' : 'pending', - }; - if (escrowTxHash) { - escrowUpdate.deposit_tx_hash = escrowTxHash; - escrowUpdate.escrow_deposited_at = new Date().toISOString(); - } - - const { error: escrowUpdateErr } = await supabase - .from('orders') - .update(escrowUpdate) - .eq('id', orderId); - - if (escrowUpdateErr) { - logger.warn('[escrow] Failed to update escrow booking reference:', escrowUpdateErr.message); - } - - res.json({ message: 'Bid accepted. Driver and truck assigned.' }); + res.json({ + message: 'Bid accepted. Awaiting customer deposit signature.', + depositTx: depositTxData, + }); } catch (err) { res.status(500).json({ error: 'Internal Server Error' }); } @@ -1037,7 +1005,46 @@ router.post('/:id/cancel', authenticate, requireRole(['customer']), validatePara }); // ============================================================================ -// 16. PREDICT RIDE DEMAND (CUSTOMER OR DRIVER) +// 16. CONFIRM ESCROW DEPOSIT (CUSTOMER) +// ============================================================================ +router.post('/:id/confirm-deposit', authenticate, requireRole(['customer']), validateParams(paramIdSchema), validateBody( + z.object({ txHash: z.string().regex(/^0x([A-Fa-f0-9]{64})$/, 'Invalid transaction hash') }), +), async (req, res) => { + const orderId = req.params.id; + const { txHash } = req.body; + + try { + const { data: order, error: fetchErr } = await supabase + .from('orders') + .select('id, order_display_id, escrow_booking_id, escrow_status') + .eq('id', orderId) + .maybeSingle(); + + if (fetchErr || !order) return res.status(404).json({ error: 'Order not found' }); + if (order.escrow_status !== 'funding') { + return res.status(400).json({ error: 'Order is not in funding state' }); + } + + const bookingId = `escrow:${order.order_display_id}`; + const result = await recordDepositTx(bookingId, txHash); + + if (result.error) return res.status(422).json({ error: result.error }); + + await supabase.from('orders').update({ + escrow_status: 'funded', + deposit_tx_hash: result.txHash, + escrow_deposited_at: new Date().toISOString(), + }).eq('id', orderId); + + res.json({ message: 'Escrow deposit confirmed', txHash: result.txHash }); + } catch (err) { + console.error('[confirm-deposit] Exception:', err.message); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +// ============================================================================ +// 17. PREDICT RIDE DEMAND (CUSTOMER OR DRIVER) // ============================================================================ router.post('/predict-demand', authenticate, requireRole(['customer', 'driver']), predictDemandLimiter, validateBody(predictDemandSchema), async (req, res) => { try { diff --git a/backend/api/src/services/escrow.js b/backend/api/src/services/escrow.js index 3b326f9b..6818357e 100644 --- a/backend/api/src/services/escrow.js +++ b/backend/api/src/services/escrow.js @@ -7,8 +7,14 @@ * * The contract uses a relayer-authorization pattern. The backend's * relayer wallet (RELAYER_WALLET_PRIVATE_KEY) calls releaseFunds - * and refundFunds. deposit() is called by the customer wallet or - * a designated relayer that holds the funds. + * and refundFunds. deposit() is sent by the **customer's wallet** + * directly — the contract requires msg.sender == customer to + * prevent the relayer from bearing the escrow cost. + * + * The escrowDeposit() function below builds the deposit transaction + * and returns it as an unsigned popultated transaction so the + * customer's wallet can sign and submit it. After the customer + * confirms the on-chain deposit, the backend records the txHash. * * Required env vars (see .env.example): * POLYGON_RPC_URL — JSON-RPC endpoint @@ -59,40 +65,85 @@ export function getEscrowBookingId(orderDisplayId) { } /** - * Deposit funds into escrow for a booking. + * Build an unsigned deposit transaction for the customer's wallet to sign. * Called when a bid is accepted and the order moves to in_progress. * - * Callers should await the returned promise. If the blockchain transaction - * fails, the function throws so the caller can avoid updating off-chain state. + * The customer wallet must have MATIC on Polygon to cover the deposit amount + * plus gas. After the customer signs and submits the transaction, the + * caller should pass the returned txHash to recordDepositTx() so the + * backend can confirm the on-chain deposit. * * @param {string} orderDisplayId * @param {string} customerWalletAddress — 0x-prefixed Polygon address of the customer - * @param {string} driverWalletAddress — 0x-prefixed Polygon address of the driver - * @param {string} amountWei — amount in wei (string or bigint) - * @returns {Promise<{txHash: string|null, bookingId: string}>} + * @param {string} driverWalletAddress — 0x-prefixed Polygon address of the driver + * @param {string} amountWei — amount in wei (string or bigint) + * @returns {Promise<{txData: object|null, bookingId: string}>} */ -export async function escrowDeposit(orderDisplayId, customerWalletAddress, driverWalletAddress, amountWei) { +export async function buildDepositTx(orderDisplayId, customerWalletAddress, driverWalletAddress, amountWei) { const bookingId = getEscrowBookingId(orderDisplayId); if (!escrowContract) { - logger.warn('[escrow] Contract not initialised — skipping deposit.'); - return { txHash: null, bookingId }; + logger.warn('[escrow] Contract not initialised — cannot build deposit tx.'); + return { txData: null, bookingId }; } if (!ethers.isAddress(customerWalletAddress)) { logger.warn(`[escrow] Invalid customer wallet address "${customerWalletAddress}" — skipping deposit.`); - return { txHash: null, bookingId }; + return { txData: null, bookingId }; } if (!ethers.isAddress(driverWalletAddress)) { logger.warn(`[escrow] Invalid driver wallet address "${driverWalletAddress}" — skipping deposit.`); - return { txHash: null, bookingId }; + return { txData: null, bookingId }; } - const tx = await escrowContract.deposit(bookingId, customerWalletAddress, driverWalletAddress, { + const contractInterface = escrowContract.interface; + const data = contractInterface.encodeFunctionData('deposit', [ + bookingId, + customerWalletAddress, + driverWalletAddress, + ]); + + const feeData = await escrowContract.runner.provider.getFeeData(); + const block = await escrowContract.runner.provider.getBlock('latest'); + + const txData = { + to: contractAddress, + data, value: amountWei, - }); - logger.info(`[escrow] deposit tx submitted: ${tx.hash} for booking ${orderDisplayId}`); - const receipt = await tx.wait(1); - logger.info(`[escrow] deposit confirmed for booking ${orderDisplayId} in block ${receipt.blockNumber}`); + gasLimit: 300000n, + maxFeePerGas: feeData.maxFeePerGas ?? undefined, + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? undefined, + nonce: undefined, + chainId: block.chainId ?? undefined, + type: 2, + }; + + logger.info(`[escrow] Deposit tx built for booking ${orderDisplayId}`); + return { txData, bookingId }; +} + +/** + * Record a confirmed deposit transaction hash for a booking. + * Called by the client-facing endpoint after the customer's wallet + * has signed and submitted the transaction built by buildDepositTx(). + * + * @param {string} bookingId + * @param {string} txHash + * @returns {Promise<{txHash: string, bookingId: string} | {error: string}>} + */ +export async function recordDepositTx(bookingId, txHash) { + if (!escrowContract) { + return { error: 'Contract not initialised' }; + } + if (!ethers.isHexString(txHash, 32)) { + return { error: 'Invalid transaction hash' }; + } + + const receipt = await escrowContract.runner.provider.waitForTransaction(txHash, 1); + if (!receipt || receipt.status === 0) { + return { error: 'Transaction reverted or not found on chain' }; + } + + logger.info(`[escrow] deposit confirmed for booking ${bookingId} in block ${receipt.blockNumber}`); return { txHash: receipt.hash, bookingId }; } diff --git a/blockchain/contracts/Escrow.sol b/blockchain/contracts/Escrow.sol index f5c78f46..cdd2098c 100644 --- a/blockchain/contracts/Escrow.sol +++ b/blockchain/contracts/Escrow.sol @@ -62,6 +62,7 @@ contract Escrow { require(customer != address(0), "Invalid customer"); require(driver != address(0), "Invalid driver"); require(msg.value > 0, "Deposit required"); + require(msg.sender == customer, "Only customer can deposit"); require(escrows[bookingId].status == EscrowStatus.None, "Escrow exists"); escrows[bookingId] = BookingEscrow({ diff --git a/blockchain/test/escrow.test.cjs b/blockchain/test/escrow.test.cjs index 1a7449bc..4d2b456b 100644 --- a/blockchain/test/escrow.test.cjs +++ b/blockchain/test/escrow.test.cjs @@ -86,6 +86,23 @@ describe("Escrow", function () { await assertRejectsWith(escrow.connect(outsider).refundFunds(id), "Not authorized relayer"); }); + it("rejects deposit from non-customer wallets (relayer / outsider)", async function () { + const { escrow, relayer, customer, driver, outsider } = await deployEscrow(); + const id = bookingId("only-customer-deposit"); + const amount = ethers.parseEther("0.1"); + + await assertRejectsWith( + escrow.connect(relayer).deposit(id, customer.address, driver.address, { value: amount }), + "Only customer can deposit" + ); + await assertRejectsWith( + escrow.connect(outsider).deposit(id, customer.address, driver.address, { value: amount }), + "Only customer can deposit" + ); + const tx = await escrow.connect(customer).deposit(id, customer.address, driver.address, { value: amount }); + await tx.wait(); // should succeed + }); + it("blocks invalid state transitions and duplicate deposits", async function () { const { escrow, relayer, customer, driver } = await deployEscrow(); const id = bookingId("invalid-state"); From 6d24452825f8c76b2b75234983e58fe9eb2ae160 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Thu, 18 Jun 2026 21:43:35 +0530 Subject: [PATCH 2/4] fix: add missing zod import and wallet validation gate for bid tests - Add import { z } from 'zod' to orderRoutes.js for confirm-deposit validation - Add wallet addresses + escrow mock to two bid-acceptance RPC error tests (upstream wallet validation gate returns 422 when wallets missing) --- backend/api/src/routes/orderRoutes.js | 1 + backend/api/test/integration/bids.test.js | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/api/src/routes/orderRoutes.js b/backend/api/src/routes/orderRoutes.js index 970d68ca..ea81aaa6 100644 --- a/backend/api/src/routes/orderRoutes.js +++ b/backend/api/src/routes/orderRoutes.js @@ -23,6 +23,7 @@ import { buildDepositTx, recordDepositTx, escrowRelease, escrowRefund } from '.. import { sendDeliveryOtpNotification } from '../services/notificationService.js'; import { predictDemand, predictPrice } from '../services/ml.js'; import rateLimit from 'express-rate-limit'; +import { z } from 'zod'; import logger from '../middleware/logger.js'; const router = express.Router(); diff --git a/backend/api/test/integration/bids.test.js b/backend/api/test/integration/bids.test.js index 4fbc8687..f909bb2a 100644 --- a/backend/api/test/integration/bids.test.js +++ b/backend/api/test/integration/bids.test.js @@ -623,8 +623,12 @@ describe('Bid Routes', () => { status: 'pending', }); - m.store.profiles.push({ id: 'driver-1', full_name: 'Driver One' }); - m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null }); + m.store.profiles.push( + { id: 'customer-1', full_name: 'Customer One', polygon_wallet_address: '0xCustomerWallet' }, + { id: 'driver-1', full_name: 'Driver One' }, + ); + m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null, polygon_wallet_address: '0xDriverWallet' }); + mockEscrowDeposit.mockResolvedValue({ txHash: '0xescrowtest123' }); m.programRpcError('Load offer is no longer available'); @@ -660,8 +664,12 @@ describe('Bid Routes', () => { status: 'pending', }); - m.store.profiles.push({ id: 'driver-1', full_name: 'Driver One' }); - m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null }); + m.store.profiles.push( + { id: 'customer-1', full_name: 'Customer One', polygon_wallet_address: '0xCustomerWallet' }, + { id: 'driver-1', full_name: 'Driver One' }, + ); + m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null, polygon_wallet_address: '0xDriverWallet' }); + mockEscrowDeposit.mockResolvedValue({ txHash: '0xescrowtest123' }); m.programRpcError('Order is no longer pending'); From bf0c606f7816d824323d4e549e7e4b4dd286afe1 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Thu, 18 Jun 2026 21:56:26 +0530 Subject: [PATCH 3/4] fix: resolve CI failures for escrow-deposit-flow branch - Add missing zod import (z) to orderRoutes.js for confirm-deposit validation - Rewrite bid test mock to use buildDepositTx/recordDepositTx instead of deprecated escrowDeposit (route was rewritten for issue #595) - Rewrite escrow.test.js to test buildDepositTx/recordDepositTx instead of removed escrowDeposit function - Fix Flutter analysis: replace undefined _client/_httpClient in ProfileService.logout() with SupabaseService.client and _apiClient - Fix Flutter analysis: remove duplicate http import in profile_screen.dart - Fix Flutter analysis: remove unnecessary null check in marketplace_repository --- .../lib/services/profile_service.dart | 18 +++---- apps/driver/lib/screens/profile_screen.dart | 1 - .../lib/services/marketplace_repository.dart | 2 +- backend/api/test/integration/bids.test.js | 47 ++++++++++--------- backend/api/test/integration/escrow.test.js | 42 +++++++++++------ 5 files changed, 59 insertions(+), 51 deletions(-) diff --git a/apps/customer/lib/services/profile_service.dart b/apps/customer/lib/services/profile_service.dart index ea40e804..9945cbf4 100644 --- a/apps/customer/lib/services/profile_service.dart +++ b/apps/customer/lib/services/profile_service.dart @@ -35,29 +35,25 @@ class ProfileService { } Future logout() async { - final token = _client.auth.currentSession?.accessToken; - final userId = _client.auth.currentUser?.id; + final userId = SupabaseService.client.auth.currentUser?.id; - if (token == null || token.isEmpty || userId == null) { - await _client.auth.signOut(); + if (userId == null) { + await SupabaseService.client.auth.signOut(); return; } try { - await _httpClient.post( - Uri.parse('$_apiBaseUrl/api/auth/logout'), + await _apiClient.post( + '/api/auth/logout', headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', 'x-user-id': userId, 'x-user-role': 'customer', }, - ).timeout(const Duration(seconds: 5)); + ); } catch (e) { - // Log error but proceed to sign out locally print('Backend logout failed: $e'); } finally { - await _client.auth.signOut(); + await SupabaseService.client.auth.signOut(); } } } diff --git a/apps/driver/lib/screens/profile_screen.dart b/apps/driver/lib/screens/profile_screen.dart index 31bf7d8d..f11583d1 100644 --- a/apps/driver/lib/screens/profile_screen.dart +++ b/apps/driver/lib/screens/profile_screen.dart @@ -13,7 +13,6 @@ import '../services/fcm_service.dart'; import '../../core/supabase_config.dart'; import 'package:truxify_shared/truxify_shared.dart' hide NotificationsScreen; import 'notifications_screen.dart'; -import 'package:http/http.dart' as http; class ProfileScreen extends StatefulWidget { const ProfileScreen({ diff --git a/apps/driver/lib/services/marketplace_repository.dart b/apps/driver/lib/services/marketplace_repository.dart index b1199586..f225d7ac 100644 --- a/apps/driver/lib/services/marketplace_repository.dart +++ b/apps/driver/lib/services/marketplace_repository.dart @@ -185,7 +185,7 @@ class MarketplaceRepository { callback: (payload) { try { final newRecord = payload.newRecord; - if (newRecord != null && newRecord.isNotEmpty) { + if (newRecord.isNotEmpty) { final offer = _mapLoadOffer(newRecord); controller.add(offer); } diff --git a/backend/api/test/integration/bids.test.js b/backend/api/test/integration/bids.test.js index f909bb2a..59201634 100644 --- a/backend/api/test/integration/bids.test.js +++ b/backend/api/test/integration/bids.test.js @@ -13,12 +13,14 @@ vi.mock('../../src/config/db.js', () => ({ })); vi.mock('../../src/services/escrow.js', () => ({ - escrowDeposit: vi.fn(), + buildDepositTx: vi.fn(), + recordDepositTx: vi.fn(), + escrowRelease: vi.fn(), escrowRefund: vi.fn(), })); const { default: orderRouter } = await import('../../src/routes/orderRoutes.js'); -const { escrowDeposit: mockEscrowDeposit, escrowRefund: mockEscrowRefund } = await import('../../src/services/escrow.js'); +const { buildDepositTx: mockBuildDepositTx, recordDepositTx: mockRecordDepositTx, escrowRefund: mockEscrowRefund } = await import('../../src/services/escrow.js'); function buildApp() { const app = express(); @@ -46,7 +48,8 @@ describe('Bid Routes', () => { m.store.driver_details = []; m.store.trucks = []; m.calls.length = 0; - mockEscrowDeposit.mockReset(); + mockBuildDepositTx.mockReset(); + mockRecordDepositTx.mockReset(); mockEscrowRefund.mockReset(); }); @@ -268,7 +271,7 @@ describe('Bid Routes', () => { }); it('POST /:id/bids/:bidId/accept executes RPC', async () => { - mockEscrowDeposit.mockResolvedValue({ txHash: '0xescrowtest123' }); + mockBuildDepositTx.mockResolvedValue({ txData: '0xdeadbeef' }); m.store.orders.push({ id: 'order-1', @@ -317,7 +320,7 @@ describe('Bid Routes', () => { }); it('POST /:id/bids/:bidId/accept triggers escrow deposit when wallet addresses present', async () => { - mockEscrowDeposit.mockResolvedValue({ txHash: '0xescrowtest123' }); + mockBuildDepositTx.mockResolvedValue({ txData: '0xdeadbeef' }); m.store.orders.push({ id: 'order-escrow', @@ -360,8 +363,9 @@ describe('Bid Routes', () => { .set(CUSTOMER); expect(res.status).toBe(200); + expect(res.body.depositTx).toBe('0xdeadbeef'); - expect(mockEscrowDeposit).toHaveBeenCalledWith( + expect(mockBuildDepositTx).toHaveBeenCalledWith( 'OD-ESCROW', '0x1234567890abcdef1234567890abcdef12345678', '0xAbcdef1234567890Abcdef1234567890Abcdef12', @@ -369,12 +373,12 @@ describe('Bid Routes', () => { ); let order = m.store.orders.find(o => o.id === 'order-escrow'); - expect(order.escrow_status).toBe('funded'); - expect(order.deposit_tx_hash).toBe('0xescrowtest123'); + expect(order.escrow_status).toBe('funding'); + expect(order.escrow_booking_id).toBe('escrow:OD-ESCROW'); }); it('POST /:id/bids/:bidId/accept returns error when escrow deposit fails before accepting bid', async () => { - mockEscrowDeposit.mockRejectedValue(new Error('Out of gas')); + mockBuildDepositTx.mockRejectedValue(new Error('Out of gas')); m.store.orders.push({ id: 'order-escrow-fail', @@ -417,15 +421,14 @@ describe('Bid Routes', () => { .set(CUSTOMER); expect(res.status).toBe(500); - expect(res.body).toMatchObject({ error: 'Escrow deposit failed. Bid was not accepted.' }); + expect(res.body).toMatchObject({ error: 'Internal Server Error' }); let order = m.store.orders.find(o => o.id === 'order-escrow-fail'); expect(order.escrow_status).toBeUndefined(); }); - it('POST /:id/bids/:bidId/accept executes compensating refund when accept_bid_tx RPC fails after successful escrow deposit', async () => { - mockEscrowDeposit.mockResolvedValue({ txHash: '0xescrowtest123' }); - mockEscrowRefund.mockResolvedValue({ txHash: '0xrefundtest456' }); + it('POST /:id/bids/:bidId/accept returns 500 when RPC fails after buildDepositTx succeeds', async () => { + mockBuildDepositTx.mockResolvedValue({ txData: '0xdeadbeef' }); const originalRpc = m.supabase.rpc; m.supabase.rpc = vi.fn().mockResolvedValue({ data: null, error: { message: 'accept_bid_tx RPC failed' } }); @@ -474,20 +477,18 @@ describe('Bid Routes', () => { expect(res.body).toMatchObject({ error: 'Failed to accept bid atomically.', details: 'accept_bid_tx RPC failed', - recovery: 'The escrow deposit has been refunded. Please try again.' + recovery: 'The pending escrow deposit has been voided. Please try again.' }); - expect(mockEscrowDeposit).toHaveBeenCalledWith( + expect(mockBuildDepositTx).toHaveBeenCalledWith( 'OD-COMP-FAIL', '0x1234567890abcdef1234567890abcdef12345678', '0xAbcdef1234567890Abcdef1234567890Abcdef12', expect.any(BigInt) ); - expect(mockEscrowRefund).toHaveBeenCalledWith('OD-COMP-FAIL'); - let order = m.store.orders.find(o => o.id === 'order-comp-fail'); - expect(order.escrow_status).toBeUndefined(); + expect(order.escrow_status).toBe('funding'); expect(order.status).toBeUndefined(); m.supabase.rpc = originalRpc; @@ -536,7 +537,7 @@ describe('Bid Routes', () => { expect(res.status).toBe(422); expect(res.body.error).toBe('Both customer and driver must connect a wallet before escrow can be initiated.'); - expect(mockEscrowDeposit).not.toHaveBeenCalled(); + expect(mockBuildDepositTx).not.toHaveBeenCalled(); }); it('POST /:id/bids/:bidId/accept rejects with 422 when driver wallet missing', async () => { @@ -582,7 +583,7 @@ describe('Bid Routes', () => { expect(res.status).toBe(422); expect(res.body.error).toBe('Both customer and driver must connect a wallet before escrow can be initiated.'); - expect(mockEscrowDeposit).not.toHaveBeenCalled(); + expect(mockBuildDepositTx).not.toHaveBeenCalled(); }); it('POST /:id/bids/:bidId/accept rejects invalid ownership', async () => { @@ -592,7 +593,7 @@ describe('Bid Routes', () => { order_display_id: 'OD1', }); - const app = buildApp(); + const app = buildApp(); const res = await request(app) .post('/api/orders/order-1/bids/bid-1/accept') @@ -628,7 +629,7 @@ describe('Bid Routes', () => { { id: 'driver-1', full_name: 'Driver One' }, ); m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null, polygon_wallet_address: '0xDriverWallet' }); - mockEscrowDeposit.mockResolvedValue({ txHash: '0xescrowtest123' }); + mockBuildDepositTx.mockResolvedValue({ txData: '0xdeadbeef' }); m.programRpcError('Load offer is no longer available'); @@ -669,7 +670,7 @@ describe('Bid Routes', () => { { id: 'driver-1', full_name: 'Driver One' }, ); m.store.driver_details.push({ user_id: 'driver-1', rating: 4.9, truck_id: null, polygon_wallet_address: '0xDriverWallet' }); - mockEscrowDeposit.mockResolvedValue({ txHash: '0xescrowtest123' }); + mockBuildDepositTx.mockResolvedValue({ txData: '0xdeadbeef' }); m.programRpcError('Order is no longer pending'); diff --git a/backend/api/test/integration/escrow.test.js b/backend/api/test/integration/escrow.test.js index 5a245527..e27a4904 100644 --- a/backend/api/test/integration/escrow.test.js +++ b/backend/api/test/integration/escrow.test.js @@ -4,11 +4,10 @@ * Tests the escrow service layer. Since the ethers.js Contract requires a * live blockchain RPC (not available in CI), these tests validate: * - getEscrowBookingId(): deterministic bytes32 derivation - * - Graceful no-contract fallback: all functions return {txHash: null, bookingId} + * - Graceful no-contract fallback: buildDepositTx returns {txData: null, bookingId}, + * recordDepositTx returns {error}, escrowRelease/Refund return {txHash: null, bookingId} * when POLYGON_RPC_URL / ESCROW_CONTRACT_ADDRESS / RELAYER_WALLET_PRIVATE_KEY * are not configured (the default CI environment) - * - Blockchain interactions are mocked via vi.mock in bids.test.js for - * full order lifecycle testing (escrowDeposit, escrowRefund on bid accept) * * Run with: npm run test:integration -- test/integration/escrow.test.js */ @@ -30,7 +29,8 @@ delete process.env.RELAYER_WALLET_PRIVATE_KEY; const { getEscrowBookingId, - escrowDeposit, + buildDepositTx, + recordDepositTx, escrowRelease, escrowRefund, } = await import('../../src/services/escrow.js'); @@ -71,31 +71,43 @@ describe('getEscrowBookingId()', () => { // When blockchain env vars are absent, escrowContract is null and all // functions return {txHash: null, bookingId} instead of throwing. -describe('escrowDeposit() — no-contract fallback', () => { - it('returns {txHash: null, bookingId} when contract not initialised', async () => { - const result = await escrowDeposit(ORDER_ID_A, CUSTOMER_ADDR, DRIVER_ADDR, AMOUNT_WEI); - expect(result.txHash).toBeNull(); +describe('buildDepositTx() — no-contract fallback', () => { + it('returns {txData: null, bookingId} when contract not initialised', async () => { + const result = await buildDepositTx(ORDER_ID_A, CUSTOMER_ADDR, DRIVER_ADDR, AMOUNT_WEI); + expect(result.txData).toBeNull(); expect(result.bookingId).toBe(getEscrowBookingId(ORDER_ID_A)); }); - it('returns {txHash: null} for invalid customer address without throwing', async () => { - const result = await escrowDeposit(ORDER_ID_A, 'invalid', DRIVER_ADDR, AMOUNT_WEI); - expect(result.txHash).toBeNull(); + it('returns {txData: null} for invalid customer address without throwing', async () => { + const result = await buildDepositTx(ORDER_ID_A, 'invalid', DRIVER_ADDR, AMOUNT_WEI); + expect(result.txData).toBeNull(); expect(result.bookingId).toBe(getEscrowBookingId(ORDER_ID_A)); }); - it('returns {txHash: null} for invalid driver address without throwing', async () => { - const result = await escrowDeposit(ORDER_ID_A, CUSTOMER_ADDR, 'invalid', AMOUNT_WEI); - expect(result.txHash).toBeNull(); + it('returns {txData: null} for invalid driver address without throwing', async () => { + const result = await buildDepositTx(ORDER_ID_A, CUSTOMER_ADDR, 'invalid', AMOUNT_WEI); + expect(result.txData).toBeNull(); expect(result.bookingId).toBe(getEscrowBookingId(ORDER_ID_A)); }); it('bookingId is consistent with getEscrowBookingId()', async () => { - const result = await escrowDeposit(ORDER_ID_B, CUSTOMER_ADDR, DRIVER_ADDR, AMOUNT_WEI); + const result = await buildDepositTx(ORDER_ID_B, CUSTOMER_ADDR, DRIVER_ADDR, AMOUNT_WEI); expect(result.bookingId).toBe(getEscrowBookingId(ORDER_ID_B)); }); }); +describe('recordDepositTx() — no-contract fallback', () => { + it('returns {error: "Contract not initialised"} when contract not configured', async () => { + const result = await recordDepositTx(getEscrowBookingId(ORDER_ID_A), '0x' + 'a'.repeat(64)); + expect(result.error).toBe('Contract not initialised'); + }); + + it('returns {error} for invalid transaction hash', async () => { + const result = await recordDepositTx(getEscrowBookingId(ORDER_ID_A), 'invalid'); + expect(result.error).toBeDefined(); + }); +}); + describe('escrowRelease() — no-contract fallback', () => { it('returns {txHash: null, bookingId} when contract not initialised', async () => { const result = await escrowRelease(ORDER_ID_A); From bee7e88c7bd7e3dca60188cd17b5c6fe01939949 Mon Sep 17 00:00:00 2001 From: KanishJebaMathewM Date: Fri, 19 Jun 2026 21:06:57 +0530 Subject: [PATCH 4/4] fix(escrow): add on-chain tx validation in recordDepositTx and add integration tests --- backend/api/src/services/escrow.js | 37 ++++++++--- backend/api/test/integration/bids.test.js | 78 ++++++++++++++++++++++- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/backend/api/src/services/escrow.js b/backend/api/src/services/escrow.js index 6818357e..f054e6e1 100644 --- a/backend/api/src/services/escrow.js +++ b/backend/api/src/services/escrow.js @@ -121,15 +121,6 @@ export async function buildDepositTx(orderDisplayId, customerWalletAddress, driv return { txData, bookingId }; } -/** - * Record a confirmed deposit transaction hash for a booking. - * Called by the client-facing endpoint after the customer's wallet - * has signed and submitted the transaction built by buildDepositTx(). - * - * @param {string} bookingId - * @param {string} txHash - * @returns {Promise<{txHash: string, bookingId: string} | {error: string}>} - */ export async function recordDepositTx(bookingId, txHash) { if (!escrowContract) { return { error: 'Contract not initialised' }; @@ -138,11 +129,37 @@ export async function recordDepositTx(bookingId, txHash) { return { error: 'Invalid transaction hash' }; } - const receipt = await escrowContract.runner.provider.waitForTransaction(txHash, 1); + const provider = escrowContract.runner.provider; + const receipt = await provider.waitForTransaction(txHash, 1); if (!receipt || receipt.status === 0) { return { error: 'Transaction reverted or not found on chain' }; } + const tx = await provider.getTransaction(txHash); + if (!tx) { + return { error: 'Transaction details not found' }; + } + + if (!tx.to || tx.to.toLowerCase() !== contractAddress.toLowerCase()) { + return { error: 'Transaction destination is not the Escrow contract' }; + } + + let decoded; + try { + decoded = escrowContract.interface.parseTransaction({ data: tx.data, value: tx.value }); + } catch (err) { + return { error: 'Failed to parse transaction data' }; + } + + if (!decoded || decoded.name !== 'deposit') { + return { error: 'Transaction is not a deposit call' }; + } + + const [txBookingId] = decoded.args; + if (txBookingId !== bookingId) { + return { error: 'Transaction booking ID does not match' }; + } + logger.info(`[escrow] deposit confirmed for booking ${bookingId} in block ${receipt.blockNumber}`); return { txHash: receipt.hash, bookingId }; } diff --git a/backend/api/test/integration/bids.test.js b/backend/api/test/integration/bids.test.js index 59201634..5b9dbd75 100644 --- a/backend/api/test/integration/bids.test.js +++ b/backend/api/test/integration/bids.test.js @@ -593,7 +593,7 @@ describe('Bid Routes', () => { order_display_id: 'OD1', }); - const app = buildApp(); + const app = buildApp(); const res = await request(app) .post('/api/orders/order-1/bids/bid-1/accept') @@ -681,4 +681,80 @@ describe('Bid Routes', () => { expect(res.body.details).toBe('Order is no longer pending'); expect(m.calls.find(c => c.rpc === 'accept_bid_tx')).toBeTruthy(); }); + + describe('Confirm Deposit Route', () => { + it('POST /:id/confirm-deposit rejects unauthenticated request', async () => { + const app = buildApp(); + const res = await request(app) + .post('/api/orders/order-1/confirm-deposit') + .send({ txHash: '0x' + '1'.repeat(64) }); + expect(res.status).toBe(401); + }); + + it('POST /:id/confirm-deposit returns 400 if order is not in funding state', async () => { + m.store.orders.push({ + id: 'order-1', + customer_id: 'customer-1', + order_display_id: 'OD1', + escrow_status: 'pending', + }); + + const app = buildApp(); + const res = await request(app) + .post('/api/orders/order-1/confirm-deposit') + .set(CUSTOMER) + .send({ txHash: '0x' + '1'.repeat(64) }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Order is not in funding state'); + }); + + it('POST /:id/confirm-deposit returns 422 if recordDepositTx fails', async () => { + m.store.orders.push({ + id: 'order-1', + customer_id: 'customer-1', + order_display_id: 'OD1', + escrow_status: 'funding', + }); + + mockRecordDepositTx.mockResolvedValue({ error: 'Transaction reverted or not found on chain' }); + + const app = buildApp(); + const res = await request(app) + .post('/api/orders/order-1/confirm-deposit') + .set(CUSTOMER) + .send({ txHash: '0x' + '1'.repeat(64) }); + + expect(res.status).toBe(422); + expect(res.body.error).toBe('Transaction reverted or not found on chain'); + expect(mockRecordDepositTx).toHaveBeenCalledWith('escrow:OD1', '0x' + '1'.repeat(64)); + }); + + it('POST /:id/confirm-deposit succeeds and marks order as funded', async () => { + m.store.orders.push({ + id: 'order-1', + customer_id: 'customer-1', + order_display_id: 'OD1', + escrow_status: 'funding', + }); + + const expectedTx = '0x' + '1'.repeat(64); + mockRecordDepositTx.mockResolvedValue({ txHash: expectedTx, bookingId: 'escrow:OD1' }); + + const app = buildApp(); + const res = await request(app) + .post('/api/orders/order-1/confirm-deposit') + .set(CUSTOMER) + .send({ txHash: expectedTx }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Escrow deposit confirmed'); + expect(res.body.txHash).toBe(expectedTx); + + const order = m.store.orders.find(o => o.id === 'order-1'); + expect(order.escrow_status).toBe('funded'); + expect(order.deposit_tx_hash).toBe(expectedTx); + expect(order.escrow_deposited_at).toBeDefined(); + }); + }); });