From 355257490f25abf19958c3babdd5b1382fffa18e Mon Sep 17 00:00:00 2001 From: nasalehj Date: Thu, 27 Aug 2026 15:52:07 +0000 Subject: [PATCH] fix(checkout): submit real batch purchase transaction Completes the batch checkout integration started in #960 by wiring a real wagmi/viem executor. When a batch-purchase contract address is configured, executeBatchPurchase now resolves the connected wallet client, submits the batchPurchase call with per-item slippage-protected minimum amounts, and only reports success after the transaction receipt is observed on chain. The default path fails closed with a configuration error when no contract address is set, and disconnected wallets are rejected before any submission. The timeout/Math.random simulation is not reintroduced anywhere. Closes #813 --- .env.example | 4 + docs/smart-contract-integration.md | 7 ++ src/config/batchPurchase.ts | 52 ++++++++ src/lib/__tests__/batchTransaction.test.ts | 138 +++++++++++++++++++++ src/lib/batchPurchaseExecutor.ts | 65 ++++++++++ src/lib/batchTransaction.ts | 23 +++- 6 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 src/config/batchPurchase.ts create mode 100644 src/lib/batchPurchaseExecutor.ts diff --git a/.env.example b/.env.example index 8c90b3a5..a799108a 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,10 @@ POLYGON_MAINNET_RPC_URL=https://polygon-rpc.com # Binance Smart Chain Mainnet RPC URL BSC_MAINNET_RPC_URL=https://bsc-dataseed.binance.org +# Batch purchase contract address (deployed batch-purchase contract). +# Checkout fails closed with a configuration error when this is unset. +NEXT_PUBLIC_BATCH_PURCHASE_ADDRESS= + # ----------------------------------------------------------------------------- # Wallet Connect Configuration # ----------------------------------------------------------------------------- diff --git a/docs/smart-contract-integration.md b/docs/smart-contract-integration.md index 070f97eb..f55951f1 100644 --- a/docs/smart-contract-integration.md +++ b/docs/smart-contract-integration.md @@ -18,8 +18,15 @@ Set contract addresses via environment variables: NEXT_PUBLIC_PROPERTY_NFT_ADDRESS=0x... NEXT_PUBLIC_MARKETPLACE_ADDRESS=0x... NEXT_PUBLIC_STAKING_ADDRESS=0x... +NEXT_PUBLIC_BATCH_PURCHASE_ADDRESS=0x... ``` +The batch purchase contract (`NEXT_PUBLIC_BATCH_PURCHASE_ADDRESS`) backs the +cart checkout flow. When it is unset, checkout fails closed with a +configuration error; when set, `src/lib/batchTransaction.ts` submits a real +`batchPurchase` transaction via the connected wallet and only reports success +after the receipt is observed on chain. + ## ABI Files ABI files live in `src/lib/abis/`. To update an ABI after a contract upgrade: diff --git a/src/config/batchPurchase.ts b/src/config/batchPurchase.ts new file mode 100644 index 00000000..efbc07d8 --- /dev/null +++ b/src/config/batchPurchase.ts @@ -0,0 +1,52 @@ +/** + * Batch purchase contract configuration. + * + * The batch purchase contract accepts the list of property token contracts, + * quantities, per-item minimum amounts (derived from the user's slippage + * tolerance), and a deadline, and is payable with the quoted total. + * + * The deployed address is read from NEXT_PUBLIC_BATCH_PURCHASE_ADDRESS and + * validated before use. When it is unset or malformed, checkout fails closed + * with an explicit configuration error instead of submitting anywhere. + */ + +const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/; + +/** + * ABI for the batch purchase entry point. + * + * `minAmounts` are the slippage-protected minimum amounts in wei: the + * frontend computes them from the quoted per-token price and the user's + * slippage tolerance, and the contract must reject the purchase if any + * property token would be received below its minimum. + */ +export const BATCH_PURCHASE_ABI = [ + { + type: "function" as const, + name: "batchPurchase", + stateMutability: "payable" as const, + inputs: [ + { name: "propertyTokens", type: "address[]" }, + { name: "quantities", type: "uint256[]" }, + { name: "minAmounts", type: "uint256[]" }, + { name: "deadline", type: "uint256" }, + ], + outputs: [], + }, +] as const; + +/** + * Resolve the configured batch purchase contract address. + * + * Returns null when the address is missing or malformed so callers can fail + * closed. A single network-agnostic variable is used for now; the config + * should move to a per-chain map once deployments exist on more than one + * network. + */ +export const getBatchPurchaseContractAddress = (): `0x${string}` | null => { + const address = process.env.NEXT_PUBLIC_BATCH_PURCHASE_ADDRESS; + if (!address || !ADDRESS_PATTERN.test(address)) { + return null; + } + return address as `0x${string}`; +}; diff --git a/src/lib/__tests__/batchTransaction.test.ts b/src/lib/__tests__/batchTransaction.test.ts index 98d9dbc6..ed14903c 100644 --- a/src/lib/__tests__/batchTransaction.test.ts +++ b/src/lib/__tests__/batchTransaction.test.ts @@ -16,7 +16,22 @@ jest.mock("@/utils/revertDecoder", () => ({ decodeRevertReason: jest.fn(() => "Insufficient balance"), })); +jest.mock("@/config/wagmi", () => ({ + config: { mocked: true }, +})); + +jest.mock("@wagmi/core/actions", () => ({ + getWalletClient: jest.fn(), + getPublicClient: jest.fn(), +})); + +import { getPublicClient, getWalletClient } from "@wagmi/core/actions"; + +const mockGetWalletClient = getWalletClient as jest.Mock; +const mockGetPublicClient = getPublicClient as jest.Mock; + const walletAddress = "0x1234567890123456789012345678901234567890"; +const contractAddress = "0x1111111111111111111111111111111111111111"; const transactionHash = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" as const; @@ -69,7 +84,28 @@ const createExecutor = ( execute: jest.fn(async () => response), }); +const createViemClients = ( + receiptStatus: "success" | "reverted" = "success", +) => { + const walletClient = { + chain: { id: 1 }, + getChainId: jest.fn(async () => 1), + writeContract: jest.fn(async () => transactionHash), + }; + const publicClient = { + waitForTransactionReceipt: jest.fn(async () => ({ status: receiptStatus })), + }; + mockGetWalletClient.mockResolvedValue(walletClient); + mockGetPublicClient.mockReturnValue(publicClient); + return { walletClient, publicClient }; +}; + describe("BatchTransactionService", () => { + afterEach(() => { + delete process.env.NEXT_PUBLIC_BATCH_PURCHASE_ADDRESS; + jest.clearAllMocks(); + }); + it("rejects an empty cart without invoking an executor", async () => { const { BatchTransactionService } = await import("../batchTransaction"); const executor = createExecutor({ @@ -152,6 +188,7 @@ describe("BatchTransactionService", () => { error: "Batch purchase is not configured for this network.", }); expect(result.transactionHash).toBeUndefined(); + expect(mockGetWalletClient).not.toHaveBeenCalled(); }); it("returns the executor hash only after a successful receipt", async () => { @@ -183,6 +220,7 @@ describe("BatchTransactionService", () => { items: [ { propertyId: "prop-1", + tokenAddress: walletAddress, quantity: 2, expectedAmount: 0.2, minimumAmount: 0.199, @@ -264,4 +302,104 @@ describe("BatchTransactionService", () => { it("computes minimum amounts from the requested slippage", () => { expect(calculateMinimumAmount(0.2, 0.1)).toBeCloseTo(0.18); }); + + describe("with the configured wagmi/viem executor", () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_BATCH_PURCHASE_ADDRESS = contractAddress; + mockGetWalletClient.mockReset(); + mockGetPublicClient.mockReset(); + }); + + it("submits a real transaction and returns the real hash after the receipt", async () => { + const { walletClient, publicClient } = createViemClients("success"); + const { BatchTransactionService } = await import("../batchTransaction"); + + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + ); + + expect(mockGetWalletClient).toHaveBeenCalledWith( + { mocked: true }, + { account: walletAddress }, + ); + expect(walletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: contractAddress, + functionName: "batchPurchase", + account: walletAddress, + args: [ + [walletAddress], + [2n], + [expect.any(BigInt)], + expect.any(BigInt), + ], + value: expect.any(BigInt), + }), + ); + expect(publicClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: transactionHash, + }); + expect(result.success).toBe(true); + expect(result.transactionHash).toBe(transactionHash); + }); + + it("does not report success when the on-chain receipt reverted", async () => { + createViemClients("reverted"); + const { BatchTransactionService } = await import("../batchTransaction"); + + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Batch purchase transaction reverted."); + expect(result.transactionHash).toBeUndefined(); + }); + + it("rejects a wallet that is not connected before any submission", async () => { + mockGetWalletClient.mockRejectedValue(new Error("Connector not found")); + const { BatchTransactionService } = await import("../batchTransaction"); + + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Connector not found"); + expect(result.transactionHash).toBeUndefined(); + }); + + it("returns a user rejection with a decoded reason", async () => { + const walletClient = { + chain: { id: 1 }, + getChainId: jest.fn(async () => 1), + writeContract: jest.fn(async () => { + throw Object.assign(new Error("User denied transaction"), { + code: 4001, + }); + }), + }; + mockGetWalletClient.mockResolvedValue(walletClient); + mockGetPublicClient.mockReturnValue({ + waitForTransactionReceipt: jest.fn(), + }); + const { BatchTransactionService } = await import("../batchTransaction"); + + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Transaction rejected by the user."); + expect(result.transactionHash).toBeUndefined(); + }); + }); }); diff --git a/src/lib/batchPurchaseExecutor.ts b/src/lib/batchPurchaseExecutor.ts new file mode 100644 index 00000000..48574232 --- /dev/null +++ b/src/lib/batchPurchaseExecutor.ts @@ -0,0 +1,65 @@ +import { getPublicClient, getWalletClient } from "@wagmi/core/actions"; +import { parseEther } from "viem"; +import { config } from "@/config/wagmi"; +import { BATCH_PURCHASE_ABI } from "@/config/batchPurchase"; +import type { + BatchPurchaseExecutor, + BatchPurchaseRequest, +} from "./batchTransaction"; + +const DEADLINE_SECONDS = 60 * 30; // 30 minutes from now + +/** + * Build the real batch-purchase executor backed by the connected wallet. + * + * The executor resolves the wallet client for the connected account (throwing + * before any submission when the account is not connected), submits the + * `batchPurchase` call, and only reports success after the receipt has been + * observed on chain. + */ +export const createWagmiBatchPurchaseExecutor = ( + contractAddress: `0x${string}`, +): BatchPurchaseExecutor => ({ + execute: async (request: BatchPurchaseRequest) => { + // getWalletClient throws when the requested account is not connected to + // any connector, so nothing is submitted for a disconnected wallet. + const walletClient = await getWalletClient(config, { + account: request.walletAddress, + }); + const chainId = walletClient.chain?.id ?? (await walletClient.getChainId()); + const publicClient = getPublicClient(config, { chainId }); + + const propertyTokens = request.items.map( + (item) => item.tokenAddress as `0x${string}`, + ); + const quantities = request.items.map((item) => BigInt(item.quantity)); + const minAmounts = request.items.map((item) => + parseEther(item.minimumAmount.toString()), + ); + const value = request.items.reduce( + (sum, item) => sum + parseEther(item.expectedAmount.toString()), + 0n, + ); + const deadline = + BigInt(Math.floor(Date.now() / 1000)) + BigInt(DEADLINE_SECONDS); + + const transactionHash = await walletClient.writeContract({ + address: contractAddress, + abi: BATCH_PURCHASE_ABI, + functionName: "batchPurchase", + args: [propertyTokens, quantities, minAmounts, deadline], + account: request.walletAddress, + chain: walletClient.chain, + value, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash: transactionHash, + }); + + return { + transactionHash, + receiptStatus: receipt.status === "success" ? "success" : "reverted", + }; + }, +}); diff --git a/src/lib/batchTransaction.ts b/src/lib/batchTransaction.ts index bd399845..44209b9a 100644 --- a/src/lib/batchTransaction.ts +++ b/src/lib/batchTransaction.ts @@ -1,6 +1,7 @@ import type { CartItem, BatchTransactionResult } from "@/types/cart"; import { logger } from "@/utils/logger"; import { decodeRevertReason } from "@/utils/revertDecoder"; +import { getBatchPurchaseContractAddress } from "@/config/batchPurchase"; const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/; @@ -8,6 +9,7 @@ export interface BatchPurchaseRequest { walletAddress: `0x${string}`; items: Array<{ propertyId: string; + tokenAddress: string; quantity: number; expectedAmount: number; minimumAmount: number; @@ -94,6 +96,21 @@ export const calculateMinimumAmount = ( slippageTolerance: number, ): number => expectedAmount * (1 - slippageTolerance); +/** + * Resolve the executor backed by the connected wallet and the configured + * batch-purchase contract. Returns null when no contract address is + * configured so checkout fails closed instead of fabricating a result. + */ +const resolveBatchPurchaseExecutor = + async (): Promise => { + const contractAddress = getBatchPurchaseContractAddress(); + if (!contractAddress) return null; + + const { createWagmiBatchPurchaseExecutor } = + await import("./batchPurchaseExecutor"); + return createWagmiBatchPurchaseExecutor(contractAddress); + }; + export const BatchTransactionService = { executeBatchPurchase: async ( items: CartItem[], @@ -119,7 +136,8 @@ export const BatchTransactionService = { ); } - if (!executor) { + const resolvedExecutor = executor ?? (await resolveBatchPurchaseExecutor()); + if (!resolvedExecutor) { return failureResult( items, "Batch purchase is not configured for this network.", @@ -133,6 +151,7 @@ export const BatchTransactionService = { const expectedAmount = item.quantity * item.property.price.perToken; return { propertyId: item.property.id, + tokenAddress: item.property.tokenInfo.contractAddress, quantity: item.quantity, expectedAmount, minimumAmount: calculateMinimumAmount( @@ -151,7 +170,7 @@ export const BatchTransactionService = { try { const { transactionHash, receiptStatus } = - await executor.execute(request); + await resolvedExecutor.execute(request); if (receiptStatus !== "success") { return failureResult(items, "Batch purchase transaction reverted."); }