Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# -----------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions docs/smart-contract-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
52 changes: 52 additions & 0 deletions src/config/batchPurchase.ts
Original file line number Diff line number Diff line change
@@ -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}`;
};
138 changes: 138 additions & 0 deletions src/lib/__tests__/batchTransaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -183,6 +220,7 @@ describe("BatchTransactionService", () => {
items: [
{
propertyId: "prop-1",
tokenAddress: walletAddress,
quantity: 2,
expectedAmount: 0.2,
minimumAmount: 0.199,
Expand Down Expand Up @@ -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();
});
});
});
65 changes: 65 additions & 0 deletions src/lib/batchPurchaseExecutor.ts
Original file line number Diff line number Diff line change
@@ -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",
};
},
});
23 changes: 21 additions & 2 deletions src/lib/batchTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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}$/;

export interface BatchPurchaseRequest {
walletAddress: `0x${string}`;
items: Array<{
propertyId: string;
tokenAddress: string;
quantity: number;
expectedAmount: number;
minimumAmount: number;
Expand Down Expand Up @@ -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<BatchPurchaseExecutor | null> => {
const contractAddress = getBatchPurchaseContractAddress();
if (!contractAddress) return null;

const { createWagmiBatchPurchaseExecutor } =
await import("./batchPurchaseExecutor");
return createWagmiBatchPurchaseExecutor(contractAddress);
};

export const BatchTransactionService = {
executeBatchPurchase: async (
items: CartItem[],
Expand All @@ -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.",
Expand All @@ -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(
Expand All @@ -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.");
}
Expand Down
Loading