diff --git a/package-lock.json b/package-lock.json index 8a6143b..cb44867 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1786,6 +1786,10 @@ "events": "3.3.0" } }, + "node_modules/@walletconnect/staking-cli": { + "resolved": "packages/staking-cli", + "link": true + }, "node_modules/@walletconnect/time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@walletconnect/time/-/time-1.0.2.tgz", @@ -2965,6 +2969,21 @@ "dev": true, "license": "ISC" }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4383,6 +4402,114 @@ "punycode": "^2.1.0" } }, + "node_modules/viem": { + "version": "2.45.3", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.45.3.tgz", + "integrity": "sha512-axOD7rIbGiDHHA1MHKmpqqTz3CMCw7YpE/FVypddQMXL5i364VkNZh9JeEJH17NO484LaZUOMueo35IXyL76Mw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.12.1", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/ox": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.12.1.tgz", + "integrity": "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -4658,7 +4785,28 @@ "qrcode-terminal": "^0.12.0" }, "bin": { - "wc": "dist/cli.js" + "walletconnect": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "tsup": "^8.0.0", + "typescript": "^5.5.0", + "vitest": "^3.2.0" + } + }, + "packages/staking-cli": { + "name": "@walletconnect/staking-cli", + "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@walletconnect/cli-sdk": "*", + "viem": "^2.30.0" + }, + "bin": { + "walletconnect-staking": "dist/cli.js" }, "devDependencies": { "@types/node": "^25.2.3", diff --git a/packages/cli-sdk/src/cli.ts b/packages/cli-sdk/src/cli.ts index e6d39d4..61b6aea 100644 --- a/packages/cli-sdk/src/cli.ts +++ b/packages/cli-sdk/src/cli.ts @@ -1,4 +1,5 @@ import { WalletConnectCLI } from "./client.js"; +import { resolveProjectId, setConfigValue, getConfigValue } from "./config.js"; const METADATA = { name: "walletconnect", @@ -11,23 +12,28 @@ function usage(): void { console.log(`Usage: walletconnect [options] Commands: - connect Connect to a wallet via QR code - whoami Show current session info - sign Sign a message with the connected wallet - disconnect Disconnect the current session + connect Connect to a wallet via QR code + whoami Show current session info + sign Sign a message with the connected wallet + disconnect Disconnect the current session + config set Set a config value (e.g. project-id) + config get Get a config value Options: --browser Use browser UI instead of terminal QR code --help Show this help message +Config keys: + project-id WalletConnect Cloud project ID + Environment: - WALLETCONNECT_PROJECT_ID Required for connect and sign commands`); + WALLETCONNECT_PROJECT_ID Overrides config project-id when set`); } function getProjectId(): string { - const id = process.env.WALLETCONNECT_PROJECT_ID; + const id = resolveProjectId(); if (!id) { - console.error("Error: WALLETCONNECT_PROJECT_ID environment variable is required."); + console.error("Error: No project ID found. Set via: walletconnect config set project-id "); process.exit(1); } return id; @@ -102,12 +108,9 @@ async function cmdSign(message: string, browser: boolean): Promise { console.log(); } - const walletName = result.session.peer.metadata.name; const { chain, address } = parseAccount(result.accounts[0]); const hexMessage = "0x" + Buffer.from(message, "utf8").toString("hex"); - console.log(`Requesting signature from ${walletName}...\n`); - const signature = await sdk.request({ chainId: chain, request: { @@ -163,6 +166,32 @@ async function main(): Promise { case "disconnect": await cmdDisconnect(); break; + case "config": { + const action = filtered[1]; + const key = filtered[2]; + if (action === "set") { + const value = filtered[3]; + if (key === "project-id" && value) { + setConfigValue("projectId", value); + console.log(`Saved project-id to ~/.walletconnect-cli/config.json`); + } else { + console.error("Usage: walletconnect config set project-id "); + process.exit(1); + } + } else if (action === "get") { + if (key === "project-id") { + const value = getConfigValue("projectId"); + console.log(value || "(not set)"); + } else { + console.error("Usage: walletconnect config get project-id"); + process.exit(1); + } + } else { + console.error("Usage: walletconnect config [value]"); + process.exit(1); + } + break; + } case "--help": case "-h": case undefined: @@ -175,7 +204,10 @@ async function main(): Promise { } } -main().catch((err) => { - console.error(err instanceof Error ? err.message : err); - process.exit(1); -}); +main().then( + () => process.exit(0), + (err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }, +); diff --git a/packages/cli-sdk/src/client.ts b/packages/cli-sdk/src/client.ts index a897276..58e926a 100644 --- a/packages/cli-sdk/src/client.ts +++ b/packages/cli-sdk/src/client.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "events"; +import { execSync } from "child_process"; import { homedir } from "os"; import { join } from "path"; import { KeyValueStorage } from "@walletconnect/keyvaluestorage"; @@ -113,6 +114,8 @@ export class WalletConnectCLI extends EventEmitter { throw new Error("No active session. Call connect() first."); } + this.logRequestDetails(options); + try { return await client.request({ topic, @@ -142,14 +145,23 @@ export class WalletConnectCLI extends EventEmitter { try { const client = await this.ensureClient(); - await client.disconnect({ - topic: this.currentSession.topic, - reason: { code: 6000, message: "User disconnected" }, - }); + // Absorb relay WebSocket errors during disconnect so they don't + // surface as unhandled 'error' events that crash the process. + const swallow = () => {}; + client.core.relayer.on("error", swallow); + try { + await client.disconnect({ + topic: this.currentSession.topic, + reason: { code: 6000, message: "User disconnected" }, + }); + } finally { + client.core.relayer.off("error", swallow); + } } catch { // Ignore disconnect errors — session may have already expired } + // Always clean up local state even if relay notification failed this.currentSession = null; this.emit("disconnect"); } @@ -175,6 +187,13 @@ export class WalletConnectCLI extends EventEmitter { this.browserUI = null; } this.removeAllListeners(); + if (this.signClient) { + try { + await this.signClient.core.relayer.transportClose(); + } catch { + // ignore cleanup errors + } + } this.signClient = null; this.currentSession = null; } @@ -201,6 +220,32 @@ export class WalletConnectCLI extends EventEmitter { // ---------- Private -------------------------------------------------- // + private logRequestDetails(options: RequestOptions): void { + const walletName = this.currentSession?.peer.metadata.name; + if (walletName) { + console.log(`\nRequesting approval on ${walletName}...`); + } + + if (options.request.method === "eth_sendTransaction") { + const params = options.request.params as Array<{ data?: string }>; + const data = params[0]?.data; + if (data && data !== "0x") { + try { + const decoded = execSync(`cast 4d ${data}`, { + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + if (decoded) { + console.log(`\n Decoded calldata:\n${decoded.split("\n").map((l) => ` ${l}`).join("\n")}\n`); + } + } catch { + // cast not available or decode failed — skip silently + } + } + } + } + private async ensureClient(): Promise> { if (this.signClient) return this.signClient; diff --git a/packages/cli-sdk/src/config.ts b/packages/cli-sdk/src/config.ts new file mode 100644 index 0000000..d1cdbbe --- /dev/null +++ b/packages/cli-sdk/src/config.ts @@ -0,0 +1,40 @@ +import { homedir } from "os"; +import { join } from "path"; +import { readFileSync, writeFileSync, mkdirSync } from "fs"; + +const CONFIG_DIR = join(homedir(), ".walletconnect-cli"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +interface Config { + projectId?: string; +} + +function readConfig(): Config { + try { + return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as Config; + } catch { + return {}; + } +} + +function writeConfig(config: Config): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n"); +} + +/** Get a config value */ +export function getConfigValue(key: keyof Config): string | undefined { + return readConfig()[key]; +} + +/** Set a config value */ +export function setConfigValue(key: keyof Config, value: string): void { + const config = readConfig(); + config[key] = value; + writeConfig(config); +} + +/** Resolve project ID: env var > config file. Returns undefined if neither is set. */ +export function resolveProjectId(): string | undefined { + return process.env.WALLETCONNECT_PROJECT_ID || getConfigValue("projectId"); +} diff --git a/packages/cli-sdk/src/index.ts b/packages/cli-sdk/src/index.ts index fec7ebf..76dabe0 100644 --- a/packages/cli-sdk/src/index.ts +++ b/packages/cli-sdk/src/index.ts @@ -5,6 +5,7 @@ export { WalletConnectCLI } from "./client.js"; export { createSessionManager } from "./session.js"; export { createTerminalUI } from "./terminal-ui.js"; export { createBrowserUI } from "./browser-ui/server.js"; +export { getConfigValue, setConfigValue, resolveProjectId } from "./config.js"; export type { WalletConnectCLIOptions, diff --git a/packages/staking-cli/package.json b/packages/staking-cli/package.json new file mode 100644 index 0000000..787f7c5 --- /dev/null +++ b/packages/staking-cli/package.json @@ -0,0 +1,52 @@ +{ + "name": "@walletconnect/staking-cli", + "description": "WalletConnect WCT staking CLI — stake, unstake, claim rewards from the terminal", + "version": "1.0.0", + "private": false, + "author": "WalletConnect, Inc. ", + "license": "SEE LICENSE IN LICENSE.md", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": ["dist"], + "keywords": [ + "walletconnect", + "wct", + "staking", + "cli", + "terminal", + "optimism", + "web3" + ], + "bin": { + "walletconnect-staking": "dist/cli.js" + }, + "sideEffects": false, + "scripts": { + "build": "tsup", + "test": "vitest run --dir test --reporter=verbose", + "lint": "eslint --fix 'src/**/*.ts'", + "prettier": "prettier --check '{src,test}/**/*.{js,ts,jsx,tsx}'" + }, + "dependencies": { + "@walletconnect/cli-sdk": "*", + "viem": "^2.30.0" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "tsup": "^8.0.0", + "typescript": "^5.5.0", + "vitest": "^3.2.0" + } +} diff --git a/packages/staking-cli/src/api.ts b/packages/staking-cli/src/api.ts new file mode 100644 index 0000000..3c37354 --- /dev/null +++ b/packages/staking-cli/src/api.ts @@ -0,0 +1,44 @@ +import { FOUNDATION_API_URL } from "./constants.js"; + +export interface StakingPosition { + isPermanent: boolean; + amount: string; + createdAt: string; + unlocksAt?: string; + duration?: string; +} + +export interface StakingRewards { + amount: string; +} + +export interface StakingResponse { + position: StakingPosition | null; + rewards: StakingRewards | null; +} + +export interface StakeWeightResponse { + stakeWeight: string; +} + +function getBaseUrl(): string { + return process.env.FOUNDATION_API_URL || FOUNDATION_API_URL; +} + +export async function fetchStaking(address: string): Promise { + const url = `${getBaseUrl()}/staking?address=${address}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Foundation API error: ${res.status} ${res.statusText}`); + } + return (await res.json()) as StakingResponse; +} + +export async function fetchStakeWeight(): Promise { + const url = `${getBaseUrl()}/stake-weight`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Foundation API error: ${res.status} ${res.statusText}`); + } + return (await res.json()) as StakeWeightResponse; +} diff --git a/packages/staking-cli/src/cli.ts b/packages/staking-cli/src/cli.ts new file mode 100644 index 0000000..71ac967 --- /dev/null +++ b/packages/staking-cli/src/cli.ts @@ -0,0 +1,230 @@ +import { WalletConnectCLI, resolveProjectId } from "@walletconnect/cli-sdk"; +import { CAIP2_CHAIN_ID, CLI_METADATA } from "./constants.js"; +import { stake, unstake, claim, status, balance } from "./commands.js"; + +function usage(): void { + console.log(`Usage: walletconnect-staking [options] + +Commands: + stake Stake WCT (approve + createLock/updateLock) + unstake Withdraw all staked WCT (after lock expires) + claim Claim staking rewards + status Show staking position, rewards, and APY + balance Show WCT token balance + +Options: + --address=0x... Use address directly (for read-only commands) + --browser Use browser UI for wallet connection + --help Show this help message + +Environment: + WALLETCONNECT_PROJECT_ID Overrides config project-id when set + +Configure project ID globally with: walletconnect config set project-id `); +} + +function getProjectId(): string { + const id = resolveProjectId(); + if (!id) { + console.error("Error: No project ID found. Set via: walletconnect config set project-id "); + process.exit(1); + } + return id; +} + +function parseArgs(argv: string[]): { + command: string | undefined; + positional: string[]; + address: string | undefined; + browser: boolean; +} { + let address: string | undefined; + let browser = false; + const positional: string[] = []; + + for (const arg of argv) { + if (arg.startsWith("--address=")) { + address = arg.slice("--address=".length); + } else if (arg === "--browser") { + browser = true; + } else if (arg === "--help" || arg === "-h") { + positional.unshift("--help"); + } else if (!arg.startsWith("-")) { + positional.push(arg); + } + } + + return { + command: positional[0], + positional: positional.slice(1), + address, + browser, + }; +} + +function parseAccount(caip10: string): string { + const lastColon = caip10.lastIndexOf(":"); + return caip10.slice(lastColon + 1); +} + +/** Find an eip155:10 account in the session, or return null */ +function findOptimismAccount(accounts: string[]): string | null { + const match = accounts.find((a) => a.startsWith("eip155:10:")); + return match ? parseAccount(match) : null; +} + +async function resolveAddress(opts: { + address?: string; + requireWallet: boolean; + browser: boolean; +}): Promise<{ address: string; wallet: WalletConnectCLI | null }> { + if (opts.address) { + return { address: opts.address, wallet: null }; + } + + const projectId = opts.requireWallet ? getProjectId() : (process.env.WALLETCONNECT_PROJECT_ID || ""); + const wallet = new WalletConnectCLI({ + projectId, + metadata: CLI_METADATA, + chains: [CAIP2_CHAIN_ID], + ui: opts.browser ? "browser" : "terminal", + }); + + // Try restoring an existing session + const existing = await wallet.tryRestore(); + if (existing) { + const addr = findOptimismAccount(existing.accounts); + if (addr) { + return { address: addr, wallet }; + } + // Session exists but doesn't have Optimism — need a new connection + console.log("Existing session does not include Optimism. Requesting new connection...\n"); + await wallet.disconnect(); + } + + if (!opts.requireWallet) { + await wallet.destroy(); + console.error("Error: No session with Optimism support found. Use --address=0x... or connect a wallet first."); + process.exit(1); + } + + // Connect fresh — autoConnect would reuse the old session, so bypass it + console.log("Scan this QR code with your wallet app:\n"); + const result = await wallet.connect(); + console.log(`\nConnected to ${result.session.peer.metadata.name}\n`); + + const addr = findOptimismAccount(result.accounts); + if (!addr) { + await wallet.destroy(); + console.error("Error: Wallet did not approve Optimism (eip155:10). Please try again and approve the Optimism chain."); + process.exit(1); + } + + return { address: addr, wallet }; +} + +async function main(): Promise { + const { command, positional, address, browser } = parseArgs(process.argv.slice(2)); + + switch (command) { + case "stake": { + const amount = positional[0]; + const weeks = positional[1]; + if (!amount || !weeks) { + console.error("Usage: walletconnect-staking stake "); + process.exit(1); + } + const weeksNum = parseInt(weeks, 10); + if (isNaN(weeksNum) || weeksNum <= 0) { + console.error("Error: must be a positive integer."); + process.exit(1); + } + const { address: addr, wallet } = await resolveAddress({ + address, + requireWallet: true, + browser, + }); + try { + await stake(wallet!, addr, amount, weeksNum); + } finally { + await wallet!.destroy(); + } + break; + } + + case "unstake": { + const { address: addr, wallet } = await resolveAddress({ + address, + requireWallet: true, + browser, + }); + try { + await unstake(wallet!, addr); + } finally { + await wallet!.destroy(); + } + break; + } + + case "claim": { + const { address: addr, wallet } = await resolveAddress({ + address, + requireWallet: true, + browser, + }); + try { + await claim(wallet!, addr); + } finally { + await wallet!.destroy(); + } + break; + } + + case "status": { + const { address: addr, wallet } = await resolveAddress({ + address, + requireWallet: false, + browser, + }); + try { + await status(addr); + } finally { + if (wallet) await wallet.destroy(); + } + break; + } + + case "balance": { + const { address: addr, wallet } = await resolveAddress({ + address, + requireWallet: false, + browser, + }); + try { + await balance(addr); + } finally { + if (wallet) await wallet.destroy(); + } + break; + } + + case "--help": + case "-h": + case undefined: + usage(); + break; + + default: + console.error(`Unknown command: ${command}`); + usage(); + process.exit(1); + } +} + +main().then( + () => process.exit(0), + (err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }, +); diff --git a/packages/staking-cli/src/commands.ts b/packages/staking-cli/src/commands.ts new file mode 100644 index 0000000..2a2b011 --- /dev/null +++ b/packages/staking-cli/src/commands.ts @@ -0,0 +1,197 @@ +import { parseUnits } from "viem"; +import type { WalletConnectCLI } from "@walletconnect/cli-sdk"; +import { + CAIP2_CHAIN_ID, + STAKE_WEIGHT_ADDRESS, + ONE_WEEK_IN_SECONDS, + WCT_DECIMALS, +} from "./constants.js"; +import { + buildApprove, + buildCreateLock, + buildUpdateLock, + buildIncreaseLockAmount, + buildIncreaseUnlockTime, + buildWithdrawAll, + buildClaim, + buildBalanceOfCallData, + buildAllowanceCallData, + buildLocksCallData, + type TxData, +} from "./contracts.js"; +import { readUint256, readLocks, estimateGas, waitForTx } from "./rpc.js"; +import { fetchStaking, fetchStakeWeight } from "./api.js"; +import { formatWCT, formatDate, calculateAPY, calculateWeeklyAPY, label } from "./format.js"; + +// ---- Helpers ---------------------------------------------------------- // + +function computeUnlockTime(weeks: number): bigint { + const now = Math.floor(Date.now() / 1000); + return (BigInt(Math.floor((now + weeks * ONE_WEEK_IN_SECONDS) / ONE_WEEK_IN_SECONDS)) * + BigInt(ONE_WEEK_IN_SECONDS)); +} + +async function sendTx( + wallet: WalletConnectCLI, + from: string, + tx: TxData, +): Promise { + const gas = await estimateGas(from, tx); + return wallet.request({ + chainId: CAIP2_CHAIN_ID, + request: { + method: "eth_sendTransaction", + params: [{ from, to: tx.to, data: tx.data, value: "0x0", gas }], + }, + }); +} + +// ---- Commands --------------------------------------------------------- // + +export async function stake( + wallet: WalletConnectCLI, + address: string, + amount: string, + weeks: number, +): Promise { + const amountWei = parseUnits(amount, WCT_DECIMALS); + const requestedUnlockTime = computeUnlockTime(weeks); + + // Read on-chain position to determine the right action + const lock = await readLocks(buildLocksCallData(address)); + const hasPosition = lock.amount > 0n; + + if (hasPosition) { + console.log("\nExisting staking position:"); + console.log(label("Staked", `${formatWCT(BigInt(lock.amount))} WCT`)); + console.log(label("Unlocks", formatDate(Number(lock.end)))); + } + + // Determine effective unlock time — never shorten an existing lock + let effectiveUnlockTime = requestedUnlockTime; + const extendingTime = !hasPosition || requestedUnlockTime > lock.end; + + if (hasPosition && requestedUnlockTime <= lock.end) { + effectiveUnlockTime = lock.end; + console.log(`\nRequested unlock (${formatDate(Number(requestedUnlockTime))}) is before existing lock end.`); + console.log(`Keeping current unlock date: ${formatDate(Number(lock.end))}`); + } + + console.log(`\nAdding ${amount} WCT${extendingTime ? `, extending lock to ${formatDate(Number(effectiveUnlockTime))}` : ""}...`); + + // Check allowance and approve if needed + const allowance = await readUint256(buildAllowanceCallData(address, STAKE_WEIGHT_ADDRESS)); + if (allowance < amountWei) { + console.log("\nApproving WCT spend..."); + const approveTxHash = await sendTx(wallet, address, buildApprove(STAKE_WEIGHT_ADDRESS, amountWei)); + console.log(label("Approve tx", approveTxHash)); + console.log("Waiting for confirmation..."); + await waitForTx(approveTxHash); + } + + let txHash: string; + + if (!hasPosition) { + console.log("\nCreating new lock..."); + txHash = await sendTx(wallet, address, buildCreateLock(amountWei, effectiveUnlockTime)); + } else if (extendingTime) { + console.log("\nUpdating lock (amount + time)..."); + txHash = await sendTx(wallet, address, buildUpdateLock(amountWei, effectiveUnlockTime)); + } else { + console.log("\nIncreasing lock amount..."); + txHash = await sendTx(wallet, address, buildIncreaseLockAmount(amountWei)); + } + + console.log(label("Tx hash", txHash)); + console.log("\nStake submitted successfully."); +} + +export async function unstake( + wallet: WalletConnectCLI, + address: string, +): Promise { + const staking = await fetchStaking(address); + + if (!staking.position) { + console.log("\nNo staking position found."); + return; + } + + if (staking.position.unlocksAt) { + const unlocksAt = new Date(staking.position.unlocksAt).getTime() / 1000; + const now = Math.floor(Date.now() / 1000); + if (unlocksAt > now) { + console.log(`\nLock has not expired yet. Unlocks ${formatDate(unlocksAt)}.`); + return; + } + } + + console.log("\nWithdrawing all staked WCT..."); + const txHash = await sendTx(wallet, address, buildWithdrawAll()); + console.log(label("Tx hash", txHash)); + console.log("\nUnstake submitted successfully."); +} + +export async function claim( + wallet: WalletConnectCLI, + address: string, +): Promise { + const staking = await fetchStaking(address); + + if (!staking.rewards || staking.rewards.amount === "0") { + console.log("\nNo rewards to claim."); + return; + } + + console.log(`\nClaiming ${staking.rewards.amount} WCT in rewards...`); + const txHash = await sendTx(wallet, address, buildClaim(address)); + console.log(label("Tx hash", txHash)); + console.log("\nClaim submitted successfully."); +} + +export async function status(address: string): Promise { + const [staking, stakeWeightRes] = await Promise.all([ + fetchStaking(address), + fetchStakeWeight(), + ]); + + console.log(`\nStaking status for ${address}\n`); + + if (!staking.position) { + console.log(" No staking position found.\n"); + } else { + const pos = staking.position; + console.log(label("Amount", `${pos.amount} WCT`)); + console.log(label("Permanent", pos.isPermanent ? "Yes" : "No")); + console.log(label("Created", new Date(pos.createdAt).toLocaleDateString("en-US"))); + if (pos.unlocksAt) { + console.log(label("Unlocks", new Date(pos.unlocksAt).toLocaleDateString("en-US"))); + } + if (pos.duration) { + const durationWeeks = Math.round(parseInt(pos.duration, 10) / ONE_WEEK_IN_SECONDS); + console.log(label("Duration", `${durationWeeks} week(s)`)); + } + console.log(); + } + + if (staking.rewards) { + console.log(label("Rewards", `${staking.rewards.amount} WCT`)); + } + + const stakeWeight = parseFloat(stakeWeightRes.stakeWeight); + const baseAPY = calculateAPY(stakeWeight); + console.log(label("Base APY", `${baseAPY.toFixed(2)}%`)); + + if (staking.position?.duration) { + const weeks = Math.round(parseInt(staking.position.duration, 10) / ONE_WEEK_IN_SECONDS); + const weeklyAPY = calculateWeeklyAPY(baseAPY, weeks); + console.log(label("Your APY", `${weeklyAPY.toFixed(2)}%`)); + } + + console.log(); +} + +export async function balance(address: string): Promise { + const bal = await readUint256(buildBalanceOfCallData(address)); + console.log(`\n${label("WCT balance", `${formatWCT(bal)} WCT`)}\n`); +} diff --git a/packages/staking-cli/src/constants.ts b/packages/staking-cli/src/constants.ts new file mode 100644 index 0000000..da82047 --- /dev/null +++ b/packages/staking-cli/src/constants.ts @@ -0,0 +1,34 @@ +/** Optimism chain ID */ +export const CHAIN_ID = 10; +export const CAIP2_CHAIN_ID = "eip155:10"; + +/** Contract addresses on Optimism */ +export const L2_WCT_ADDRESS = "0xeF4461891DfB3AC8572cCf7C794664A8DD927945"; +export const STAKE_WEIGHT_ADDRESS = "0x521B4C065Bbdbe3E20B3727340730936912DfA46"; +export const STAKING_REWARD_DISTRIBUTOR_ADDRESS = "0xF368F535e329c6d08DFf0d4b2dA961C4e7F3fCAF"; + +/** Public Optimism RPC endpoint */ +export const OPTIMISM_RPC_URL = "https://mainnet.optimism.io"; + +/** Foundation API */ +export const FOUNDATION_API_URL = "https://api.walletconnect.network"; + +/** Time constants */ +export const ONE_WEEK_IN_SECONDS = 604800; + +/** WCT token decimals */ +export const WCT_DECIMALS = 18; + +/** APY formula constants (from math.ts) */ +export const APY_SLOPE = -0.06464; +export const APY_INTERCEPT = 12.0808; +export const APY_STAKE_WEIGHT_DIVISOR = 1_000_000; +export const MAX_LOCK_WEEKS = 104; + +/** CLI metadata for WalletConnect pairing */ +export const CLI_METADATA = { + name: "walletconnect-staking", + description: "WalletConnect WCT Staking CLI", + url: "https://walletconnect.com", + icons: [], +}; diff --git a/packages/staking-cli/src/contracts.ts b/packages/staking-cli/src/contracts.ts new file mode 100644 index 0000000..6c89241 --- /dev/null +++ b/packages/staking-cli/src/contracts.ts @@ -0,0 +1,145 @@ +import { encodeFunctionData, parseAbi } from "viem"; +import { + L2_WCT_ADDRESS, + STAKE_WEIGHT_ADDRESS, + STAKING_REWARD_DISTRIBUTOR_ADDRESS, +} from "./constants.js"; + +// ---- ABI fragments ---------------------------------------------------- // + +const erc20Abi = parseAbi([ + "function approve(address spender, uint256 value) returns (bool)", + "function balanceOf(address account) view returns (uint256)", + "function allowance(address owner, address spender) view returns (uint256)", +]); + +const stakeWeightAbi = parseAbi([ + "function createLock(uint256 amount, uint256 unlockTime)", + "function updateLock(uint256 amount, uint256 unlockTime)", + "function increaseLockAmount(uint256 amount)", + "function increaseUnlockTime(uint256 newUnlockTime)", + "function withdrawAll()", + "function locks(address) view returns (int128 amount, uint256 end, uint256 transferredAmount)", +]); + +const stakingRewardDistributorAbi = parseAbi([ + "function claim(address user) returns (uint256)", +]); + +// ---- Transaction builders --------------------------------------------- // + +export interface TxData { + to: string; + data: string; +} + +export function buildApprove(spender: string, amount: bigint): TxData { + return { + to: L2_WCT_ADDRESS, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [spender as `0x${string}`, amount], + }), + }; +} + +export function buildCreateLock(amount: bigint, unlockTime: bigint): TxData { + return { + to: STAKE_WEIGHT_ADDRESS, + data: encodeFunctionData({ + abi: stakeWeightAbi, + functionName: "createLock", + args: [amount, unlockTime], + }), + }; +} + +export function buildUpdateLock(amount: bigint, unlockTime: bigint): TxData { + return { + to: STAKE_WEIGHT_ADDRESS, + data: encodeFunctionData({ + abi: stakeWeightAbi, + functionName: "updateLock", + args: [amount, unlockTime], + }), + }; +} + +export function buildIncreaseLockAmount(amount: bigint): TxData { + return { + to: STAKE_WEIGHT_ADDRESS, + data: encodeFunctionData({ + abi: stakeWeightAbi, + functionName: "increaseLockAmount", + args: [amount], + }), + }; +} + +export function buildIncreaseUnlockTime(newUnlockTime: bigint): TxData { + return { + to: STAKE_WEIGHT_ADDRESS, + data: encodeFunctionData({ + abi: stakeWeightAbi, + functionName: "increaseUnlockTime", + args: [newUnlockTime], + }), + }; +} + +export function buildWithdrawAll(): TxData { + return { + to: STAKE_WEIGHT_ADDRESS, + data: encodeFunctionData({ + abi: stakeWeightAbi, + functionName: "withdrawAll", + }), + }; +} + +export function buildClaim(user: string): TxData { + return { + to: STAKING_REWARD_DISTRIBUTOR_ADDRESS, + data: encodeFunctionData({ + abi: stakingRewardDistributorAbi, + functionName: "claim", + args: [user as `0x${string}`], + }), + }; +} + +// ---- Call data builders (for eth_call) -------------------------------- // + +export function buildBalanceOfCallData(account: string): TxData { + return { + to: L2_WCT_ADDRESS, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "balanceOf", + args: [account as `0x${string}`], + }), + }; +} + +export function buildAllowanceCallData(owner: string, spender: string): TxData { + return { + to: L2_WCT_ADDRESS, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "allowance", + args: [owner as `0x${string}`, spender as `0x${string}`], + }), + }; +} + +export function buildLocksCallData(account: string): TxData { + return { + to: STAKE_WEIGHT_ADDRESS, + data: encodeFunctionData({ + abi: stakeWeightAbi, + functionName: "locks", + args: [account as `0x${string}`], + }), + }; +} diff --git a/packages/staking-cli/src/format.ts b/packages/staking-cli/src/format.ts new file mode 100644 index 0000000..ab19bb7 --- /dev/null +++ b/packages/staking-cli/src/format.ts @@ -0,0 +1,45 @@ +import { formatUnits } from "viem"; +import { + WCT_DECIMALS, + APY_SLOPE, + APY_INTERCEPT, + APY_STAKE_WEIGHT_DIVISOR, + MAX_LOCK_WEEKS, +} from "./constants.js"; + +/** Format a bigint WCT amount as a human-readable string with 2 decimals */ +export function formatWCT(amount: bigint): string { + const raw = formatUnits(amount, WCT_DECIMALS); + const num = parseFloat(raw); + return num.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +/** Format a unix timestamp as a locale date string */ +export function formatDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +/** Calculate base APY from total stake weight (from math.ts) */ +export function calculateAPY(stakeWeight: number): number { + return Math.max( + (stakeWeight / APY_STAKE_WEIGHT_DIVISOR) * APY_SLOPE + APY_INTERCEPT, + 0, + ); +} + +/** Calculate weekly APY adjusted for lock duration */ +export function calculateWeeklyAPY(baseAPY: number, weeks: number): number { + return baseAPY * (Math.min(weeks, MAX_LOCK_WEEKS) / 52); +} + +/** Print a labeled key-value pair */ +export function label(key: string, value: string): string { + return ` ${key.padEnd(12)} ${value}`; +} diff --git a/packages/staking-cli/src/index.ts b/packages/staking-cli/src/index.ts new file mode 100644 index 0000000..4d4ff29 --- /dev/null +++ b/packages/staking-cli/src/index.ts @@ -0,0 +1,25 @@ +export { stake, unstake, claim, status, balance } from "./commands.js"; +export { formatWCT, formatDate, calculateAPY, calculateWeeklyAPY } from "./format.js"; +export { fetchStaking, fetchStakeWeight } from "./api.js"; +export type { StakingPosition, StakingRewards, StakingResponse, StakeWeightResponse } from "./api.js"; +export { + buildApprove, + buildCreateLock, + buildUpdateLock, + buildIncreaseLockAmount, + buildIncreaseUnlockTime, + buildWithdrawAll, + buildClaim, + buildBalanceOfCallData, + buildAllowanceCallData, + buildLocksCallData, +} from "./contracts.js"; +export type { TxData } from "./contracts.js"; +export { + CHAIN_ID, + CAIP2_CHAIN_ID, + L2_WCT_ADDRESS, + STAKE_WEIGHT_ADDRESS, + STAKING_REWARD_DISTRIBUTOR_ADDRESS, + CLI_METADATA, +} from "./constants.js"; diff --git a/packages/staking-cli/src/rpc.ts b/packages/staking-cli/src/rpc.ts new file mode 100644 index 0000000..da3400c --- /dev/null +++ b/packages/staking-cli/src/rpc.ts @@ -0,0 +1,101 @@ +import { decodeAbiParameters } from "viem"; +import { OPTIMISM_RPC_URL } from "./constants.js"; +import type { TxData } from "./contracts.js"; + +let requestId = 1; + +async function rpcRequest( + method: string, + params: unknown[], + rpcUrl: string, +): Promise { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: requestId++, + method, + params, + }), + }); + + if (!res.ok) { + throw new Error(`RPC request failed: ${res.status} ${res.statusText}`); + } + + const json = (await res.json()) as { result?: string; error?: { message: string } }; + + if (json.error) { + throw new Error(`RPC error: ${json.error.message}`); + } + + return json.result!; +} + +async function ethCall(tx: TxData, rpcUrl: string): Promise { + return rpcRequest("eth_call", [{ to: tx.to, data: tx.data }, "latest"], rpcUrl); +} + +/** Read a uint256 from an eth_call result */ +export async function readUint256( + tx: TxData, + rpcUrl: string = OPTIMISM_RPC_URL, +): Promise { + const result = await ethCall(tx, rpcUrl); + const [value] = decodeAbiParameters([{ type: "uint256" }], result as `0x${string}`); + return value; +} + +/** Estimate gas for a transaction, with a 20% buffer */ +export async function estimateGas( + from: string, + tx: TxData, + rpcUrl: string = OPTIMISM_RPC_URL, +): Promise { + const result = await rpcRequest( + "eth_estimateGas", + [{ from, to: tx.to, data: tx.data, value: "0x0" }, "latest"], + rpcUrl, + ); + // Add 20% buffer to the estimate + const estimate = BigInt(result); + const buffered = estimate + estimate / 5n; + return `0x${buffered.toString(16)}`; +} + +/** Wait for a transaction to be confirmed (polls eth_getTransactionReceipt) */ +export async function waitForTx( + txHash: string, + rpcUrl: string = OPTIMISM_RPC_URL, + { intervalMs = 2000, timeoutMs = 60000 } = {}, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const result = await rpcRequest( + "eth_getTransactionReceipt", + [txHash], + rpcUrl, + ); + if (result) return; + } catch { + // receipt not available yet + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error(`Transaction ${txHash} not confirmed within ${timeoutMs / 1000}s`); +} + +/** Read the locks() return: (int128 amount, uint256 end, uint256 transferredAmount) */ +export async function readLocks( + tx: TxData, + rpcUrl: string = OPTIMISM_RPC_URL, +): Promise<{ amount: bigint; end: bigint; transferredAmount: bigint }> { + const result = await ethCall(tx, rpcUrl); + const [amount, end, transferredAmount] = decodeAbiParameters( + [{ type: "int128" }, { type: "uint256" }, { type: "uint256" }], + result as `0x${string}`, + ); + return { amount: BigInt(amount), end, transferredAmount }; +} diff --git a/packages/staking-cli/test/contracts.test.ts b/packages/staking-cli/test/contracts.test.ts new file mode 100644 index 0000000..0862161 --- /dev/null +++ b/packages/staking-cli/test/contracts.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { + buildApprove, + buildCreateLock, + buildUpdateLock, + buildWithdrawAll, + buildClaim, + buildBalanceOfCallData, + buildAllowanceCallData, + buildLocksCallData, +} from "../src/contracts.js"; +import { + L2_WCT_ADDRESS, + STAKE_WEIGHT_ADDRESS, + STAKING_REWARD_DISTRIBUTOR_ADDRESS, +} from "../src/constants.js"; + +const TEST_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; +const TEST_SPENDER = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + +describe("transaction builders", () => { + it("buildApprove targets WCT contract", () => { + const tx = buildApprove(TEST_SPENDER, 1000n); + expect(tx.to).toBe(L2_WCT_ADDRESS); + expect(tx.data).toMatch(/^0x/); + // approve(address,uint256) selector: 0x095ea7b3 + expect(tx.data.startsWith("0x095ea7b3")).toBe(true); + }); + + it("buildCreateLock targets StakeWeight contract", () => { + const tx = buildCreateLock(1000n, 1700000000n); + expect(tx.to).toBe(STAKE_WEIGHT_ADDRESS); + expect(tx.data).toMatch(/^0x/); + }); + + it("buildUpdateLock targets StakeWeight contract", () => { + const tx = buildUpdateLock(1000n, 1700000000n); + expect(tx.to).toBe(STAKE_WEIGHT_ADDRESS); + expect(tx.data).toMatch(/^0x/); + }); + + it("buildWithdrawAll targets StakeWeight contract", () => { + const tx = buildWithdrawAll(); + expect(tx.to).toBe(STAKE_WEIGHT_ADDRESS); + expect(tx.data).toMatch(/^0x/); + }); + + it("buildClaim targets StakingRewardDistributor", () => { + const tx = buildClaim(TEST_ADDRESS); + expect(tx.to).toBe(STAKING_REWARD_DISTRIBUTOR_ADDRESS); + expect(tx.data).toMatch(/^0x/); + }); +}); + +describe("call data builders", () => { + it("buildBalanceOfCallData targets WCT contract", () => { + const tx = buildBalanceOfCallData(TEST_ADDRESS); + expect(tx.to).toBe(L2_WCT_ADDRESS); + // balanceOf(address) selector: 0x70a08231 + expect(tx.data.startsWith("0x70a08231")).toBe(true); + }); + + it("buildAllowanceCallData targets WCT contract", () => { + const tx = buildAllowanceCallData(TEST_ADDRESS, TEST_SPENDER); + expect(tx.to).toBe(L2_WCT_ADDRESS); + // allowance(address,address) selector: 0xdd62ed3e + expect(tx.data.startsWith("0xdd62ed3e")).toBe(true); + }); + + it("buildLocksCallData targets StakeWeight contract", () => { + const tx = buildLocksCallData(TEST_ADDRESS); + expect(tx.to).toBe(STAKE_WEIGHT_ADDRESS); + expect(tx.data).toMatch(/^0x/); + }); +}); diff --git a/packages/staking-cli/test/format.test.ts b/packages/staking-cli/test/format.test.ts new file mode 100644 index 0000000..d47d108 --- /dev/null +++ b/packages/staking-cli/test/format.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { formatWCT, formatDate, calculateAPY, calculateWeeklyAPY, label } from "../src/format.js"; + +describe("formatWCT", () => { + it("formats zero", () => { + expect(formatWCT(0n)).toBe("0.00"); + }); + + it("formats whole tokens", () => { + expect(formatWCT(1000000000000000000n)).toBe("1.00"); + }); + + it("formats large amounts with commas", () => { + // 1,234,567 WCT + expect(formatWCT(1234567000000000000000000n)).toBe("1,234,567.00"); + }); + + it("formats fractional amounts", () => { + // 1.5 WCT + expect(formatWCT(1500000000000000000n)).toBe("1.50"); + }); +}); + +describe("formatDate", () => { + it("formats a unix timestamp", () => { + // Jan 1, 2025 00:00:00 UTC + const result = formatDate(1735689600); + expect(result).toContain("2025"); + expect(result).toContain("Jan"); + }); +}); + +describe("calculateAPY", () => { + it("returns positive APY for low stake weight", () => { + const apy = calculateAPY(0); + expect(apy).toBeCloseTo(12.0808, 2); + }); + + it("returns lower APY for higher stake weight", () => { + const apy = calculateAPY(100_000_000); + expect(apy).toBeCloseTo(Math.max(100 * -0.06464 + 12.0808, 0), 2); + }); + + it("never returns negative APY", () => { + const apy = calculateAPY(1_000_000_000); + expect(apy).toBe(0); + }); +}); + +describe("calculateWeeklyAPY", () => { + it("scales APY by lock duration", () => { + expect(calculateWeeklyAPY(10, 52)).toBeCloseTo(10, 2); + }); + + it("caps at 104 weeks", () => { + expect(calculateWeeklyAPY(10, 200)).toBeCloseTo(10 * (104 / 52), 2); + }); + + it("returns lower APY for shorter locks", () => { + expect(calculateWeeklyAPY(10, 26)).toBeCloseTo(5, 2); + }); +}); + +describe("label", () => { + it("pads the key", () => { + const result = label("Key", "Value"); + expect(result).toContain("Key"); + expect(result).toContain("Value"); + expect(result.startsWith(" ")).toBe(true); + }); +}); diff --git a/packages/staking-cli/tsconfig.json b/packages/staking-cli/tsconfig.json new file mode 100644 index 0000000..41c5e63 --- /dev/null +++ b/packages/staking-cli/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/staking-cli/tsup.config.ts b/packages/staking-cli/tsup.config.ts new file mode 100644 index 0000000..669bdc3 --- /dev/null +++ b/packages/staking-cli/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "tsup"; + +export default defineConfig([ + { + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + sourcemap: true, + minify: true, + clean: true, + }, + { + entry: ["src/cli.ts"], + format: ["esm"], + banner: { js: "#!/usr/bin/env node" }, + }, +]); diff --git a/packages/staking-cli/vitest.config.ts b/packages/staking-cli/vitest.config.ts new file mode 100644 index 0000000..4870abb --- /dev/null +++ b/packages/staking-cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 30000, + }, +}); diff --git a/skills/walletconnect-staking/SKILL.md b/skills/walletconnect-staking/SKILL.md new file mode 100644 index 0000000..14f4cb4 --- /dev/null +++ b/skills/walletconnect-staking/SKILL.md @@ -0,0 +1,106 @@ +--- +name: walletconnect-staking +description: Manages the walletconnect-staking CLI for WCT token staking on Optimism. Use when the user wants to stake WCT, unstake, claim rewards, check staking status, or view WCT balance. +--- + +# WalletConnect Staking CLI + +## Goal + +Operate the `walletconnect-staking` CLI to stake/unstake WCT tokens, claim staking rewards, and check positions on Optimism (chain ID 10). + +## When to use + +- User asks to stake WCT tokens +- User asks to unstake or withdraw staked WCT +- User asks to claim staking rewards +- User asks about their staking position, APY, or rewards +- User asks about their WCT token balance +- User mentions `walletconnect-staking` or WCT staking + +## When not to use + +- User wants basic wallet connection without staking (use `walletconnect` skill) +- User is working on the staking-cli source code (just edit normally) +- User wants to interact with WCT on a chain other than Optimism + +## Prerequisites + +- Project ID must be configured for write commands (stake, unstake, claim). Set globally with `walletconnect config set project-id ` or override per-command with `WALLETCONNECT_PROJECT_ID` env var. +- Binary is at `packages/staking-cli/dist/cli.js` (or globally linked as `walletconnect-staking`) +- Build first if needed: `npm run build -w @walletconnect/staking-cli` + +## Commands + +```bash +# Stake WCT — locks tokens for N weeks +walletconnect-staking stake + +# Unstake — withdraw all (only after lock expires) +walletconnect-staking unstake + +# Claim staking rewards +walletconnect-staking claim + +# Check staking position, rewards, APY (read-only) +walletconnect-staking status --address=0x... + +# Check WCT balance (read-only) +walletconnect-staking balance --address=0x... +``` + +## Default workflow + +### For write commands (stake, unstake, claim) +1. Ensure project ID is configured (`walletconnect config get project-id`) +2. Run the command — CLI auto-restores session or prompts QR connection +3. The session must include Optimism (eip155:10). If the existing session doesn't, the CLI disconnects and requests a new one +4. Inform the user to confirm the transaction in their wallet app +5. Use 60s+ timeout for all wallet interaction commands + +### For read-only commands (status, balance) +1. Use `--address=0x...` to skip wallet connection entirely +2. Or let the CLI restore an existing session for the address +3. No project ID needed when using `--address` + +## Important notes + +- **Optimism only**: All transactions happen on Optimism (chain ID 10) +- **Lock duration**: Stake lock time is rounded down to the nearest week boundary +- **Approve flow**: `stake` automatically checks allowance and sends an approve tx first if needed +- **Existing position**: `stake` detects existing positions and calls `updateLock` instead of `createLock` +- **Unlock check**: `unstake` refuses to run if the lock hasn't expired yet +- **Read-only shortcut**: `status` and `balance` work with just `--address=0x...`, no wallet connection needed +- **APY formula**: `APY = max((stakeWeight / 1M) * -0.06464 + 12.0808, 0)`, adjusted by `min(weeks, 104) / 52` + +## Validation checklist + +- [ ] Project ID is configured (`walletconnect config get project-id`) for write commands +- [ ] Binary is built and linked (`walletconnect-staking --help` works) +- [ ] Wallet session includes Optimism chain approval +- [ ] Transaction output (tx hash) is shown to the user +- [ ] Timeouts are 60s+ for wallet interaction commands +- [ ] For read-only queries, `--address` is used when no session is needed + +## Examples + +### Stake 100 WCT for 4 weeks +``` +User: "Stake 100 WCT for a month" +Action: Run `walletconnect-staking stake 100 4` +Note: May send 2 transactions (approve + createLock/updateLock). Inform user to confirm each. +``` + +### Check staking status +``` +User: "What's my staking position for 0xABC...?" +Action: Run `walletconnect-staking status --address=0xABC...` +Note: No project ID or wallet connection needed. +``` + +### Check WCT balance +``` +User: "How much WCT do I have?" +Action: Run `walletconnect-staking balance --address=0x...` +Note: If no address provided, ask the user for one or use an existing session. +``` diff --git a/skills/walletconnect/SKILL.md b/skills/walletconnect/SKILL.md new file mode 100644 index 0000000..2a58c37 --- /dev/null +++ b/skills/walletconnect/SKILL.md @@ -0,0 +1,106 @@ +--- +name: walletconnect +description: Manages the walletconnect CLI for wallet connection, session management, and message signing. Use when the user wants to connect a wallet, check session status, sign messages, or disconnect. +--- + +# WalletConnect CLI + +## Goal + +Operate the `walletconnect` CLI binary to connect wallets via QR code, inspect sessions, sign messages, and disconnect. + +## When to use + +- User asks to connect a wallet or scan a QR code +- User asks to check which wallet is connected (`whoami`) +- User asks to sign a message with their wallet +- User asks to disconnect their wallet session +- User mentions `walletconnect` CLI in context of wallet operations + +## When not to use + +- User wants to stake, unstake, or claim WCT rewards (use `walletconnect-staking` skill) +- User is working on the SDK source code itself (just edit normally) +- User wants to interact with a dApp or smart contract beyond signing + +## Prerequisites + +- Project ID must be configured (see below) for `connect` and `sign` commands +- Binary is at `packages/cli-sdk/dist/cli.js` (or globally linked as `walletconnect`) +- Build first if needed: `npm run build -w @walletconnect/cli-sdk` + +## Project ID configuration + +The project ID is resolved in this order: `WALLETCONNECT_PROJECT_ID` env var > `~/.walletconnect-cli/config.json`. + +```bash +# Set globally (persists across sessions) +walletconnect config set project-id + +# Check current value +walletconnect config get project-id + +# Or override per-command via env var +WALLETCONNECT_PROJECT_ID= walletconnect connect +``` + +## Commands + +```bash +# Connect a wallet (displays QR code in terminal) +walletconnect connect + +# Connect via browser UI instead of terminal QR +walletconnect connect --browser + +# Check current session +walletconnect whoami + +# Sign a message +walletconnect sign "Hello, World!" + +# Disconnect +walletconnect disconnect + +# Manage config +walletconnect config set project-id +walletconnect config get project-id +``` + +## Default workflow + +1. Check project ID is configured: `walletconnect config get project-id` +2. Check if a session exists: `walletconnect whoami` +3. If not connected, connect: `walletconnect connect` +4. Perform the requested operation (sign, check status, etc.) +5. If done, optionally disconnect: `walletconnect disconnect` + +## Important notes + +- The `connect` and `sign` commands require a project ID — set it with `walletconnect config set project-id ` if not configured +- Sessions persist across invocations in `~/.walletconnect-cli/` +- `sign` auto-connects if no session exists +- The `--browser` flag opens a local web page with the QR code instead of rendering in terminal +- Always use a 60s+ timeout for commands that require wallet interaction (QR scan, signing) + +## Validation checklist + +- [ ] Project ID is configured (`walletconnect config get project-id`) +- [ ] Binary is built and linked (`walletconnect --help` works) +- [ ] Command output is shown to the user +- [ ] Timeouts are sufficient for wallet interaction (60s+) + +## Examples + +### Check session status +``` +User: "Am I connected to a wallet?" +Action: Run `walletconnect whoami` +``` + +### Sign a message +``` +User: "Sign the message 'verify-ownership' with my wallet" +Action: Run `walletconnect sign "verify-ownership"` +Note: Inform user to confirm in their wallet app +```