diff --git a/src/components/CartSidebar.tsx b/src/components/CartSidebar.tsx index e1f92c9c..520e7fa2 100644 --- a/src/components/CartSidebar.tsx +++ b/src/components/CartSidebar.tsx @@ -6,11 +6,14 @@ import Image from "next/image"; import { SlippageControl } from "./SlippageControl"; import { X, Plus, Minus, ShoppingCart, Trash2, Fuel } from "lucide-react"; import { useCartStore } from "@/store/cartStore"; +import { useWalletStore } from "@/store/walletStore"; import { formatPrice } from "@/utils/searchUtils"; import type { CartItem } from "@/types/cart"; const logger = createLogger("CartSidebar"); +export const CartSidebar: React.FC = () => { + const address = useWalletStore((state) => state.address); const { items, totalCost, @@ -45,13 +48,9 @@ const logger = createLogger("CartSidebar"); const { BatchTransactionService } = await import("@/lib/batchTransaction"); - // Mock wallet address - in real app, this would come from wallet connection - const walletAddress = "0x1234567890123456789012345678901234567890"; - - // Show loading state const result = await BatchTransactionService.executeBatchPurchase( items, - walletAddress, + address ?? "", slippageTolerance, ); @@ -301,4 +300,4 @@ const CartItemRow: React.FC = ({ ); -}; \ No newline at end of file +}; diff --git a/src/lib/__tests__/batchTransaction.test.ts b/src/lib/__tests__/batchTransaction.test.ts index fcf7b2eb..98d9dbc6 100644 --- a/src/lib/__tests__/batchTransaction.test.ts +++ b/src/lib/__tests__/batchTransaction.test.ts @@ -1,121 +1,267 @@ -import type { CartItem } from '@/types/cart'; - -const mockProperty = (overrides = {}) => ({ - id: 'prop-1', - title: 'Test Property', - tokenInfo: { available: 100, price: 0.1 }, - status: 'active', - ...overrides, -}); +import type { CartItem } from "@/types/cart"; +import { + calculateMinimumAmount, + type BatchPurchaseExecutor, + type BatchPurchaseRequest, +} from "../batchTransaction"; + +jest.mock("@/utils/logger", () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock("@/utils/revertDecoder", () => ({ + decodeRevertReason: jest.fn(() => "Insufficient balance"), +})); + +const walletAddress = "0x1234567890123456789012345678901234567890"; +const transactionHash = + "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" as const; const validItem: CartItem = { - id: 'item-1', - property: mockProperty(), - quantity: 1, - addedAt: new Date().toISOString(), + id: "item-1", + property: { + id: "prop-1", + name: "Test Property", + description: "A test property", + location: { + address: "1 Test Street", + city: "Test City", + state: "TS", + country: "Test Country", + zipCode: "12345", + coordinates: { lat: 0, lng: 0 }, + }, + price: { total: 10, perToken: 0.1, currency: "ETH" }, + propertyType: "residential", + blockchain: "ethereum", + tokenInfo: { + totalSupply: 100, + available: 100, + sold: 0, + contractAddress: walletAddress, + tokenSymbol: "PROP", + }, + metrics: { + roi: 5, + annualReturn: 1, + transactionVolume: 0, + appreciationRate: 2, + }, + details: { + squareFeet: 1000, + yearBuilt: 2020, + amenities: [], + }, + images: ["/property.jpg"], + listedDate: "2026-01-01", + status: "active", + }, + quantity: 2, + addedAt: "2026-01-01T00:00:00.000Z", }; -describe('BatchTransactionService', () => { - const walletAddress = '0x1234567890123456789012345678901234567890'; +const createExecutor = ( + response: Awaited>, +): BatchPurchaseExecutor => ({ + execute: jest.fn(async () => response), +}); - beforeEach(() => { - delete process.env.NEXT_PUBLIC_DEMO_TX; - jest.resetModules(); +describe("BatchTransactionService", () => { + it("rejects an empty cart without invoking an executor", async () => { + const { BatchTransactionService } = await import("../batchTransaction"); + const executor = createExecutor({ + transactionHash, + receiptStatus: "success", + }); + + const result = await BatchTransactionService.executeBatchPurchase( + [], + walletAddress, + 0.005, + executor, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("At least one item is required."); + expect(executor.execute).not.toHaveBeenCalled(); }); - describe('executeBatchPurchase', () => { - it('returns validation error when item quantity exceeds available', async () => { - const { BatchTransactionService } = await import('../batchTransaction'); - const overPurchased: CartItem = { - ...validItem, - property: mockProperty({ tokenInfo: { available: 1, price: 0.1 } }), - quantity: 5, - }; - - const result = await BatchTransactionService.executeBatchPurchase( - [overPurchased], - walletAddress - ); - - expect(result.success).toBe(false); - expect(result.error).toContain('Validation failed'); - expect(result.results[0].error).toContain('Insufficient tokens'); + it("rejects a disconnected wallet before submission", async () => { + const { BatchTransactionService } = await import("../batchTransaction"); + const executor = createExecutor({ + transactionHash, + receiptStatus: "success", }); - it('returns validation error when property is inactive', async () => { - const { BatchTransactionService } = await import('../batchTransaction'); - const inactiveItem: CartItem = { - ...validItem, - property: mockProperty({ status: 'inactive' }), - }; + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + "", + 0.005, + executor, + ); - const result = await BatchTransactionService.executeBatchPurchase( - [inactiveItem], - walletAddress - ); + expect(result.success).toBe(false); + expect(result.error).toBe("A connected wallet is required."); + expect(executor.execute).not.toHaveBeenCalled(); + }); - expect(result.success).toBe(false); + it("rejects quantities above the available balance before submission", async () => { + const { BatchTransactionService } = await import("../batchTransaction"); + const executor = createExecutor({ + transactionHash, + receiptStatus: "success", }); + const item = { + ...validItem, + quantity: validItem.property.tokenInfo.available + 1, + }; - it('throws when no items provided and catches error', async () => { - const { BatchTransactionService } = await import('../batchTransaction'); - const result = await BatchTransactionService.executeBatchPurchase([], walletAddress); + const result = await BatchTransactionService.executeBatchPurchase( + [item], + walletAddress, + 0.005, + executor, + ); - expect(result.success).toBe(false); - expect(result.results).toHaveLength(0); - }); + expect(result.success).toBe(false); + expect(result.error).toContain("Insufficient tokens available"); + expect(executor.execute).not.toHaveBeenCalled(); + }); - it('uses demo mode when NEXT_PUBLIC_DEMO_TX is true', async () => { - process.env.NEXT_PUBLIC_DEMO_TX = 'true'; - jest.resetModules(); + it("fails honestly when no deployed contract executor is configured", async () => { + const { BatchTransactionService } = await import("../batchTransaction"); - const { BatchTransactionService } = await import('../batchTransaction'); - const result = await BatchTransactionService.executeBatchPurchase( - [validItem], - walletAddress - ); + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + ); - expect(result.success).toBe(true); - expect(result.transactionHash).toMatch(/^0x[a-f0-9]{64}$/); - expect(result.totalGasUsed).toBeGreaterThan(0); + expect(result).toEqual({ + success: false, + results: [ + { + propertyId: "prop-1", + success: false, + error: "Batch purchase is not configured for this network.", + }, + ], + error: "Batch purchase is not configured for this network.", }); + expect(result.transactionHash).toBeUndefined(); }); - describe('estimateGas', () => { - it('returns base gas for empty items', async () => { - const { BatchTransactionService } = await import('../batchTransaction'); - const gas = BatchTransactionService.estimateGas([]); - expect(gas).toBe(0.005); + it("returns the executor hash only after a successful receipt", async () => { + const { BatchTransactionService } = await import("../batchTransaction"); + const executor = createExecutor({ + transactionHash, + receiptStatus: "success", }); - it('calculates gas proportionally to item count', async () => { - const { BatchTransactionService } = await import('../batchTransaction'); - const gas1 = BatchTransactionService.estimateGas([validItem]); - const gas3 = BatchTransactionService.estimateGas([validItem, validItem, validItem]); - expect(gas3).toBeGreaterThan(gas1); + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + executor, + ); + + expect(result.success).toBe(true); + expect(result.transactionHash).toBe(transactionHash); + expect(result.results).toEqual([ + { + propertyId: "prop-1", + success: true, + transactionHash, + }, + ]); + expect(executor.execute).toHaveBeenCalledWith({ + walletAddress, + slippageTolerance: 0.005, + items: [ + { + propertyId: "prop-1", + quantity: 2, + expectedAmount: 0.2, + minimumAmount: 0.199, + }, + ], + } satisfies BatchPurchaseRequest); + }); + + it("does not report success when the receipt is reverted", async () => { + const { BatchTransactionService } = await import("../batchTransaction"); + const executor = createExecutor({ + transactionHash, + receiptStatus: "reverted", }); + + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + executor, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Batch purchase transaction reverted."); + expect(result.transactionHash).toBeUndefined(); }); - describe('getTransactionStatus', () => { - it('returns pending when receipt is not available', async () => { - const { BatchTransactionService } = await import('../batchTransaction'); - const result = await BatchTransactionService.getTransactionStatus( - '0x0000000000000000000000000000000000000000000000000000000000000000' - ); + it("returns a decoded reason for a provider revert", async () => { + const { BatchTransactionService } = await import("../batchTransaction"); + const executor: BatchPurchaseExecutor = { + execute: jest.fn(async () => { + throw Object.assign(new Error("execution reverted"), { + data: "0x08c379a0", + }); + }), + }; - expect(result.status).toBe('pending'); - }); + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + executor, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Insufficient balance"); }); - describe('waitForConfirmation', () => { - it('returns timeout when transaction is not found', async () => { - const { BatchTransactionService } = await import('../batchTransaction'); - const result = await BatchTransactionService.waitForConfirmation( - '0x0000000000000000000000000000000000000000000000000000000000000000', - 100 - ); + it("returns a user rejection without fabricating a hash", async () => { + const { BatchTransactionService } = await import("../batchTransaction"); + const executor: BatchPurchaseExecutor = { + execute: jest.fn(async () => { + throw Object.assign(new Error("User denied transaction"), { + code: 4001, + }); + }), + }; + + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + executor, + ); - expect(result.status).toBe('timeout'); + expect(result).toEqual({ + success: false, + results: [ + { + propertyId: "prop-1", + success: false, + error: "Transaction rejected by the user.", + }, + ], + error: "Transaction rejected by the user.", }); }); + + it("computes minimum amounts from the requested slippage", () => { + expect(calculateMinimumAmount(0.2, 0.1)).toBeCloseTo(0.18); + }); }); diff --git a/src/lib/batchTransaction.ts b/src/lib/batchTransaction.ts index e09691e9..bd399845 100644 --- a/src/lib/batchTransaction.ts +++ b/src/lib/batchTransaction.ts @@ -1,72 +1,174 @@ -import { CartItem } from "@/types/cart"; +import type { CartItem, BatchTransactionResult } from "@/types/cart"; import { logger } from "@/utils/logger"; import { decodeRevertReason } from "@/utils/revertDecoder"; -export interface BatchTransactionResult { - success: boolean; - transactionHash?: string; - error?: string; +const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/; + +export interface BatchPurchaseRequest { + walletAddress: `0x${string}`; + items: Array<{ + propertyId: string; + quantity: number; + expectedAmount: number; + minimumAmount: number; + }>; + slippageTolerance: number; +} + +/** + * Adapter boundary for the deployed batch-purchase contract. + * The adapter must return only after it has observed the transaction receipt. + */ +export interface BatchPurchaseExecutor { + execute: (request: BatchPurchaseRequest) => Promise<{ + transactionHash: `0x${string}`; + receiptStatus: "success" | "reverted"; + }>; } +const failureResult = ( + items: CartItem[], + error: string, +): BatchTransactionResult => ({ + success: false, + results: items.map((item) => ({ + propertyId: item.property.id, + success: false, + error, + })), + error, +}); + +const getErrorData = (error: unknown): `0x${string}` | undefined => { + if (typeof error !== "object" || error === null || !("data" in error)) { + return undefined; + } + + const data = error.data; + return typeof data === "string" && /^0x[\da-fA-F]*$/.test(data) + ? (data as `0x${string}`) + : undefined; +}; + +const getFailureReason = (error: unknown): string => { + const data = getErrorData(error); + if (data) return decodeRevertReason(data); + + if (typeof error === "object" && error !== null && "code" in error) { + if (error.code === 4001) return "Transaction rejected by the user."; + } + + if (error instanceof Error && error.message) return error.message; + return "Batch purchase failed."; +}; + +const validateItems = (items: CartItem[]): string | undefined => { + if (items.length === 0) return "At least one item is required."; + + for (const item of items) { + if (item.property.status !== "active") { + return `Property ${item.property.id} is not available for purchase.`; + } + if ( + !Number.isInteger(item.quantity) || + item.quantity <= 0 || + !Number.isFinite(item.property.tokenInfo.available) || + item.quantity > item.property.tokenInfo.available + ) { + return `Insufficient tokens available for property ${item.property.id}.`; + } + if ( + !Number.isFinite(item.property.price.perToken) || + item.property.price.perToken < 0 || + !Number.isFinite(item.quantity * item.property.price.perToken) + ) { + return `Invalid price for property ${item.property.id}.`; + } + } + + return undefined; +}; + +export const calculateMinimumAmount = ( + expectedAmount: number, + slippageTolerance: number, +): number => expectedAmount * (1 - slippageTolerance); + export const BatchTransactionService = { executeBatchPurchase: async ( items: CartItem[], walletAddress: string, slippageTolerance: number, + executor?: BatchPurchaseExecutor, ): Promise => { - logger.info("Executing batch purchase", { + const validationError = validateItems(items); + if (validationError) return failureResult(items, validationError); + + if (!ADDRESS_PATTERN.test(walletAddress)) { + return failureResult(items, "A connected wallet is required."); + } + + if ( + !Number.isFinite(slippageTolerance) || + slippageTolerance < 0 || + slippageTolerance >= 1 + ) { + return failureResult( + items, + "Slippage tolerance must be between 0 and 1.", + ); + } + + if (!executor) { + return failureResult( + items, + "Batch purchase is not configured for this network.", + ); + } + + const request: BatchPurchaseRequest = { + walletAddress: walletAddress as `0x${string}`, + slippageTolerance, + items: items.map((item) => { + const expectedAmount = item.quantity * item.property.price.perToken; + return { + propertyId: item.property.id, + quantity: item.quantity, + expectedAmount, + minimumAmount: calculateMinimumAmount( + expectedAmount, + slippageTolerance, + ), + }; + }), + }; + + logger.info("Executing configured batch purchase", { itemCount: items.length, walletAddress, slippageTolerance, }); - // TODO: Fetch 24h price volatility to set a dynamic default slippage. + try { + const { transactionHash, receiptStatus } = + await executor.execute(request); + if (receiptStatus !== "success") { + return failureResult(items, "Batch purchase transaction reverted."); + } - // Calculate the minimum amount of tokens to be received for each item. - const itemsWithSlippage = items.map((item) => { - const pricePerToken = item.property.price.perToken; - const expectedAmount = item.quantity * pricePerToken; - const minAmount = expectedAmount * (1 - slippageTolerance); return { - ...item, - minAmount, + success: true, + transactionHash, + results: items.map((item) => ({ + propertyId: item.property.id, + success: true, + transactionHash, + })), }; - }); - - logger.info("Items with slippage", { itemsWithSlippage }); - - // TODO: Include slippage intent in EIP-712 typed data. - - // For now, we'll simulate a successful transaction. - try { - return new Promise((resolve) => { - setTimeout(() => { - // Simulate a revert for demonstration purposes - if (Math.random() < 0.2) { - // This is a sample error byte string. In a real scenario, - // this would come from the 'e.data' field of a viem revert. - const sampleErrorBytes = - "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001a496e73756666696369656e742062616c616e636520666f72207472616e736665720000000000000000000000"; - const reason = decodeRevertReason( - sampleErrorBytes as `0x${string}`, - ); - resolve({ success: false, error: reason }); - } else { - resolve({ - success: true, - transactionHash: `0x${[...Array(64)] - .map(() => Math.floor(Math.random() * 16).toString(16)) - .join("")}`, - }); - } - }, 2000); - }); - } catch (e: any) { - const reason = e.data - ? decodeRevertReason(e.data) - : "An unknown error occurred."; + } catch (error: unknown) { + const reason = getFailureReason(error); logger.error("Batch purchase failed", { error: reason }); - return { success: false, error: reason }; + return failureResult(items, reason); } }, };