Skip to content

Commit 0cd42e8

Browse files
Merge pull request #960 from nasalehj/fix/issue-813-real-batch-checkout
fix(checkout): remove fabricated batch transaction results
2 parents 4150be4 + b3987dc commit 0cd42e8

3 files changed

Lines changed: 391 additions & 144 deletions

File tree

src/components/CartSidebar.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import Image from "next/image";
66
import { SlippageControl } from "./SlippageControl";
77
import { X, Plus, Minus, ShoppingCart, Trash2, Fuel } from "lucide-react";
88
import { useCartStore } from "@/store/cartStore";
9+
import { useWalletStore } from "@/store/walletStore";
910
import { formatPrice } from "@/utils/searchUtils";
1011
import type { CartItem } from "@/types/cart";
1112

1213
const logger = createLogger("CartSidebar");
1314

15+
export const CartSidebar: React.FC = () => {
16+
const address = useWalletStore((state) => state.address);
1417
const {
1518
items,
1619
totalCost,
@@ -45,13 +48,9 @@ const logger = createLogger("CartSidebar");
4548
const { BatchTransactionService } =
4649
await import("@/lib/batchTransaction");
4750

48-
// Mock wallet address - in real app, this would come from wallet connection
49-
const walletAddress = "0x1234567890123456789012345678901234567890";
50-
51-
// Show loading state
5251
const result = await BatchTransactionService.executeBatchPurchase(
5352
items,
54-
walletAddress,
53+
address ?? "",
5554
slippageTolerance,
5655
);
5756

@@ -301,4 +300,4 @@ const CartItemRow: React.FC<CartItemRowProps> = ({
301300
</div>
302301
</div>
303302
);
304-
};
303+
};
Lines changed: 235 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,267 @@
1-
import type { CartItem } from '@/types/cart';
2-
3-
const mockProperty = (overrides = {}) => ({
4-
id: 'prop-1',
5-
title: 'Test Property',
6-
tokenInfo: { available: 100, price: 0.1 },
7-
status: 'active',
8-
...overrides,
9-
});
1+
import type { CartItem } from "@/types/cart";
2+
import {
3+
calculateMinimumAmount,
4+
type BatchPurchaseExecutor,
5+
type BatchPurchaseRequest,
6+
} from "../batchTransaction";
7+
8+
jest.mock("@/utils/logger", () => ({
9+
logger: {
10+
info: jest.fn(),
11+
error: jest.fn(),
12+
},
13+
}));
14+
15+
jest.mock("@/utils/revertDecoder", () => ({
16+
decodeRevertReason: jest.fn(() => "Insufficient balance"),
17+
}));
18+
19+
const walletAddress = "0x1234567890123456789012345678901234567890";
20+
const transactionHash =
21+
"0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" as const;
1022

1123
const validItem: CartItem = {
12-
id: 'item-1',
13-
property: mockProperty(),
14-
quantity: 1,
15-
addedAt: new Date().toISOString(),
24+
id: "item-1",
25+
property: {
26+
id: "prop-1",
27+
name: "Test Property",
28+
description: "A test property",
29+
location: {
30+
address: "1 Test Street",
31+
city: "Test City",
32+
state: "TS",
33+
country: "Test Country",
34+
zipCode: "12345",
35+
coordinates: { lat: 0, lng: 0 },
36+
},
37+
price: { total: 10, perToken: 0.1, currency: "ETH" },
38+
propertyType: "residential",
39+
blockchain: "ethereum",
40+
tokenInfo: {
41+
totalSupply: 100,
42+
available: 100,
43+
sold: 0,
44+
contractAddress: walletAddress,
45+
tokenSymbol: "PROP",
46+
},
47+
metrics: {
48+
roi: 5,
49+
annualReturn: 1,
50+
transactionVolume: 0,
51+
appreciationRate: 2,
52+
},
53+
details: {
54+
squareFeet: 1000,
55+
yearBuilt: 2020,
56+
amenities: [],
57+
},
58+
images: ["/property.jpg"],
59+
listedDate: "2026-01-01",
60+
status: "active",
61+
},
62+
quantity: 2,
63+
addedAt: "2026-01-01T00:00:00.000Z",
1664
};
1765

18-
describe('BatchTransactionService', () => {
19-
const walletAddress = '0x1234567890123456789012345678901234567890';
66+
const createExecutor = (
67+
response: Awaited<ReturnType<BatchPurchaseExecutor["execute"]>>,
68+
): BatchPurchaseExecutor => ({
69+
execute: jest.fn(async () => response),
70+
});
2071

21-
beforeEach(() => {
22-
delete process.env.NEXT_PUBLIC_DEMO_TX;
23-
jest.resetModules();
72+
describe("BatchTransactionService", () => {
73+
it("rejects an empty cart without invoking an executor", async () => {
74+
const { BatchTransactionService } = await import("../batchTransaction");
75+
const executor = createExecutor({
76+
transactionHash,
77+
receiptStatus: "success",
78+
});
79+
80+
const result = await BatchTransactionService.executeBatchPurchase(
81+
[],
82+
walletAddress,
83+
0.005,
84+
executor,
85+
);
86+
87+
expect(result.success).toBe(false);
88+
expect(result.error).toBe("At least one item is required.");
89+
expect(executor.execute).not.toHaveBeenCalled();
2490
});
2591

26-
describe('executeBatchPurchase', () => {
27-
it('returns validation error when item quantity exceeds available', async () => {
28-
const { BatchTransactionService } = await import('../batchTransaction');
29-
const overPurchased: CartItem = {
30-
...validItem,
31-
property: mockProperty({ tokenInfo: { available: 1, price: 0.1 } }),
32-
quantity: 5,
33-
};
34-
35-
const result = await BatchTransactionService.executeBatchPurchase(
36-
[overPurchased],
37-
walletAddress
38-
);
39-
40-
expect(result.success).toBe(false);
41-
expect(result.error).toContain('Validation failed');
42-
expect(result.results[0].error).toContain('Insufficient tokens');
92+
it("rejects a disconnected wallet before submission", async () => {
93+
const { BatchTransactionService } = await import("../batchTransaction");
94+
const executor = createExecutor({
95+
transactionHash,
96+
receiptStatus: "success",
4397
});
4498

45-
it('returns validation error when property is inactive', async () => {
46-
const { BatchTransactionService } = await import('../batchTransaction');
47-
const inactiveItem: CartItem = {
48-
...validItem,
49-
property: mockProperty({ status: 'inactive' }),
50-
};
99+
const result = await BatchTransactionService.executeBatchPurchase(
100+
[validItem],
101+
"",
102+
0.005,
103+
executor,
104+
);
51105

52-
const result = await BatchTransactionService.executeBatchPurchase(
53-
[inactiveItem],
54-
walletAddress
55-
);
106+
expect(result.success).toBe(false);
107+
expect(result.error).toBe("A connected wallet is required.");
108+
expect(executor.execute).not.toHaveBeenCalled();
109+
});
56110

57-
expect(result.success).toBe(false);
111+
it("rejects quantities above the available balance before submission", async () => {
112+
const { BatchTransactionService } = await import("../batchTransaction");
113+
const executor = createExecutor({
114+
transactionHash,
115+
receiptStatus: "success",
58116
});
117+
const item = {
118+
...validItem,
119+
quantity: validItem.property.tokenInfo.available + 1,
120+
};
59121

60-
it('throws when no items provided and catches error', async () => {
61-
const { BatchTransactionService } = await import('../batchTransaction');
62-
const result = await BatchTransactionService.executeBatchPurchase([], walletAddress);
122+
const result = await BatchTransactionService.executeBatchPurchase(
123+
[item],
124+
walletAddress,
125+
0.005,
126+
executor,
127+
);
63128

64-
expect(result.success).toBe(false);
65-
expect(result.results).toHaveLength(0);
66-
});
129+
expect(result.success).toBe(false);
130+
expect(result.error).toContain("Insufficient tokens available");
131+
expect(executor.execute).not.toHaveBeenCalled();
132+
});
67133

68-
it('uses demo mode when NEXT_PUBLIC_DEMO_TX is true', async () => {
69-
process.env.NEXT_PUBLIC_DEMO_TX = 'true';
70-
jest.resetModules();
134+
it("fails honestly when no deployed contract executor is configured", async () => {
135+
const { BatchTransactionService } = await import("../batchTransaction");
71136

72-
const { BatchTransactionService } = await import('../batchTransaction');
73-
const result = await BatchTransactionService.executeBatchPurchase(
74-
[validItem],
75-
walletAddress
76-
);
137+
const result = await BatchTransactionService.executeBatchPurchase(
138+
[validItem],
139+
walletAddress,
140+
0.005,
141+
);
77142

78-
expect(result.success).toBe(true);
79-
expect(result.transactionHash).toMatch(/^0x[a-f0-9]{64}$/);
80-
expect(result.totalGasUsed).toBeGreaterThan(0);
143+
expect(result).toEqual({
144+
success: false,
145+
results: [
146+
{
147+
propertyId: "prop-1",
148+
success: false,
149+
error: "Batch purchase is not configured for this network.",
150+
},
151+
],
152+
error: "Batch purchase is not configured for this network.",
81153
});
154+
expect(result.transactionHash).toBeUndefined();
82155
});
83156

84-
describe('estimateGas', () => {
85-
it('returns base gas for empty items', async () => {
86-
const { BatchTransactionService } = await import('../batchTransaction');
87-
const gas = BatchTransactionService.estimateGas([]);
88-
expect(gas).toBe(0.005);
157+
it("returns the executor hash only after a successful receipt", async () => {
158+
const { BatchTransactionService } = await import("../batchTransaction");
159+
const executor = createExecutor({
160+
transactionHash,
161+
receiptStatus: "success",
89162
});
90163

91-
it('calculates gas proportionally to item count', async () => {
92-
const { BatchTransactionService } = await import('../batchTransaction');
93-
const gas1 = BatchTransactionService.estimateGas([validItem]);
94-
const gas3 = BatchTransactionService.estimateGas([validItem, validItem, validItem]);
95-
expect(gas3).toBeGreaterThan(gas1);
164+
const result = await BatchTransactionService.executeBatchPurchase(
165+
[validItem],
166+
walletAddress,
167+
0.005,
168+
executor,
169+
);
170+
171+
expect(result.success).toBe(true);
172+
expect(result.transactionHash).toBe(transactionHash);
173+
expect(result.results).toEqual([
174+
{
175+
propertyId: "prop-1",
176+
success: true,
177+
transactionHash,
178+
},
179+
]);
180+
expect(executor.execute).toHaveBeenCalledWith({
181+
walletAddress,
182+
slippageTolerance: 0.005,
183+
items: [
184+
{
185+
propertyId: "prop-1",
186+
quantity: 2,
187+
expectedAmount: 0.2,
188+
minimumAmount: 0.199,
189+
},
190+
],
191+
} satisfies BatchPurchaseRequest);
192+
});
193+
194+
it("does not report success when the receipt is reverted", async () => {
195+
const { BatchTransactionService } = await import("../batchTransaction");
196+
const executor = createExecutor({
197+
transactionHash,
198+
receiptStatus: "reverted",
96199
});
200+
201+
const result = await BatchTransactionService.executeBatchPurchase(
202+
[validItem],
203+
walletAddress,
204+
0.005,
205+
executor,
206+
);
207+
208+
expect(result.success).toBe(false);
209+
expect(result.error).toBe("Batch purchase transaction reverted.");
210+
expect(result.transactionHash).toBeUndefined();
97211
});
98212

99-
describe('getTransactionStatus', () => {
100-
it('returns pending when receipt is not available', async () => {
101-
const { BatchTransactionService } = await import('../batchTransaction');
102-
const result = await BatchTransactionService.getTransactionStatus(
103-
'0x0000000000000000000000000000000000000000000000000000000000000000'
104-
);
213+
it("returns a decoded reason for a provider revert", async () => {
214+
const { BatchTransactionService } = await import("../batchTransaction");
215+
const executor: BatchPurchaseExecutor = {
216+
execute: jest.fn(async () => {
217+
throw Object.assign(new Error("execution reverted"), {
218+
data: "0x08c379a0",
219+
});
220+
}),
221+
};
105222

106-
expect(result.status).toBe('pending');
107-
});
223+
const result = await BatchTransactionService.executeBatchPurchase(
224+
[validItem],
225+
walletAddress,
226+
0.005,
227+
executor,
228+
);
229+
230+
expect(result.success).toBe(false);
231+
expect(result.error).toBe("Insufficient balance");
108232
});
109233

110-
describe('waitForConfirmation', () => {
111-
it('returns timeout when transaction is not found', async () => {
112-
const { BatchTransactionService } = await import('../batchTransaction');
113-
const result = await BatchTransactionService.waitForConfirmation(
114-
'0x0000000000000000000000000000000000000000000000000000000000000000',
115-
100
116-
);
234+
it("returns a user rejection without fabricating a hash", async () => {
235+
const { BatchTransactionService } = await import("../batchTransaction");
236+
const executor: BatchPurchaseExecutor = {
237+
execute: jest.fn(async () => {
238+
throw Object.assign(new Error("User denied transaction"), {
239+
code: 4001,
240+
});
241+
}),
242+
};
243+
244+
const result = await BatchTransactionService.executeBatchPurchase(
245+
[validItem],
246+
walletAddress,
247+
0.005,
248+
executor,
249+
);
117250

118-
expect(result.status).toBe('timeout');
251+
expect(result).toEqual({
252+
success: false,
253+
results: [
254+
{
255+
propertyId: "prop-1",
256+
success: false,
257+
error: "Transaction rejected by the user.",
258+
},
259+
],
260+
error: "Transaction rejected by the user.",
119261
});
120262
});
263+
264+
it("computes minimum amounts from the requested slippage", () => {
265+
expect(calculateMinimumAmount(0.2, 0.1)).toBeCloseTo(0.18);
266+
});
121267
});

0 commit comments

Comments
 (0)