Skip to content
Open
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
18 changes: 7 additions & 11 deletions apps/customer/lib/services/profile_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,29 +35,25 @@ class ProfileService {
}

Future<void> 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: <String, String>{
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
'x-user-id': userId,
'x-user-role': 'customer',
},
).timeout(const Duration(seconds: 5));
);
Comment thread
ionfwsrijan marked this conversation as resolved.
} 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();
}
}
}
1 change: 0 additions & 1 deletion apps/driver/lib/screens/profile_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion apps/driver/lib/services/marketplace_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
108 changes: 58 additions & 50 deletions backend/api/src/routes/orderRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ 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';
import { z } from 'zod';
import logger from '../middleware/logger.js';

const router = express.Router();
Expand Down Expand Up @@ -666,26 +667,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);
}
}

Expand All @@ -698,42 +692,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,
});
Comment thread
ionfwsrijan marked this conversation as resolved.
} catch (err) {
res.status(500).json({ error: 'Internal Server Error' });
}
Expand Down Expand Up @@ -1037,7 +1006,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) => {
Comment thread
ionfwsrijan marked this conversation as resolved.
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' });
Comment thread
ionfwsrijan marked this conversation as resolved.
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);
Comment thread
ionfwsrijan marked this conversation as resolved.

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 {
Expand Down
87 changes: 69 additions & 18 deletions backend/api/src/services/escrow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
};
Comment thread
ionfwsrijan marked this conversation as resolved.

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 };
}
Comment thread
ionfwsrijan marked this conversation as resolved.

Expand Down
Loading