Stake
{formatAplo(stakeStats.staked)}
Stake Level
{stakeStats.multiplier > 0 ? "Mining enabled" : "Not staked"}
Mining Status
{stakeStats.canMine ? "Stake OK" : `Needs ${MIN_STAKE_APLO} APLO stake`}
diff --git a/src/features/webminer/components/ModeSelector.tsx b/src/features/webminer/components/ModeSelector.tsx
deleted file mode 100644
index c19e3c1..0000000
--- a/src/features/webminer/components/ModeSelector.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import type { MinerMode } from "../types";
-
-interface ModeSelectorProps {
- activeMode: MinerMode;
- isMining: boolean;
- onSwitchMode: (mode: MinerMode) => void;
-}
-
-export function ModeSelector({ activeMode, isMining, onSwitchMode }: ModeSelectorProps) {
- return (
-
-
-
-
- );
-}
diff --git a/src/features/webminer/components/StakingControls.tsx b/src/features/webminer/components/StakingControls.tsx
index b654b9f..7cf57af 100644
--- a/src/features/webminer/components/StakingControls.tsx
+++ b/src/features/webminer/components/StakingControls.tsx
@@ -23,7 +23,7 @@ export function StakingControls({ activeMode, walletAddress, privateKey, stakeAm
const walletBlocked = !walletAddress || (activeMode === "legacy" && !privateKey);
return (
-
+
Stake / Unstake APLO
onStakeAmountChange(e.target.value)} disabled={isMining || isStaking} />
)}
diff --git a/src/features/webminer/components/WebMinerShell.tsx b/src/features/webminer/components/WebMinerShell.tsx
index 3de46bb..a3ee172 100644
--- a/src/features/webminer/components/WebMinerShell.tsx
+++ b/src/features/webminer/components/WebMinerShell.tsx
@@ -1,46 +1,62 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { MinerNavigation } from "@/components/MinerNavigation";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
-import { useToast } from "@/hooks/use-toast";
import { useWebMinerController } from "../hooks/useWebMinerController";
import { MinedSharesTable } from "./MinedSharesTable";
import { MinerStatsPanel } from "./MinerStatsPanel";
import { MiningToggle } from "./MiningToggle";
-import { ModeSelector } from "./ModeSelector";
import { RpcNodeSelector } from "./RpcNodeSelector";
import { StakeRewardTable } from "./StakeRewardTable";
import { StakingControls } from "./StakingControls";
import { WalletAccessPanel } from "./WalletAccessPanel";
-export function WebMinerShell() {
- const controller = useWebMinerController();
- const { toast } = useToast();
+export type MinerSurface = "miner" | "staking" | "legacy";
- const requestPermission = () => {
- controller.requestErc7715MiningPermission().catch((error) => {
- const message = error instanceof Error ? error.message : "Failed to request ERC-7715 permission";
- toast({ variant: "destructive", title: "ERC-7715 Error", description: message });
- });
- };
+const surfaceMeta = {
+ miner: { path: "/", title: "GAplo Web Miner", description: "Mine GAPLO with your connected wallet." },
+ staking: { path: "/staking", title: "APLO Staking", description: "Manage APLO stake and review mining eligibility." },
+ legacy: { path: "/legacy", title: "Legacy Miner", description: "Local private-key signing for dedicated mining wallets." },
+} as const;
+
+export function WebMinerShell({ surface = "miner" }: { surface?: MinerSurface }) {
+ const activeMode = surface === "legacy" ? "legacy" : "current";
+ const controller = useWebMinerController(activeMode);
+ const meta = surfaceMeta[surface];
return (
-
-
-
- GAplo Web Miner
+
+
+
+
+
+
+
+
+ {meta.title}
+ {meta.description}
-
-
+
-
-
-
-
-
+
+
+ {surface === "staking" ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
-
+
+ {surface !== "staking" &&
}
);
-}
+}
\ No newline at end of file
diff --git a/src/features/webminer/config.ts b/src/features/webminer/config.ts
index 781aa10..c9598ad 100644
--- a/src/features/webminer/config.ts
+++ b/src/features/webminer/config.ts
@@ -9,7 +9,7 @@ export const APLO_STAKING_ADDRESS = "0x0000000000000000000000000000000000001235"
export const MIN_STAKE_APLO = "1000";
export const MIN_STAKE_WEI = BigInt("1000000000000000000000");
export const MAX_UINT256 = BigInt("115792089237316195423570985008687907853269984665640564039457584007913129639935");
-export const EXECUTION_MODE_SINGLE_DEFAULT = `0x${"0".repeat(64)}`;
+
export const APLO_STAKING_ABI = [
{
@@ -42,19 +42,6 @@ export const APLO_STAKING_ABI = [
},
];
-export const ERC7715_DELEGATION_MANAGER_ABI = [
- {
- inputs: [
- { internalType: "bytes[]", name: "_permissionContexts", type: "bytes[]" },
- { internalType: "bytes32[]", name: "_modes", type: "bytes32[]" },
- { internalType: "bytes[]", name: "_executionCallData", type: "bytes[]" },
- ],
- name: "redeemDelegations",
- outputs: [],
- stateMutability: "nonpayable",
- type: "function",
- },
-];
export const CONTRACT_ABI = [
{ inputs: [], stateMutability: "nonpayable", type: "constructor" },
diff --git a/src/features/webminer/hooks/useWebMinerController.ts b/src/features/webminer/hooks/useWebMinerController.ts
index 5bd8205..b989c42 100644
--- a/src/features/webminer/hooks/useWebMinerController.ts
+++ b/src/features/webminer/hooks/useWebMinerController.ts
@@ -11,33 +11,30 @@ import {
CONTRACT_ABI,
CONTRACT_ADDRESS,
DEFAULT_DIFFICULTY,
- ERC7715_DELEGATION_MANAGER_ABI,
- EXECUTION_MODE_SINGLE_DEFAULT,
MIN_STAKE_APLO,
MIN_STAKE_WEI,
PRESET_RPC_NODES,
} from "../config";
-import type { Erc7715PermissionResponse, MinedShare, MinerMode, MinerParams, MiningWorkerMessage } from "../types";
+import type { MinedShare, MinerMode, MinerParams, MiningWorkerMessage } from "../types";
+import { submitWalletMineTransaction } from "../wallet-mining";
import {
formatAplo,
formatMiningDifficulty,
formatPrivateKey,
- getAddressFromPrivateKey,
isValidStakeAmount,
validatePrivateKey,
validateRpcUrl,
withTimeout,
} from "../utils";
-export const useWebMinerController = () => {
+export const useWebMinerController = (initialMode: MinerMode = "current") => {
const { toast } = useToast();
- const [activeMode, setActiveMode] = useState("current");
+ const [activeMode] = useState(initialMode);
const [walletAddress, setWalletAddress] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [isWalletConnecting, setIsWalletConnecting] = useState(false);
- const [aaSessionAddress, setAaSessionAddress] = useState("");
- const [erc7715Permission, setErc7715Permission] = useState(null);
+
const [isMining, setIsMining] = useState(false);
const [minedShares, setMinedShares] = useState([]);
const [minerStats, setMinerStats] = useState<{
@@ -101,23 +98,6 @@ export const useWebMinerController = () => {
}
}, []);
- useEffect(() => {
- if (typeof window === "undefined") return;
- const savedSessionKey = localStorage.getItem("aaSessionPrivateKey");
- if (savedSessionKey) {
- const sessionAddress = getAddressFromPrivateKey(savedSessionKey);
- if (sessionAddress) setAaSessionAddress(sessionAddress);
- }
-
- const savedPermission = localStorage.getItem("erc7715MiningPermission");
- if (savedPermission) {
- try {
- setErc7715Permission(JSON.parse(savedPermission));
- } catch {
- localStorage.removeItem("erc7715MiningPermission");
- }
- }
- }, []);
// Initialize Web3 with current RPC URL
const initializeWeb3 = (rpcUrl: string) => {
@@ -292,159 +272,28 @@ export const useWebMinerController = () => {
return await web3.eth.sendSignedTransaction(signedTx.rawTransaction);
};
- const getOrCreateAaSessionKey = () => {
- if (!web3Ref.current) throw new Error("Web3 is not initialized");
- if (typeof window === "undefined") throw new Error("Session keys are browser-only");
-
- const saved = localStorage.getItem("aaSessionPrivateKey");
- if (saved && validatePrivateKey(saved)) {
- const address = getAddressFromPrivateKey(saved);
- setAaSessionAddress(address);
- return { privateKey: formatPrivateKey(saved), address };
- }
-
- const account = web3Ref.current.eth.accounts.create();
- localStorage.setItem("aaSessionPrivateKey", account.privateKey);
- setAaSessionAddress(account.address);
- return { privateKey: account.privateKey, address: account.address };
- };
-
- const getCurrentChainId = async () => {
- if (!window.ethereum) throw new Error("Connect an EIP-1193 wallet first");
- return (await window.ethereum.request({ method: "eth_chainId" })) as string;
- };
-
- const requestErc7715MiningPermission = async () => {
- if (!window.ethereum) throw new Error("Connect an EIP-1193 wallet first");
- if (!walletAddress) throw new Error("Connect wallet first");
-
- const sessionKey = getOrCreateAaSessionKey();
- const chainId = await getCurrentChainId();
-
- const supported = await window.ethereum
- .request({ method: "wallet_getSupportedExecutionPermissions", params: [] })
- .catch(() => null);
-
- if (supported && !supported["contract-call"]) {
- throw new Error(
- "Connected wallet does not advertise ERC-7715 contract-call execution permissions yet. Update/switch wallet or use Legacy mode."
- );
- }
-
- const expiry = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30;
- const [permission] = (await window.ethereum.request({
- method: "wallet_requestExecutionPermissions",
- params: [
- [
- {
- chainId,
- from: walletAddress,
- to: sessionKey.address,
- permission: {
- type: "contract-call",
- isAdjustmentAllowed: false,
- data: {
- target: CONTRACT_ADDRESS,
- selector: "0x2fdc505e",
- valueLimit: "0x0",
- description: "Allow WebMiner session key to submit mine(bytes32) transactions without repeated wallet popups",
- },
- },
- rules: [
- { type: "expiry", data: { timestamp: expiry } },
- ],
- },
- ],
- ],
- })) as Erc7715PermissionResponse[];
-
- if (!permission?.context || !permission.delegationManager) {
- throw new Error("Wallet did not return a usable ERC-7715 permission");
- }
-
- localStorage.setItem("erc7715MiningPermission", JSON.stringify(permission));
- setErc7715Permission(permission);
- toast({ title: "Mining permission granted", description: "ERC-7715 session permission is ready" });
- return permission;
- };
-
- const getUsableErc7715Permission = async () => {
- if (erc7715Permission?.context && erc7715Permission.delegationManager) {
- return erc7715Permission;
- }
- return await requestErc7715MiningPermission();
- };
-
- const encodeErc7715Execution = (target: string, value: string, callData: string) => {
- if (!web3Ref.current) throw new Error("Web3 is not initialized");
- const encoded = web3Ref.current.utils.encodePacked(
- { type: "address", value: target },
- { type: "uint256", value },
- { type: "bytes", value: callData }
- );
- if (!encoded) throw new Error("Failed to encode ERC-7715 execution");
- return encoded;
- };
-
- const sendCurrentMineUserOperation = async (nonce: bigint) => {
+ const sendCurrentMineTransaction = async (nonce: bigint) => {
if (!web3Ref.current || !contractRef.current)
throw new Error("Not initialized");
+ if (!window.ethereum || !walletAddress)
+ throw new Error("Connect an EIP-1193 wallet first");
- const sessionKey = getOrCreateAaSessionKey();
- const permission = await getUsableErc7715Permission();
const nonceHex = web3Ref.current.utils.padLeft(web3Ref.current.utils.toHex(nonce), 64);
const callData = contractRef.current.methods.mine(nonceHex).encodeABI();
- const executionCalldata = encodeErc7715Execution(CONTRACT_ADDRESS, "0", callData);
- const delegationManager = new web3Ref.current.eth.Contract(
- ERC7715_DELEGATION_MANAGER_ABI as any,
- permission.delegationManager
- );
- const redeemTx = delegationManager.methods.redeemDelegations(
- [permission.context],
- [EXECUTION_MODE_SINGLE_DEFAULT],
- [executionCalldata]
- );
-
- for (const dependency of permission.dependencies ?? []) {
- if (dependency.factory && dependency.factoryData) {
- throw new Error(
- "ERC-7715 permission returned undeployed dependencies. Deploying permission dependency contracts is not supported by this miner yet."
- );
- }
- }
-
- const gasEstimate = await withTimeout(
- redeemTx.estimateGas({ from: sessionKey.address }),
- 15000,
- "ERC-7715 redeem gas estimate"
- );
- const gasPrice = await withTimeout(web3Ref.current.eth.getGasPrice(), 15000, "getGasPrice RPC");
- const sessionNonce = await withTimeout(
- web3Ref.current.eth.getTransactionCount(sessionKey.address, "pending"),
- 15000,
- "session nonce RPC"
- );
-
- const signedTx = await web3Ref.current.eth.accounts.signTransaction(
- {
- from: sessionKey.address,
- to: permission.delegationManager,
- data: redeemTx.encodeABI(),
- gas: Number(gasEstimate) + 10000,
- gasPrice,
- nonce: sessionNonce,
- },
- sessionKey.privateKey
- );
-
- if (!signedTx.rawTransaction) throw new Error("Failed to sign ERC-7715 mining transaction");
- return await web3Ref.current.eth.sendSignedTransaction(signedTx.rawTransaction);
+ return await submitWalletMineTransaction({
+ provider: window.ethereum,
+ from: walletAddress,
+ to: CONTRACT_ADDRESS,
+ data: callData,
+ waitForReceipt: (transactionHash) =>
+ web3Ref.current!.eth.getTransactionReceipt(transactionHash),
+ });
};
const sendMineTransaction = (nonce: bigint) =>
activeMode === "legacy"
? sendLegacyMineTransaction(nonce)
- : sendCurrentMineUserOperation(nonce);
+ : sendCurrentMineTransaction(nonce);
const getStakeStatus = async () => {
if (!web3Ref.current || !stakingContractRef.current || !walletAddress) {
@@ -730,7 +579,6 @@ export const useWebMinerController = () => {
const account = accounts?.[0];
if (!account) throw new Error("Wallet did not return an account");
setWalletAddress(account);
- getOrCreateAaSessionKey();
toast({ title: "Wallet connected", description: account });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to connect wallet.";
@@ -907,20 +755,6 @@ export const useWebMinerController = () => {
}
};
- const switchMode = (mode: MinerMode) => {
- if (isMining) {
- setIsMining(false);
- miningRef.current = false;
- stopMiningWorker();
- if (statsIntervalRef.current) {
- clearInterval(statsIntervalRef.current);
- statsIntervalRef.current = null;
- }
- }
- setActiveMode(mode);
- setWalletAddress("");
- if (mode === "current") setPrivateKey("");
- };
// Handle RPC node change
const handleNodeChange = (nodeType: string) => {
@@ -1020,8 +854,7 @@ export const useWebMinerController = () => {
walletAddress,
privateKey,
isWalletConnecting,
- aaSessionAddress,
- erc7715Permission,
+
isMining,
minedShares,
minerStats,
@@ -1035,11 +868,11 @@ export const useWebMinerController = () => {
setPrivateKey,
setWalletAddress,
setStakeAmount,
- switchMode,
+
handleNodeChange,
handleCustomRpcChange,
connectWallet,
- requestErc7715MiningPermission,
+
handleStake,
handleUnstake,
toggleMining,
diff --git a/src/features/webminer/types.ts b/src/features/webminer/types.ts
index 581f508..7d32901 100644
--- a/src/features/webminer/types.ts
+++ b/src/features/webminer/types.ts
@@ -32,14 +32,6 @@ export type MiningWorkerMessage =
| { type: "heartbeat"; jobId: number; at: number }
| { type: "error"; jobId: number; message: string };
-export interface Erc7715PermissionResponse {
- chainId: string;
- from: string;
- to: string;
- context: string;
- delegationManager: string;
- dependencies?: { factory?: string; factoryData?: string }[];
-}
export interface EthereumProvider {
request: (args: { method: string; params?: unknown[] }) => Promise;
diff --git a/src/features/webminer/wallet-mining.ts b/src/features/webminer/wallet-mining.ts
new file mode 100644
index 0000000..abc1aef
--- /dev/null
+++ b/src/features/webminer/wallet-mining.ts
@@ -0,0 +1,42 @@
+import type { EthereumProvider } from "./types";
+
+interface SubmitWalletMineTransactionOptions {
+ provider: EthereumProvider;
+ from: string;
+ to: string;
+ data: string;
+ waitForReceipt: (transactionHash: string) => Promise;
+ delay?: (milliseconds: number) => Promise;
+ pollIntervalMs?: number;
+ timeoutMs?: number;
+}
+
+const defaultDelay = (milliseconds: number) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+export async function submitWalletMineTransaction({
+ provider,
+ from,
+ to,
+ data,
+ waitForReceipt,
+ delay = defaultDelay,
+ pollIntervalMs = 1000,
+ timeoutMs = 120000,
+}: SubmitWalletMineTransactionOptions): Promise {
+ const transactionHash = await provider.request({
+ method: "eth_sendTransaction",
+ params: [{ from, to, data }],
+ });
+ if (typeof transactionHash !== "string" || !transactionHash) {
+ throw new Error("Wallet did not return a transaction hash");
+ }
+
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < timeoutMs) {
+ const receipt = await waitForReceipt(transactionHash);
+ if (receipt) return receipt;
+ await delay(pollIntervalMs);
+ }
+ throw new Error(`Timed out waiting for wallet transaction ${transactionHash}`);
+}
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..0ecbed3
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,5 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach } from "vitest";
+
+afterEach(() => cleanup());
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..9ecd2b4
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "vitest/config";
+import path from "node:path";
+
+export default defineConfig({
+ resolve: {
+ alias: { "@": path.resolve(__dirname, "src") },
+ },
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./src/test/setup.ts"],
+ },
+});