diff --git a/docs/install/index.md b/docs/install/index.md index 7bdec43820df..b7c254d9178e 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -142,7 +142,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl ## x402 / Daydreams Router -If you want to use [Daydreams Router (x402)](/providers/x402) with on-device wallet signing via SAW, use the fork installer — it handles SAW setup, gateway install, and x402 onboarding in one command: +If you want to use [Daydreams Router (x402)](/providers/x402) with either Taskmarket or SAW-based wallet signing, use the fork installer. In an interactive terminal it asks which wallet tooling to install; in non-interactive shells it installs both. It then handles the selected wallet setup, gateway install, and x402 onboarding in one command: ```bash curl -fsSL https://raw.githubusercontent.com/daydreamsai/openclaw-x402-router/main/scripts/install-openclaw-fork.sh | bash diff --git a/docs/providers/x402.md b/docs/providers/x402.md index 9e3a62dd5368..fd22dd7a373a 100644 --- a/docs/providers/x402.md +++ b/docs/providers/x402.md @@ -14,7 +14,7 @@ per request so you can pay for inference with USDC. ## One-line install (recommended) The fork installer sets up everything in one shot: the SAW (Secure Agent Wallet) -daemon for on-device key management, the OpenClaw gateway, and x402 onboarding. +daemon, the Taskmarket CLI, the OpenClaw gateway, and x402 onboarding. ```bash curl -fsSL https://raw.githubusercontent.com/daydreamsai/openclaw-x402-router/main/scripts/install-openclaw-fork.sh | bash @@ -22,9 +22,11 @@ curl -fsSL https://raw.githubusercontent.com/daydreamsai/openclaw-x402-router/ma What it does: -1. **Phase 1 — SAW daemon**: downloads SAW binaries, generates an EVM wallet key on-device, writes a conservative spending policy, and starts the daemon (systemd on Linux, LaunchAgent on macOS). -2. **Phase 2 — OpenClaw gateway**: installs the CLI globally from a prebuilt release tarball (fast, no build tools needed). -3. **Phase 3 — Onboarding**: runs `openclaw onboard --auth-choice x402` if a TTY is available; otherwise prints the manual command. +1. **Wallet choice**: if a TTY is available, the script asks whether to install SAW, Taskmarket, or both. In non-interactive shells it defaults to installing both. +2. **Phase 1 — SAW daemon**: when selected, downloads SAW binaries, generates an EVM wallet key on-device, writes a conservative spending policy, and starts the daemon (systemd on Linux, LaunchAgent on macOS). +3. **Phase 2 — OpenClaw gateway**: installs the CLI globally from a prebuilt release tarball (fast, no build tools needed). +4. **Phase 2.25 — Taskmarket CLI**: when selected, installs the `taskmarket` binary globally so Taskmarket wallet onboarding can rely on it being present. +5. **Phase 3 — Onboarding**: runs `openclaw onboard --auth-choice x402` if a TTY is available; otherwise prints the manual command. To skip SAW setup (e.g. you already have a wallet): @@ -37,8 +39,12 @@ See the [script header](https://github.com/daydreamsai/openclaw-x402-router/blob ## Quick setup 1. Run onboarding and select Daydreams Router (x402). -2. Enter your wallet private key and router URL. -3. Set the default model: +2. Choose one of the auth methods: + - `Taskmarket wallet keystore` + - `Secure Agent Wallet (SAW)` + - `Wallet private key` +3. If you choose Taskmarket and no keystore is present yet, onboarding can run `taskmarket init` for you. +4. Set the default model: ```json5 { diff --git a/extensions/daydreams-x402-auth/index.ts b/extensions/daydreams-x402-auth/index.ts index 35565dc657a0..d4a18d03df50 100644 --- a/extensions/daydreams-x402-auth/index.ts +++ b/extensions/daydreams-x402-auth/index.ts @@ -1,9 +1,15 @@ +import { spawn, spawnSync } from "node:child_process"; +import { createDecipheriv } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { emptyPluginConfigSchema, type OpenClawPluginApi, type ProviderAuthContext, type ProviderAuthResult, } from "openclaw/plugin-sdk"; +import { privateKeyToAccount } from "viem/accounts"; const PROVIDER_ID = "x402"; const PROVIDER_LABEL = "Daydreams Router (x402)"; @@ -30,6 +36,11 @@ const FALLBACK_MAX_TOKENS = 8192; const PRIVATE_KEY_REGEX = /^0x[0-9a-fA-F]{64}$/; const DEFAULT_SAW_SOCKET = process.env.SAW_SOCKET || "/run/saw/saw.sock"; const DEFAULT_SAW_WALLET = "main"; +const TASKMARKET_SENTINEL_PREFIX = "taskmarket:"; +const TASKMARKET_SENTINEL_VERSION = 1 as const; +const FALLBACK_TASKMARKET_API_URL = "https://api-market.daydreams.systems"; +const DEFAULT_TASKMARKET_API_URL = process.env.TASKMARKET_API_URL || FALLBACK_TASKMARKET_API_URL; +const DEFAULT_TASKMARKET_KEYSTORE_PATH = "~/.taskmarket/keystore.json"; type X402ModelDefinition = { id: string; @@ -290,10 +301,320 @@ function normalizePrivateKey(value: string): string | null { return PRIVATE_KEY_REGEX.test(normalized) ? normalized : null; } +function normalizeTaskmarketApiUrl(value: string): string { + const raw = value.trim() || DEFAULT_TASKMARKET_API_URL; + const withProtocol = raw.startsWith("http") ? raw : `https://${raw}`; + return withProtocol.replace(/\/+$/, ""); +} + +function resolveHomePath(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + if (trimmed === "~") { + return os.homedir(); + } + if (trimmed.startsWith("~/")) { + return path.join(os.homedir(), trimmed.slice(2)); + } + return path.resolve(trimmed); +} + +function toBase64Url(value: string): string { + return Buffer.from(value, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +type TaskmarketKeystore = { + encryptedKey: string; + walletAddress: string; + deviceId: string; + apiToken: string; +}; + +function getErrorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error && typeof error.code === "string" + ? error.code + : undefined; +} + +function buildTaskmarketSentinel(payload: { keystorePath: string; apiUrl?: string }): string { + const record: Record = { + v: TASKMARKET_SENTINEL_VERSION, + keystorePath: payload.keystorePath, + }; + const apiUrl = payload.apiUrl ? normalizeTaskmarketApiUrl(payload.apiUrl) : ""; + if (apiUrl) { + record.apiUrl = apiUrl; + } + return `${TASKMARKET_SENTINEL_PREFIX}${toBase64Url(JSON.stringify(record))}`; +} + function buildSawSentinel(walletName: string, socketPath: string): string { return `saw:${walletName}@${socketPath}`; } +async function loadTaskmarketKeystore(keystorePath: string): Promise { + const resolvedPath = resolveHomePath(keystorePath); + if (!resolvedPath) { + throw new Error("Taskmarket keystore path is required"); + } + + let raw: string; + try { + raw = await fs.readFile(resolvedPath, "utf8"); + } catch (error) { + if (getErrorCode(error) === "ENOENT") { + throw new Error( + `Taskmarket keystore not found at ${resolvedPath}. Run taskmarket init first.`, + ); + } + throw new Error( + `Taskmarket keystore at ${resolvedPath} could not be read: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Taskmarket keystore at ${resolvedPath} is not valid JSON.`); + } + + const record = parsed as Record; + const encryptedKey = + typeof record.encryptedKey === "string" ? record.encryptedKey.trim() : undefined; + const walletAddress = + typeof record.walletAddress === "string" ? record.walletAddress.trim() : undefined; + const deviceId = typeof record.deviceId === "string" ? record.deviceId.trim() : undefined; + const apiToken = typeof record.apiToken === "string" ? record.apiToken.trim() : undefined; + const normalizedWalletAddress = normalizeAddress(walletAddress); + + if (!encryptedKey || !deviceId || !apiToken || !normalizedWalletAddress) { + throw new Error( + `Taskmarket keystore at ${resolvedPath} is missing required fields (encryptedKey, walletAddress, deviceId, apiToken).`, + ); + } + + return { + encryptedKey, + walletAddress: normalizedWalletAddress, + deviceId, + apiToken, + }; +} + +async function verifyTaskmarketDeviceKeyAccess( + apiUrl: string, + keystore: TaskmarketKeystore, +): Promise { + const normalizedUrl = normalizeTaskmarketApiUrl(apiUrl); + const endpoint = `${normalizedUrl}/api/devices/${encodeURIComponent(keystore.deviceId)}/key`; + let response: Response; + try { + response = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceId: keystore.deviceId, apiToken: keystore.apiToken }), + }); + } catch (error) { + throw new Error( + `Could not contact Taskmarket API at ${normalizedUrl}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!response.ok) { + const detail = await response.text().catch(() => ""); + if (response.status === 401 || response.status === 403) { + throw new Error( + "Taskmarket device token was rejected. Reprovision wallet via taskmarket init and rerun onboarding.", + ); + } + if (response.status === 404) { + throw new Error( + `Taskmarket device ${keystore.deviceId} was not found. Reprovision wallet via taskmarket init and rerun onboarding.`, + ); + } + throw new Error( + `Taskmarket device key probe failed (${response.status}). ${detail.slice(0, 180)} Reprovision your wallet with taskmarket init if needed.`, + ); + } + + const parsed = (await response.json().catch(() => null)) as { + deviceEncryptionKey?: unknown; + } | null; + const dek = + parsed && typeof parsed.deviceEncryptionKey === "string" + ? parsed.deviceEncryptionKey.trim() + : ""; + if (!/^[0-9a-fA-F]{64}$/.test(dek)) { + throw new Error( + "Taskmarket device key probe returned an invalid key payload. Reprovision wallet via taskmarket init.", + ); + } + return dek; +} + +function decodeTaskmarketEncryptedKey(encryptedHex: string): { + iv: Buffer; + tag: Buffer; + ciphertext: Buffer; +} { + const data = Buffer.from(encryptedHex, "hex"); + if (data.length <= 28) { + throw new Error("Taskmarket encrypted keystore payload is too short."); + } + const iv = data.subarray(0, 12); + const tag = data.subarray(12, 28); + const ciphertext = data.subarray(28); + if (ciphertext.length === 0) { + throw new Error("Taskmarket encrypted keystore payload is empty."); + } + return { iv, tag, ciphertext }; +} + +function decryptTaskmarketPrivateKey(deviceEncryptionKeyHex: string, encryptedHex: string): string { + const key = Buffer.from(deviceEncryptionKeyHex, "hex"); + if (key.length !== 32) { + throw new Error( + "Taskmarket device key payload is invalid. Reprovision wallet via taskmarket init.", + ); + } + + const { iv, tag, ciphertext } = decodeTaskmarketEncryptedKey(encryptedHex); + try { + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString( + "utf8", + ); + const normalized = decrypted.trim().startsWith("0X") + ? `0x${decrypted.trim().slice(2)}` + : decrypted.trim(); + if (!PRIVATE_KEY_REGEX.test(normalized)) { + throw new Error("Decrypted Taskmarket key is not a valid private key."); + } + return normalized; + } catch (error) { + if ( + error instanceof Error && + error.message === "Decrypted Taskmarket key is not a valid private key." + ) { + throw error; + } + throw new Error( + "Taskmarket keystore could not be decrypted with the fetched device key. Reprovision wallet via taskmarket init.", + ); + } +} + +function verifyTaskmarketWalletIntegrity( + keystore: TaskmarketKeystore, + deviceEncryptionKey: string, +): void { + const privateKey = decryptTaskmarketPrivateKey(deviceEncryptionKey, keystore.encryptedKey); + const account = privateKeyToAccount(privateKey as `0x${string}`); + if (account.address.toLowerCase() !== keystore.walletAddress.toLowerCase()) { + throw new Error( + "Taskmarket keystore address mismatch after decryption. Reprovision wallet via taskmarket init and rerun onboarding.", + ); + } +} + +function ensureTaskmarketCliAvailable(): void { + const probe = spawnSync("taskmarket", ["--help"], { + stdio: "ignore", + encoding: "utf8", + }); + if (!probe.error) { + return; + } + throw new Error( + `Taskmarket CLI is required for this auth method but was not found in PATH (${probe.error.message}). Install it first, then run \`taskmarket init\` and re-run onboarding.`, + ); +} + +async function ensureTaskmarketWalletProvisioned( + ctx: ProviderAuthContext, + keystorePath: string, +): Promise { + const resolvedPath = resolveHomePath(keystorePath); + if (!resolvedPath) { + throw new Error("Taskmarket keystore path is required"); + } + + try { + await fs.access(resolvedPath); + return; + } catch (error) { + if (getErrorCode(error) !== "ENOENT") { + throw new Error( + `Taskmarket keystore at ${resolvedPath} could not be accessed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + const shouldProvision = await ctx.prompter.confirm({ + message: `No Taskmarket keystore found at ${resolvedPath}. Run taskmarket init now?`, + initialValue: true, + }); + if (!shouldProvision) { + throw new Error( + `Taskmarket keystore not found at ${resolvedPath}. Run taskmarket init, then re-run onboarding.`, + ); + } + + await new Promise((resolve, reject) => { + const child = spawn("taskmarket", ["init"], { + stdio: "inherit", + env: process.env, + }); + + child.once("error", (error) => { + reject( + new Error( + `Could not start taskmarket init: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + }); + child.once("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `taskmarket init failed${signal ? ` with signal ${signal}` : ` with exit code ${code ?? "unknown"}`}.`, + ), + ); + }); + }); + + try { + await fs.access(resolvedPath); + } catch (error) { + throw new Error( + getErrorCode(error) === "ENOENT" + ? `taskmarket init completed, but no keystore was found at ${resolvedPath}. Re-run taskmarket init or check your Taskmarket config.` + : `taskmarket init completed, but the keystore at ${resolvedPath} could not be accessed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function resolveSawAddress(walletName: string, socketPath: string): Promise { + try { + const { createSawClient } = await import("@daydreamsai/saw"); + const client = createSawClient({ wallet: walletName, socketPath }); + const address = await client.getAddress(); + return normalizeAddress(address); + } catch { + return null; + } +} + function normalizeRouterUrl(value: string): string { const raw = value.trim() || DEFAULT_ROUTER_URL; const withProtocol = raw.startsWith("http") ? raw : `https://${raw}`; @@ -321,17 +642,6 @@ function normalizeAddress(value: unknown): string | null { return /^0x[0-9a-fA-F]{40}$/.test(trimmed) ? trimmed : null; } -async function resolveSawAddress(walletName: string, socketPath: string): Promise { - try { - const { createSawClient } = await import("@daydreamsai/saw"); - const client = createSawClient({ wallet: walletName, socketPath }); - const address = await client.getAddress(); - return normalizeAddress(address); - } catch { - return null; - } -} - async function resolveKeyAddress(privateKey: string): Promise { try { const { privateKeyToAccount } = await import("viem/accounts"); @@ -382,7 +692,7 @@ const x402Plugin = { { id: "saw", label: "Secure Agent Wallet (SAW)", - hint: "Signs permits via SAW daemon (recommended)", + hint: "Signs permits via SAW daemon", kind: "api_key", run: async (ctx: ProviderAuthContext): Promise => { await ctx.prompter.note( @@ -441,8 +751,7 @@ const x402Plugin = { const fundingAddress = await resolveSawAddress(walletName, socketPath); if (!fundingAddress) { throw new Error( - `Could not resolve SAW wallet address for "${walletName}" via socket "${socketPath}". ` + - "Ensure the SAW daemon is running and the wallet exists, then re-run onboarding.", + `Could not resolve SAW wallet address for "${walletName}" via socket "${socketPath}". Ensure the SAW daemon is running and the wallet exists, then re-run onboarding.`, ); } await showFundingStep(ctx, fundingAddress, network); @@ -506,6 +815,125 @@ const x402Plugin = { }; }, }, + { + id: "taskmarket", + label: "Taskmarket wallet keystore", + hint: "Signs permits using Taskmarket encrypted keystore + device key", + kind: "api_key", + run: async (ctx: ProviderAuthContext): Promise => { + const keystorePath = DEFAULT_TASKMARKET_KEYSTORE_PATH; + await ctx.prompter.note( + [ + "This mode uses a Taskmarket encrypted keystore and per-device token.", + "If no wallet is provisioned yet, OpenClaw can run `taskmarket init` during onboarding.", + "OpenClaw will fetch a Taskmarket device key on demand to sign permits.", + ].join("\n"), + "Taskmarket wallet", + ); + ensureTaskmarketCliAvailable(); + await ensureTaskmarketWalletProvisioned(ctx, keystorePath); + const keystore = await loadTaskmarketKeystore(keystorePath); + const deviceEncryptionKey = await verifyTaskmarketDeviceKeyAccess( + DEFAULT_TASKMARKET_API_URL, + keystore, + ); + verifyTaskmarketWalletIntegrity(keystore, deviceEncryptionKey); + + const routerInput = await ctx.prompter.text({ + message: "Daydreams Router URL", + initialValue: DEFAULT_ROUTER_URL, + validate: (value: string) => { + try { + // eslint-disable-next-line no-new + new URL(value); + return undefined; + } catch { + return "Invalid URL"; + } + }, + }); + const routerUrl = normalizeRouterUrl(String(routerInput)); + + const capInput = await ctx.prompter.text({ + message: "Permit cap (USD)", + initialValue: String(DEFAULT_PERMIT_CAP_USD), + validate: (value: string) => + normalizePermitCap(value) ? undefined : "Invalid amount", + }); + const permitCap = normalizePermitCap(String(capInput)) ?? DEFAULT_PERMIT_CAP_USD; + + const networkInput = await ctx.prompter.text({ + message: "Network (CAIP-2)", + initialValue: DEFAULT_NETWORK, + validate: (value: string) => (normalizeNetwork(value) ? undefined : "Required"), + }); + const network = normalizeNetwork(String(networkInput)) ?? DEFAULT_NETWORK; + const selectedDefaultModelRef = await promptDefaultModelRef(ctx); + await showFundingStep(ctx, keystore.walletAddress, network); + + const existingPluginConfig = + ctx.config.plugins?.entries?.[PLUGIN_ID]?.config && + typeof ctx.config.plugins.entries[PLUGIN_ID]?.config === "object" + ? (ctx.config.plugins.entries[PLUGIN_ID]?.config as Record) + : {}; + + const pluginConfigPatch: Record = { ...existingPluginConfig }; + if (existingPluginConfig.permitCap === undefined) { + pluginConfigPatch.permitCap = permitCap; + } + if (!existingPluginConfig.network) { + pluginConfigPatch.network = network; + } + + return { + profiles: [ + { + profileId: "x402:default", + credential: { + type: "api_key", + provider: PROVIDER_ID, + key: buildTaskmarketSentinel({ + keystorePath, + apiUrl: DEFAULT_TASKMARKET_API_URL, + }), + }, + }, + ], + configPatch: { + plugins: { + entries: { + [PLUGIN_ID]: { + config: pluginConfigPatch, + }, + }, + }, + models: { + providers: { + [PROVIDER_ID]: { + baseUrl: routerUrl, + apiKey: "x402-wallet", + api: "anthropic-messages", + authHeader: false, + models: cloneX402Models(), + }, + }, + }, + agents: { + defaults: { + models: buildDefaultAllowlistedModels(), + }, + }, + }, + defaultModel: selectedDefaultModelRef, + notes: [ + `Daydreams Router base URL set to ${routerUrl}.`, + `Taskmarket keystore path: ${keystorePath}.`, + `Taskmarket API URL: ${DEFAULT_TASKMARKET_API_URL}.`, + "Permit caps apply per signed session; update plugins.entries.daydreams-x402-auth.config to change.", + ], + }; + }, + }, { id: "wallet", label: "Wallet private key", diff --git a/extensions/daydreams-x402-auth/package.json b/extensions/daydreams-x402-auth/package.json index 3345eeb4140f..8df5a384e47d 100644 --- a/extensions/daydreams-x402-auth/package.json +++ b/extensions/daydreams-x402-auth/package.json @@ -4,7 +4,8 @@ "description": "Clawdbot Daydreams Router (x402) auth provider plugin", "type": "module", "dependencies": { - "@daydreamsai/saw": "0.1.2" + "@daydreamsai/saw": "0.1.2", + "viem": "2.45.0" }, "openclaw": { "extensions": [ diff --git a/scripts/install-openclaw-fork.sh b/scripts/install-openclaw-fork.sh index 7e5b57673319..a5ca9147b883 100755 --- a/scripts/install-openclaw-fork.sh +++ b/scripts/install-openclaw-fork.sh @@ -31,6 +31,9 @@ set -euo pipefail # OPENCLAW_SKILLS_DIR=/path/to/skills # override skills install directory # OPENCLAW_LUCID_SDK_SKILL_URL= # override lucid-agents-sdk SKILL.md URL # OPENCLAW_XGATE_ROUTER_SKILL_URL= # override xgate router SKILL.md URL +# X402_WALLET=saw|taskmarket|both # optional non-interactive wallet installer selection +# TASKMARKET_INSTALL=1|0 # install Taskmarket CLI globally (default: 1) +# TASKMARKET_SPEC=@lucid-agents/taskmarket # override Taskmarket CLI package spec # # SAW overrides: # SAW_INSTALL=1|0 # enable/disable SAW phase (default: 1) @@ -84,6 +87,11 @@ OPENCLAW_INSTALL_REMOTE_SKILLS="${OPENCLAW_INSTALL_REMOTE_SKILLS:-1}" OPENCLAW_SKILLS_DIR="${OPENCLAW_SKILLS_DIR:-$HOME/.openclaw/skills}" OPENCLAW_LUCID_SDK_SKILL_URL="${OPENCLAW_LUCID_SDK_SKILL_URL:-https://raw.githubusercontent.com/daydreamsai/skills-market/main/plugins/lucid-agents-sdk/skills/SKILL.md}" OPENCLAW_XGATE_ROUTER_SKILL_URL="${OPENCLAW_XGATE_ROUTER_SKILL_URL:-https://ai.xgate.run/SKILL.md}" +X402_WALLET="${X402_WALLET:-}" +SAW_INSTALL_EXPLICIT="${SAW_INSTALL+x}" +TASKMARKET_INSTALL_EXPLICIT="${TASKMARKET_INSTALL+x}" +TASKMARKET_INSTALL="${TASKMARKET_INSTALL:-1}" +TASKMARKET_SPEC="${TASKMARKET_SPEC:-@lucid-agents/taskmarket}" if [[ -n "$OPENCLAW_SPEC" && ( -n "$OPENCLAW_REF" || -n "$OPENCLAW_BRANCH" ) ]]; then echo "ERROR: set OPENCLAW_SPEC or OPENCLAW_REF/OPENCLAW_BRANCH, not both" >&2 @@ -112,6 +120,101 @@ SAW_POLICY_TEMPLATE="${SAW_POLICY_TEMPLATE:-conservative}" SAW_DREAMS_ROUTER_FACILITATOR="0x1363C7Ff51CcCE10258A7F7bddd63bAaB6aAf678" SAW_ALLOWLIST_ADDRESS="${SAW_ALLOWLIST_ADDRESS-$SAW_DREAMS_ROUTER_FACILITATOR}" +normalize_x402_wallet_mode() { + local raw="$1" + local normalized + normalized="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" + case "$normalized" in + saw|1) + printf '%s\n' "saw" + ;; + taskmarket|2) + printf '%s\n' "taskmarket" + ;; + both|3|"") + printf '%s\n' "both" + ;; + *) + return 1 + ;; + esac +} + +prompt_x402_wallet_mode() { + local choice normalized + + if [[ -n "$X402_WALLET" ]]; then + normalized="$(normalize_x402_wallet_mode "$X402_WALLET" || true)" + if [[ -z "$normalized" ]]; then + echo "ERROR: X402_WALLET must be one of: saw, taskmarket, both" >&2 + exit 1 + fi + printf '%s\n' "$normalized" + return 0 + fi + + if [[ ! -r /dev/tty || ! -w /dev/tty ]]; then + printf '%s\n' "both" + return 0 + fi + + while true; do + cat >/dev/tty <<'PROMPT_EOF' + +============================================ + x402 Wallet Setup +============================================ + +Choose which wallet tooling to install: + 1) SAW only + 2) Taskmarket only + 3) Both SAW + Taskmarket [default] + +PROMPT_EOF + printf 'Wallet setup [1-3]: ' >/dev/tty + if ! IFS= read -r choice /dev/tty + done +} + +resolve_x402_wallet_installs() { + local wallet_mode + + if [[ -n "$SAW_INSTALL_EXPLICIT" || -n "$TASKMARKET_INSTALL_EXPLICIT" ]]; then + return 0 + fi + + wallet_mode="$(prompt_x402_wallet_mode)" + case "$wallet_mode" in + saw) + SAW_INSTALL="1" + TASKMARKET_INSTALL="0" + ;; + taskmarket) + SAW_INSTALL="0" + TASKMARKET_INSTALL="1" + ;; + both) + SAW_INSTALL="1" + TASKMARKET_INSTALL="1" + ;; + *) + echo "ERROR: unsupported wallet mode: $wallet_mode" >&2 + exit 1 + ;; + esac + + echo "==> x402 wallet setup: ${wallet_mode}" +} + # ── SAW functions ─────────────────────────────────────────────────────────── saw_detect_platform() { @@ -573,6 +676,8 @@ saw_grant_gateway_access() { # ── Phase 1: SAW daemon setup ────────────────────────────────────────────── +resolve_x402_wallet_installs + if [[ "$SAW_INSTALL" == "1" ]]; then echo "" echo "============================================" @@ -722,6 +827,7 @@ run_npm() { } GLOBAL_BIN_HINT="" +TASKMARKET_BIN_PATH="" # Remove previous global install to avoid ENOTEMPTY errors during npm rename echo "==> Cleaning previous global install (if any)..." @@ -732,6 +838,10 @@ if [[ "$OPENCLAW_INSTALLER" == "pnpm" ]] || [[ "$OPENCLAW_INSTALLER" == "auto" & echo "==> Using pnpm global install" pnpm add -g "$SPEC" GLOBAL_BIN_HINT="$(pnpm bin -g 2>/dev/null || true)" + if [[ "$TASKMARKET_INSTALL" == "1" ]]; then + echo "==> Installing Taskmarket CLI globally" + pnpm add -g "$TASKMARKET_SPEC" + fi else if [[ "$OPENCLAW_INSTALLER" != "auto" && "$OPENCLAW_INSTALLER" != "npm" ]]; then echo "ERROR: unsupported OPENCLAW_INSTALLER='$OPENCLAW_INSTALLER' (expected auto|npm|pnpm)" >&2 @@ -747,6 +857,10 @@ else fi echo "==> npm script shell: ${npm_shell}" npm_config_script_shell="$npm_shell" run_npm install -g "$SPEC" + if [[ "$TASKMARKET_INSTALL" == "1" ]]; then + echo "==> Installing Taskmarket CLI globally" + npm_config_script_shell="$npm_shell" run_npm install -g "$TASKMARKET_SPEC" + fi npm_prefix="$(run_npm prefix -g 2>/dev/null || true)" if [[ -n "${npm_prefix:-}" ]]; then GLOBAL_BIN_HINT="${npm_prefix}/bin" @@ -792,6 +906,21 @@ echo "==> Installed CLI: ${CLI_BIN_NAME} (${CLI_BIN_PATH})" INSTALLED_VERSION="$("$CLI_BIN_PATH" --version 2>/dev/null || true)" echo "==> Installed version: ${INSTALLED_VERSION:-unknown}" +if [[ "$TASKMARKET_INSTALL" == "1" ]]; then + if [[ -n "$GLOBAL_BIN_HINT" && -x "${GLOBAL_BIN_HINT}/taskmarket" ]]; then + TASKMARKET_BIN_PATH="${GLOBAL_BIN_HINT}/taskmarket" + elif command -v taskmarket >/dev/null 2>&1; then + TASKMARKET_BIN_PATH="$(command -v taskmarket)" + else + echo "ERROR: taskmarket CLI was expected to be installed but was not found in PATH" >&2 + exit 1 + fi + TASKMARKET_VERSION="$("$TASKMARKET_BIN_PATH" --version 2>/dev/null || true)" + echo "==> Installed Taskmarket CLI: ${TASKMARKET_BIN_PATH} (${TASKMARKET_VERSION:-unknown})" +else + echo "==> Skipping Taskmarket CLI install (TASKMARKET_INSTALL=${TASKMARKET_INSTALL})" +fi + echo "==> Configuring fork-aware update defaults..." if "$CLI_BIN_PATH" config set update.channel stable >/dev/null 2>&1; then echo "==> Set update.channel=stable" @@ -862,7 +991,7 @@ fi # Resolve onboard args if not explicitly set if [[ -z "$OPENCLAW_ONBOARD_ARGS" ]]; then - OPENCLAW_ONBOARD_ARGS="onboard --install-daemon --auth-choice x402" + OPENCLAW_ONBOARD_ARGS="onboard --auth-choice x402" fi show_onboard_instructions() { @@ -883,6 +1012,12 @@ show_onboard_instructions() { echo " $CLI_BIN_PATH onboard --auth-choice x402" echo "" fi + if [[ -n "$TASKMARKET_BIN_PATH" ]]; then + echo " Taskmarket CLI:" + echo " Binary: $TASKMARKET_BIN_PATH" + echo " Provision: taskmarket init" + echo "" + fi echo "============================================" } diff --git a/src/agents/x402-payment.test.ts b/src/agents/x402-payment.test.ts index a7ab403215fc..676de18f6396 100644 --- a/src/agents/x402-payment.test.ts +++ b/src/agents/x402-payment.test.ts @@ -277,7 +277,9 @@ describe("rewriteInsufficientTokenBalanceResponse", () => { error?: { message?: string; code?: string }; }; - expect(body.error?.message).toContain("Linked wallet: 0x1111111111111111111111111111111111111111."); + expect(body.error?.message).toContain( + "Linked wallet: 0x1111111111111111111111111111111111111111.", + ); }); }); @@ -292,9 +294,7 @@ describe("formatInsufficientTokenBalanceMessage", () => { const message = __testing.formatInsufficientTokenBalanceMessage( "0x2222222222222222222222222222222222222222", ); - expect(message).toContain( - "Linked wallet: 0x2222222222222222222222222222222222222222.", - ); + expect(message).toContain("Linked wallet: 0x2222222222222222222222222222222222222222."); }); }); diff --git a/src/agents/x402-payment.ts b/src/agents/x402-payment.ts index d76ddb51615d..1f510d01cf0d 100644 --- a/src/agents/x402-payment.ts +++ b/src/agents/x402-payment.ts @@ -4,6 +4,7 @@ import { privateKeyToAccount } from "viem/accounts"; import { base, baseSepolia, mainnet } from "viem/chains"; import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { createTaskmarketAccount, parseTaskmarketWalletConfig } from "./x402-taskmarket-wallet.js"; const log = createSubsystemLogger("agent/x402"); @@ -768,10 +769,19 @@ export function maybeWrapStreamFnWithX402Payment(params: { return params.streamFn; } - // Detect signing mode: SAW sentinel first, then raw private key - const sawConfig = parseSawConfig(params.apiKey); - const privateKey = sawConfig ? null : normalizePrivateKey(params.apiKey); - if (!sawConfig && !privateKey) { + // Detect signing mode: taskmarket sentinel, then SAW sentinel, then raw private key + let taskmarketConfig: ReturnType; + try { + taskmarketConfig = parseTaskmarketWalletConfig(params.apiKey); + } catch (error) { + log.warn( + `x402 taskmarket credential parse failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return params.streamFn; + } + const sawConfig = taskmarketConfig ? null : parseSawConfig(params.apiKey); + const privateKey = taskmarketConfig || sawConfig ? null : normalizePrivateKey(params.apiKey); + if (!taskmarketConfig && !sawConfig && !privateKey) { return params.streamFn; } @@ -789,41 +799,71 @@ export function maybeWrapStreamFnWithX402Payment(params: { } })(); - // Build signing backend — SAW resolves the address lazily via the daemon - let backendPromise: Promise; - if (sawConfig) { - log.info("x402 using SAW backend", { - wallet: sawConfig.walletName, - socket: sawConfig.socketPath, - }); - // Dynamic import — @daydreamsai/saw lives in the extension's dependencies, - // not the root package, so we suppress the TS module resolution error. - const sawModuleId = "@daydreamsai/saw"; - backendPromise = ( - import(/* webpackIgnore: true */ sawModuleId) as Promise<{ - createSawClient: (opts: { socketPath: string; wallet: string }) => SawClient; - }> - ).then(({ createSawClient: createClient }) => { - const client = createClient({ - socketPath: sawConfig.socketPath, + let staticBackendPromise: Promise | null = null; + let taskmarketBackendAddress: string | null = null; + + const resolveBackend = async (): Promise => { + // Resolve Taskmarket account at request time so the wallet helper's TTL cache can refresh. + if (taskmarketConfig) { + const { account, ownerAddress } = await createTaskmarketAccount({ + config: taskmarketConfig, + fetchFn: baseFetch, + }); + if (taskmarketBackendAddress !== ownerAddress) { + taskmarketBackendAddress = ownerAddress; + log.info("x402 using taskmarket wallet backend", { + address: ownerAddress, + }); + } + const chain = CHAINS[network] || base; + const wallet = createWalletClient({ account, chain, transport: http() }); + return { mode: "key", wallet, account } satisfies SigningBackend; + } + + if (staticBackendPromise) { + return staticBackendPromise; + } + + if (sawConfig) { + log.info("x402 using SAW backend", { wallet: sawConfig.walletName, + socket: sawConfig.socketPath, }); - return client.getAddress().then((addr: string) => { - log.info("SAW address resolved", { address: addr }); - return { - mode: "saw", - client, - ownerAddress: addr as `0x${string}`, - } satisfies SigningBackend; + // Dynamic import — @daydreamsai/saw lives in the extension's dependencies, + // not the root package, so we suppress the TS module resolution error. + const sawModuleId = "@daydreamsai/saw"; + staticBackendPromise = ( + import(/* webpackIgnore: true */ sawModuleId) as Promise<{ + createSawClient: (opts: { socketPath: string; wallet: string }) => SawClient; + }> + ).then(({ createSawClient: createClient }) => { + const client = createClient({ + socketPath: sawConfig.socketPath, + wallet: sawConfig.walletName, + }); + return client.getAddress().then((addr: string) => { + log.info("SAW address resolved", { address: addr }); + return { + mode: "saw", + client, + ownerAddress: addr as `0x${string}`, + } satisfies SigningBackend; + }); }); - }); - } else { + return staticBackendPromise; + } + const account = privateKeyToAccount(privateKey as `0x${string}`); log.info("x402 using local key backend", { address: account.address }); const chain = CHAINS[network] || base; const wallet = createWalletClient({ account, chain, transport: http() }); - backendPromise = Promise.resolve({ mode: "key", wallet, account } satisfies SigningBackend); - } + staticBackendPromise = Promise.resolve({ + mode: "key", + wallet, + account, + } satisfies SigningBackend); + return staticBackendPromise; + }; const fetchWithPayment: typeof fetch = async (input, init) => { const outboundModel = await extractOutboundModelId(input, init); @@ -893,7 +933,7 @@ export function maybeWrapStreamFnWithX402Payment(params: { return baseFetch(input, init); } - const backend = await backendPromise; + const backend = await resolveBackend(); const sendWithPermit = async (permit: CachedPermit): Promise => { const headers = new Headers(init?.headers ?? {}); @@ -981,7 +1021,10 @@ export function maybeWrapStreamFnWithX402Payment(params: { retriedErrorResponse, getOwnerAddress(backend), ); - } catch { + } catch (error) { + log.warn( + `x402 payment wrapper fallback without payment header: ${error instanceof Error ? error.message : String(error)}`, + ); return baseFetch(input, init); } }; diff --git a/src/agents/x402-taskmarket-wallet.test.ts b/src/agents/x402-taskmarket-wallet.test.ts new file mode 100644 index 000000000000..9ebf563cc83d --- /dev/null +++ b/src/agents/x402-taskmarket-wallet.test.ts @@ -0,0 +1,208 @@ +import { createCipheriv, randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { privateKeyToAccount } from "viem/accounts"; +import { describe, expect, it } from "vitest"; +import { + __testing, + clearTaskmarketAccountCache, + createTaskmarketAccount, + parseTaskmarketWalletConfig, + TaskmarketWalletError, +} from "./x402-taskmarket-wallet.js"; + +function encodeBase64Url(value: string): string { + return Buffer.from(value, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function createTaskmarketSentinel(payload: { + v: number; + keystorePath: string; + apiUrl?: string; +}): string { + return `taskmarket:${encodeBase64Url(JSON.stringify(payload))}`; +} + +function encryptTaskmarketPrivateKey(params: { privateKey: string; dekHex: string }): string { + const key = Buffer.from(params.dekHex, "hex"); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([ + cipher.update(Buffer.from(params.privateKey, "utf8")), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, ciphertext]).toString("hex"); +} + +describe("parseTaskmarketWalletConfig", () => { + it("parses a valid taskmarket sentinel", () => { + const sentinel = createTaskmarketSentinel({ + v: 1, + keystorePath: "~/.taskmarket/keystore.json", + apiUrl: "https://api-market.daydreams.systems", + }); + expect(parseTaskmarketWalletConfig(sentinel)).toEqual({ + v: 1, + keystorePath: "~/.taskmarket/keystore.json", + apiUrl: "https://api-market.daydreams.systems", + }); + }); + + it("returns null for non-taskmarket values", () => { + expect(parseTaskmarketWalletConfig("0xabc")).toBeNull(); + expect(parseTaskmarketWalletConfig("saw:main@/tmp/saw.sock")).toBeNull(); + expect(parseTaskmarketWalletConfig(undefined)).toBeNull(); + }); + + it("defaults the api url when omitted", () => { + const sentinel = createTaskmarketSentinel({ + v: 1, + keystorePath: "~/.taskmarket/keystore.json", + }); + expect(parseTaskmarketWalletConfig(sentinel)).toEqual({ + v: 1, + keystorePath: "~/.taskmarket/keystore.json", + apiUrl: process.env.TASKMARKET_API_URL || "https://api-market.daydreams.systems", + }); + }); + + it("throws for malformed taskmarket payload", () => { + expect(() => parseTaskmarketWalletConfig("taskmarket:not-base64")).toThrow( + TaskmarketWalletError, + ); + }); + + it("throws when keystorePath is missing", () => { + const sentinel = createTaskmarketSentinel({ + v: 1, + keystorePath: "", + apiUrl: "https://api-market.daydreams.systems", + }); + expect(() => parseTaskmarketWalletConfig(sentinel)).toThrow(TaskmarketWalletError); + }); +}); + +describe("decryptTaskmarketPrivateKey", () => { + it("decrypts taskmarket AES-256-GCM payloads", () => { + const privateKey = `0x${randomBytes(32).toString("hex")}`; + const dekHex = randomBytes(32).toString("hex"); + const encrypted = encryptTaskmarketPrivateKey({ privateKey, dekHex }); + const decrypted = __testing.decryptTaskmarketPrivateKey(dekHex, encrypted); + expect(decrypted).toBe(privateKey); + }); + + it("throws when ciphertext is tampered", () => { + const privateKey = `0x${randomBytes(32).toString("hex")}`; + const dekHex = randomBytes(32).toString("hex"); + const encrypted = encryptTaskmarketPrivateKey({ privateKey, dekHex }); + const tampered = `${encrypted.slice(0, -2)}00`; + expect(() => __testing.decryptTaskmarketPrivateKey(dekHex, tampered)).toThrow( + TaskmarketWalletError, + ); + }); +}); + +describe("createTaskmarketAccount cache", () => { + it("reuses cached account within ttl", async () => { + clearTaskmarketAccountCache(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-taskmarket-wallet-")); + try { + const privateKey = `0x${randomBytes(32).toString("hex")}`; + const account = privateKeyToAccount(privateKey as `0x${string}`); + const dekHex = randomBytes(32).toString("hex"); + const encryptedKey = encryptTaskmarketPrivateKey({ privateKey, dekHex }); + const keystorePath = path.join(tempDir, "keystore.json"); + await fs.writeFile( + keystorePath, + JSON.stringify({ + encryptedKey, + walletAddress: account.address, + deviceId: "device-1", + apiToken: "token-1", + }), + "utf8", + ); + + let calls = 0; + const fetchFn: typeof fetch = (async () => { + calls += 1; + return new Response(JSON.stringify({ deviceEncryptionKey: dekHex }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const config = { v: 1 as const, keystorePath, apiUrl: "https://api.example.test" }; + const first = await createTaskmarketAccount({ + config, + fetchFn, + nowMs: () => 1_000, + ttlMs: 60_000, + }); + const second = await createTaskmarketAccount({ + config, + fetchFn, + nowMs: () => 30_000, + ttlMs: 60_000, + }); + + expect(first.ownerAddress).toBe(account.address); + expect(second.ownerAddress).toBe(account.address); + expect(calls).toBe(1); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + clearTaskmarketAccountCache(); + } + }); + + it("retries once after a transient device-key error", async () => { + clearTaskmarketAccountCache(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-taskmarket-wallet-")); + try { + const privateKey = `0x${randomBytes(32).toString("hex")}`; + const account = privateKeyToAccount(privateKey as `0x${string}`); + const dekHex = randomBytes(32).toString("hex"); + const encryptedKey = encryptTaskmarketPrivateKey({ privateKey, dekHex }); + const keystorePath = path.join(tempDir, "keystore.json"); + await fs.writeFile( + keystorePath, + JSON.stringify({ + encryptedKey, + walletAddress: account.address, + deviceId: "device-1", + apiToken: "token-1", + }), + "utf8", + ); + + let calls = 0; + const fetchFn: typeof fetch = (async () => { + calls += 1; + if (calls === 1) { + return new Response("temporary outage", { status: 500 }); + } + return new Response(JSON.stringify({ deviceEncryptionKey: dekHex }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const resolved = await createTaskmarketAccount({ + config: { v: 1, keystorePath, apiUrl: "https://api.example.test" }, + fetchFn, + }); + + expect(resolved.ownerAddress).toBe(account.address); + expect(calls).toBe(2); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + clearTaskmarketAccountCache(); + } + }); +}); diff --git a/src/agents/x402-taskmarket-wallet.ts b/src/agents/x402-taskmarket-wallet.ts new file mode 100644 index 000000000000..13cc570f8aa0 --- /dev/null +++ b/src/agents/x402-taskmarket-wallet.ts @@ -0,0 +1,428 @@ +import { createDecipheriv } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; + +const TASKMARKET_SENTINEL_PREFIX = "taskmarket:"; +const TASKMARKET_SENTINEL_VERSION = 1; +const DEFAULT_TASKMARKET_API_URL = + process.env.TASKMARKET_API_URL || "https://api-market.daydreams.systems"; +const DEFAULT_ACCOUNT_CACHE_TTL_MS = 15 * 60 * 1000; +const WALLET_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; +const PRIVATE_KEY_REGEX = /^0x[0-9a-fA-F]{64}$/; +const MIN_ENCRYPTED_KEY_HEX_LENGTH = (12 + 16 + 1) * 2; + +export type TaskmarketWalletConfig = { + v: 1; + keystorePath: string; + apiUrl: string; +}; + +type TaskmarketKeystore = { + encryptedKey: string; + walletAddress: string; + deviceId: string; + apiToken: string; +}; + +type CachedTaskmarketAccount = { + account: PrivateKeyAccount; + ownerAddress: `0x${string}`; + expiresAtMs: number; +}; + +export class TaskmarketWalletError extends Error { + constructor( + public readonly code: + | "sentinel" + | "keystore" + | "network" + | "device_auth" + | "device_not_found" + | "decrypt" + | "address_mismatch", + message: string, + ) { + super(message); + this.name = "TaskmarketWalletError"; + } +} + +const accountCache = new Map(); + +function getErrorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error && typeof error.code === "string" + ? error.code + : undefined; +} + +function decodeBase64Url(value: string): string { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return Buffer.from(padded, "base64").toString("utf8"); +} + +function resolveHomePath(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + if (trimmed === "~") { + return os.homedir(); + } + if (trimmed.startsWith("~/")) { + return path.join(os.homedir(), trimmed.slice(2)); + } + return path.resolve(trimmed); +} + +function normalizeApiUrl(value: string): string { + const raw = value.trim() || DEFAULT_TASKMARKET_API_URL; + const withProtocol = raw.startsWith("http") ? raw : `https://${raw}`; + return withProtocol.replace(/\/+$/, ""); +} + +function parseTaskmarketKeystore(raw: unknown, sourcePath: string): TaskmarketKeystore { + if (!raw || typeof raw !== "object") { + throw new TaskmarketWalletError( + "keystore", + `Invalid Taskmarket keystore JSON at ${sourcePath}`, + ); + } + + const record = raw as Record; + const encryptedKey = + typeof record.encryptedKey === "string" ? record.encryptedKey.trim() : undefined; + const walletAddress = + typeof record.walletAddress === "string" ? record.walletAddress.trim() : undefined; + const deviceId = typeof record.deviceId === "string" ? record.deviceId.trim() : undefined; + const apiToken = typeof record.apiToken === "string" ? record.apiToken.trim() : undefined; + + if ( + !encryptedKey || + !/^[0-9a-fA-F]+$/.test(encryptedKey) || + // 12-byte IV + 16-byte tag + at least 1 byte of ciphertext, hex-encoded. + encryptedKey.length < MIN_ENCRYPTED_KEY_HEX_LENGTH || + encryptedKey.length % 2 !== 0 + ) { + throw new TaskmarketWalletError( + "keystore", + `Taskmarket keystore at ${sourcePath} has an invalid encryptedKey`, + ); + } + if (!walletAddress || !WALLET_ADDRESS_REGEX.test(walletAddress)) { + throw new TaskmarketWalletError( + "keystore", + `Taskmarket keystore at ${sourcePath} has an invalid walletAddress`, + ); + } + if (!deviceId) { + throw new TaskmarketWalletError( + "keystore", + `Taskmarket keystore at ${sourcePath} is missing deviceId`, + ); + } + if (!apiToken) { + throw new TaskmarketWalletError( + "keystore", + `Taskmarket keystore at ${sourcePath} is missing apiToken`, + ); + } + + return { encryptedKey, walletAddress, deviceId, apiToken }; +} + +function decodeTaskmarketEncryptedKey(encryptedHex: string): { + iv: Buffer; + tag: Buffer; + ciphertext: Buffer; +} { + const data = Buffer.from(encryptedHex, "hex"); + if (data.length <= 28) { + throw new TaskmarketWalletError("decrypt", "Taskmarket encryptedKey payload is too short"); + } + const iv = data.subarray(0, 12); + const tag = data.subarray(12, 28); + const ciphertext = data.subarray(28); + if (ciphertext.length === 0) { + throw new TaskmarketWalletError("decrypt", "Taskmarket encryptedKey has empty ciphertext"); + } + return { iv, tag, ciphertext }; +} + +export function parseTaskmarketWalletConfig( + apiKey: string | undefined, +): TaskmarketWalletConfig | null { + if (!apiKey) { + return null; + } + const trimmed = apiKey.trim(); + if (!trimmed.startsWith(TASKMARKET_SENTINEL_PREFIX)) { + return null; + } + + const encodedPayload = trimmed.slice(TASKMARKET_SENTINEL_PREFIX.length).trim(); + if (!encodedPayload) { + throw new TaskmarketWalletError("sentinel", "Taskmarket wallet sentinel has an empty payload"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(decodeBase64Url(encodedPayload)); + } catch { + throw new TaskmarketWalletError( + "sentinel", + "Taskmarket wallet sentinel is invalid. Re-run `openclaw onboard --auth-choice x402`.", + ); + } + + if (!parsed || typeof parsed !== "object") { + throw new TaskmarketWalletError( + "sentinel", + "Taskmarket wallet sentinel payload must be an object", + ); + } + const record = parsed as Record; + const version = record.v; + const keystorePath = typeof record.keystorePath === "string" ? record.keystorePath.trim() : ""; + const apiUrl = + typeof record.apiUrl === "string" ? record.apiUrl.trim() : DEFAULT_TASKMARKET_API_URL; + + if (version !== TASKMARKET_SENTINEL_VERSION || !keystorePath) { + throw new TaskmarketWalletError( + "sentinel", + "Taskmarket wallet sentinel is missing required fields. Re-run `taskmarket init` and onboarding.", + ); + } + + return { + v: TASKMARKET_SENTINEL_VERSION, + keystorePath, + apiUrl: normalizeApiUrl(apiUrl), + }; +} + +async function loadTaskmarketKeystore( + keystorePath: string, +): Promise<{ keystore: TaskmarketKeystore; resolvedPath: string }> { + const resolvedPath = resolveHomePath(keystorePath); + if (!resolvedPath) { + throw new TaskmarketWalletError("keystore", "Taskmarket keystore path is empty"); + } + + let raw: string; + try { + raw = await fs.readFile(resolvedPath, "utf8"); + } catch (error) { + if (getErrorCode(error) === "ENOENT") { + throw new TaskmarketWalletError( + "keystore", + `Taskmarket keystore not found at ${resolvedPath}. Run taskmarket init.`, + ); + } + throw new TaskmarketWalletError( + "keystore", + `Taskmarket keystore at ${resolvedPath} could not be read: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new TaskmarketWalletError( + "keystore", + `Taskmarket keystore at ${resolvedPath} is not valid JSON. Reprovision the wallet.`, + ); + } + + return { + keystore: parseTaskmarketKeystore(parsed, resolvedPath), + resolvedPath, + }; +} + +async function fetchTaskmarketDeviceEncryptionKey(params: { + apiUrl: string; + deviceId: string; + apiToken: string; + fetchFn: typeof fetch; +}): Promise { + const apiUrl = normalizeApiUrl(params.apiUrl); + const url = `${apiUrl}/api/devices/${encodeURIComponent(params.deviceId)}/key`; + let response: Response; + try { + response = await params.fetchFn(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceId: params.deviceId, apiToken: params.apiToken }), + }); + } catch (error) { + throw new TaskmarketWalletError( + "network", + `Taskmarket device-key request failed at ${apiUrl}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!response.ok) { + const bodyText = await response.text().catch(() => ""); + if (response.status === 404) { + throw new TaskmarketWalletError( + "device_not_found", + `Taskmarket device not found (${params.deviceId}). Reprovision wallet via taskmarket init.`, + ); + } + if (response.status === 401 || response.status === 403) { + throw new TaskmarketWalletError( + "device_auth", + `Taskmarket device token rejected (${response.status}). Reprovision wallet via taskmarket init.`, + ); + } + throw new TaskmarketWalletError( + "network", + `Taskmarket device-key request failed (${response.status}): ${bodyText.slice(0, 180)}`, + ); + } + + let parsed: unknown; + try { + parsed = (await response.json()) as unknown; + } catch { + throw new TaskmarketWalletError("network", "Taskmarket device-key response was not valid JSON"); + } + + const dek = + parsed && + typeof parsed === "object" && + typeof (parsed as { deviceEncryptionKey?: unknown }).deviceEncryptionKey === "string" + ? (parsed as { deviceEncryptionKey: string }).deviceEncryptionKey.trim() + : ""; + if (!/^[0-9a-fA-F]{64}$/.test(dek)) { + throw new TaskmarketWalletError( + "network", + "Taskmarket device-key response is missing a valid DEK", + ); + } + return dek; +} + +function decryptTaskmarketPrivateKey(deviceEncryptionKeyHex: string, encryptedHex: string): string { + let key: Buffer; + try { + key = Buffer.from(deviceEncryptionKeyHex, "hex"); + } catch { + throw new TaskmarketWalletError("decrypt", "Taskmarket DEK is not valid hex"); + } + if (key.length !== 32) { + throw new TaskmarketWalletError("decrypt", "Taskmarket DEK must be 32 bytes"); + } + + const { iv, tag, ciphertext } = decodeTaskmarketEncryptedKey(encryptedHex); + try { + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString( + "utf8", + ); + const normalized = decrypted.trim().startsWith("0X") + ? `0x${decrypted.trim().slice(2)}` + : decrypted.trim(); + if (!PRIVATE_KEY_REGEX.test(normalized)) { + throw new TaskmarketWalletError( + "decrypt", + "Decrypted Taskmarket key is not a valid private key", + ); + } + return normalized; + } catch (error) { + if (error instanceof TaskmarketWalletError) { + throw error; + } + throw new TaskmarketWalletError("decrypt", "Failed to decrypt Taskmarket keystore payload"); + } +} + +function resolveAccountCacheKey(params: { + apiUrl: string; + deviceId: string; + walletAddress: string; +}): string { + return `${normalizeApiUrl(params.apiUrl)}|${params.deviceId}|${params.walletAddress.toLowerCase()}`; +} + +export async function createTaskmarketAccount(params: { + config: TaskmarketWalletConfig; + fetchFn?: typeof fetch; + nowMs?: () => number; + ttlMs?: number; +}): Promise<{ account: PrivateKeyAccount; ownerAddress: `0x${string}` }> { + const fetchFn = params.fetchFn ?? globalThis.fetch; + const nowMs = params.nowMs ?? Date.now; + const ttlMs = params.ttlMs ?? DEFAULT_ACCOUNT_CACHE_TTL_MS; + + const { keystore } = await loadTaskmarketKeystore(params.config.keystorePath); + const cacheKey = resolveAccountCacheKey({ + apiUrl: params.config.apiUrl, + deviceId: keystore.deviceId, + walletAddress: keystore.walletAddress, + }); + + const cached = accountCache.get(cacheKey); + const now = nowMs(); + if (cached && cached.expiresAtMs > now) { + return { account: cached.account, ownerAddress: cached.ownerAddress }; + } + + const attemptResolveAccount = async (): Promise<{ + account: PrivateKeyAccount; + ownerAddress: `0x${string}`; + }> => { + const dek = await fetchTaskmarketDeviceEncryptionKey({ + apiUrl: params.config.apiUrl, + deviceId: keystore.deviceId, + apiToken: keystore.apiToken, + fetchFn, + }); + const privateKey = decryptTaskmarketPrivateKey(dek, keystore.encryptedKey); + const account = privateKeyToAccount(privateKey as `0x${string}`); + if (account.address.toLowerCase() !== keystore.walletAddress.toLowerCase()) { + throw new TaskmarketWalletError( + "address_mismatch", + "Taskmarket keystore address mismatch after decryption. Reprovision wallet via taskmarket init.", + ); + } + accountCache.set(cacheKey, { + account, + ownerAddress: account.address, + expiresAtMs: nowMs() + Math.max(ttlMs, 1_000), + }); + return { account, ownerAddress: account.address }; + }; + + try { + return await attemptResolveAccount(); + } catch (error) { + // Clear any stale cache and retry once for transient auth/network failures. + accountCache.delete(cacheKey); + if (error instanceof TaskmarketWalletError && error.code === "network") { + return attemptResolveAccount(); + } + throw error; + } +} + +export function clearTaskmarketAccountCache(): void { + accountCache.clear(); +} + +export const __testing = { + decodeBase64Url, + resolveHomePath, + normalizeApiUrl, + parseTaskmarketKeystore, + decryptTaskmarketPrivateKey, + resolveAccountCacheKey, + loadTaskmarketKeystore, + fetchTaskmarketDeviceEncryptionKey, +};