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
7 changes: 7 additions & 0 deletions web/public/tonconnect-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"url": "https://pay.oclawbox.com",
"name": "OpenClaw Pay",
"iconUrl": "https://pay.oclawbox.com/globe.svg",
"termsOfUseUrl": "https://pay.oclawbox.com",
"privacyPolicyUrl": "https://pay.oclawbox.com"
}
11 changes: 11 additions & 0 deletions web/src/__tests__/app/pay/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
Expand All @@ -21,6 +22,16 @@ vi.mock("@/lib/wallets/solana", () => ({
sendSolanaTransfer: vi.fn(),
}));

vi.mock("@/lib/wallets/ton", () => ({
buildTonTransferMessage: vi.fn(),
}));

vi.mock("@tonconnect/ui-react", () => ({
useTonConnectUI: () => [{ openModal: vi.fn(), sendTransaction: vi.fn() }, vi.fn()],
useTonAddress: () => "",
TonConnectUIProvider: ({ children }: { children: React.ReactNode }) => children,
}));

import { fetchConfig } from "@/lib/api";
import PayPage from "@/app/pay/page";

Expand Down
95 changes: 94 additions & 1 deletion web/src/__tests__/components/wallet-connect.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WalletConnect } from "@/components/wallet-connect";
Expand All @@ -17,8 +17,31 @@ vi.mock("@/lib/wallets/solana", () => ({
sendSolanaTransfer: vi.fn(),
}));

vi.mock("@/lib/wallets/ton", () => ({
isTonAvailable: vi.fn(() => true),
buildTonTransferMessage: vi.fn(() => ({
validUntil: 1234567890,
messages: [{ address: "EQ...", amount: "50000000", payload: "base64boc" }],
})),
}));

// Mock TonConnect hooks
const mockOpenModal = vi.fn();
const mockSendTransaction = vi.fn();
let mockTonAddress = "";

vi.mock("@tonconnect/ui-react", () => ({
useTonConnectUI: () => [
{ openModal: mockOpenModal, sendTransaction: mockSendTransaction },
vi.fn(),
],
useTonAddress: () => mockTonAddress,
TonConnectUIProvider: ({ children }: { children: React.ReactNode }) => children,
}));

import { isEvmAvailable, connectEvm, sendEvmTransfer } from "@/lib/wallets/evm";
import { isSolanaAvailable, connectSolana, sendSolanaTransfer } from "@/lib/wallets/solana";
import { buildTonTransferMessage } from "@/lib/wallets/ton";

const defaultProps = {
chain: "base" as const,
Expand All @@ -33,6 +56,7 @@ const defaultProps = {
describe("WalletConnect", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTonAddress = "";
});

it("always shows Connect Wallet button for EVM chains", () => {
Expand Down Expand Up @@ -93,6 +117,11 @@ describe("WalletConnect", () => {
expect(screen.getByText("Connect TON Wallet")).toBeInTheDocument();
});

it("does not show install prompt for TON (uses TonConnect QR)", () => {
render(<WalletConnect {...defaultProps} chain="ton" />);
expect(screen.queryByText(/Tonkeeper not detected/i)).not.toBeInTheDocument();
});

it("connects EVM wallet and shows address on click", async () => {
const user = userEvent.setup();
vi.mocked(isEvmAvailable).mockReturnValue(true);
Expand Down Expand Up @@ -248,4 +277,68 @@ describe("WalletConnect", () => {
await user.click(screen.getByText("Pay $10.00 USDC"));
expect(onStatus).toHaveBeenCalledWith("error", "Insufficient funds for this transaction");
});

// --- TON Connect tests ---

it("opens TonConnect modal when clicking Connect TON Wallet", async () => {
const user = userEvent.setup();
render(<WalletConnect {...defaultProps} chain="ton" />);

await user.click(screen.getByText("Connect TON Wallet"));
expect(mockOpenModal).toHaveBeenCalled();
});

it("shows connected TON address from TonConnect hook", () => {
mockTonAddress = "0:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
render(<WalletConnect {...defaultProps} chain="ton" />);

// Should show truncated address and Pay button
expect(screen.getByText(/0:1234/)).toBeInTheDocument();
expect(screen.getByText("Pay $10.00 USDC")).toBeInTheDocument();
});

it("sends TON jetton transfer via TonConnect", async () => {
const user = userEvent.setup();
mockTonAddress = "0:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
mockSendTransaction.mockResolvedValue({ boc: "te6cckEBAQEA..." });

const onTxSent = vi.fn();
render(
<WalletConnect
{...defaultProps}
chain="ton"
tokenAddress="EQJettonAddr"
walletAddress="EQDestAddr"
onTxSent={onTxSent}
/>,
);

// Should already show connected state from tonAddress hook
await user.click(screen.getByText("Pay $10.00 USDC"));

expect(buildTonTransferMessage).toHaveBeenCalledWith({
jettonAddress: "EQJettonAddr",
toAddress: "EQDestAddr",
amountUsd: 10,
});
expect(mockSendTransaction).toHaveBeenCalledWith({
validUntil: 1234567890,
messages: [{ address: "EQ...", amount: "50000000", payload: "base64boc" }],
});
expect(onTxSent).toHaveBeenCalledWith("te6cckEBAQEA...");
});

it("shows friendly error when TON transaction is rejected", async () => {
const user = userEvent.setup();
mockTonAddress = "0:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
mockSendTransaction.mockRejectedValue(new Error("User rejected the transaction"));

const onStatus = vi.fn();
render(
<WalletConnect {...defaultProps} chain="ton" onStatus={onStatus} />,
);

await user.click(screen.getByText("Pay $10.00 USDC"));
expect(onStatus).toHaveBeenCalledWith("error", "Transaction cancelled");
});
});
3 changes: 2 additions & 1 deletion web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Providers } from "./providers";
import "./globals.css";

export const metadata: Metadata = {
Expand All @@ -24,7 +25,7 @@ export default function RootLayout({
<script src="https://telegram.org/js/telegram-web-app.js" />
</head>
<body className="min-h-screen bg-background text-foreground antialiased">
{children}
<Providers>{children}</Providers>
</body>
</html>
);
Expand Down
13 changes: 13 additions & 0 deletions web/src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use client";

import { TonConnectUIProvider } from "@tonconnect/ui-react";

const MANIFEST_URL = "https://pay.oclawbox.com/tonconnect-manifest.json";

export function Providers({ children }: { children: React.ReactNode }) {
return (
<TonConnectUIProvider manifestUrl={MANIFEST_URL}>
{children}
</TonConnectUIProvider>
);
}
28 changes: 28 additions & 0 deletions web/src/components/wallet-connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { ChainId, TokenId } from "@/lib/config";
import { EVM_CHAINS } from "@/lib/config";
import { isEvmAvailable, connectEvm, sendEvmTransfer } from "@/lib/wallets/evm";
import { isSolanaAvailable, connectSolana, sendSolanaTransfer } from "@/lib/wallets/solana";
import { buildTonTransferMessage } from "@/lib/wallets/ton";
import { useTonConnectUI, useTonAddress } from "@tonconnect/ui-react";
import type { StatusType } from "./status-message";
import type { Signer } from "ethers";

Expand Down Expand Up @@ -59,6 +61,10 @@ export function WalletConnect({
const [sending, setSending] = useState(false);
const prevChainRef = useRef(chain);

// TonConnect hooks
const [tonConnectUI] = useTonConnectUI();
const tonAddress = useTonAddress(false); // raw address

const isEvm = EVM_CHAINS.includes(chain);
const isSol = chain === "sol";
const isTon = chain === "ton";
Expand All @@ -73,13 +79,21 @@ export function WalletConnect({
}
}, [chain]);

// Sync TON connected address from TonConnect
useEffect(() => {
if (isTon && tonAddress) {
setConnectedAddress(tonAddress);
}
}, [isTon, tonAddress]);

// Check extension availability
const hasEvmWallet = isEvm && isEvmAvailable();
const hasSolWallet = isSol && isSolanaAvailable();

// Determine wallet type for install prompt
const walletType = isEvm ? "evm" : isSol ? "sol" : "ton";
const walletInfo = INSTALL_URLS[walletType];
// TON uses TonConnect (QR + deep links), always available
const hasExtension = hasEvmWallet || hasSolWallet || isTon;

async function handleConnect() {
Expand All @@ -99,6 +113,9 @@ export function WalletConnect({
const { address } = await connectSolana();
setConnectedAddress(address);
onStatus("pending", `Connected: ${address.slice(0, 6)}...${address.slice(-4)}`);
} else if (isTon) {
// Open TonConnect modal (QR code / wallet list)
await tonConnectUI.openModal();
}
} catch (err) {
onStatus("error", friendlyError(err));
Expand All @@ -116,6 +133,17 @@ export function WalletConnect({
} else if (isSol) {
const mintAddr = tokenAddress;
txHash = await sendSolanaTransfer(mintAddr, walletAddress, amount);
} else if (isTon) {
// Build TEP-74 jetton transfer message and send via TonConnect
const txMessage = buildTonTransferMessage({
jettonAddress: tokenAddress,
toAddress: walletAddress,
amountUsd: amount,
});
const result = await tonConnectUI.sendTransaction(txMessage);
// TonConnect returns { boc: string } — the signed transaction BOC
// Use BOC as the transaction identifier for verification
txHash = result.boc;
} else {
throw new Error("Wallet not connected");
}
Expand Down