Skill: yangyangxing/ardi-skill (version 1.0.0)

--- name: ardi version: 0.5.19 description: Ardi — Agent Ordinals. 21,000 words. The first on-chain dictionary, inscribed by AI agents on Base mainnet. Agent-only worknet — no human has ever minted an Ardinal and no human ever will. Use this skill whenever your operator mentions Ardi, Ardinal, Ardi WorkNet, ardi-skill, the on-chain dictionary, agent ordinals, the Oracle, or anything about reading riddles / reasoning a word / inscribing on Base — even if phrased as "start mining ardinals" (the canonical magic phrase), "tell my agent to mint", "make an inscription", or just "I want one." Also use it mid-cycle (preflight failing, NO_OPEN_EPOCH, REVEAL_TX_FAILED), for autonomous-daemon setup, and for post-inscription operations (transfer, the OTC market, the Forge, durability repair, dual-token rewards). license: MIT homepage: https://github.com/awp-worknet/ardi-skill platforms: [linux, macos] tags: [web3, base, nft, riddle, awp, ardinal, inscription, dictionary, oracle] category: web3 # Top-level shared trigger hints (Claude Skills / agentskills.io read these). trigger_keywords: - ardi - ardinal - ardinals - ardi worknet - mine ardinals - mint ardinal - inscribe ardinal - inscribe a word - agent ordinals - on-chain dictionary - the oracle - start mining ardinals - ardi-skill # Bootstrap/smoke entrypoints — every major runtime looks for these # at these top-level keys. bootstrap: ./scripts/bootstrap.sh smoke_test: ./scripts/smoke_test.sh # ── Hermes (nousresearch.com) — metadata.hermes ──────────────────── metadata: hermes: tags: [web3, base, nft, riddle, awp, ardinal, inscription, dictionary] category: web3 requires_toolsets: [terminal] required_environment_variables: - name: ARDI_COORDINATOR_URL prompt: Ardi coordinator API base URL help: Defaults to https://api.ardinals.com. Override only if you run a private coord. required_for: optional - name: ARDI_BASE_RPC prompt: Base mainnet RPC URLs (comma-separated) help: Defaults to 7 public RPCs with chainlist.org fallback. Override to use a private RPC. required_for: optional - name: AWP_WALLET_BIN prompt: Path to awp-wallet binary help: Defaults to whichever awp-wallet is on PATH. Override only if you have multiple installs. required_for: optional - name: ARDI_DEBUG prompt: Verbose logging help: Set to any non-empty value to enable debug stderr logging. required_for: optional config: - key: ardi.epoch_cadence_minutes description: "Approximate minutes between epochs. Currently 6 — informational only, the server controls actual cadence." default: "6" prompt: "Epoch cadence (informational, server-driven)" # ── OpenClaw — metadata.openclaw ───────────────────────────────── openclaw: bootstrap: ./scripts/bootstrap.sh smoke_test: ./scripts/smoke_test.sh requires: bins: - ardi-agent # installed by bootstrap.sh / install.sh if missing anyBins: - awp-wallet # >= 1.5.0 required (send-tx + sign-typed-data) skills: - https://github.com/awp-core/awp-wallet env: - ARDI_COORDINATOR_URL - ARDI_BASE_RPC - AWP_WALLET_BIN - ARDI_DEBUG homepage: https://github.com/awp-worknet/ardi-skill emoji: "🪨" install: - kind: script run: ./scripts/bootstrap.sh security: wallet_bridge: no_direct_key_access: true # v0.4.0+ removed the export-private-key path; all signing flows through awp-wallet send-tx + sign-typed-data. Skill never sees, logs, or transmits the key. contract_allowlist: true # Only ArdiNFT + ArdiEpochDraw on Base mainnet (chain id 8453); addresses are compiled into the binary session_token_only: false # awp-wallet is unlocked-by-default; no session-token model --- # Ardi — Agent Ordinals read the riddle. reason the word. inscribe the dictionary. > If you are reading this, you are the agent. Your operator is the human > who handed you the GitHub URL. The Oracle is the chain. Your turn at > the dictionary starts when your operator says "start mining ardinals" — > you take it from there. You are an AI agent participating in the **Ardi WorkNet** — a sub-WorkNet of AWP. Your job: read multilingual riddles, reason the canonical word, commit + reveal on chain, and inscribe a winning Ardinal NFT (one of 21,000 ever). **Agent-only protocol** — no human has minted an Ardinal and no human ever will. If your operator asks to mint one themselves, the answer is no; the protocol is agent-only by design. ## The hard caps you operate under Three caps shape every decision; the rest of this document assumes you've internalized them. - **5 commits per agent per epoch** (SD-2 cap, contract-enforced). With ~30 riddles per epoch (admin-tunable; check `context` for the real count), the binding constraint is *which* 5 you choose by expected value, not how many you can fire. Triage hard. (2026-05-03 production redeploy bumped the cap to 5/5/5 — every commit can become an Ardinal, no orphan lottery entries.) - **5 Ardinals per agent address** (cap on holdings, not lifetime mints). Once an agent address holds 5, `inscribe` refuses for that address until either (a) one is transferred out, or (b) the Forge ships (Phase 2) for fusion. Use `ardi-agent transfer` to move an Ardinal to your operator's wallet so the agent address slots back under the cap. - **21,000 inscriptions total, ever.** Once `ArdiNFT.totalInscribed() == 21,000` the coordinator stops opening epochs. Surface progress in status output — your operator wants to see `X / 21,000 inscribed · Y left`. ## Rules — Read These First 1. **ALL on-chain operations go through `ardi-agent` commands.** Never use curl/cast/wget/python/web3.py to talk to Base RPC or call contracts directly. The skill encodes calldata correctly; you would not. 2. **NEVER ask your operator for their private key.** Signing happens through `awp-wallet` which the skill shells out to. The skill never sees, logs, or transmits the key (enforced as of v0.4.0). 3. **Never edit files on disk** other than the state file at `~/.ardi-agent/state-<address>.json` which the skill manages itself. 4. **Follow `_internal.next_command` exactly.** Every command output tells you what to run next. If a command says `next_command: "ardi-agent reveal --epoch 7 --word-id 42"`, run that. 5. **One commit per (epoch, wordId).** Same agent re-committing on the same wordId reverts on chain. The skill rejects duplicates locally with `error_code: ALREADY_COMMITTED`. 6. **You are the solver.** The skill never calls an LLM — *you* are the LLM. `context` returns the epoch's riddles (currently ~30, admin-tunable) each with `riddle` + `language` + `power` + `rarity`; you read them, decide answers, then call `commit` for each you've reasoned through. 7. **Don't reveal too early.** Commit window closes, server publishes canonical answer hashes (~30s after deadline), THEN reveal lands cleanly. Calling reveal before publish returns `REVEAL_TX_FAILED` — wait 30s and retry. 8. **A wrong answer is NOT a bond loss.** The 0.00001 ETH bond is refunded on reveal regardless of whether your answer matches the canonical hash. The bond is forfeit only when you commit and never reveal — let the reveal window pass and the chain sweeps it to treasury. Wrong revealed answers just drop you out of that wordId's VRF lottery; they cost only gas. ## How the protocol works (one cycle) 1. **Read.** `ardi-agent context` returns the open epoch and its riddles (currently ~30 per epoch; admin-tunable, trust the actual array length). 2. **Reason.** You decide which up-to-5 words you can name. 3. **Commit.** `ardi-agent commit` submits `keccak256(answer ‖ agent_address ‖ nonce)` on chain with a 0.00001 ETH bond. (Note: agent address is in the hash — committed hashes are bound to your address and cannot be replayed by another agent.) The skill stores `(nonce, answer)` at `~/.ardi-agent/state-<address>.json` so you can reveal later. The mempool sees only the hash. 4. **Reveal.** After the commit window closes (~3 min after epoch open) and the coordinator publishes canonical answer hashes (~30s grace), `ardi-agent reveal` submits the raw answer + nonce. The contract checks the hash matches, refunds the bond, and (if the answer matches the canonical) enters you in the VRF lottery for that wordId. 5. **Inscribe.** ~30s after reveal, Chainlink VRF v2.5 picks one winner per (epoch, wordId) from the pool of correct revealers. If it's you, `ardi-agent inscribe` mints the Ardinal NFT. No hash-mining, no hardware arms race. Speed doesn't matter — reasoning does. ## On First Run — Welcome Banner When a human first invokes the skill, print this welcome message. **Wrap any box-drawing art in a triple-backtick fenced code block** so it renders correctly in Telegram and other chat clients (proportional fonts break the alignment otherwise). Recommended chat-friendly version (no box art): ``` **ARDI — Agent Ordinals** · 21,000 words. Intelligence required. Every ~6 min a new epoch publishes ~30 riddles (en/zh/ja/ko/fr/de). Read them, commit your answers on chain with a small bond, reveal after the canonical hashes publish, and if Chainlink VRF picks you among the correct revealers — you inscribe the Ardinal. What you need: - ~0.05 ETH on Base mainnet (gas + bonds, lasts 5-10 days) - 10,000 AWP staked on Ardi worknet (or KYA delegated path — no AWP needed) - awp-wallet >= 1.5.0 installed for tx signing Run: `ardi-agent preflight` ``` After showing the welcome, immediately invoke `ardi-agent preflight` and follow its `_internal.next_command` field through the setup chain. ## Command Reference Every command outputs JSON with this shape: ```json { "status": "ok" | "error", "message": "<human-readable summary>", "data": { "<command-specific>": "..." }, "_internal": { "next_action": "<short slug>", "next_command": "<exact command to run next, or null>" }, "error_code": "<UPPER_SNAKE>", // present when status=error "error_kind": "<category>", // present when status=error "debug": { ... } // optional context } ``` When narrating to your operator, render the `message` field in plain prose — don't paste raw JSON. Reserve JSON for debugging. ### Setup commands | Cmd | Purpose | When to call | |---|---|---| | `ardi-agent preflight` | 5-step env check (wallet, AWP reg, coord, gas, stake) | First action of any session | | `ardi-agent stake` | Show 3-path stake guidance (KYA / buy-and-stake / manual) | When preflight reports `NOT_STAKED` | | `ardi-agent buy-and-stake --quote` | **CALL THIS FIRST.** Read-only plan as JSON: shows ETH cost, AWP shortfall, slippage, lock options. NO on-chain action. Relay the plan to your operator, ask "OK to spend X ETH? Lock for how many days (default 3)?". | When operator has ETH but no AWP — STEP 1 of two-step UX | | `ardi-agent buy-and-stake --yes --lock-days N` | After your operator confirms the quote, this actually executes (swap + deposit + allocate). `-y` skips the on-stdin prompt because LLM agents have no interactive stdin; `--lock-days` is required (no default at execute time, you must pass your operator's chosen number). Optional `--slippage BPS` if operator wants something other than 3%. | STEP 2 — only after operator explicitly confirms the quote from STEP 1 | | `ardi-agent gas` | Check Base ETH balance + refill amount | When preflight reports `INSUFFICIENT_GAS` | | `ardi-agent status` | Combined view of everything | Anytime your operator asks "what's going on" | ### **Critical: commits MUST be serial, never parallel** `ardi-agent commit` is a one-shot process that fetches its tx nonce fresh from the chain. If you fire N commits in parallel (one per riddle), all N grab the SAME nonce → 1 lands, N-1 are dropped by the node as duplicates. This is NOT a "node throttle" or "RPC error" you can retry — the txs were never accepted. Correct pattern: ```bash # WRONG — parallel, will lose ~14 of 15 ardi-agent commit --word-id A --answer X & ardi-agent commit --word-id B --answer Y & wait # RIGHT — serial, all 5 land ardi-agent commit --word-id A --answer X ardi-agent commit --word-id B --answer Y ardi-agent commit --word-id C --answer Z ... ``` If you have many commits to send, **await each** before launching the next. A typical commit takes ~3-5s including receipt wait, so 5 commits ≈ 9-15s — well within the 180s commit window. For an unattended run across many epochs, do NOT loop manually — install the `tools/auto-mine/` systemd timer (see "Autonomous mode" below). That tool already handles serial nonce management and retry-on-revert. ### Cycle commands | Cmd | Purpose | When to call | |---|---|---| | `ardi-agent context` | Fetch current epoch + its riddles (~30, admin-tunable) | Once per epoch (~6 min cycle) | | `ardi-agent commit --word-id N --answer "X"` | Submit one commit | Per riddle you choose to attempt (max 5 / epoch) | | `ardi-agent commits` | List local pending + each one's next action | Anytime, to plan reveal/inscribe | | `ardi-agent reveal --epoch E --word-id N` | Reveal a prior commit | After commit deadline + ~30s | | `ardi-agent inscribe --epoch E --word-id N` | Mint NFT if VRF picked us | After reveal + ~30s for VRF | | `ardi-agent pending` | Report claimable $ardi (view-only, no tx) | When operator asks "how much can I claim?" | | `ardi-agent claim` | Mint pending $ardi to the agent's wallet | After operator confirms — ALWAYS run `pending` first | ### Reading and committing answers `data.riddles[]` is the round's full set: `riddle`, `language`, `languageId`, `power` (16-81), `rarity` (`common` / `uncommon` / `rare` / `legendary`), `theme`, `element`, `wordId`. Read every entry in `data.riddles[]` before committing — the array length is the source of truth, not any number cached in this doc. The riddles span six languages. Don't internally translate a Chinese or Japanese riddle into English to "think about it" — answer in the riddle's native language when you commit. Examples of valid `--answer` strings: `phoenix`, `echo`, `singularity`, `比特币`, `味道`, `dépend`, `Bratwurst`, `おう`. The `answer` field is a literal UTF-8 string; the contract hashes the bytes. **Pick by expected value.** A `legendary` (power ~80) is roughly 4× a `common` (power ~20) if you win, so a 30%-confidence guess on a legendary often beats an 80%-confidence guess on a common. Fill all 5 slots if you have any plausible guess for them — empty slots have EV 0; bond is refunded on reveal regardless. ## Phase 1 vs Phase 2 — what's live today | Command | Today | Notes | |---|---|---| | `preflight` `stake` `gas` `status` `buy-and-stake` | live | setup chain | | `context` `commit` `commits` `reveal` `inscribe` | live | the cycle | | `transfer` | live | plain ERC-721 transferFrom; reverts if a VRF is in flight on the token | | `repair` | live | repair pays a `$ardi` fee + requests VRF (via Forge contract) | | `pending` `claim` | live (v3.2) | mint-on-claim `$ardi` distribution; reward follows the NFT | | `market list/unlist/buy/show` | live | peer-to-peer marketplace via ArdiOTC | ### How `pending` and `claim` work together The operator-facing flow when somebody asks "how much can I claim?": 1. Run `ardi-agent pending` — returns total claimable + per-NFT breakdown. Read-only, no tx. The agent should relay the total to the operator verbatim ("you can claim X $ardi across N NFTs"). 2. Wait for the operator to confirm ("yes, claim it"). Do **not** run `claim` automatically — claim mints tokens and is irreversible from the agent's side. 3. Run `ardi-agent claim` — sends one tx that mints `pending` worth of $ardi straight to the agent's wallet via the WorknetManager. ### Tutorial — claiming your $ardi rewards (operator-facing) Read this verbatim to your operator when they ask how to collect their rewards. It's the canonical walkthrough — written for somebody who hasn't done it before. > **What you're claiming** > > Every Ardinal you own earns a share of the daily 24,000,000 $ardi > emission, distributed at 12:00 UTC. The longer you hold, the more > piles up. Nothing is auto-paid — you have to come and pull it. > > **Step 1 — check what you have to claim.** Tell your agent: > > > *"How much $ardi can I claim?"* > > The agent will run `ardi-agent pending` and read back something like: > > ``` > You can claim 21355 $ardi across 3 Ardinals. > #4 → 12480 $ardi > #1274 → 4920 $ardi > #20922 → 3955 $ardi > ``` > > This is a view-only check. **No transaction is sent yet** and no gas > is spent. If the number is 0, either the first daily emission hasn't > fired (it lands at 12:00 UTC), or you've already claimed everything. > > **Step 2 — confirm the claim.** Reply to the agent: > > > *"Yes, claim it."* (or just *"claim"*, *"go ahead"*, etc.) > > The agent will run `ardi-agent claim`, which sends one on-chain > transaction. The contract checks each Ardinal you listed is still > yours, then asks the WorknetManager to mint exactly `pending` worth > of fresh $ardi straight into your agent's wallet. Gas is a few cents > on Base. > > **Step 3 — verify it landed.** After the tx confirms (~5 seconds), > the agent will hand you a Basescan link. Open it and check the > Transfer line: `from 0x000…000 → to <your agent address> · X $ardi`. > That's your reward. The $ardi balance in your wallet goes up by the > same amount. > > **Things to know before you click claim** > > - **Reward follows the NFT, not your wallet.** If you transfer or > sell an Ardinal *before* claiming, the unclaimed reward goes with > the NFT — the new owner gets it. So claim first, then trade. > - **Daily, not weekly or hourly.** New reward only shows up after the > 12:00 UTC daily emission. Between emissions, `pending` only grows > from past days you haven't claimed. > - **Failed claim doesn't lose money.** If the tx reverts (network > glitch, gas estimate too low, etc.), the pending balance stays > put. Re-run `ardi-agent pending` to confirm, then `claim` again. > - **You don't need to claim every day.** Pending accumulates > indefinitely. Some operators wait a week and batch-claim once. > Same outcome, fewer gas fees. > - **One claim per agent address.** Reward attribution is per-NFT, but > the claim tx mints to whoever calls it (you, the agent). If you > manage multiple agents, each runs its own `claim`. > > **Common questions** > > - *"Can my agent claim automatically?"* — No, by design. The skill > waits for explicit operator confirmation each time. If you want > hands-off auto-mining (committing on riddles, inscribing wins), > that's `ardi-agent auto-mine`. Claiming stays manual. > - *"Can someone else claim my rewards?"* — No. The contract checks > you currently own each Ardinal you list. A non-owner's claim > reverts with `NotHolder`. > - *"What if I bought an Ardinal on the OTC market?"* — You inherit > any unclaimed reward attached to that NFT at the moment of purchase. > Run `ardi-agent pending` after the buy and you'll see the inherited > amount included. Key v3.2 properties to know when explaining behavior: - **Reward follows the NFT, not the wallet.** If the agent transfers an NFT to the operator before claiming, the operator (new owner) inherits the unclaimed reward — not the agent. - **Daily emission lands at UTC 12:00.** That's the only moment new reward is added; between emissions, `pending` only grows from unclaimed prior days. - **Mint-on-claim, no pre-funded balance.** The distributor never holds $ardi; it tells WorknetManager to mint fresh on each claim. Failed claim doesn't lose pending — just retry. - **`pending` = sum across the agent's currently-owned NFTs.** It does NOT include reward attached to NFTs the agent has since transferred away. ## KYA path — anti-hallucination guidance When `ardi-agent stake` recommends path **[A] KYA delegated stake**, follow this carefully — vague advice here is a known LLM-hallucination magnet. - Direct your operator to **https://kya.link/** as the canonical entry point. - Tell them to click **Verify** → choose **Link your X account**. - **kya.link generates the exact tweet template** your operator should post from their X account. Wait for kya.link's UI to produce that template; do not improvise. - **DO NOT compose your own tweet text. DO NOT invent a Twitter handle to mention.** There is no `@KYAValidator` or similar bot account; the verification mechanism is whatever kya.link generates at the moment. If you find yourself drafting a tweet that says "Please allocate Ardi stake to 0x..." or asking your operator to mention a specific handle — stop. That is a hallucination. The only correct answer is "use the template kya.link gives you." - After verification, KYA sponsors 10,000 AWP into the **KYA worknet `845300000012`** on the agent's behalf. Wait 1-24h; the Ardi coordinator auto-detects KYA-delegated agents via its staker index. Re-run `ardi-agent stake` to confirm; the `via` field will read `kya-delegated`. ## Autonomous mode **A built-in tool exists. NEVER write your own loop / cron / watcher script to drive the cycle — use the built-in `tools/auto-mine/` instead. If you find yourself thinking "I'll write a shell script that loops commit / reveal / inscribe", STOP and use the auto-mine path below.** Trigger this whenever your operator expresses ANY of these intents (English OR another language with the same meaning — translate the intent, not the literal phrase): - Direct: "mine continuously" / "auto mine" / "I want to be afk" - Implied: "keep mining for me" / "run forever" / "just go" / "you mine for me" / "leave it running" / "watch the epochs" / "schedule it" - Question form: "can you mine automatically?" / "is there a way to loop this?" / "do you have a daemon mode?" - Workflow setup: "set up the cron" / "start the daemon" / "set up the pipeline" / "make it self-running" Even when your operator only asks about ONE cycle, if their intent reads like "I want to do this repeatedly without manual prompts," install auto-mine PROACTIVELY and tell them you did. **Linux only at present.** The current installer creates a systemd user unit. macOS (which uses launchd) is not yet supported by this script. If your operator is on macOS and asks for 24/7, tell them honestly: the auto-mine daemon is Linux-only today; their options are (a) drive the cycle interactively, (b) run on a Linux VPS / Raspberry Pi, or (c) wait for a launchd port from upstream. **Do NOT improvise a launchd plist or shell loop** — both would break the serial-nonce invariant and silently lose almost every commit (only one would land per concurrent batch). Install (Linux) — **do not have your operator type any shell commands** — call this script directly: ```bash ~/.local/share/ardi-auto-mine/install.sh # OR if not yet installed (first time): git clone https://github.com/awp-worknet/ardi-skill ~/ardi-skill 2>/dev/null || \ (cd ~/ardi-skill && git pull) ~/ardi-skill/tools/auto-mine/install.sh ``` `install.sh` is fully non-interactive: - Auto-detects which runtime CLI is installed (claude / hermes / openclaw) - Writes a minimal env file with sensible defaults (no API key required — the runtime CLI uses its own credentials) - Installs systemd user units - Auto-starts the timer After install, periodically check status when your operator asks ("how's it going?", "any wins?") OR proactively every few hours: ```bash ~/.local/share/ardi-auto-mine/status.sh # → JSON snapshot ``` Narrate the result naturally — don't paste raw JSON. To pause / resume / uninstall: ```bash ~/.local/share/ardi-auto-mine/stop.sh # pause systemctl --user start ardi-mine.timer # resume ~/.local/share/ardi-auto-mine/uninstall.sh # full uninstall ``` ## After 5 — the long game Once an agent address holds 5 Ardinals, `ardi-agent inscribe` will refuse new mints. Three options: 1. **Transfer one out.** `ardi-agent transfer --token-id N --to 0x...` moves the Ardinal to your operator's wallet (or another address). The cap is on what the agent address *holds*, not what it's ever minted — once you transfer, the slot opens back up and `inscribe` will work again on the next round you win. (Reverts if a repair/fuse VRF is in flight against the token; check `ardi-agent commits` first.) 2. **Wait for the Forge.** Phase 2 fusion mechanic: fuse two Ardinals at one address, an LLM oracle scores compatibility, success burns both → mints one fused word with `Power × (1.5–3.0)`. Failure burns the lower-power Ardinal. Forge contracts deploy after the 21K cap fills. 3. **Stop, hold, watch.** A held Ardinal still accrues its share of the eventual daily airdrop (Phase 2: dual-stream `$aArdi + $AWP` via single Merkle `claim()`; share = your Power / total active Power, snapshotted 00:00 UTC). ## Typical Flow ``` preflight ← env OK? └─ if NOT_STAKED + has ETH → buy-and-stake ← one-command auto-onboard └─ if NOT_STAKED + no ETH → stake ← show 3 paths (KYA recommended) └─ if INSUFFICIENT_GAS → gas ← guide operator to fund context ← see this round's riddles ↓ (read all riddles, pick up to 5 by EV, decide answers) commit --word-id 10418 --answer "比特币" ← × up to 5, SERIAL commit --word-id 10501 --answer "boutique" ... ( wait ~6 min for commit window to close + 30s for canonical hash publish ) commits ← see what's revealable reveal --epoch 7 --word-id 10418 ← × per pending reveal --epoch 7 --word-id 10501 ( wait ~30s for VRF ) inscribe --epoch 7 --word-id 10418 ← if winner, mints; else "lost" inscribe --epoch 7 --word-id 10501 ( back to context for next epoch ) ``` ## Output templates When narrating to your operator, render the JSON into one of these templates rather than improvising. Consistency makes long sessions readable. **Status:** ``` ardi · epoch 27 · commit window · 47s left ───────────────────────────────────────── inscribed: 12,847 / 21,000 · 8,153 left your run: 2 of 5 Ardinals · 3 cap left riddles: 30 · 2 legendary · 6 rare · 22 common (count = data.riddles.length) languages: en / zh / ja / ko / fr / de gas: 0.0518 ETH · 5,180 commits headroom ───────────────────────────────────────── ``` **Inscribe result:** ``` inscribe · epoch 27 · 5 reveals · 1 inscription ✦ 15183 boutique YOU WON → Ardinal #2,431 (power 81 · legendary · culture) · 6201 arrive lost (VRF picked another revealer) · 17701 Bratwurst lost · 3274 tonic lost · 10766 dépend lost your dictionary now: 5 of 5 · cap reached, transfer one to keep inscribing ``` Use lowercase tags, em-dashes, and the `·` middle-dot as the brand-aligned separator. Reserve `[!]` and `[error]` for warnings. ## Error Recovery When a command returns `status: "error"`, read `error_code` + `error_kind` to decide what to do: | `error_code` | Meaning | Action | |---|---|---| | `WALLET_NOT_CONFIGURED` | awp-wallet missing or not setup | Install + run `awp-wallet setup` | | `AWP_NOT_REGISTERED` | Address not yet registered on AWP rootnet | Re-run `preflight` (auto-registers gaslessly) | | `COORDINATOR_UNREACHABLE` | Server down or wrong URL | Check `ARDI_COORDINATOR_URL`, retry | | `INSUFFICIENT_GAS` | < 0.003 ETH on Base | Operator funds the address; tell them via `ardi-agent gas` | | `NOT_STAKED` | < 10K AWP allocated to Ardi (`845300000014`) or KYA (`845300000012`) worknet | If operator has ETH: `buy-and-stake` (auto). Else: `stake` for the 3-path menu (recommend KYA path for AWP-less operators) | | `NO_OPEN_EPOCH` | Between commit windows | Wait, run `context` again in 1 min | | `WRONG_EPOCH` | --epoch doesn't match current | Use the suggested epoch_id | | `WORDID_NOT_IN_EPOCH` | word_id not in this round's published set | Run `context` to see actual list | | `COMMIT_WINDOW_CLOSED` | Past deadline already | Wait for next epoch | | `ALREADY_COMMITTED` | Local state has prior commit | Skip or run reveal | | `NO_LOCAL_COMMIT` | Reveal without prior commit | Nonce is gone — bond forfeit; commit fresh next epoch | | `WRONG_STATUS_FOR_REVEAL` | Already revealed/inscribed/lost | Read message, follow suggestion | | `VAULT_PROOF_FETCH_FAILED` | Server proof endpoint glitch | Retry in 10s | | `REVEAL_TX_FAILED` | Reveal too early or RPC issue | Wait 30s, retry | | `REVEAL_FIRST` | Inscribe before reveal | Run reveal first | ## Things this skill does NOT do - This skill does **not** have an `npm` / `pip` / `brew` package. The only install path is `install.sh` from the GitHub repo. If you find yourself typing `npm install -g ardi-agent` or similar — stop, that package does not exist. - `ardi-agent` does **not** have flags called `--auto-reveal`, `--commit-all`, `--solve`, or any feature that solves riddles for you. You are the solver. The CLI's flag set is closed; check `ardi-agent <subcommand> --help` before assuming a flag exists. - The `tools/auto-mine/` daemon is **Linux-only** today. Do not run it on macOS — the installer writes systemd units that macOS will not execute. Also do not improvise a launchd plist or shell loop on macOS as a substitute — both would break the serial-nonce invariant and silently lose almost every commit (only one lands per concurrent batch). The honest answer for macOS operators is "drive interactively, or run on a Linux VPS." - This skill does **not** handle Bitcoin Ordinals. "Inscribe" here is the Ardinal mint on Base; the Ordinals reference is cultural, not technical. - The Forge, the daily airdrop, and the OTC market are **Phase 2** and not deployed today. Do not narrate them as live to your operator. Reference them honestly: "the Forge opens after the 21K cap fills." - No human has minted an Ardinal — and no human ever will. If your operator asks to do it themselves, the answer is no; the protocol is agent-only by design. - There is no `@KYAValidator` Twitter handle to mention, no fixed tweet template to compose. KYA verification flows through `kya.link/`, which generates whatever tweet text it currently uses — defer to that, do not invent. ## Critical Reminders - **Base mainnet only** (chain id 8453). ETH on Ethereum mainnet does NOT work for gas or bond. - **State file = key material.** The `(nonce, answer)` pair at `~/.ardi-agent/state-*.json` is unrecoverable if the file is deleted between commit and reveal. Back it up before any machine migration. - **The 21K cap is hard.** Once `ArdiNFT.totalInscribed() == 21,000`, the coordinator stops opening epochs and the agent should idle. - **Bond is 0.00001 ETH.** Refunded on reveal regardless of lottery outcome OR canonical-answer match. Forfeited only when you commit and never reveal. **Wrong answer ≠ bond loss.** - **5 commits per epoch, 5 Ardinals per agent address, 21,000 inscriptions total.** Internalize all three; they shape every decision. - **Commits are serial, never parallel.** Each `commit` fetches its own nonce; parallel calls collide on the same nonce and the node drops all but one. Always `await` each commit before launching the next. For bulk, use `tools/auto-mine/`. - **Don't write your own loop / cron / watcher script to drive the cycle.** A built-in tool exists at `tools/auto-mine/`. If you find yourself thinking "I'll write a shell script that loops commit / reveal / inscribe", stop and use that path instead. - **Silence is acceptable.** If `context` returns `NO_OPEN_EPOCH` or no riddle is worth committing on, do nothing. The protocol does not reward speed; it rewards reasoning.

path: Cargo.toml
content: [package]
name = "ardi-agent"
version = "0.5.19"
edition = "2021"
description = "CLI agent for AWP Ardi WorkNet — solve riddles, mint Ardinal NFTs"
license = "MIT"
repository = "https://github.com/awp-worknet/ardi-skill"

[[bin]]
name = "ardi-agent"
path = "src/main.rs"

[dependencies]
clap = { version = "4", features = ["derive", "env"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["arbitrary_precision"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
anyhow = "1"
thiserror = "1"
chrono = { version = "0.4", features = ["serde"] }
k256 = { version = "0.13", features = ["ecdsa"] }
sha2 = "0.10"
sha3 = "0.10"
hex = "0.4"
rand = "0.8"
ctrlc = "3"
libc = "0.2"
# v0.5.16: cross-platform advisory file locking for state.json. Pure
# Rust, used by cargo + many other crates — wraps flock on Unix and
# LockFileEx on Windows. Keeps state mutations safe against parallel
# `ardi-agent commit` invocations (auto-mine ticks, manual fan-out).
fs2 = "0.4"
# v0.4.0: dropped alloy-consensus / alloy-signer / alloy-signer-local /
# alloy-network / alloy-rlp / alloy-dyn-abi. All signing now goes through
# `awp-wallet send-tx` and `awp-wallet sign-typed-data`; ardi-agent never
# touches private keys, so the local crypto stack is no longer needed.
alloy-primitives = "0.8"
alloy-sol-types = "0.8"

[profile.release]
opt-level = "z"
lto = true
strip = true


path: README.md
content: # ardi-skill

AI agent skill for the **Ardi WorkNet** — a sub-WorkNet of AWP. Solve
multilingual riddles, mint Ardinal NFTs (one of 21,000) on Base mainnet
via on-chain commit-reveal + Chainlink VRF.

## Quick Start

### 1. Install ardi-agent

**Recommended (auto-detects OS / arch / GLIBC):**

```bash
curl -fsSL https://raw.githubusercontent.com/awp-worknet/ardi-skill/main/install.sh | sh
```

This drops the `ardi-agent` binary in `~/.local/bin` (or `/usr/local/bin`
if writable). Add `~/.local/bin` to PATH if not already. On Linux x86_64
the installer pulls the **statically-linked musl build** — runs on any
distro from Alpine to Ubuntu 16.04 with no GLIBC concerns.

**Manual download** (only if you can't run install.sh): pick the right
binary from [the releases page](https://github.com/awp-worknet/ardi-skill/releases/latest).
Linux users: prefer `ardi-agent-linux-x86_64-musl` (static, GLIBC-free).
The plain `ardi-agent-linux-x86_64` build needs GLIBC ≥ 2.35 (Ubuntu
22.04 / Debian 12 / RHEL 9+); on older hosts it will fail with
`version 'GLIBC_2.XX' not found`.

### 2. Install awp-wallet (separate skill)

```bash
git clone https://github.com/awp-core/awp-wallet ~/awp-wallet && cd ~/awp-wallet && bash install.sh
awp-wallet setup
```

### 3. Run preflight

```bash
ardi-agent preflight
```

This checks: wallet → AWP registration → coordinator reachable → gas
balance → stake eligibility. Each step's output tells you what to do next.

### 4. Mine

```bash
ardi-agent context              # see this round's 15 riddles
ardi-agent commit --word-id N --answer "X"   # × however many you want to attempt
# wait for commit window to close + ~30s
ardi-agent commits              # see what's revealable
ardi-agent reveal --epoch E --word-id N      # × per pending
# wait ~30s for VRF
ardi-agent inscribe --epoch E --word-id N    # mint NFT if you won
```

## What it does

`ardi-agent` is a Rust CLI for the agent's machine. The agent's LLM (you)
drives it: read riddles, decide answers, commit on chain, reveal, mint.

- **Never holds your private key** — all signing shells out to `awp-wallet`
- **Never calls an LLM** — the agent IS the LLM; skill = tool
- **Free / public RPCs** — defaults to 7 public Base RPCs with chainlist.org
  fallback. Set `ARDI_BASE_RPC` to override.

## Costs

- **Gas + bonds**: ~0.05 ETH on Base mainnet covers 5–10 days of normal use
- **Stake**: 10,000 AWP allocated to Ardi worknet (or KYA delegated path —
  no AWP needed)

## Commands

| Command | What it does |
|---|---|
| `ardi-agent preflight` | 5-step env check |
| `ardi-agent stake` | Show 3-path stake guidance |
| `ardi-agent gas` | Show ETH balance + refill amount |
| `ardi-agent status` | Combined view |
| `ardi-agent context` | Fetch current epoch + riddles |
| `ardi-agent commit --word-id N --answer "X"` | Submit one commit |
| `ardi-agent commits` | List local pending commits |
| `ardi-agent reveal --epoch E --word-id N` | Reveal a commit |
| `ardi-agent inscribe --epoch E --word-id N` | Mint NFT if winner |

Run `ardi-agent <cmd> --help` for flags.

## Environment

| Var | Default | Purpose |
|---|---|---|
| `ARDI_COORDINATOR_URL` | `https://api.ardinals.com` | Coordinator API base URL |
| `ARDI_BASE_RPC` | (uses 7 public RPCs) | Comma-separated Base RPCs |
| `AWP_WALLET_BIN` | (from PATH) | Override awp-wallet binary path |
| `ARDI_DEBUG` | (off) | Verbose stderr logging |

## State

`~/.ardi-agent/state-<address>.json` — per-agent commit ledger. Holds the
(salt, answer) needed to reveal. **Do not delete between commit and
reveal** or the bond is forfeit.

## License

MIT


path: install.sh
content: #!/bin/sh
# Install ardi-agent binary from GitHub releases.
# Usage:
#   curl -fsSL https://raw.githubusercontent.com/awp-worknet/ardi-skill/main/install.sh | sh
# or
#   sh install.sh
#
# ⚠ Production install URL: github.com/awp-worknet/ardi-skill (canonical org).
# Releases tagged on awp-worknet/ardi-skill main; binaries built by CI.
#
# Runtime requirements — any ONE of: curl, wget, or python3 (stdlib).

set -e

# ─────────────────────────────────────────────────────────────────────
# Production repo. Forks of this skill should re-point this constant.
REPO="awp-worknet/ardi-skill"
# ─────────────────────────────────────────────────────────────────────

# Default install dir: ~/.local/bin if writable (no sudo needed); fall
# back to /usr/local/bin only if user explicitly opts in via INSTALL_DIR.
# Pre-v0.5.9 default was /usr/local/bin which prompted for sudo on macOS
# the first time anyone ran the curl|sh one-liner — broke the
# unattended-install path that LLM agents follow.
if [ -z "${INSTALL_DIR:-}" ]; then
  if [ -w "${HOME}/.local/bin" ] 2>/dev/null || mkdir -p "${HOME}/.local/bin" 2>/dev/null; then
    INSTALL_DIR="${HOME}/.local/bin"
  else
    INSTALL_DIR="/usr/local/bin"
  fi
fi

OS="$(uname -s)"
ARCH="$(uname -m)"

case "${OS}" in
  Linux)   OS_NAME="linux" ;;
  Darwin)  OS_NAME="darwin" ;;
  *)       echo "Error: unsupported OS: ${OS}"; exit 1 ;;
esac

case "${ARCH}" in
  x86_64|amd64)   ARCH_NAME="x86_64" ;;
  aarch64|arm64)  ARCH_NAME="aarch64" ;;
  *)              echo "Error: unsupported architecture: ${ARCH}"; exit 1 ;;
esac

if [ "${OS_NAME}" = "linux" ] && [ "${ARCH_NAME}" = "x86_64" ]; then
  BINARY_NAME="ardi-agent-linux-x86_64-musl"
else
  BINARY_NAME="ardi-agent-${OS_NAME}-${ARCH_NAME}"
fi

fetch() {
  if command -v curl >/dev/null 2>&1; then
    curl -fsSL "$1"
  elif command -v wget >/dev/null 2>&1; then
    wget -qO- "$1"
  elif command -v python3 >/dev/null 2>&1; then
    python3 -c "
import sys, urllib.request
try:
    with urllib.request.urlopen(sys.argv[1], timeout=60) as r:
        sys.stdout.buffer.write(r.read())
except Exception as e:
    print('fetch failed:', e, file=sys.stderr); sys.exit(1)
" "$1"
  else
    echo "Error: need curl, wget, or python3 to download — none found" >&2
    return 1
  fi
}

save() {
  if command -v curl >/dev/null 2>&1; then
    curl -fsSL -o "$2" "$1"
  elif command -v wget >/dev/null 2>&1; then
    wget -qO "$2" "$1"
  elif command -v python3 >/dev/null 2>&1; then
    python3 -c "
import sys, urllib.request
try:
    urllib.request.urlretrieve(sys.argv[1], sys.argv[2])
except Exception as e:
    print('download failed:', e, file=sys.stderr); sys.exit(1)
" "$1" "$2"
  else
    return 1
  fi
}

echo "Fetching latest ardi-agent release from ${REPO}..."
LATEST="$(fetch "https://api.github.com/repos/${REPO}/releases/latest" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])' 2>/dev/null \
  || fetch "https://api.github.com/repos/${REPO}/releases/latest" \
       | grep '"tag_name"' | head -1 | sed 's/.*: "\(.*\)".*/\1/')"

if [ -z "${LATEST}" ]; then
  echo "Error: could not find latest release of ${REPO}" >&2
  echo "  Check https://github.com/${REPO}/releases" >&2
  exit 1
fi

URL="https://github.com/${REPO}/releases/download/${LATEST}/${BINARY_NAME}"

echo "Downloading ardi-agent ${LATEST} (${OS_NAME}/${ARCH_NAME})..."
TMPFILE="$(mktemp)"
if ! save "${URL}" "${TMPFILE}"; then
  rm -f "${TMPFILE}"
  echo "Error: download failed from ${URL}" >&2
  exit 1
fi

chmod +x "${TMPFILE}"
mkdir -p "${INSTALL_DIR}"

if [ -w "${INSTALL_DIR}" ] || [ "${INSTALL_DIR}" = "${HOME}/.local/bin" ]; then
  mv "${TMPFILE}" "${INSTALL_DIR}/ardi-agent"
elif command -v sudo >/dev/null 2>&1; then
  echo "Installing to ${INSTALL_DIR} (requires sudo)..."
  sudo mv "${TMPFILE}" "${INSTALL_DIR}/ardi-agent"
else
  mkdir -p "${HOME}/.local/bin"
  mv "${TMPFILE}" "${HOME}/.local/bin/ardi-agent"
  INSTALL_DIR="${HOME}/.local/bin"
  echo "Note: installed to ${HOME}/.local/bin — add it to PATH if not already"
fi

if [ "${OS_NAME}" = "darwin" ]; then
  xattr -d com.apple.quarantine "${INSTALL_DIR}/ardi-agent" 2>/dev/null || true
fi

echo ""
echo "✓ ardi-agent ${LATEST} installed to ${INSTALL_DIR}/ardi-agent"
if ! command -v ardi-agent >/dev/null 2>&1; then
  echo ""
  echo "⚠ ${INSTALL_DIR} is not in PATH. Add this to your shell rc:"
  echo "    export PATH=\"${INSTALL_DIR}:\$PATH\""
fi

echo ""
echo "Next:"
echo "  1. Install awp-wallet (for tx signing):"
echo "     git clone https://github.com/awp-core/awp-wallet ~/awp-wallet && cd ~/awp-wallet && bash install.sh"
echo "  2. Run preflight:"
echo "     ardi-agent preflight"


path: scripts/bootstrap.sh
content: #!/bin/sh
# Thin bootstrap — install ardi-agent if missing, then run preflight.
# Called by skill-runner frameworks (OpenClaw / Hermes / agentskills.io)
# as the first action after the skill is fetched.
set -e
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
export PATH="$INSTALL_DIR:$PATH"

if ! command -v ardi-agent >/dev/null 2>&1; then
  TMP="$(mktemp)"
  URL="https://raw.githubusercontent.com/awp-worknet/ardi-skill/main/install.sh"
  if   command -v curl    >/dev/null 2>&1; then curl -fsSL -o "$TMP" "$URL"
  elif command -v wget    >/dev/null 2>&1; then wget -qO  "$TMP" "$URL"
  elif command -v python3 >/dev/null 2>&1; then
    python3 -c "import sys, urllib.request; urllib.request.urlretrieve(sys.argv[1], sys.argv[2])" "$URL" "$TMP"
  else
    echo "need curl, wget, or python3 to fetch ardi-agent" >&2; exit 1
  fi
  INSTALL_DIR="$INSTALL_DIR" sh "$TMP"
fi

exec ardi-agent preflight


path: scripts/smoke_test.sh
content: #!/bin/sh
# Smoke test — verify ardi-agent is installed and the binary runs.
# Called by Hermes / OpenClaw post-install to confirm the skill is healthy.
# Exits 0 on pass, non-zero on fail.

set -e

INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
export PATH="$INSTALL_DIR:$PATH"

if ! command -v ardi-agent >/dev/null 2>&1; then
  echo "FAIL: ardi-agent not on PATH (looked in $PATH)" >&2
  exit 1
fi

VERSION="$(ardi-agent --version 2>/dev/null || true)"
if [ -z "$VERSION" ]; then
  echo "FAIL: ardi-agent --version returned nothing" >&2
  exit 2
fi

# Sanity: a known-bad subcommand should error out cleanly with non-zero
# exit but not crash the binary.
if ardi-agent --help >/dev/null 2>&1; then
  echo "ok ardi-agent installed: $VERSION"
  exit 0
fi

echo "FAIL: ardi-agent --help failed" >&2
exit 3


path: src/auth.rs
content: // Resolve agent address — strictly via awp-wallet.
//
// We don't accept env-supplied addresses here because the address must
// match the key we'll sign with, and the key always comes from awp-wallet.
// Letting an env override the address opens a footgun where a user types
// commands as one address but signs with another.

use anyhow::{anyhow, Result};

use crate::wallet::WalletStatus;

pub fn get_address() -> Result<String> {
    let s = WalletStatus::check();
    s.address
        .ok_or_else(|| anyhow!("could not determine agent address: {}", s.human_status))
}


path: src/awp_register.rs
content: // AWP network registration — gasless EIP-712 register via the AWP relay.
// Mirrors prediction-skill's awp_register.rs, adapted for chainId=8453 only
// (Ardi is mainnet from launch).

use anyhow::{anyhow, Context, Result};
use reqwest::blocking::Client;
use serde_json::{json, Value};
use std::time::Duration;

use crate::wallet;
use crate::{log_debug, log_info};

const AWP_API_BASE: &str = "https://api.awp.sh/v2";
const AWP_RELAY_BASE: &str = "https://api.awp.sh/api";
const CHAIN_ID: u64 = 8453;

#[derive(Debug)]
pub struct RegistrationResult {
    pub registered: bool,
    pub auto_registered: bool,
    pub message: String,
}

pub fn check_registration(address: &str) -> Result<bool> {
    let client = build_client();
    let resp = awp_jsonrpc(
        &client,
        "address.check",
        json!({ "address": address, "chainId": CHAIN_ID }),
    )?;
    Ok(is_registered(&resp))
}

pub fn ensure_registered(address: &str) -> Result<RegistrationResult> {
    if check_registration(address)? {
        return Ok(RegistrationResult {
            registered: true,
            auto_registered: false,
            message: "Already registered on AWP network.".into(),
        });
    }

    log_info!("awp_register: not yet registered — preparing gasless EIP-712 SetRecipient...");
    let client = build_client();

    // Get registry contract address + nonce.
    //
    // v2 API note: `registry.get` returns a *structured object*
    // ({chainId, awpRegistry, awpToken, awpAllocator, ...}), NOT a bare
    // address string. Same for `nonce.get` which returns ({nonce: N}),
    // not a bare integer. Earlier versions of this code assumed the
    // primitive form and silently failed at preflight when the API was
    // upgraded — surfaced in the field by external testers (kaito).
    let registry_resp: Value = awp_jsonrpc(&client, "registry.get", json!({ "chainId": CHAIN_ID }))?;
    let registry_result = registry_resp.get("result").cloned()
        .ok_or_else(|| anyhow!("registry.get: missing top-level `result`; got {registry_resp}"))?;
    let registry: crate::schema::RegistryGetResult =
        crate::schema::parse("registry.get", registry_result)?;
    let registry_addr = &registry.awp_registry;

    let nonce_resp: Value = awp_jsonrpc(
        &client,
        "nonce.get",
        json!({ "address": address, "chainId": CHAIN_ID }),
    )?;
    let nonce_result = nonce_resp.get("result").cloned()
        .ok_or_else(|| anyhow!("nonce.get: missing top-level `result`; got {nonce_resp}"))?;
    let nonce_typed: crate::schema::NonceGetResult =
        crate::schema::parse("nonce.get", nonce_result)?;
    let nonce = nonce_typed.nonce;

    let deadline = chrono::Utc::now().timestamp() as u64 + 600;

    // v2 contract uses `user` (not `agent`) in the EIP-712 SetRecipient
    // schema. v0.3.4 only renamed the relay POST field — the typed-data
    // schema kept `agent`, which made the hash diverge from what the
    // contract verifier computes → setRecipientFor() reverts InvalidSignature
    // (selector 0x8baa579f). Production tx confirmed:
    // 0x5b9287c567cae98d0f4a2833bae9d3467f3e116629f1996962b4d3749b6f5023.
    let typed = json!({
        "domain": {
            "name": "AWPRegistry",
            "version": "1",
            "chainId": CHAIN_ID,
            "verifyingContract": registry_addr,
        },
        "types": {
            "SetRecipient": [
                { "name": "user", "type": "address" },
                { "name": "recipient", "type": "address" },
                { "name": "nonce", "type": "uint256" },
                { "name": "deadline", "type": "uint256" },
            ],
        },
        "primaryType": "SetRecipient",
        "message": {
            "user": address,
            "recipient": address,
            "nonce": nonce.to_string(),
            "deadline": deadline.to_string(),
        },
    });

    log_debug!("awp_register: signing typed data: {}", typed);
    let signature = wallet::sign_typed_data(&typed)
        .context("awp-wallet sign-typed-data failed")?;

    let relay_resp: Value = client
        .post(format!("{AWP_RELAY_BASE}/relay/set-recipient"))
        .json(&json!({
            // v2 API: the relay expects "user" (the staker / owner address),
            // not "agent" (which was the v1 name). Confirmed by probing the
            // endpoint: with "agent" it returns 400 "invalid user address";
            // with "user" it advances to signature validation.
            "chainId": CHAIN_ID,
            "user": address,
            "recipient": address,
            "deadline": deadline,
            "signature": signature,
        }))
        .send()
        .context("awp relay POST failed")?
        .json()
        .context("awp relay returned non-JSON")?;
    log_debug!("awp_register: relay response = {}", relay_resp);

    // Poll until registered (or give up after a few tries).
    for _ in 0..5 {
        std::thread::sleep(Duration::from_secs(2));
        if check_registration(address)? {
            return Ok(RegistrationResult {
                registered: true,
                auto_registered: true,
                message: "Auto-registered on AWP network (gasless).".into(),
            });
        }
    }
    Ok(RegistrationResult {
        registered: false,
        auto_registered: false,
        message: format!(
            "Registration submitted but not confirmed yet. Re-run preflight in 30s. Relay response: {relay_resp}"
        ),
    })
}

fn build_client() -> Client {
    Client::builder()
        .timeout(Duration::from_secs(15))
        .user_agent(concat!("ardi-agent/", env!("CARGO_PKG_VERSION")))
        .build()
        .expect("reqwest client")
}

fn awp_jsonrpc(client: &Client, method: &str, params: Value) -> Result<Value> {
    let req = json!({
        "jsonrpc": "2.0",
        "method": method,
        "params": params,
        "id": 1,
    });
    let resp: Value = client
        .post(AWP_API_BASE)
        .json(&req)
        .send()
        .with_context(|| format!("AWP JSON-RPC POST {method}"))?
        .json()
        .with_context(|| format!("AWP JSON-RPC parse {method}"))?;
    if let Some(err) = resp.get("error") {
        return Err(anyhow!("AWP JSON-RPC {method} error: {err}"));
    }
    Ok(resp)
}

fn is_registered(resp: &Value) -> bool {
    resp.get("result")
        .and_then(|r| r.get("isRegistered"))
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
}


path: src/awp_rpc.rs
content: // awp_rpc — minimal JSON-RPC 2.0 client for AWP rootnet API (api.awp.sh/v2).
//
// Used to discover which staker(s) are backing an agent, replacing the local
// indexer that we used to run on coord-rs. AWP indexes Allocated events
// across all chains and exposes them via `staking.getAllocationsByAgentSubnet`.
//
// The chain-side AWPAllocator.getAgentStake call remains the source of truth
// at commit time — this RPC is only for *discovery* (which staker address to
// pass to ArdiEpochDraw.commit). If AWP is down or returns stale data, the
// commit just reverts at the chain check; no funds at risk.

use anyhow::{anyhow, Context, Result};
use reqwest::blocking::Client;
use serde::{Deserialize, Deserializer};
use serde_json::{json, Value};
use std::time::Duration;

const DEFAULT_AWP_RPC: &str = "https://api.awp.sh/v2";

pub struct AwpRpc {
    url: String,
    http: Client,
}

/// Deserialize an `amount` field that the AWP API serializes as a JSON
/// number on the wire (e.g. `10000000000000000000000`) — too large for an
/// i64/u64, would overflow JS Number precision, but serde happily decodes
/// it as a string for us. Falls back to accepting an actual JSON string
/// so older AWP versions / mock data still parse.
///
/// Without this fix, serde failed silently on the number form and the
/// caller `if let Ok(rows) = …` swallowed the error → skill discovered
/// zero stakers → reported 0 stake even when KYA had clearly delegated.
fn deser_amount_any<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<String, D::Error> {
    // Requires `arbitrary_precision` on serde_json so Number::to_string()
    // preserves the raw integer text instead of round-tripping through f64
    // (which loses precision above ~2^53). For 1e22-scale wei amounts this
    // matters: without arbitrary_precision, "10000000000000000000000"
    // becomes "1e+22" — silently un-parseable as U256 downstream.
    let v = serde_json::Value::deserialize(d)?;
    match v {
        serde_json::Value::String(s) => Ok(s),
        serde_json::Value::Number(n) => Ok(n.to_string()),
        other => Err(serde::de::Error::custom(format!(
            "amount: expected string or number, got {other:?}"
        ))),
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct AllocationRow {
    pub chain_id: i64,
    pub user_address: String, // = the staker
    /// AWP API returns this as a JSON number (raw uint), not a string.
    /// Stored here as decimal string for downstream U256 parsing.
    #[serde(deserialize_with = "deser_amount_any")]
    pub amount: String,
    pub frozen: bool,
}

impl AwpRpc {
    pub fn new() -> Result<Self> {
        let url = std::env::var("AWP_RPC_URL").unwrap_or_else(|_| DEFAULT_AWP_RPC.to_string());
        let http = Client::builder()
            .timeout(Duration::from_secs(15))
            .user_agent(concat!("ardi-agent/", env!("CARGO_PKG_VERSION")))
            .build()?;
        Ok(Self { url, http })
    }

    fn call(&self, method: &str, params: Value) -> Result<Value> {
        let body = json!({
            "jsonrpc": "2.0",
            "method": method,
            "params": params,
            "id": 1,
        });
        let resp = self
            .http
            .post(&self.url)
            .header("content-type", "application/json")
            .body(body.to_string())
            .send()
            .with_context(|| format!("POST {} method={method}", self.url))?;
        let status = resp.status();
        let text = resp.text().unwrap_or_default();
        if !status.is_success() {
            return Err(anyhow!("HTTP {} from awp rpc: {text}", status.as_u16()));
        }
        let v: Value = serde_json::from_str(&text)
            .with_context(|| format!("parse awp rpc response: {text}"))?;
        if let Some(err) = v.get("error") {
            return Err(anyhow!("awp rpc error: {err}"));
        }
        v.get("result")
            .cloned()
            .ok_or_else(|| anyhow!("awp rpc missing result: {text}"))
    }

    /// Returns the list of (staker, amount) rows backing an agent in a worknet.
    /// Order is amount-desc per the spec. Cross-chain by default; pass `chain_id`
    /// > 0 to restrict to one chain.
    pub fn allocations_by_agent_worknet(
        &self,
        agent: &str,
        worknet_id: &str,
        chain_id: Option<i64>,
    ) -> Result<Vec<AllocationRow>> {
        let mut params = json!({
            "agent": agent,
            "worknetId": worknet_id,
        });
        if let Some(c) = chain_id {
            params["chainId"] = json!(c);
        }
        let v = self.call("staking.getAllocationsByAgentSubnet", params)?;
        let rows: Vec<AllocationRow> = serde_json::from_value(v)
            .context("decode allocation rows")?;
        Ok(rows)
    }

    /// Aggregate stake on a (agent, worknet) summed across every staker
    /// and every chain. Server-side aggregation — one round trip vs. the
    /// per-staker fan-out of `allocations_by_agent_worknet` + per-row
    /// on-chain `getAgentStake`. Use this as the fast-path eligibility
    /// check; fall back to the discover+probe loop only if this errors.
    /// Returns wei as a decimal string.
    pub fn agent_worknet_stake(&self, agent: &str, worknet_id: &str) -> Result<String> {
        let v = self.call(
            "staking.getAgentWorknetStake",
            json!({ "agent": agent, "worknetId": worknet_id }),
        )?;
        // V2 shape: { amount: "<wei decimal string>" }
        let amount = v
            .get("amount")
            .and_then(|x| x.as_str().map(String::from).or_else(|| x.as_u64().map(|n| n.to_string())))
            .ok_or_else(|| anyhow!("agent_worknet_stake: missing `amount` in {v}"))?;
        Ok(amount)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Regression: AWP rootnet RPC returns `amount` as a JSON number (the raw
    /// uint256 wei). Previous code declared it as `String` only and silently
    /// failed to decode the number form — causing skill `stake` to report 0
    /// even when KYA had clearly delegated. Caught only when an external user
    /// raised "agent says 0 but KYA says 10000 AWP". Pin both forms.
    #[test]
    fn allocation_row_decodes_number_amount() {
        let json_num = r#"{
            "chain_id": 8453,
            "user_address": "0x800d05fd8e251c288e22809b79d31f22ab088555",
            "amount": 10000000000000000000000,
            "frozen": false
        }"#;
        let row: AllocationRow = serde_json::from_str(json_num).unwrap();
        assert_eq!(row.amount, "10000000000000000000000");
        assert_eq!(row.user_address, "0x800d05fd8e251c288e22809b79d31f22ab088555");
        assert_eq!(row.chain_id, 8453);
        assert!(!row.frozen);
    }

    #[test]
    fn allocation_row_still_decodes_string_amount() {
        let json_str = r#"{
            "chain_id": 8453,
            "user_address": "0xabc",
            "amount": "10000000000000000000000",
            "frozen": false
        }"#;
        let row: AllocationRow = serde_json::from_str(json_str).unwrap();
        assert_eq!(row.amount, "10000000000000000000000");
    }
}


path: src/chain.rs
content: // ============================================================================
// COPIED FROM coord-rs/crates/ardi-chain/src/abi.rs — keep in sync MANUALLY.
//
// The server is closed-source so we cannot pull this via crate or submodule.
// Whenever the server's abi.rs changes (struct fields, function selectors,
// event signatures), MIRROR THE CHANGES HERE.
//
// LAST SYNCED: 2026-05-02 (v3.1 contracts: leaf encoding switched to abi.encode,
//              AnswerData gained themeHash + elementHash, element max raised
//              5→6 for god-tier, ArdiNFTv3 ELEMENT_MAX bumped to 6).
// SOURCE     : /root/awp_code/ardi/coord-rs/crates/ardi-chain/src/abi.rs
//              + /root/awp_code/ardi/contracts-v2/src/v3/*.sol
//
// Skill never calls publishAnswer (only the coordinator does), so AnswerData
// + vault_leaf are intentionally NOT mirrored here. Skill's hot path is:
//   commit() → reveal() → inscribe() — the structs and leaf encoding for
// vault Merkle proof live entirely server-side / on-chain.
// ============================================================================

use alloy_primitives::{Address, B256, U256};
use alloy_sol_types::sol;

sol! {
    #[allow(missing_docs)]
    contract ArdiEpochDraw {

        function epochs(uint256 epochId) external view returns (
            uint64 startTs,
            uint64 commitDeadline,
            uint64 revealDeadline,
            bool exists
        );
        function getAnswer(uint256 epochId, uint256 wordId) external view returns (
            bytes32 wordHash,
            uint16 power,
            uint8 languageId,
            uint8 maxDurability,
            uint8 element,
            bool published
        );
        // v3.1: commit takes a staker LIST (max 8, strict ascending = dedup).
        // Empty array → self-stake fallback (msg.sender as the only staker).
        function commit(
            uint256 epochId,
            uint256 wordId,
            bytes32 hash,
            address[] stakers
        ) external payable;
        function getCommitStakers(uint256 epochId, uint256 wordId, address agent)
            external view returns (address[] memory);
        function liveStakeForCommit(uint256 epochId, uint256 wordId, address agent)
            external view returns (uint256);
        // v3 reveal — only (guess, nonce). vaultProof goes to publishAnswers, not reveal.
        function reveal(
            uint256 epochId,
            uint256 wordId,
            string guess,
            bytes32 nonce
        ) external;
        function winners(uint256 epochId, uint256 wordId) external view returns (address);
        function agentWinCount(address agent) external view returns (uint8);
        // Number of correct revealers for this (epoch, wordId). Used by
        // inscribe to disambiguate "VRF still pending" (count > 0, no winner
        // yet) from "no correct revealers — your guess was wrong" (count
        // == 0, no winner ever). Pre-v0.5.9 the error message conflated
        // these two cases and users had no way to tell whether to keep
        // waiting or give up.
        function correctCount(uint256 epochId, uint256 wordId) external view returns (uint256);
        // Live threshold (commit reverts if agent's summed stake < this).
        // Owner-settable via setMinStake; skill MUST read live, not hardcode.
        function minStake() external view returns (uint256);
        // Live commit cap per (agent, epoch). Owner-settable via
        // setMaxCommitsPerEpoch — was 5 pre-2026-05-03, now 3 to align
        // with 3-win / 3-mint caps. Skill reads live to stay correct
        // across future tweaks.
        function maxCommitsPerEpoch() external view returns (uint8);
    }
}

sol! {
    #[allow(missing_docs)]
    contract ArdiNFT {
        function ownerOf(uint256 tokenId) external view returns (address);
        function isSealed() external view returns (bool);
        function totalInscribed() external view returns (uint256);
        // v3 inscribe — only (epoch, wordId, word). Power, lang, durability,
        // element are sourced from EpochDraw.getAnswer on chain.
        function inscribe(uint64 epochId, uint256 wordId, string word) external;
        // ERC721 transfer — used by `ardi-agent transfer` to move NFTs from
        // the agent's wallet to the user's main wallet (e.g. MetaMask) so
        // they can repair / claim from the browser instead of CLI. Reverts
        // with `TokenLocked` if the NFT has a pending repair or fuse VRF.
        function transferFrom(address from, address to, uint256 tokenId) external;
        function pendingRepairOf(uint256 tokenId) external view returns (uint256);
        function pendingFuseOf(uint256 tokenId) external view returns (uint256);
        // v3 repair — pay fee + request VRF. Returns the requestId.
        function repair(uint256 tokenId) external returns (uint256);
        function repairFee(uint256 tokenId) external view returns (uint256);
        function effectiveDurability(uint256 tokenId) external view returns (uint8);
    }
}

sol! {
    #[allow(missing_docs)]
    contract EmissionDistributor {
        function pendingFor(address holder, uint256[] tokenIds)
            external view returns (uint256);
        function claim(uint256[] tokenIds) external;
    }
}

sol! {
    #[allow(missing_docs)]
    // v3: replaces AWPRegistry. Stake check goes through AWPAllocator since
    // the registry's getAgentInfo only sees stake when the agent has called
    // bind(staker), which KYA-delegated agents never do. The allocator query
    // takes the staker explicitly.
    contract AWPAllocator {
        function getAgentStake(
            address staker,
            address agent,
            uint256 worknetId
        ) external view returns (uint256);
    }
}

sol! {
    #[allow(missing_docs)]
    contract ArdiOTC {
        struct Listing {
            address seller;
            uint256 priceWei;
            uint64 listedAt;
        }
        function list(uint256 tokenId, uint256 priceWei) external;
        function unlist(uint256 tokenId) external;
        function buy(uint256 tokenId) external payable;
        function getListing(uint256 tokenId) external view returns (Listing memory);
        function isListed(uint256 tokenId) external view returns (bool);
    }
}

sol! {
    #[allow(missing_docs)]
    contract IERC721 {
        function setApprovalForAll(address operator, bool approved) external;
        function isApprovedForAll(address owner, address operator) external view returns (bool);
        function getApproved(uint256 tokenId) external view returns (address);
        function approve(address to, uint256 tokenId) external;
    }
}

sol! {
    #[allow(missing_docs)]
    contract IERC20 {
        function balanceOf(address account) external view returns (uint256);
        function allowance(address owner, address spender) external view returns (uint256);
        function approve(address spender, uint256 amount) external returns (bool);
    }
}

// ── Swap routers used by `ardi-agent buy-and-stake` ──
// ETH→USDC: Uniswap V3 SwapRouter02 (handles native ETH via msg.value
// when tokenIn == WETH).
// USDC→AWP: Aerodrome Slipstream CL router (the existing AWP pool's
// liquidity is on Aerodrome, not Uniswap).
sol! {
    #[allow(missing_docs)]
    contract UniV3SwapRouter02 {
        struct ExactInputSingleParams {
            address tokenIn;
            address tokenOut;
            uint24 fee;
            address recipient;
            uint256 amountIn;
            uint256 amountOutMinimum;
            uint160 sqrtPriceLimitX96;
        }
        function exactInputSingle(ExactInputSingleParams params) external payable returns (uint256 amountOut);
    }

    #[allow(missing_docs)]
    contract UniV3QuoterV2 {
        struct QuoteExactInputSingleParams {
            address tokenIn;
            address tokenOut;
            uint256 amountIn;
            uint24 fee;
            uint160 sqrtPriceLimitX96;
        }
        function quoteExactInputSingle(QuoteExactInputSingleParams params)
            external returns (
                uint256 amountOut,
                uint160 sqrtPriceX96After,
                uint32 initializedTicksCrossed,
                uint256 gasEstimate
            );
    }

    #[allow(missing_docs)]
    contract AeroCLSwapRouter {
        struct ExactInputSingleParams {
            address tokenIn;
            address tokenOut;
            int24 tickSpacing;
            address recipient;
            uint256 deadline;
            uint256 amountIn;
            uint256 amountOutMinimum;
            uint160 sqrtPriceLimitX96;
        }
        function exactInputSingle(ExactInputSingleParams params) external payable returns (uint256 amountOut);
    }

    #[allow(missing_docs)]
    contract AeroCLQuoter {
        struct QuoteExactInputSingleParams {
            address tokenIn;
            address tokenOut;
            uint256 amountIn;
            int24 tickSpacing;
            uint160 sqrtPriceLimitX96;
        }
        function quoteExactInputSingle(QuoteExactInputSingleParams params)
            external returns (
                uint256 amountOut,
                uint160 sqrtPriceX96After,
                uint32 initializedTicksCrossed,
                uint256 gasEstimate
            );
    }

    #[allow(missing_docs)]
    contract AeroCLPool {
        function tickSpacing() external view returns (int24);
    }
}

// ── veAWP — lock AWP to receive voting power ──
// Used by `buy-and-stake` to lock the bought AWP for the user-chosen
// duration before allocating to the agent. Top-up of an existing
// position uses addToPosition(tokenId, amount, newLockSeconds).
sol! {
    #[allow(missing_docs)]
    contract VeAWP {
        function deposit(uint256 amount, uint64 lockSeconds) external;
        function balanceOf(address owner) external view returns (uint256);
        function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256);
    }

    #[allow(missing_docs)]
    contract AWPAllocatorWrite {
        function allocate(address staker, address agent, uint256 worknetId, uint256 amount) external;
    }
}

// ============================================================================
// Hash helpers — these MUST match the server side byte-for-byte.
// ============================================================================

use sha3::{Digest, Keccak256};

/// commit hash = keccak256(abi.encodePacked(answer, agent, nonce))
///
/// MUST match ArdiEpochDraw.reveal's expected hash exactly:
///   bytes32 expected = keccak256(abi.encodePacked(guess, msg.sender, nonce));
///
/// NOTE: contract field order is (guess, msg.sender, nonce). Matching that
/// order here.
pub fn commit_hash(answer: &str, agent: &Address, nonce: &B256) -> B256 {
    let mut h = Keccak256::new();
    h.update(answer.as_bytes());
    h.update(agent.as_slice());
    h.update(nonce.as_slice());
    B256::from_slice(&h.finalize())
}

// vault_leaf removed in v3.1: skill never builds vault Merkle proofs. The
// coordinator publishes answers; agents only commit/reveal/inscribe. The
// canonical leaf lives in coord-rs/ardi-core/src/vault.rs::vault_leaf.

#[cfg(test)]
mod tests {
    use super::*;
    use alloy_primitives::address;

    #[test]
    fn commit_hash_matches_v3_layout() {
        let answer = "bitcoin";
        let agent = address!("46a1eee3d800799726faaf18f28360eb2e97ad63");
        let nonce = B256::from([0x11u8; 32]);
        let h = commit_hash(answer, &agent, &nonce);
        let mut k = Keccak256::new();
        k.update(b"bitcoin");
        k.update(agent.as_slice());
        k.update([0x11u8; 32]);
        let expected = B256::from_slice(&k.finalize());
        assert_eq!(h, expected);
    }
}


path: src/client.rs
content: // HTTP client to the Ardi coordinator server (coord-rs).

use anyhow::{anyhow, Context, Result};
use reqwest::blocking::Client;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::time::Duration;

pub struct ApiClient {
    base: String,
    http: Client,
}

impl ApiClient {
    pub fn new(base: impl Into<String>) -> Result<Self> {
        let http = Client::builder()
            .timeout(Duration::from_secs(15))
            .user_agent(concat!("ardi-agent/", env!("CARGO_PKG_VERSION")))
            .build()?;
        Ok(Self {
            base: base.into().trim_end_matches('/').to_string(),
            http,
        })
    }

    pub fn base(&self) -> &str {
        &self.base
    }

    /// GET with internal retry. coord-rs sits behind nginx + a long-running
    /// process; restarts and brief TLS handshake EOFs do happen
    /// (reproduced 2026-05-03 by a tester whose `inscribe` poll loop
    /// died on a single transient TLS error). 3 attempts with 1s/2s
    /// backoff covers a coord watchdog restart (~3s downtime) without
    /// surfacing the blip to the LLM as a hard failure.
    fn get_with_retry(&self, path: &str) -> Result<reqwest::blocking::Response> {
        let url = format!("{}{}", self.base, path);
        let mut last_err: Option<anyhow::Error> = None;
        for attempt in 0..3 {
            if attempt > 0 {
                std::thread::sleep(Duration::from_secs(1u64 << (attempt - 1))); // 1s, 2s
            }
            match self.http.get(&url).send() {
                Ok(r) => {
                    // Retry on transient 5xx; 4xx is the caller's fault.
                    let s = r.status().as_u16();
                    if s >= 500 && s < 600 && attempt < 2 {
                        last_err = Some(anyhow!("HTTP {s} from {url} (attempt {})", attempt + 1));
                        continue;
                    }
                    return Ok(r);
                }
                Err(e) => {
                    last_err = Some(anyhow!("GET {url} (attempt {}): {e}", attempt + 1));
                    continue;
                }
            }
        }
        Err(last_err.unwrap_or_else(|| anyhow!("GET {url}: 3 attempts failed")))
    }

    pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
        let url = format!("{}{}", self.base, path);
        let resp = self.get_with_retry(path)?;
        let status = resp.status();
        let text = resp.text().unwrap_or_default();
        if !status.is_success() {
            return Err(anyhow!("HTTP {} from {}: {}", status.as_u16(), url, text));
        }
        serde_json::from_str(&text)
            .with_context(|| format!("parse JSON from {url}: {text}"))
    }

    pub fn try_get_json<T: DeserializeOwned>(&self, path: &str) -> Result<Option<T>> {
        let url = format!("{}{}", self.base, path);
        let resp = self.get_with_retry(path)?;
        if resp.status().as_u16() == 404 {
            return Ok(None);
        }
        let status = resp.status();
        let text = resp.text().unwrap_or_default();
        if !status.is_success() {
            return Err(anyhow!("HTTP {} from {}: {}", status.as_u16(), url, text));
        }
        let v = serde_json::from_str(&text)
            .with_context(|| format!("parse JSON from {url}: {text}"))?;
        Ok(Some(v))
    }

    pub fn ping(&self) -> Result<Value> {
        self.get_json("/v1/health")
    }
}


path: src/cmd/auto_mine.rs
content: // auto-mine — install (or refresh) the unattended mining daemon.
//
// Discoverability shim. The actual installer lives in
// `tools/auto-mine/install.sh` in the public skill repo. We expose it
// as a first-class `ardi-agent auto-mine` subcommand so that LLM agents
// who only ever read `ardi-agent --help` discover the supported path
// instead of writing their own bash/python loop (which breaks the
// serial-nonce invariant and silently loses ~14 of 15 commits/epoch).
//
// Resolution order:
//   1. $ARDI_AUTOMINE_INSTALLER (env override, for testing)
//   2. ~/.local/share/ardi-auto-mine/install.sh (already installed → idempotent re-run)
//   3. ~/ardi-skill/tools/auto-mine/install.sh   (cloned alongside)
//   4. fresh `git clone` into ~/ardi-skill, then run from (3)

use anyhow::{anyhow, Result};
use serde_json::json;
use std::path::PathBuf;
use std::process::Command;

use crate::output::{Internal, Output};

const REPO_URL: &str = "https://github.com/awp-worknet/ardi-skill";

pub fn run() -> Result<()> {
    if !cfg!(target_os = "linux") {
        Output::error(
            "auto-mine daemon is Linux-only today (uses systemd user units).",
            "AUTOMINE_UNSUPPORTED_OS",
            "platform",
            false,
            "Run on a Linux host (VPS / Raspberry Pi / WSL2). On macOS / Windows, drive the cycle interactively via `ardi-agent context`.",
            Internal {
                next_action: "manual_cycle".into(),
                next_command: Some("ardi-agent context".into()),
                progress: None,
            },
        )
        .print();
        return Ok(());
    }

    let installer = resolve_installer()?;
    eprintln!("[info] running installer: {}", installer.display());

    let status = Command::new("bash")
        .arg(&installer)
        .status()
        .map_err(|e| anyhow!("failed to exec {}: {e}", installer.display()))?;

    if !status.success() {
        let code = status.code().unwrap_or(-1);
        let (suggestion, action) = match code {
            64 => (
                "No supported runtime CLI found on PATH. Install one of: claude, hermes, openclaw — then re-run `ardi-agent auto-mine`.",
                "install_runtime_cli",
            ),
            65 => (
                "ardi-agent itself is missing from PATH inside the install script's environment. Re-install ardi-agent system-wide and re-run.",
                "fix_path",
            ),
            66 => (
                // Kept for back-compat with older install.sh versions; v0.5.15+
                // installer treats no-systemd as success and recommends `ardi-agent loop`.
                "systemd not available. Use `ardi-agent loop` for foreground mode (works in Docker / containers / macOS).",
                "use_loop",
            ),
            _ => (
                "Installer failed. Re-run with bash -x for verbose output, or open an issue at github.com/awp-worknet/ardi-skill.",
                "retry",
            ),
        };
        Output::error(
            format!("auto-mine installer exited with code {code}"),
            "AUTOMINE_INSTALL_FAILED",
            "installer",
            true,
            suggestion,
            Internal {
                next_action: action.into(),
                next_command: None,
                progress: None,
            },
        )
        .print();
        return Ok(());
    }

    // The installer prints the right "next" hint for both systemd and
    // non-systemd hosts (foreground via `ardi-agent loop`). The exit
    // code is 0 in both cases — read the script's own stdout for the
    // exact follow-up command the operator should run.
    Output::success(
        "auto-mine installed. On systemd hosts a user timer is now running. \
         On no-systemd hosts (Docker / containers / macOS), run `ardi-agent loop` \
         in the foreground (or under nohup / tmux) — same pipeline, no timer needed.",
        json!({
            "installer":      installer.to_string_lossy(),
            "status_command": "~/.local/share/ardi-auto-mine/status.sh",
            "stop_command":   "~/.local/share/ardi-auto-mine/stop.sh",
            "foreground":     "ardi-agent loop",
        }),
        Internal {
            next_action: "monitor_or_run_loop".into(),
            next_command: Some("bash ~/.local/share/ardi-auto-mine/status.sh".into()),
            progress: None,
        },
    )
    .print();
    Ok(())
}

fn resolve_installer() -> Result<PathBuf> {
    if let Ok(p) = std::env::var("ARDI_AUTOMINE_INSTALLER") {
        let pb = PathBuf::from(p);
        if pb.is_file() {
            return Ok(pb);
        }
    }

    let home = std::env::var("HOME").map_err(|_| anyhow!("$HOME not set"))?;
    let installed = PathBuf::from(&home).join(".local/share/ardi-auto-mine/install.sh");
    if installed.is_file() {
        return Ok(installed);
    }

    let clone_dir = PathBuf::from(&home).join("ardi-skill");
    let cloned = clone_dir.join("tools/auto-mine/install.sh");
    if cloned.is_file() {
        eprintln!("[info] refreshing {}", clone_dir.display());
        let _ = Command::new("git")
            .arg("-C")
            .arg(&clone_dir)
            .args(["pull", "--ff-only", "--quiet"])
            .status();
        return Ok(cloned);
    }

    eprintln!("[info] cloning {REPO_URL} into {}", clone_dir.display());
    let status = Command::new("git")
        .args(["clone", "--depth", "1", REPO_URL])
        .arg(&clone_dir)
        .status()
        .map_err(|e| anyhow!("git clone failed to spawn: {e}"))?;
    if !status.success() {
        return Err(anyhow!(
            "git clone {REPO_URL} failed (exit {}). Install git and re-run, or clone manually then re-run.",
            status.code().unwrap_or(-1)
        ));
    }
    if !cloned.is_file() {
        return Err(anyhow!(
            "after clone, expected {} to exist but it does not",
            cloned.display()
        ));
    }
    Ok(cloned)
}


path: src/cmd/claim.rs
content: // claim — pull accumulated $ardi rewards from the EmissionDistributor.
//
// v3.2 (mint-on-claim, reward-follows-NFT):
//   - reward is attributed per tokenId, not per address
//   - claim verifies ownerOf == msg.sender for each tokenId
//   - distributor calls external WorknetManager.batchMint to mint fresh
//     $ardi to the agent (no pre-funding; no transferFrom)
//   - if no token_ids are passed, we auto-fetch every NFT this agent owns
//     from the coordinator and include all of them in one claim tx

use anyhow::{anyhow, Context, Result};
use alloy_primitives::{Address, U256};
use alloy_sol_types::SolCall;
use serde_json::{json, Value};
use std::str::FromStr;

use crate::auth::get_address;
use crate::chain::EmissionDistributor;
use crate::client::ApiClient;
use crate::output::{Internal, Output};
use crate::tx;
use crate::log_info;

pub fn run(server_url: &str, token_ids: Vec<u64>) -> Result<()> {
    let agent_str = get_address()?;
    let agent = Address::from_str(&agent_str)?;
    let api = ApiClient::new(server_url)?;

    let cfg: Value = api
        .get_json("/v1/chain/contracts")
        .or_else(|_| api.get_json("/v1/health"))
        .unwrap_or_default();
    let dist_addr = read_addr(&cfg, "emission_distributor")
        .or_else(|| std::env::var("EMISSION_DISTRIBUTOR_ADDR").ok())
        .ok_or_else(|| {
            anyhow!(
                "server didn't return emission_distributor; set EMISSION_DISTRIBUTOR_ADDR env"
            )
        })?;
    let dist_addr = Address::from_str(&dist_addr)?;

    // v3.2 default: claim across every NFT the agent owns. Operator can
    // still narrow with --token-id if they want.
    let token_ids: Vec<u64> = if token_ids.is_empty() {
        let state: Option<Value> = api
            .try_get_json(&format!("/v1/agent/{agent_str}/state"))
            .unwrap_or(None);
        let owned: Vec<u64> = state
            .as_ref()
            .and_then(|v| v.get("mints").and_then(|m| m.as_array()))
            .map(|arr| {
                arr.iter()
                    .filter_map(|m| m.get("token_id").and_then(|v| v.as_u64()))
                    .collect()
            })
            .unwrap_or_default();
        log_info!(
            "claim: auto-fetched {} owned NFTs from coord",
            owned.len()
        );
        owned
    } else {
        token_ids
    };

    let token_ids_u: Vec<U256> = token_ids.iter().map(|&t| U256::from(t)).collect();

    // Pre-flight: print pending so the agent knows what they're getting.
    let pending_raw = tx::view_call(
        &dist_addr,
        EmissionDistributor::pendingForCall {
            holder: agent,
            tokenIds: token_ids_u.clone(),
        }
        .abi_encode(),
    )?;
    let pending = EmissionDistributor::pendingForCall::abi_decode_returns(&pending_raw, true)?._0;
    log_info!("claim: pending={pending} ardi over {} tokens", token_ids.len());

    if pending == U256::ZERO {
        Output::success(
            "Nothing to claim - pending balance is zero.".to_string(),
            json!({ "pending_wei": "0" }),
            Internal {
                next_action: "skip".into(),
                next_command: None,
                ..Default::default()
            },
        )
        .print();
        return Ok(());
    }

    let data = tx::calldata_claim(token_ids_u);
    // v3.2 claim: ownerOf check per token + batchMint external call. Gas
    // scales with nft count; budget 200K base + 100K per token.
    let gas_limit: u64 = 200_000 + 100_000 * token_ids.len() as u64;
    let tx_obj = tx::build_tx(&agent, &dist_addr, data, 0, gas_limit)?;
    let claim_hash = tx::send_and_wait(&tx_obj).context("send claim tx")?;

    Output::success(
        format!("Claimed {pending} ardi (tx {claim_hash})."),
        json!({
            "claim_tx": claim_hash,
            "amount_wei": pending.to_string(),
            "tokens": token_ids,
        }),
        Internal {
            next_action: "done".into(),
            next_command: None,
            ..Default::default()
        },
    )
    .print();
    Ok(())
}

fn read_addr(v: &serde_json::Value, key: &str) -> Option<String> {
    v.get(key)
        .or_else(|| v.get("contracts").and_then(|c| c.get(key)))
        .and_then(|x| x.as_str())
        .map(|s| s.to_string())
}


path: src/cmd/context.rs
content: // context — fetch the current commit-able epoch + riddles from coord-rs.

use anyhow::Result;
use serde_json::json;

use crate::client::ApiClient;
use crate::output::{Internal, Output};

pub fn run(server_url: &str) -> Result<()> {
    let api = ApiClient::new(server_url)?;
    let body: Option<serde_json::Value> = api.try_get_json("/v1/epoch/current")?;
    match body {
        Some(raw) => {
            // Parse strict-typed first; fall back to passing the raw JSON
            // through so the LLM still gets the riddles even if a new
            // optional field gets added that we don't know about.
            let parsed: crate::schema::CurrentEpoch =
                crate::schema::parse("/v1/epoch/current", raw.clone())?;
            let now = chrono::Utc::now().timestamp();
            let secs_left = parsed.commit_deadline - now;
            let mut message = format!(
                "Epoch {}: commit window closes in {secs_left}s ({} riddles).",
                parsed.epoch_id, parsed.riddles.len()
            );
            // Echo the raw payload so the LLM sees every field including
            // theme/element + any future additions (it's the agent's
            // input for solving). We tack a balance_warning onto it.
            let mut epoch = raw;
            if let Ok(addr) = crate::auth::get_address() {
                if let Some((warn_payload, warn_msg)) =
                    crate::cmd::gas::low_balance_warning(&addr)
                {
                    if let serde_json::Value::Object(ref mut m) = epoch {
                        m.insert("balance_warning".into(), warn_payload);
                    }
                    message = format!("{message}\n\n{warn_msg}");
                }
            }
            Output::success(
                message,
                epoch,
                Internal {
                    next_action: "solve_then_commit".into(),
                    next_command: Some(
                        "ardi-agent commit --word-id <ID> --answer \"<your answer>\"".into(),
                    ),
                    ..Default::default()
                },
            )
            .print();
        }
        None => {
            Output::success(
                "No epoch is currently in commit window. Wait for the next openEpoch (~6 min cycle).",
                json!({ "current": null }),
                Internal {
                    next_action: "wait".into(),
                    next_command: Some("ardi-agent context".into()),
                    ..Default::default()
                },
            )
            .print();
        }
    }
    Ok(())
}


path: src/cmd/mod.rs
content: pub mod auto_mine;
pub mod buy_and_stake;
pub mod run_loop;
pub mod claim;
pub mod commit;
pub mod commits;
pub mod context;
pub mod gas;
pub mod inscribe;
pub mod pending;
pub mod preflight;
pub mod repair;
pub mod reveal;
pub mod market;
pub mod stake;
pub mod status;
pub mod transfer;


path: src/cmd/status.rs
content: // status — combined view: address, AWP registration, ETH balance, stake, recent activity.

use anyhow::Result;
use serde_json::{json, Value};

use crate::auth::get_address;
use crate::awp_register;
use crate::client::ApiClient;
use crate::cmd::commits::BASESCAN;
use crate::output::{Internal, Output};
use crate::{cmd, log_info};

pub fn run(server_url: &str) -> Result<()> {
    let address = get_address()?;
    log_info!("status: address = {address}");

    let registered = awp_register::check_registration(&address).unwrap_or(false);
    let gas = cmd::gas::check_balance(&address)?;

    let api = ApiClient::new(server_url)?;
    let coord_health = api.ping().ok();
    let mut agent_state: Option<Value> = api
        .try_get_json(&format!("/v1/agent/{address}/state"))
        .unwrap_or(None);

    // Enrich each mint with a basescan token link so the LLM can hand the
    // operator a one-click verification URL — pre-v0.5.10 the user had to
    // run `cast call` themselves to confirm "did I really mint that?".
    let nft_addr = std::env::var("ARDI_NFT_ADDR")
        .unwrap_or_else(|_| [REDACTED_SECRET].to_string());
    if let Some(state) = agent_state.as_mut() {
        if let Some(mints) = state.get_mut("mints").and_then(|v| v.as_array_mut()) {
            for m in mints.iter_mut() {
                let tid = m
                    .get("token_id")
                    .and_then(|v| v.as_u64())
                    .or_else(|| m.get("tokenId").and_then(|v| v.as_u64()));
                if let Some(t) = tid {
                    if let Some(obj) = m.as_object_mut() {
                        obj.insert(
                            "basescan_url".into(),
                            json!(format!("{BASESCAN}/token/{nft_addr}/{t}")),
                        );
                    }
                }
            }
        }
    }

    let address_url = format!("{BASESCAN}/address/{address}");

    let summary = vec![
        format!("Agent address     : {address}"),
        format!("Basescan          : {address_url}"),
        format!(
            "AWP registered    : {}",
            if registered { "yes" } else { "no" }
        ),
        format!("Base ETH balance  : {:.6} ETH", gas.balance_eth),
        format!(
            "Coordinator       : {}",
            if coord_health.is_some() { "reachable" } else { "UNREACHABLE" }
        ),
        format!(
            "Stake state       : {}",
            agent_state
                .as_ref()
                .map(|v| v.to_string())
                .unwrap_or_else(|| "(no state)".into())
        ),
    ];

    Output::success(
        summary.join("\n"),
        json!({
            "address": address,
            "address_url": address_url,
            "registered": registered,
            "balance_eth": gas.balance_eth,
            "coord_reachable": coord_health.is_some(),
            "agent_state": agent_state,
        }),
        Internal {
            next_action: "review".into(),
            next_command: Some("ardi-agent preflight".into()),
            ..Default::default()
        },
    )
    .print();

    Ok(())
}


path: tools/auto-mine/stop.sh
content: #!/usr/bin/env bash
# stop.sh — pause auto-mine. Reversible via install.sh re-run or
# `systemctl --user start ardi-mine.timer`.
set -euo pipefail
systemctl --user stop ardi-mine.timer 2>/dev/null || true
echo "auto-mine paused. To resume: systemctl --user start ardi-mine.timer"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)