From 47d329dd77103707f0058bc3290a1a9b45eba07e Mon Sep 17 00:00:00 2001 From: shkudun Date: Sun, 25 Jan 2026 15:22:33 +0300 Subject: [PATCH 1/6] Add Harkan ABS game example --- examples/harkan-abs/README.md | 48 ++ examples/harkan-abs/agw-panel.js | 211 +++++++++ examples/harkan-abs/app.js | 748 +++++++++++++++++++++++++++++++ examples/harkan-abs/index.html | 245 ++++++++++ examples/harkan-abs/styles.css | 483 ++++++++++++++++++++ 5 files changed, 1735 insertions(+) create mode 100644 examples/harkan-abs/README.md create mode 100644 examples/harkan-abs/agw-panel.js create mode 100644 examples/harkan-abs/app.js create mode 100644 examples/harkan-abs/index.html create mode 100644 examples/harkan-abs/styles.css diff --git a/examples/harkan-abs/README.md b/examples/harkan-abs/README.md new file mode 100644 index 0000000..a2879d0 --- /dev/null +++ b/examples/harkan-abs/README.md @@ -0,0 +1,48 @@ +\# Harkan ABS — Abstract Testnet Game + + + +Cyberpunk mini‑game inspired by Harkan, built for Abstract Testnet. + + + +\## Demo + +https://abs-game001.vercel.app/ + + + +\## Contract (Abstract Testnet) + +0x51F2C923a5307E2701F228DcC4cB3D72B1aAb804 + + + +\## Features + +\- AGW login + sponsored tx option + +\- On‑chain score submission + leaderboard + +\- Static frontend (no build step) + + + +\## Run locally + +python -m http.server 5173 + +\# then open http://localhost:5173 + + + +\## Usage + +\- Switch to Abstract Testnet + +\- Connect wallet + +\- Play a run + +\- Submit on‑chain score + diff --git a/examples/harkan-abs/agw-panel.js b/examples/harkan-abs/agw-panel.js new file mode 100644 index 0000000..17ac5ee --- /dev/null +++ b/examples/harkan-abs/agw-panel.js @@ -0,0 +1,211 @@ +import React, { useEffect, useMemo, useState } from "https://esm.sh/react@18.2.0"; +import { createRoot } from "https://esm.sh/react-dom@18.2.0/client"; +import { + AbstractWalletProvider, + useLoginWithAbstract, + useWriteContractSponsored, +} from "https://esm.sh/@abstract-foundation/agw-react@0.6.0"; +import { useAccount, useWriteContract } from "https://esm.sh/wagmi@2.12.5"; +import { + isAddress, + parseAbi, +} from "https://esm.sh/viem@2.21.25"; +import { getGeneralPaymasterInput } from "https://esm.sh/viem@2.21.25/zksync"; +import { abstractTestnet } from "https://esm.sh/viem@2.21.25/chains"; + +const STORAGE_KEYS = { + contract: "harkanContractAddress", + paymaster: "harkanPaymasterAddress", +}; + +const CONTRACT_ABI = parseAbi([ + "function submitScore(uint256 score,uint256 duration,uint256 hits,uint256 misses)", +]); + +function getStoredValue(key) { + if (typeof localStorage === "undefined") return ""; + return localStorage.getItem(key) || ""; +} + +function setStoredValue(key, value) { + if (typeof localStorage === "undefined") return; + if (!value) { + localStorage.removeItem(key); + return; + } + localStorage.setItem(key, value); +} + +function useLatestScore() { + const [latestScore, setLatestScore] = useState( + typeof window !== "undefined" ? window.harkanLatestScore || null : null + ); + + useEffect(() => { + const handler = (event) => { + setLatestScore(event.detail); + }; + window.addEventListener("harkan:runComplete", handler); + return () => window.removeEventListener("harkan:runComplete", handler); + }, []); + + return latestScore; +} + +function AgwPanel() { + const { login, logout } = useLoginWithAbstract(); + const { address, status } = useAccount(); + const { writeContractAsync } = useWriteContract(); + const { writeContractSponsoredAsync, isPending } = + useWriteContractSponsored(); + const latestScore = useLatestScore(); + + const [contractAddress, setContractAddress] = useState( + getStoredValue(STORAGE_KEYS.contract) + ); + const [paymasterAddress, setPaymasterAddress] = useState( + getStoredValue(STORAGE_KEYS.paymaster) + ); + const [statusMsg, setStatusMsg] = useState("Waiting for run..."); + const [lastHash, setLastHash] = useState(""); + + const isConnected = status === "connected"; + const scoreSummary = useMemo(() => { + if (!latestScore) return "No run data yet"; + return `${latestScore.score} pts • ${latestScore.hits} hits • ${latestScore.accuracy}%`; + }, [latestScore]); + + const handleContractChange = (event) => { + const value = event.target.value.trim(); + setContractAddress(value); + setStoredValue(STORAGE_KEYS.contract, value); + }; + + const handlePaymasterChange = (event) => { + const value = event.target.value.trim(); + setPaymasterAddress(value); + setStoredValue(STORAGE_KEYS.paymaster, value); + }; + + async function submitScore() { + if (!latestScore) { + setStatusMsg("Play a run first"); + return; + } + if (!isAddress(contractAddress)) { + setStatusMsg("Set a valid contract address"); + return; + } + if (!isConnected) { + setStatusMsg("Connect AGW first"); + return; + } + try { + setStatusMsg("Submitting..."); + const args = [ + BigInt(latestScore.score), + BigInt(latestScore.duration), + BigInt(latestScore.hits), + BigInt(latestScore.misses), + ]; + + let hash = ""; + if (paymasterAddress) { + if (!isAddress(paymasterAddress)) { + setStatusMsg("Invalid paymaster address"); + return; + } + hash = await writeContractSponsoredAsync({ + abi: CONTRACT_ABI, + address: contractAddress, + functionName: "submitScore", + args, + paymaster: paymasterAddress, + paymasterInput: getGeneralPaymasterInput({ innerInput: "0x" }), + }); + } else { + hash = await writeContractAsync({ + abi: CONTRACT_ABI, + address: contractAddress, + functionName: "submitScore", + args, + }); + } + + setLastHash(hash); + setStatusMsg("Score submitted"); + } catch (error) { + setStatusMsg("Submission failed"); + } + } + + return ( +
+
+ Status: {isConnected ? "Connected" : "Disconnected"} +
+
+ Address: {address ? `${address.slice(0, 6)}...${address.slice(-4)}` : "—"} +
+
Latest run: {scoreSummary}
+ + +
+ {!isConnected ? ( + + ) : ( + + )} + +
+
{statusMsg}
+ {lastHash ? ( +
+ Tx:{" "} + + {lastHash.slice(0, 10)}... + +
+ ) : null} +
+ ); +} + +function Root() { + return ( + + + + ); +} + +const mountNode = document.getElementById("agwMount"); +if (mountNode) { + createRoot(mountNode).render(); +} diff --git a/examples/harkan-abs/app.js b/examples/harkan-abs/app.js new file mode 100644 index 0000000..465ea5a --- /dev/null +++ b/examples/harkan-abs/app.js @@ -0,0 +1,748 @@ +import { + createPublicClient, + createWalletClient, + custom, + http, + isAddress, + parseAbiItem, +} from "https://esm.sh/viem@2.21.25"; +import { abstractTestnet } from "https://esm.sh/viem@2.21.25/chains"; +import { eip712WalletActions } from "https://esm.sh/viem@2.21.25/zksync"; + +const networkBtn = document.getElementById("networkBtn"); +const connectBtn = document.getElementById("connectBtn"); +const walletStatus = document.getElementById("walletStatus"); +const sessionStatus = document.getElementById("sessionStatus"); +const modal = document.getElementById("modal"); +const howBtn = document.getElementById("howBtn"); +const closeModal = document.getElementById("closeModal"); +const enterBtn = document.getElementById("enterBtn"); + +const scoreValue = document.getElementById("scoreValue"); +const comboValue = document.getElementById("comboValue"); +const timeValue = document.getElementById("timeValue"); +const hitsValue = document.getElementById("hitsValue"); +const missesValue = document.getElementById("missesValue"); +const accuracyValue = document.getElementById("accuracyValue"); +const rankValue = document.getElementById("rankValue"); + +const startBtn = document.getElementById("startBtn"); +const pauseBtn = document.getElementById("pauseBtn"); +const resetBtn = document.getElementById("resetBtn"); +const saveBtn = document.getElementById("saveBtn"); +const submitBtn = document.getElementById("submitBtn"); +const overlay = document.getElementById("overlay"); +const overlayStart = document.getElementById("overlayStart"); + +const leaderboardList = document.getElementById("leaderboardList"); +const refreshLeaderboard = document.getElementById("refreshLeaderboard"); +const lastTx = document.getElementById("lastTx"); + +const playerName = document.getElementById("playerName"); +const contractAddressInput = document.getElementById("contractAddress"); +const bestScoreValue = document.getElementById("bestScoreValue"); +const runsValue = document.getElementById("runsValue"); +const accuracyTotalValue = document.getElementById("accuracyTotalValue"); +const difficultySelect = document.getElementById("difficultySelect"); +const soundToggle = document.getElementById("soundToggle"); +const dailyObjective = document.getElementById("dailyObjective"); + +const canvas = document.getElementById("gameCanvas"); +const ctx = canvas.getContext("2d"); + +const DEFAULT_CONTRACT_ADDRESS = "0x0000000000000000000000000000000000000000"; +const SCORE_EVENT = parseAbiItem( + "event ScoreSubmitted(address indexed player,uint256 score,uint256 duration,uint256 hits,uint256 misses,uint256 timestamp)" +); +const LOG_BLOCK_RANGE = 50000n; +const LEADERBOARD_ABI = [ + { + type: "function", + name: "submitScore", + stateMutability: "nonpayable", + inputs: [ + { name: "score", type: "uint256" }, + { name: "duration", type: "uint256" }, + { name: "hits", type: "uint256" }, + { name: "misses", type: "uint256" }, + ], + outputs: [], + }, +]; + +const STORAGE_KEYS = { + profile: "harkanProfile", + leaderboard: "harkanLeaderboard", + settings: "harkanSettings", + daily: "harkanDailyObjective", + lastTx: "harkanLastTx", + contract: "harkanContractAddress", +}; + +const difficultyMap = { + easy: { spawnInterval: 850, ttlMin: 2600, ttlMax: 4200, scoreBoost: 0.9 }, + standard: { spawnInterval: 700, ttlMin: 2200, ttlMax: 3800, scoreBoost: 1 }, + hard: { spawnInterval: 550, ttlMin: 1700, ttlMax: 3200, scoreBoost: 1.2 }, + nightmare: { spawnInterval: 420, ttlMin: 1400, ttlMax: 2600, scoreBoost: 1.35 }, +}; + +let walletAddress = null; +let walletClient = null; +let contractAddress = DEFAULT_CONTRACT_ADDRESS; +const publicClient = createPublicClient({ + chain: abstractTestnet, + transport: http(), +}).extend(eip712WalletActions()); + +const game = { + running: false, + paused: false, + score: 0, + combo: 1, + timeLeft: 45, + hits: 0, + misses: 0, + targets: [], + spawnTimer: null, + countdownTimer: null, + lastHitTime: 0, + runStart: 0, + runEnd: 0, +}; + +const profile = loadProfile(); +const settings = loadSettings(); +const objective = loadObjective(); + +let audioContext = null; +let canSubmitScore = false; + +function setStatus(message, isConnected = false) { + walletStatus.textContent = message; + walletStatus.style.color = isConnected ? "#76ffb3" : "#84f0ff"; +} + +function formatChainId(id) { + return `0x${id.toString(16)}`; +} + +async function switchNetwork() { + if (!window.ethereum) { + setStatus("Wallet not found"); + return; + } + + try { + await window.ethereum.request({ + method: "wallet_addEthereumChain", + params: [ + { + chainId: formatChainId(abstractTestnet.id), + chainName: "Abstract Testnet", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: ["https://api.testnet.abs.xyz"], + blockExplorerUrls: ["https://sepolia.abscan.org/"], + }, + ], + }); + setStatus("Abstract Testnet ready", true); + } catch (error) { + setStatus("Network switch rejected"); + } +} + +async function connectWallet() { + if (!window.ethereum) { + setStatus("Install a wallet"); + return; + } + + try { + const accounts = await window.ethereum.request({ + method: "eth_requestAccounts", + }); + walletAddress = accounts[0] || null; + if (walletAddress) { + const short = + walletAddress.slice(0, 6) + "..." + walletAddress.slice(-4); + setStatus(`Connected ${short}`, true); + walletClient = createWalletClient({ + chain: abstractTestnet, + transport: custom(window.ethereum), + }).extend(eip712WalletActions()); + } + } catch (error) { + setStatus("Connection rejected"); + } +} + +function openModal() { + modal.classList.remove("hidden"); +} + +function closeModalView() { + modal.classList.add("hidden"); +} + +function resetGame() { + game.running = false; + game.paused = false; + game.score = 0; + game.combo = 1; + game.timeLeft = 45; + game.hits = 0; + game.misses = 0; + game.targets = []; + game.runStart = 0; + game.runEnd = 0; + canSubmitScore = false; + clearTimers(); + updateHud(); + setScoreButtons(); + overlay.style.display = "grid"; + overlay.querySelector("h2").textContent = "NEURAL LINK READY"; + overlay.querySelector("p").textContent = + "Use your mouse or tap to lock onto signals. Each hit boosts your sync score."; + sessionStatus.textContent = "Standby"; + draw(); +} + +function startGame() { + if (game.running) { + return; + } + game.running = true; + game.paused = false; + game.runStart = performance.now(); + sessionStatus.textContent = "Live"; + overlay.style.display = "none"; + spawnTargets(); + startCountdown(); + requestAnimationFrame(loop); +} + +function pauseGame() { + if (!game.running) { + return; + } + game.paused = !game.paused; + sessionStatus.textContent = game.paused ? "Paused" : "Live"; + if (game.paused) { + clearTimers(); + } else { + spawnTargets(); + startCountdown(); + requestAnimationFrame(loop); + } +} + +function endGame() { + game.running = false; + game.runEnd = performance.now(); + clearTimers(); + overlay.style.display = "grid"; + overlay.querySelector("h2").textContent = "RUN COMPLETE"; + overlay.querySelector("p").textContent = `Final score: ${game.score}`; + sessionStatus.textContent = "Complete"; + canSubmitScore = true; + setScoreButtons(); + updateObjective(); + const entry = buildScoreEntry(); + window.harkanLatestScore = entry; + window.dispatchEvent(new CustomEvent("harkan:runComplete", { detail: entry })); +} + +function clearTimers() { + if (game.spawnTimer) { + clearInterval(game.spawnTimer); + game.spawnTimer = null; + } + if (game.countdownTimer) { + clearInterval(game.countdownTimer); + game.countdownTimer = null; + } +} + +function updateHud() { + scoreValue.textContent = game.score; + comboValue.textContent = `x${game.combo}`; + timeValue.textContent = game.timeLeft; + hitsValue.textContent = game.hits; + missesValue.textContent = game.misses; + accuracyValue.textContent = `${calculateAccuracy(game.hits, game.misses)}%`; + rankValue.textContent = calculateRank(game.score); +} + +function getDifficulty() { + return difficultyMap[settings.difficulty] || difficultyMap.standard; +} + +function spawnTargets() { + const difficulty = getDifficulty(); + game.spawnTimer = setInterval(() => { + if (!game.running || game.paused) return; + const radius = 12 + Math.random() * 18; + game.targets.push({ + id: crypto.randomUUID(), + x: Math.random() * (canvas.width - radius * 2) + radius, + y: Math.random() * (canvas.height - radius * 2) + radius, + radius, + ttl: + difficulty.ttlMin + + Math.random() * (difficulty.ttlMax - difficulty.ttlMin), + born: performance.now(), + }); + }, difficulty.spawnInterval); +} + +function startCountdown() { + game.countdownTimer = setInterval(() => { + if (game.paused) return; + game.timeLeft -= 1; + updateHud(); + if (game.timeLeft <= 0) { + endGame(); + } + }, 1000); +} + +function handleHit() { + const difficulty = getDifficulty(); + const now = performance.now(); + if (now - game.lastHitTime < 1200) { + game.combo += 1; + } else { + game.combo = 1; + } + game.lastHitTime = now; + const scoreDelta = Math.round(10 * game.combo * difficulty.scoreBoost); + game.score += scoreDelta; + game.hits += 1; + playTone(420); + updateHud(); +} + +function handleMiss() { + game.combo = 1; + game.misses += 1; + playTone(160); + updateHud(); +} + +function calculateAccuracy(hits, misses) { + const total = hits + misses; + if (!total) return 0; + return Math.round((hits / total) * 100); +} + +function calculateRank(score) { + if (score >= 1200) return "Warden"; + if (score >= 900) return "Vanguard"; + if (score >= 650) return "Specter"; + if (score >= 400) return "Runner"; + if (score >= 200) return "Scout"; + return "Cadet"; +} + +function drawBackground() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "rgba(7, 16, 30, 0.65)"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = "rgba(93, 244, 255, 0.08)"; + ctx.lineWidth = 1; + for (let i = 0; i < canvas.width; i += 60) { + ctx.beginPath(); + ctx.moveTo(i, 0); + ctx.lineTo(i, canvas.height); + ctx.stroke(); + } + for (let j = 0; j < canvas.height; j += 60) { + ctx.beginPath(); + ctx.moveTo(0, j); + ctx.lineTo(canvas.width, j); + ctx.stroke(); + } +} + +function drawTargets() { + const now = performance.now(); + game.targets = game.targets.filter((target) => { + const age = now - target.born; + if (age > target.ttl) { + handleMiss(); + return false; + } + const life = 1 - age / target.ttl; + ctx.beginPath(); + ctx.fillStyle = `rgba(93, 244, 255, ${life})`; + ctx.shadowColor = "rgba(93, 244, 255, 0.8)"; + ctx.shadowBlur = 12; + ctx.arc(target.x, target.y, target.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + return true; + }); +} + +function loop() { + if (!game.running || game.paused) { + return; + } + draw(); + requestAnimationFrame(loop); +} + +function draw() { + drawBackground(); + drawTargets(); +} + +function handleCanvasClick(event) { + if (!game.running || game.paused) { + return; + } + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const clickX = (event.clientX - rect.left) * scaleX; + const clickY = (event.clientY - rect.top) * scaleY; + let hit = false; + + game.targets = game.targets.filter((target) => { + const distance = Math.hypot(target.x - clickX, target.y - clickY); + if (distance <= target.radius) { + hit = true; + return false; + } + return true; + }); + + if (hit) { + handleHit(); + } else { + handleMiss(); + } +} + +function playTone(frequency) { + if (!settings.sound) return; + if (!audioContext) { + audioContext = new AudioContext(); + } + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + oscillator.frequency.value = frequency; + oscillator.type = "sine"; + gainNode.gain.value = 0.05; + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + oscillator.start(); + oscillator.stop(audioContext.currentTime + 0.08); +} + +function setScoreButtons() { + saveBtn.disabled = !canSubmitScore; + submitBtn.disabled = !canSubmitScore; +} + +function saveLocalScore() { + if (!canSubmitScore) return; + const entry = buildScoreEntry(); + const leaderboard = loadLeaderboard(); + leaderboard.push(entry); + leaderboard.sort((a, b) => b.score - a.score); + const trimmed = leaderboard.slice(0, 10); + localStorage.setItem(STORAGE_KEYS.leaderboard, JSON.stringify(trimmed)); + updateProfile(entry); + renderLeaderboard(); + setStatus("Score saved locally", true); +} + +async function submitOnChain() { + if (!canSubmitScore) return; + if (!walletClient || !walletAddress) { + setStatus("Connect wallet first"); + return; + } + if (!isAddress(contractAddress)) { + setStatus("Set contract address first"); + return; + } + + try { + const chainId = await window.ethereum.request({ method: "eth_chainId" }); + if (chainId !== formatChainId(abstractTestnet.id)) { + setStatus("Switch to Abstract Testnet"); + return; + } + + const entry = buildScoreEntry(); + const hash = await walletClient.writeContract({ + address: contractAddress, + abi: LEADERBOARD_ABI, + functionName: "submitScore", + args: [entry.score, entry.duration, entry.hits, entry.misses], + account: walletAddress, + }); + + localStorage.setItem(STORAGE_KEYS.lastTx, hash); + lastTx.innerHTML = `Tx: ${hash.slice( + 0, + 10 + )}...`; + setStatus("Score submitted", true); + } catch (error) { + setStatus("Transaction failed"); + } +} + +function buildScoreEntry() { + const duration = Math.round((game.runEnd - game.runStart) / 1000); + return { + name: profile.name, + score: game.score, + hits: game.hits, + misses: game.misses, + accuracy: calculateAccuracy(game.hits, game.misses), + duration, + timestamp: new Date().toISOString(), + }; +} + +function renderLeaderboard() { + const leaderboard = loadLeaderboard(); + leaderboardList.innerHTML = ""; + if (!leaderboard.length) { + leaderboardList.innerHTML = "
  • No runs yet. Finish a run to enter.
  • "; + return; + } + leaderboard.forEach((entry, index) => { + const item = document.createElement("li"); + item.innerHTML = `#${index + 1} ${entry.name}${ + entry.score + }`; + leaderboardList.appendChild(item); + }); +} + +function loadLeaderboard() { + const stored = localStorage.getItem(STORAGE_KEYS.leaderboard); + return stored ? JSON.parse(stored) : []; +} + +function loadProfile() { + const stored = localStorage.getItem(STORAGE_KEYS.profile); + return ( + (stored && JSON.parse(stored)) || { + name: "NeuralRunner", + bestScore: 0, + runs: 0, + accuracy: 0, + } + ); +} + +function updateProfile(entry) { + profile.runs += 1; + profile.bestScore = Math.max(profile.bestScore, entry.score); + profile.totalHits = (profile.totalHits || 0) + entry.hits; + profile.totalMisses = (profile.totalMisses || 0) + entry.misses; + profile.accuracy = calculateAccuracy(profile.totalHits, profile.totalMisses); + localStorage.setItem(STORAGE_KEYS.profile, JSON.stringify(profile)); + updateProfileUI(); +} + +function updateProfileUI() { + playerName.value = profile.name; + bestScoreValue.textContent = profile.bestScore; + runsValue.textContent = profile.runs; + accuracyTotalValue.textContent = `${profile.accuracy}%`; +} + +function loadSettings() { + const stored = localStorage.getItem(STORAGE_KEYS.settings); + return ( + (stored && JSON.parse(stored)) || { + difficulty: "standard", + sound: true, + } + ); +} + +function saveSettings() { + localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings)); +} + +function loadObjective() { + const stored = localStorage.getItem(STORAGE_KEYS.daily); + const todayKey = new Date().toISOString().slice(0, 10); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed.date === todayKey) { + return parsed; + } + } + const next = { + date: todayKey, + targetHits: 20 + Math.floor(Math.random() * 20), + targetScore: 300 + Math.floor(Math.random() * 500), + }; + localStorage.setItem(STORAGE_KEYS.daily, JSON.stringify(next)); + return next; +} + +function updateObjective() { + dailyObjective.textContent = `Hit ${objective.targetHits} signals and score ${objective.targetScore}+`; +} + +function refreshLastTx() { + const hash = localStorage.getItem(STORAGE_KEYS.lastTx); + if (hash) { + lastTx.innerHTML = `Tx: ${hash.slice( + 0, + 10 + )}...`; + } +} + +function handleNameChange() { + profile.name = playerName.value.trim() || "NeuralRunner"; + localStorage.setItem(STORAGE_KEYS.profile, JSON.stringify(profile)); + renderLeaderboard(); + refreshOnChainLeaderboard(); +} + +function handleDifficultyChange() { + settings.difficulty = difficultySelect.value; + saveSettings(); + resetGame(); +} + +function handleSoundToggle() { + settings.sound = soundToggle.checked; + saveSettings(); +} + +function loadContractAddress() { + const stored = localStorage.getItem(STORAGE_KEYS.contract); + if (stored && isAddress(stored)) { + return stored; + } + return DEFAULT_CONTRACT_ADDRESS; +} + +function handleContractChange() { + const value = contractAddressInput.value.trim(); + if (!value) { + contractAddress = DEFAULT_CONTRACT_ADDRESS; + localStorage.removeItem(STORAGE_KEYS.contract); + setStatus("Contract cleared"); + return; + } + if (!isAddress(value)) { + setStatus("Invalid contract address"); + return; + } + contractAddress = value; + localStorage.setItem(STORAGE_KEYS.contract, value); + setStatus("Contract saved", true); + refreshOnChainLeaderboard(); +} + +async function refreshOnChainLeaderboard() { + if (!isAddress(contractAddress)) { + return; + } + try { + const latestBlock = await publicClient.getBlockNumber(); + const fromBlock = + latestBlock > LOG_BLOCK_RANGE ? latestBlock - LOG_BLOCK_RANGE : 0n; + const logs = await publicClient.getLogs({ + address: contractAddress, + event: SCORE_EVENT, + fromBlock, + toBlock: "latest", + }); + const bestByPlayer = new Map(); + logs.forEach((log) => { + const player = log.args.player; + const score = Number(log.args.score); + const hits = Number(log.args.hits); + const misses = Number(log.args.misses); + const duration = Number(log.args.duration); + const existing = bestByPlayer.get(player); + if (!existing || score > existing.score) { + bestByPlayer.set(player, { + player, + score, + hits, + misses, + duration, + }); + } + }); + const entries = Array.from(bestByPlayer.values()) + .sort((a, b) => b.score - a.score) + .slice(0, 10); + if (entries.length) { + leaderboardList.innerHTML = ""; + entries.forEach((entry, index) => { + const item = document.createElement("li"); + const name = + profile.name && entry.player === walletAddress + ? profile.name + : `${entry.player.slice(0, 6)}...${entry.player.slice(-4)}`; + item.innerHTML = `#${index + 1} ${name}${ + entry.score + }`; + leaderboardList.appendChild(item); + }); + } + } catch (error) { + setStatus("On-chain sync failed"); + } +} + +networkBtn.addEventListener("click", switchNetwork); +connectBtn.addEventListener("click", connectWallet); +howBtn.addEventListener("click", openModal); +closeModal.addEventListener("click", closeModalView); +enterBtn.addEventListener("click", () => { + overlay.style.display = "grid"; + overlay.querySelector("h2").textContent = "NEURAL LINK READY"; + overlay.querySelector("p").textContent = + "Use your mouse or tap to lock onto signals. Each hit boosts your sync score."; + openModal(); +}); + +startBtn.addEventListener("click", startGame); +pauseBtn.addEventListener("click", pauseGame); +resetBtn.addEventListener("click", resetGame); +overlayStart.addEventListener("click", startGame); +canvas.addEventListener("click", handleCanvasClick); + +saveBtn.addEventListener("click", saveLocalScore); +submitBtn.addEventListener("click", submitOnChain); +refreshLeaderboard.addEventListener("click", renderLeaderboard); +playerName.addEventListener("change", handleNameChange); +difficultySelect.addEventListener("change", handleDifficultyChange); +soundToggle.addEventListener("change", handleSoundToggle); +contractAddressInput.addEventListener("change", handleContractChange); + +function boot() { + playerName.value = profile.name; + difficultySelect.value = settings.difficulty; + soundToggle.checked = settings.sound; + contractAddress = loadContractAddress(); + contractAddressInput.value = + contractAddress === DEFAULT_CONTRACT_ADDRESS ? "" : contractAddress; + updateProfileUI(); + renderLeaderboard(); + refreshOnChainLeaderboard(); + updateObjective(); + refreshLastTx(); + setScoreButtons(); + resetGame(); +} + +boot(); diff --git a/examples/harkan-abs/index.html b/examples/harkan-abs/index.html new file mode 100644 index 0000000..f56ec40 --- /dev/null +++ b/examples/harkan-abs/index.html @@ -0,0 +1,245 @@ + + + + + + Harkan ABS — Neural Arena + + + +
    +
    +
    + + HARKAN // ABS +
    +
    + + + Disconnected +
    +
    + +
    +
    +
    +

    NEURAL LINK PROTOCOL

    +

    ENTER HARKAN WORLD

    +

    + A cyberpunk arena built for Abstract. Jack in, track signals, and + claim your score on-chain. +

    +
    + + +
    +
    +
    + Chain + Abstract Testnet +
    +
    + Mode + Neural Run +
    +
    + Status + Standby +
    +
    +
    +
    +
    NEURAL SIGNALS
    +
    +

    + Tap signals before they fade. Each hit increases your sync score. + Miss too many and the link collapses. +

    +
      +
    • Time limit: 45 seconds
    • +
    • Combo bonus for consecutive hits
    • +
    • Sync score posted after run
    • +
    +
    +
    +
    + +
    +
    +
    NEURAL ARENA
    +
    +
    + Score + 0 +
    +
    + Combo + x1 +
    +
    + Time + 45 +
    +
    +
    + + + +
    +
    + + +
    +
    +
    + +
    +
    +

    NEURAL LINK READY

    +

    + Use your mouse or tap to lock onto signals. Each hit boosts your + sync score. +

    + +
    +
    +
    +
    +
    + Hits + 0 +
    +
    + Misses + 0 +
    +
    + Accuracy + 0% +
    +
    + Rank + Cadet +
    +
    +
    + +
    +
    +
    LEADERBOARD
    +
      + +
      +
      +
      ABSTRACT GLOBAL WALLET
      +
      +
      +
      +
      PLAYER PROFILE
      +
      + + +
      +
      + Best Score + 0 +
      +
      + Total Runs + 0 +
      +
      + Accuracy + 0% +
      +
      +
      + + +
      +
      + Daily Objective + Calibrating... +
      +
      + Paste your deployed NeuralArena contract to enable on-chain + leaderboards and submissions. +
      +
      +
      +
      + +
      +
      +

      Wallet Login

      +

      + Use Abstract Global Wallet or any EIP-1193 wallet to connect and + track your runs. +

      +
      +
      +

      On-chain Score

      +

      + Scores can be written to a smart contract or leaderboard indexer + after each run. +

      +
      +
      +

      Gas Sponsored

      +

      + Abstract paymasters let you sponsor gas so players can run for free. +

      +
      +
      +
      + +
      + ABS NETWORK READY + © 2026 Harkan ABS Prototype +
      + + + + + + + diff --git a/examples/harkan-abs/styles.css b/examples/harkan-abs/styles.css new file mode 100644 index 0000000..8192289 --- /dev/null +++ b/examples/harkan-abs/styles.css @@ -0,0 +1,483 @@ +@import url("https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700&family=Rajdhani:wght@400;600&display=swap"); + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Rajdhani", sans-serif; + color: #d5f5ff; + background: radial-gradient(circle at top, #0b1224 0%, #04070f 55%, #02050b 100%); + min-height: 100vh; + overflow-x: hidden; +} + +a { + color: #7ad5ff; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +main { + padding: 32px 6vw 64px; +} + +.scanlines { + position: fixed; + inset: 0; + background: repeating-linear-gradient( + to bottom, + rgba(255, 255, 255, 0.02), + rgba(255, 255, 255, 0.02) 1px, + transparent 1px, + transparent 4px + ); + pointer-events: none; + mix-blend-mode: screen; + opacity: 0.35; + z-index: 1; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 6vw; + background: rgba(3, 6, 15, 0.85); + border-bottom: 1px solid rgba(96, 225, 255, 0.2); + backdrop-filter: blur(8px); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + font-family: "Orbitron", sans-serif; + letter-spacing: 2px; + font-size: 14px; +} + +.brand-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: #5df4ff; + box-shadow: 0 0 12px #5df4ff; +} + +.wallet { + display: flex; + align-items: center; + gap: 12px; +} + +.btn { + background: linear-gradient(135deg, #29f1ff, #7a5cff); + color: #02101a; + border: none; + padding: 10px 18px; + border-radius: 999px; + font-weight: 600; + cursor: pointer; + font-family: "Rajdhani", sans-serif; + letter-spacing: 0.5px; + box-shadow: 0 0 18px rgba(41, 241, 255, 0.35); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: 0 0 22px rgba(122, 92, 255, 0.35); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +.btn.ghost { + background: transparent; + color: #a5f3ff; + border: 1px solid rgba(93, 244, 255, 0.4); + box-shadow: none; +} + +.btn.large { + padding: 14px 24px; + font-size: 15px; +} + +.status { + font-size: 12px; + color: #84f0ff; +} + +.hero { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 32px; + align-items: center; + padding: 40px 0 48px; + position: relative; + z-index: 2; +} + +.hero-copy h1 { + font-family: "Orbitron", sans-serif; + font-size: clamp(32px, 5vw, 56px); + margin: 8px 0 12px; + text-shadow: 0 0 18px rgba(93, 244, 255, 0.4); +} + +.subtle { + font-size: 12px; + letter-spacing: 4px; + text-transform: uppercase; + color: #6ee7ff; +} + +.lead { + max-width: 480px; + line-height: 1.6; + color: #b9e9ff; +} + +.hero-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin: 24px 0; +} + +.meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; + margin-top: 16px; +} + +.label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 2px; + color: #5bb9d8; +} + +.value { + font-size: 16px; + font-weight: 600; + color: #d5f5ff; +} + +.hero-panel { + border: 1px solid rgba(93, 244, 255, 0.35); + border-radius: 16px; + padding: 24px; + background: rgba(10, 24, 45, 0.65); + box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.4); +} + +.panel-title { + font-family: "Orbitron", sans-serif; + font-size: 12px; + letter-spacing: 3px; + color: #7ad5ff; + margin-bottom: 12px; +} + +.panel-body p { + margin-top: 0; + line-height: 1.6; +} + +.panel-body ul { + padding-left: 18px; + margin: 12px 0 0; + color: #a7d8ef; +} + +.arena { + margin-top: 24px; + border: 1px solid rgba(93, 244, 255, 0.25); + border-radius: 18px; + padding: 24px; + background: rgba(5, 12, 24, 0.85); + position: relative; + z-index: 2; +} + +.arena-header { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: space-between; + align-items: center; + margin-bottom: 18px; +} + +.arena-title { + font-family: "Orbitron", sans-serif; + letter-spacing: 3px; + font-size: 14px; +} + +.stats { + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.arena-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.score-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.arena-canvas { + position: relative; + border-radius: 16px; + overflow: hidden; + border: 1px solid rgba(93, 244, 255, 0.2); +} + +canvas { + width: 100%; + height: auto; + display: block; + background: radial-gradient(circle at bottom, #0f1f34, #07111f 60%, #04070f 100%); +} + +.arena-overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(2, 5, 12, 0.6); +} + +.overlay-card { + text-align: center; + padding: 24px; + border-radius: 16px; + background: rgba(8, 18, 32, 0.9); + border: 1px solid rgba(93, 244, 255, 0.35); + max-width: 320px; +} + +.run-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; + padding: 18px; + margin-top: 16px; + border-radius: 14px; + border: 1px solid rgba(93, 244, 255, 0.18); + background: rgba(6, 14, 26, 0.75); +} + +.dashboard { + margin-top: 42px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; +} + +.panel { + border-radius: 16px; + border: 1px solid rgba(93, 244, 255, 0.25); + background: rgba(8, 18, 32, 0.75); + padding: 22px; + display: flex; + flex-direction: column; + gap: 16px; +} + +#agwPanel { + min-height: 320px; +} + +.agw-card { + display: grid; + gap: 14px; +} + +.agw-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.agw-status { + font-size: 13px; + color: #c5f3ff; +} + +.agw-field { + display: grid; + gap: 6px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 2px; + color: #6bd8f5; +} + +.panel-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.leaderboard { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 10px; +} + +.leaderboard li { + display: flex; + justify-content: space-between; + padding: 10px 12px; + border-radius: 12px; + background: rgba(11, 24, 40, 0.8); + border: 1px solid rgba(93, 244, 255, 0.15); + font-size: 14px; +} + +.profile { + display: grid; + gap: 16px; +} + +.field { + display: grid; + gap: 8px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 2px; + color: #6bd8f5; +} + +.field.inline { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; +} + +.input { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(93, 244, 255, 0.3); + background: rgba(5, 10, 18, 0.9); + color: #d5f5ff; + font-family: "Rajdhani", sans-serif; + font-size: 14px; +} + +.profile-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; +} + +.settings { + display: grid; + gap: 12px; +} + +.objective { + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(93, 244, 255, 0.25); + background: rgba(10, 22, 40, 0.8); +} + +.subtle-text { + color: #7fc9e6; + font-size: 12px; +} + +.note { + line-height: 1.4; +} + +.grid { + margin-top: 48px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; +} + +.card { + padding: 20px; + border-radius: 14px; + border: 1px solid rgba(93, 244, 255, 0.2); + background: rgba(8, 18, 32, 0.7); +} + +footer { + padding: 24px 6vw 40px; + display: flex; + justify-content: space-between; + font-size: 12px; + color: #6fd6ff; + letter-spacing: 2px; + border-top: 1px solid rgba(93, 244, 255, 0.2); +} + +.modal { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: rgba(3, 6, 15, 0.7); + z-index: 20; +} + +.modal.hidden { + display: none; +} + +.modal-card { + padding: 24px; + max-width: 420px; + border-radius: 16px; + background: #0b1326; + border: 1px solid rgba(93, 244, 255, 0.3); +} + +@media (max-width: 720px) { + .topbar { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .wallet { + width: 100%; + flex-wrap: wrap; + } + + footer { + flex-direction: column; + gap: 8px; + } +} From b94286888288b5882011fbd61070439152d741e4 Mon Sep 17 00:00:00 2001 From: shkudun Date: Sun, 25 Jan 2026 20:31:31 +0300 Subject: [PATCH 2/6] fix: disable submit during non-sponsored tx --- examples/harkan-abs/agw-panel.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/harkan-abs/agw-panel.js b/examples/harkan-abs/agw-panel.js index 17ac5ee..2f6277f 100644 --- a/examples/harkan-abs/agw-panel.js +++ b/examples/harkan-abs/agw-panel.js @@ -55,8 +55,8 @@ function useLatestScore() { function AgwPanel() { const { login, logout } = useLoginWithAbstract(); const { address, status } = useAccount(); - const { writeContractAsync } = useWriteContract(); - const { writeContractSponsoredAsync, isPending } = + const { writeContractAsync, isPending: isPendingDirect } = useWriteContract(); + const { writeContractSponsoredAsync, isPending: isPendingSponsored } = useWriteContractSponsored(); const latestScore = useLatestScore(); @@ -139,6 +139,8 @@ function AgwPanel() { } } + const isSubmitting = isPendingDirect || isPendingSponsored; + return (
      @@ -176,8 +178,8 @@ function AgwPanel() { Disconnect )} -
      {statusMsg}
      From 479daec611a57aed25f2dd9d533021d6ede0095c Mon Sep 17 00:00:00 2001 From: shkudun Date: Sun, 25 Jan 2026 21:02:08 +0300 Subject: [PATCH 3/6] feat: show chain badge and explorer links --- examples/harkan-abs/app.js | 67 +++++++++++++++++++++++++++++++--- examples/harkan-abs/index.html | 13 ++++++- examples/harkan-abs/styles.css | 10 +++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/examples/harkan-abs/app.js b/examples/harkan-abs/app.js index 465ea5a..5542e6b 100644 --- a/examples/harkan-abs/app.js +++ b/examples/harkan-abs/app.js @@ -6,12 +6,14 @@ import { isAddress, parseAbiItem, } from "https://esm.sh/viem@2.21.25"; -import { abstractTestnet } from "https://esm.sh/viem@2.21.25/chains"; +import { abstract, abstractTestnet } from "https://esm.sh/viem@2.21.25/chains"; import { eip712WalletActions } from "https://esm.sh/viem@2.21.25/zksync"; const networkBtn = document.getElementById("networkBtn"); const connectBtn = document.getElementById("connectBtn"); const walletStatus = document.getElementById("walletStatus"); +const chainBadge = document.getElementById("chainBadge"); +const chainValue = document.getElementById("chainValue"); const sessionStatus = document.getElementById("sessionStatus"); const modal = document.getElementById("modal"); const howBtn = document.getElementById("howBtn"); @@ -40,6 +42,7 @@ const lastTx = document.getElementById("lastTx"); const playerName = document.getElementById("playerName"); const contractAddressInput = document.getElementById("contractAddress"); +const contractExplorer = document.getElementById("contractExplorer"); const bestScoreValue = document.getElementById("bestScoreValue"); const runsValue = document.getElementById("runsValue"); const accuracyTotalValue = document.getElementById("accuracyTotalValue"); @@ -51,6 +54,16 @@ const canvas = document.getElementById("gameCanvas"); const ctx = canvas.getContext("2d"); const DEFAULT_CONTRACT_ADDRESS = "0x0000000000000000000000000000000000000000"; +const NETWORKS = { + [abstractTestnet.id]: { + label: "Abstract Testnet", + explorer: "https://sepolia.abscan.org", + }, + [abstract.id]: { + label: "Abstract Mainnet", + explorer: "https://abscan.org", + }, +}; const SCORE_EVENT = parseAbiItem( "event ScoreSubmitted(address indexed player,uint256 score,uint256 duration,uint256 hits,uint256 misses,uint256 timestamp)" ); @@ -89,6 +102,7 @@ const difficultyMap = { let walletAddress = null; let walletClient = null; let contractAddress = DEFAULT_CONTRACT_ADDRESS; +let explorerBase = NETWORKS[abstractTestnet.id].explorer; const publicClient = createPublicClient({ chain: abstractTestnet, transport: http(), @@ -126,6 +140,36 @@ function formatChainId(id) { return `0x${id.toString(16)}`; } +function setChainInfo(chainIdHex) { + if (!chainIdHex) { + chainBadge.textContent = "Unknown"; + chainValue.textContent = "Unknown"; + return; + } + const numericId = Number.parseInt(chainIdHex, 16); + const info = NETWORKS[numericId]; + if (info) { + chainBadge.textContent = info.label; + chainValue.textContent = info.label; + explorerBase = info.explorer; + } else { + chainBadge.textContent = `Chain ${numericId}`; + chainValue.textContent = `Chain ${numericId}`; + } + refreshLastTx(); + updateContractExplorer(); +} + +function updateContractExplorer() { + if (!isAddress(contractAddress)) { + contractExplorer.setAttribute("aria-disabled", "true"); + contractExplorer.href = "#"; + return; + } + contractExplorer.removeAttribute("aria-disabled"); + contractExplorer.href = `${explorerBase}/address/${contractAddress}`; +} + async function switchNetwork() { if (!window.ethereum) { setStatus("Wallet not found"); @@ -471,8 +515,9 @@ async function submitOnChain() { try { const chainId = await window.ethereum.request({ method: "eth_chainId" }); - if (chainId !== formatChainId(abstractTestnet.id)) { - setStatus("Switch to Abstract Testnet"); + const numericId = Number.parseInt(chainId, 16); + if (![abstractTestnet.id, abstract.id].includes(numericId)) { + setStatus("Switch to Abstract network"); return; } @@ -486,7 +531,7 @@ async function submitOnChain() { }); localStorage.setItem(STORAGE_KEYS.lastTx, hash); - lastTx.innerHTML = `Tx: ${hash.slice( + lastTx.innerHTML = `Tx: ${hash.slice( 0, 10 )}...`; @@ -598,7 +643,7 @@ function updateObjective() { function refreshLastTx() { const hash = localStorage.getItem(STORAGE_KEYS.lastTx); if (hash) { - lastTx.innerHTML = `Tx: ${hash.slice( + lastTx.innerHTML = `Tx: ${hash.slice( 0, 10 )}...`; @@ -637,6 +682,7 @@ function handleContractChange() { contractAddress = DEFAULT_CONTRACT_ADDRESS; localStorage.removeItem(STORAGE_KEYS.contract); setStatus("Contract cleared"); + updateContractExplorer(); return; } if (!isAddress(value)) { @@ -646,6 +692,7 @@ function handleContractChange() { contractAddress = value; localStorage.setItem(STORAGE_KEYS.contract, value); setStatus("Contract saved", true); + updateContractExplorer(); refreshOnChainLeaderboard(); } @@ -736,6 +783,16 @@ function boot() { contractAddress = loadContractAddress(); contractAddressInput.value = contractAddress === DEFAULT_CONTRACT_ADDRESS ? "" : contractAddress; + updateContractExplorer(); + if (window.ethereum) { + window.ethereum + .request({ method: "eth_chainId" }) + .then(setChainInfo) + .catch(() => setChainInfo(null)); + window.ethereum.on("chainChanged", setChainInfo); + } else { + setChainInfo(null); + } updateProfileUI(); renderLeaderboard(); refreshOnChainLeaderboard(); diff --git a/examples/harkan-abs/index.html b/examples/harkan-abs/index.html index f56ec40..7d275ca 100644 --- a/examples/harkan-abs/index.html +++ b/examples/harkan-abs/index.html @@ -17,6 +17,7 @@ Disconnected + Unknown
      @@ -36,7 +37,7 @@

      ENTER HARKAN WORLD

      Chain - Abstract Testnet + Abstract Testnet
      Mode @@ -153,6 +154,16 @@

      NEURAL LINK READY

      spellcheck="false" /> + + Open in explorer +
      Best Score diff --git a/examples/harkan-abs/styles.css b/examples/harkan-abs/styles.css index 8192289..43798d7 100644 --- a/examples/harkan-abs/styles.css +++ b/examples/harkan-abs/styles.css @@ -120,6 +120,16 @@ main { color: #84f0ff; } +.badge { + font-size: 11px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(93, 244, 255, 0.4); + color: #9ee9ff; + text-transform: uppercase; + letter-spacing: 1px; +} + .hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); From 8406149e94c7243d8fda2d15524f5d880a6cd273 Mon Sep 17 00:00:00 2001 From: shkudun Date: Sun, 25 Jan 2026 21:08:24 +0300 Subject: [PATCH 4/6] feat: auto contract by network --- examples/harkan-abs/app.js | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/examples/harkan-abs/app.js b/examples/harkan-abs/app.js index 5542e6b..839533f 100644 --- a/examples/harkan-abs/app.js +++ b/examples/harkan-abs/app.js @@ -90,6 +90,7 @@ const STORAGE_KEYS = { daily: "harkanDailyObjective", lastTx: "harkanLastTx", contract: "harkanContractAddress", + contractAuto: "harkanContractAuto", }; const difficultyMap = { @@ -103,6 +104,7 @@ let walletAddress = null; let walletClient = null; let contractAddress = DEFAULT_CONTRACT_ADDRESS; let explorerBase = NETWORKS[abstractTestnet.id].explorer; +let contractAuto = true; const publicClient = createPublicClient({ chain: abstractTestnet, transport: http(), @@ -156,10 +158,32 @@ function setChainInfo(chainIdHex) { chainBadge.textContent = `Chain ${numericId}`; chainValue.textContent = `Chain ${numericId}`; } + applyAutoContract(numericId); refreshLastTx(); updateContractExplorer(); } +function getDefaultContract(chainId) { + if (chainId === abstractTestnet.id) { + return "0x51F2C923a5307E2701F228DcC4cB3D72B1aAb804"; + } + if (chainId === abstract.id) { + return "0x51F2C923a5307E2701F228DcC4cB3D72B1aAb804"; + } + return DEFAULT_CONTRACT_ADDRESS; +} + +function applyAutoContract(chainId) { + if (!contractAuto) return; + const stored = localStorage.getItem(STORAGE_KEYS.contract); + if (stored) return; + const autoAddress = getDefaultContract(chainId); + if (!isAddress(autoAddress)) return; + contractAddress = autoAddress; + contractAddressInput.value = autoAddress; + localStorage.setItem(STORAGE_KEYS.contract, autoAddress); +} + function updateContractExplorer() { if (!isAddress(contractAddress)) { contractExplorer.setAttribute("aria-disabled", "true"); @@ -676,21 +700,37 @@ function loadContractAddress() { return DEFAULT_CONTRACT_ADDRESS; } +function loadContractAuto() { + const stored = localStorage.getItem(STORAGE_KEYS.contractAuto); + if (stored === "false") { + return false; + } + return true; +} + +function setContractAuto(value) { + contractAuto = value; + localStorage.setItem(STORAGE_KEYS.contractAuto, value ? "true" : "false"); +} + function handleContractChange() { const value = contractAddressInput.value.trim(); if (!value) { contractAddress = DEFAULT_CONTRACT_ADDRESS; localStorage.removeItem(STORAGE_KEYS.contract); + setContractAuto(false); setStatus("Contract cleared"); updateContractExplorer(); return; } if (!isAddress(value)) { + setContractAuto(false); setStatus("Invalid contract address"); return; } contractAddress = value; localStorage.setItem(STORAGE_KEYS.contract, value); + setContractAuto(false); setStatus("Contract saved", true); updateContractExplorer(); refreshOnChainLeaderboard(); @@ -780,6 +820,7 @@ function boot() { playerName.value = profile.name; difficultySelect.value = settings.difficulty; soundToggle.checked = settings.sound; + contractAuto = loadContractAuto(); contractAddress = loadContractAddress(); contractAddressInput.value = contractAddress === DEFAULT_CONTRACT_ADDRESS ? "" : contractAddress; From 5184a1468cc922129b0e6bf75e2577e7442aef23 Mon Sep 17 00:00:00 2001 From: shkudun Date: Sun, 25 Jan 2026 21:24:24 +0300 Subject: [PATCH 5/6] fix: guard chain badge/explorer elements --- examples/harkan-abs/app.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/harkan-abs/app.js b/examples/harkan-abs/app.js index 839533f..f15778c 100644 --- a/examples/harkan-abs/app.js +++ b/examples/harkan-abs/app.js @@ -144,19 +144,19 @@ function formatChainId(id) { function setChainInfo(chainIdHex) { if (!chainIdHex) { - chainBadge.textContent = "Unknown"; - chainValue.textContent = "Unknown"; + if (chainBadge) chainBadge.textContent = "Unknown"; + if (chainValue) chainValue.textContent = "Unknown"; return; } const numericId = Number.parseInt(chainIdHex, 16); const info = NETWORKS[numericId]; if (info) { - chainBadge.textContent = info.label; - chainValue.textContent = info.label; + if (chainBadge) chainBadge.textContent = info.label; + if (chainValue) chainValue.textContent = info.label; explorerBase = info.explorer; } else { - chainBadge.textContent = `Chain ${numericId}`; - chainValue.textContent = `Chain ${numericId}`; + if (chainBadge) chainBadge.textContent = `Chain ${numericId}`; + if (chainValue) chainValue.textContent = `Chain ${numericId}`; } applyAutoContract(numericId); refreshLastTx(); @@ -185,6 +185,7 @@ function applyAutoContract(chainId) { } function updateContractExplorer() { + if (!contractExplorer) return; if (!isAddress(contractAddress)) { contractExplorer.setAttribute("aria-disabled", "true"); contractExplorer.href = "#"; From 1c50ce194ace78bc3eec203ff0b42bc0bc023d4c Mon Sep 17 00:00:00 2001 From: shkudun Date: Sun, 25 Jan 2026 21:37:18 +0300 Subject: [PATCH 6/6] fix: make UI work even if web3 modules blocked --- examples/harkan-abs/app.js | 125 ++++++++++++++++++++++++++----------- 1 file changed, 88 insertions(+), 37 deletions(-) diff --git a/examples/harkan-abs/app.js b/examples/harkan-abs/app.js index f15778c..10b1a74 100644 --- a/examples/harkan-abs/app.js +++ b/examples/harkan-abs/app.js @@ -1,13 +1,6 @@ -import { - createPublicClient, - createWalletClient, - custom, - http, - isAddress, - parseAbiItem, -} from "https://esm.sh/viem@2.21.25"; -import { abstract, abstractTestnet } from "https://esm.sh/viem@2.21.25/chains"; -import { eip712WalletActions } from "https://esm.sh/viem@2.21.25/zksync"; +let viemModules = null; +let viemReady = false; +let viemLoading = null; const networkBtn = document.getElementById("networkBtn"); const connectBtn = document.getElementById("connectBtn"); @@ -54,19 +47,29 @@ const canvas = document.getElementById("gameCanvas"); const ctx = canvas.getContext("2d"); const DEFAULT_CONTRACT_ADDRESS = "0x0000000000000000000000000000000000000000"; -const NETWORKS = { - [abstractTestnet.id]: { +const CHAIN_INFO = { + testnet: { + id: 11124, label: "Abstract Testnet", explorer: "https://sepolia.abscan.org", }, - [abstract.id]: { + mainnet: { + id: 2741, label: "Abstract Mainnet", explorer: "https://abscan.org", }, }; -const SCORE_EVENT = parseAbiItem( - "event ScoreSubmitted(address indexed player,uint256 score,uint256 duration,uint256 hits,uint256 misses,uint256 timestamp)" -); +const NETWORKS = { + [CHAIN_INFO.testnet.id]: { + label: CHAIN_INFO.testnet.label, + explorer: CHAIN_INFO.testnet.explorer, + }, + [CHAIN_INFO.mainnet.id]: { + label: CHAIN_INFO.mainnet.label, + explorer: CHAIN_INFO.mainnet.explorer, + }, +}; +let SCORE_EVENT = null; const LOG_BLOCK_RANGE = 50000n; const LEADERBOARD_ABI = [ { @@ -103,12 +106,9 @@ const difficultyMap = { let walletAddress = null; let walletClient = null; let contractAddress = DEFAULT_CONTRACT_ADDRESS; -let explorerBase = NETWORKS[abstractTestnet.id].explorer; +let explorerBase = NETWORKS[CHAIN_INFO.testnet.id].explorer; let contractAuto = true; -const publicClient = createPublicClient({ - chain: abstractTestnet, - transport: http(), -}).extend(eip712WalletActions()); +let publicClient = null; const game = { running: false, @@ -142,6 +142,46 @@ function formatChainId(id) { return `0x${id.toString(16)}`; } +function isAddressSafe(value) { + if (viemReady && viemModules) { + return viemModules.isAddress(value); + } + return /^0x[a-fA-F0-9]{40}$/.test(value); +} + +async function ensureViem() { + if (viemReady) return true; + if (viemLoading) return viemLoading; + viemLoading = (async () => { + try { + const viem = await import("https://esm.sh/viem@2.21.25"); + const chains = await import("https://esm.sh/viem@2.21.25/chains"); + const zksync = await import("https://esm.sh/viem@2.21.25/zksync"); + viemModules = { + ...viem, + abstract: chains.abstract, + abstractTestnet: chains.abstractTestnet, + eip712WalletActions: zksync.eip712WalletActions, + }; + SCORE_EVENT = viemModules.parseAbiItem( + "event ScoreSubmitted(address indexed player,uint256 score,uint256 duration,uint256 hits,uint256 misses,uint256 timestamp)" + ); + publicClient = viemModules + .createPublicClient({ + chain: viemModules.abstractTestnet, + transport: viemModules.http(), + }) + .extend(viemModules.eip712WalletActions()); + viemReady = true; + return true; + } catch (error) { + setStatus("Web3 modules blocked"); + return false; + } + })(); + return viemLoading; +} + function setChainInfo(chainIdHex) { if (!chainIdHex) { if (chainBadge) chainBadge.textContent = "Unknown"; @@ -164,10 +204,10 @@ function setChainInfo(chainIdHex) { } function getDefaultContract(chainId) { - if (chainId === abstractTestnet.id) { + if (chainId === CHAIN_INFO.testnet.id) { return "0x51F2C923a5307E2701F228DcC4cB3D72B1aAb804"; } - if (chainId === abstract.id) { + if (chainId === CHAIN_INFO.mainnet.id) { return "0x51F2C923a5307E2701F228DcC4cB3D72B1aAb804"; } return DEFAULT_CONTRACT_ADDRESS; @@ -178,7 +218,7 @@ function applyAutoContract(chainId) { const stored = localStorage.getItem(STORAGE_KEYS.contract); if (stored) return; const autoAddress = getDefaultContract(chainId); - if (!isAddress(autoAddress)) return; + if (!isAddressSafe(autoAddress)) return; contractAddress = autoAddress; contractAddressInput.value = autoAddress; localStorage.setItem(STORAGE_KEYS.contract, autoAddress); @@ -186,7 +226,7 @@ function applyAutoContract(chainId) { function updateContractExplorer() { if (!contractExplorer) return; - if (!isAddress(contractAddress)) { + if (!isAddressSafe(contractAddress)) { contractExplorer.setAttribute("aria-disabled", "true"); contractExplorer.href = "#"; return; @@ -206,11 +246,11 @@ async function switchNetwork() { method: "wallet_addEthereumChain", params: [ { - chainId: formatChainId(abstractTestnet.id), - chainName: "Abstract Testnet", + chainId: formatChainId(CHAIN_INFO.testnet.id), + chainName: CHAIN_INFO.testnet.label, nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, rpcUrls: ["https://api.testnet.abs.xyz"], - blockExplorerUrls: ["https://sepolia.abscan.org/"], + blockExplorerUrls: [CHAIN_INFO.testnet.explorer], }, ], }); @@ -227,6 +267,8 @@ async function connectWallet() { } try { + const ok = await ensureViem(); + if (!ok) return; const accounts = await window.ethereum.request({ method: "eth_requestAccounts", }); @@ -235,10 +277,12 @@ async function connectWallet() { const short = walletAddress.slice(0, 6) + "..." + walletAddress.slice(-4); setStatus(`Connected ${short}`, true); - walletClient = createWalletClient({ - chain: abstractTestnet, - transport: custom(window.ethereum), - }).extend(eip712WalletActions()); + walletClient = viemModules + .createWalletClient({ + chain: viemModules.abstractTestnet, + transport: viemModules.custom(window.ethereum), + }) + .extend(viemModules.eip712WalletActions()); } } catch (error) { setStatus("Connection rejected"); @@ -529,11 +573,13 @@ function saveLocalScore() { async function submitOnChain() { if (!canSubmitScore) return; + const ok = await ensureViem(); + if (!ok) return; if (!walletClient || !walletAddress) { setStatus("Connect wallet first"); return; } - if (!isAddress(contractAddress)) { + if (!isAddressSafe(contractAddress)) { setStatus("Set contract address first"); return; } @@ -541,7 +587,7 @@ async function submitOnChain() { try { const chainId = await window.ethereum.request({ method: "eth_chainId" }); const numericId = Number.parseInt(chainId, 16); - if (![abstractTestnet.id, abstract.id].includes(numericId)) { + if (![CHAIN_INFO.testnet.id, CHAIN_INFO.mainnet.id].includes(numericId)) { setStatus("Switch to Abstract network"); return; } @@ -695,7 +741,7 @@ function handleSoundToggle() { function loadContractAddress() { const stored = localStorage.getItem(STORAGE_KEYS.contract); - if (stored && isAddress(stored)) { + if (stored && isAddressSafe(stored)) { return stored; } return DEFAULT_CONTRACT_ADDRESS; @@ -724,7 +770,7 @@ function handleContractChange() { updateContractExplorer(); return; } - if (!isAddress(value)) { + if (!isAddressSafe(value)) { setContractAuto(false); setStatus("Invalid contract address"); return; @@ -738,7 +784,12 @@ function handleContractChange() { } async function refreshOnChainLeaderboard() { - if (!isAddress(contractAddress)) { + const ok = await ensureViem(); + if (!ok) return; + if (!publicClient || !SCORE_EVENT) { + return; + } + if (!isAddressSafe(contractAddress)) { return; } try {