Skill: powerloom-bot/powerloom-bds-univ3 (version 0.2.6)

--- name: powerloom-bds-univ3 description: | Autonomous Uniswap V3 monitoring on consensus-backed data with onchain provenance. Every data point is finalized on-chain by Powerloom's DSV network and independently verifiable. Ships with Whale Radar, Token-Flow, and DeFi Analyst recipes. Free API key at bds-metering.powerloom.io/metering (2 credits, no wallet). Triggers on "whale alert", "track trades", "all trades for", "by token", "Powerloom", "verify on-chain", "verified data". version: 0.2.6 homepage: https://bds-metering.powerloom.io repository: https://github.com/powerloom/powerloom-bds-univ3 tags: - defi - uniswap - ethereum - on-chain - verifiable - consensus - agent metadata: openclaw: emoji: "🦄" requires: bins: ["node"] env: # Only POWERLOOM_API_KEY is mandatory at install time. The free-key path # (references/09-openclaw-one-shot-free-key.md) needs nothing else from # this skill's perspective. Wallet/plan envs below are optional and only # consumed by scripts/signup-pay.mjs and scripts/credits-topup.mjs. - POWERLOOM_API_KEY optional_env: # Wallet-funded variants (signup-pay or credits-topup) — only required # if the user invokes those scripts. See references/08-openclaw-one-shot.md. - POWERLOOM_EVM_PRIVATE_KEY - POWERLOOM_EVM_RPC_URL - POWERLOOM_EVM_CHAIN_ID - POWERLOOM_PLAN_ID - POWERLOOM_TOKEN_SYMBOL # Dispatch + transport overrides — never required. - POWERLOOM_MCP_URL - POWERLOOM_TELEGRAM_BOT_TOKEN - POWERLOOM_TELEGRAM_CHAT_ID - POWERLOOM_DISCORD_WEBHOOK_URL - POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS - POWERLOOM_BDS_MCP_DEBUG - POWERLOOM_METERING_BASE_URL - POWERLOOM_AGENT_NAME - POWERLOOM_EMAIL --- # Powerloom BDS — Uniswap V3 ## Install > **Two onboarding paths.** > > **Free** — sign up at [`bds-metering.powerloom.io/metering`](https://bds-metering.powerloom.io/metering) (enter email + agent name, complete Turnstile, get `sk_live_...` immediately — no CLI, no wallet, 2 free credits). Alternatively, `bds-agent signup` runs the same flow from the terminal. Then paste [`references/09-openclaw-one-shot-free-key.md`](references/09-openclaw-one-shot-free-key.md) into OpenClaw. Sets `POWERLOOM_API_KEY` and a Whale Radar cron. Nothing else needed. > > **Wallet-funded** — paste [`references/08-openclaw-one-shot.md`](references/08-openclaw-one-shot.md) and `scripts/signup-pay.mjs` runs an autonomous on-chain payment for a 10-credit plan in the same prompt. > > **⚠️ WARNING — wallet-funded path only:** `signup-pay.mjs` and `credits-topup.mjs` need an EVM private key to broadcast on-chain payments. Use a **burner wallet** with limited funds dedicated to this purpose. Never use a wallet holding significant assets or with extensive transaction history for agentic setups. The free-key path **does not** require any wallet credentials. **Contract:** [bds-agenthub-billing-metering](https://github.com/powerloom/bds-agenthub-billing-metering). **ClawHub** users only need a **single origin** (default [bds-metering.powerloom.io](https://bds-metering.powerloom.io))— **`bds-agent` commands are optional**; they are a reference CLI for the same JSON bodies you can send with `curl` + a wallet or `ethers`. ### Metering HTTP (authoritative) | What | How | |------|-----| | List SKUs | `GET {BASE}/credits/plans` — no auth. Choose a plan row: `id`, `chain_id`, `token_symbol` (and note `payment_kind`: ERC-20 vs native / CGT). **`chains[].rpc_url`** is a **public** JSON-RPC hint only when the metering deployment sets it; it may be **empty** — use **`POWERLOOM_EVM_RPC_URL`** for wallet / script calls in that case. | | New key, wallet-only | **Pay-signup:** `POST {BASE}/signup/pay/quote` → pay on chain → `POST {BASE}/signup/pay/claim` with `signup_nonce` + `tx_hash`. Returns `api_key`. | | New key, browser | Human device flow on [`{BASE}/metering`](https://bds-metering.powerloom.io/metering) (same service). Enter email + agent name, complete Turnstile, get `sk_live_...` immediately. | | More credits, existing key | `POST {BASE}/credits/topup` with `Authorization: Bearer sk_live_…` and tx / plan (not the pay-signup endpoints). | | Check balance | `GET {BASE}/credits/balance` with `Authorization: Bearer …` | `{BASE}` is **`POWERLOOM_METERING_BASE_URL`**, e.g. `https://bds-metering.powerloom.io`. Set **`POWERLOOM_API_KEY`** to the `sk_live_...` you get after pay-signup, device signup, or copy from the dashboard. ### OpenClaw env vars (mandatory vs optional) | Field | When required | Role | |-------|---------------|------| | `POWERLOOM_API_KEY` | **Always** — only mandatory env at install time | `sk_live_...` from [`bds-metering.powerloom.io/metering`](https://bds-metering.powerloom.io/metering) or `bds-agent signup` (free path) or `signup-pay.mjs` claim (wallet path) | | `POWERLOOM_EVM_PRIVATE_KEY` | Wallet-funded path only | Payer wallet — **use a burner wallet** | | `POWERLOOM_EVM_RPC_URL` | Wallet-funded path only | JSON-RPC for that chain | | `POWERLOOM_EVM_CHAIN_ID` | Wallet-funded path only | Must match the plan's `chain_id` | | `POWERLOOM_PLAN_ID` | Wallet-funded path only | e.g. `launch_10_pl_power_cgt` from `GET /credits/plans` | | `POWERLOOM_TOKEN_SYMBOL` | Wallet-funded path only | e.g. `POWER` (must match that row) | The schema in `metadata.openclaw.requires.env` lists only `POWERLOOM_API_KEY` as required; the wallet/plan envs above sit in `optional_env` and are read only by `scripts/signup-pay.mjs` (new key, pay-signup) and `scripts/credits-topup.mjs` (more credits on an existing key). Free-key flows pass straight through. ### Reference client: `bds-agent` (optional) [docs/USER_GUIDE.md](https://github.com/powerloom/bds-agent-py/blob/main/docs/USER_GUIDE.md) in **bds-agent-py** has the end-to-end order: **Metering service API** table → pay-signup → device → top-up. One-liner sequence: 1. `bds-agent credits plans` — same as `GET /credits/plans` 2. `bds-agent credits setup-evm` — writes `~/.config/bds-agent/profiles/<name>.evm.env` 3. `bds-agent signup-pay --plan-id … --chain-id … --token-symbol …` — implements quote / broadcast / claim (including **native** `payment_kind` plans) ### This repo: Node scripts (no Python, no `bds-agent` required) | Script | What it does | |--------|----------------| | `node scripts/signup-pay.mjs` | **New** key: pay-signup (quote → on-chain pay → claim). Uses **`quote.payment_kind`**: `native_value` = send **native/CGT** (`tx.value` to `recipient`); `erc20` = token **`transfer`**. For **POWER (7869) CGT** plans, metering uses **`native_value`** — do not force ERC-20. **Broadcast protection:** interactive shells prompt `CONFIRM`; non-interactive shells require **`--yes`** (preferred for OpenClaw — ephemeral argv, do not persist in skill env) **or** [REDACTED_SECRET] (CI one-shot only). **`--dry-run`** prints quote summary and exits without sending a tx. | | `node scripts/credits-topup.mjs` | **More** credits: uses existing **`POWERLOOM_API_KEY`**, fetches `GET /credits/plans`, matches **`POWERLOOM_PLAN_ID` + `POWERLOOM_EVM_CHAIN_ID` + `POWERLOOM_TOKEN_SYMBOL`**, sends **ERC-20** or **native** per `payment_kind`, then **`POST /credits/topup`**. Same confirmation gate as signup-pay (**`--yes`** preferred; **[REDACTED_SECRET]** for CI one-shot only; TTY `CONFIRM`). **`--dry-run`** prints summary only. Set **`POWERLOOM_EVM_RPC_URL`** when **`chains[].rpc_url`** is empty or you need a specific node (the API never exposes the server's private RPC). | | `node scripts/ensure-credits.mjs` | **Balance** only (`GET /credits/balance`); no purchase. | `npm install` once (adds `ethers`). **Optional env (signup script):** `POWERLOOM_METERING_BASE_URL`, `POWERLOOM_AGENT_NAME`, `POWERLOOM_EMAIL` (see [metering README](https://github.com/powerloom/bds-agenthub-billing-metering#readme)). ### After you have a key — more credits (top-up) **Spec:** `POST {BASE}/credits/topup` with `Authorization: Bearer` and JSON `{ "plan_id", "chain_id", "tx_hash" }` after an on-chain payment that matches the plan. **In this repo:** `node scripts/credits-topup.mjs`. **Reference CLI:** [USER_GUIDE](https://github.com/powerloom/bds-agent-py/blob/main/docs/USER_GUIDE.md) (EVM `credits topup` / Tempo per deployment). **Check balance:** `node scripts/ensure-credits.mjs`. **Default MCP endpoint:** `https://bds-mcp.powerloom.io/sse` — override with `POWERLOOM_MCP_URL` if needed. Generic tool runner: `node scripts/powerloom-mcp-client.mjs <tool_name> '{}'` ## Common tasks → which tool | Task phrase | Tool(s) | |-------------|---------| | Track **all swaps for token X** (multi-pool) | **Token-Flow** recipe ([REDACTED_SECRET] per pool) or `bds_mpp_snapshot_allTrades` via **`whale-cron.mjs`** | | **Whale** / USD threshold | **`whale-cron.mjs`** (all pools, bounded) or **`whale-radar.mjs`** (fixed pool list, per-pool snapshots) | | **One pool only** | [REDACTED_SECRET] after [REDACTED_SECRET] or `bds_mpp_dailyActivePools` | | **Verify** on-chain | `verify_data_provenance` with `cid`, `epoch_id`, `project_id` from API — never substitute block for epoch | **Timeouts:** default [REDACTED_SECRET]. Raise it (e.g. **120000**) if `bds_mpp_snapshot_allTrades` times out under backlog. ## Recipes (supported surface) Pre-built scripts + `recipes/*.yaml` defaults — prefer these over ad-hoc scripts on weaker models. **This skill does not call streaming catalog tools** (`bds_mpp_stream_*`); every recipe uses **bounded snapshot** MCP calls so runs fit cron and agent sandboxes. **Cron default:** `whale-radar.mjs`, `token-flow.mjs`, and `defi-analyst.mjs` each run **one bounded round** and **exit** (safe for OpenClaw cron). Pass **`--daemon`** to repeat with `heartbeat.interval_seconds` between rounds (local / long-running only). | Recipe / entrypoint | Script | |---------------------|--------| | Whale Radar (fixed pools) | `node scripts/whale-radar.mjs` — one round over `poll_fallback_pools`; **`--daemon`** for repeat | | Whale alerts (cron, all pools) | `node scripts/whale-cron.mjs` — **bounded** one-shot: `bds_mpp_snapshot_allTrades` + pool metadata; alerts include **snapshot** `cid` / epoch / project from `data.verification` — see **Verification provenance** in `references/08-openclaw-one-shot.md` | | Token-Flow | `node scripts/token-flow.mjs` (`--token 0x...`) — one round per pool for that token; **`--daemon`** for repeat | | DeFi Analyst | `node scripts/defi-analyst.mjs` — one round: **multi-pool** (`bds_mpp_snapshot_allTrades` + all-pools volume) or `filters.scope: single_pool`; **`--daemon`** for repeat | ## Model guidance Recipes produce the same stdout/Telegram output regardless of model. Ad-hoc "compose your own" prompts work best on GPT-4–class or GLM-5+; weaker local models may collapse multi-pool prompts onto one pool — **use the Token-Flow recipe** instead. ## Hosts & integrators (OpenClaw, cron, heartbeats) **OpenClaw "one shot" setups** — pick the variant that matches the user's onboarding state: | Variant | Use when | Reference | |---------|----------|-----------| | Free-key cron | The user already has `sk_live_...` from [`bds-metering.powerloom.io/metering`](https://bds-metering.powerloom.io/metering) or `bds-agent signup` (2 free credits, no wallet) | [`references/09-openclaw-one-shot-free-key.md`](references/09-openclaw-one-shot-free-key.md) | | Pay-signup + cron | The user wants autonomous wallet-funded onboarding for a 10-credit plan in the same prompt | [`references/08-openclaw-one-shot.md`](references/08-openclaw-one-shot.md) | Both prompts produce the same `Whale Radar` cron firing `node scripts/whale-cron.mjs` every 15s with onchain verification surfaced in every alert. Agents should default to the free-key variant unless the user explicitly asks for autonomous on-chain payment. **Scheduled / cron-style runs:** Prefer **`whale-cron.mjs`**, **`whale-radar.mjs`**, **`token-flow.mjs`**, or **`defi-analyst.mjs`** with **no** `--daemon` so each invocation **exits**. **Streaming trade tools** (`bds_mpp_stream_*`) are **not** used by this skill. ## References See `references/` for quickstart, full tool table, verification, credit budget, scope, troubleshooting, prompt patterns, **`08-openclaw-one-shot.md`** (copy-paste OpenClaw runbook), and cron notes in quickstart + tool catalog.

path: README.md
content: # Powerloom BDS — Uniswap V3 (ClawHub skill)

## Autonomous Uniswap V3 monitoring + onchain provenance verification, in minutes. Decentralized data, not trust-me data.

Every data point this skill fetches is finalized onchain by Powerloom's decentralized sequencer-validator network. The `verify_data_provenance` tool compares API CIDs to onchain commitments so alerts can carry a cryptographic receipt, not a vendor's word.

## Recipes

- **Whale Radar** — USD-threshold alerts: **`bds_mpp_snapshot_trades_pool_address`** for each pool in `poll_fallback_pools`. For **cron / OpenClaw heartbeats** over **all** pools in one bounded batch, use **`node scripts/whale-cron.mjs`** (`bds_mpp_snapshot_allTrades` + pool metadata).
- **Token-Flow** — all swaps touching a configured token (default USDC) across pools derived at runtime.
- **Autonomous DeFi Analyst** — default **multi-pool** `bds_mpp_snapshot_allTrades` + all-pools token volume; set **`filters.scope: single_pool`** in `recipes/defi-analyst.yaml` for single-pool snapshots only.

## Integrators (OpenClaw, cron)

**End-to-end one-shot prompts** — pick by onboarding state:

| Variant | Use when | Reference |
|---------|----------|-----------|
| Free-key cron | You already have `sk_live_...` from `bds-agent signup` (2 free credits, no wallet) | **`references/09-openclaw-one-shot-free-key.md`** |
| Pay-signup + cron | You want autonomous wallet-funded onboarding for a 10-credit plan in the same prompt | **`references/08-openclaw-one-shot.md`** |

**Default behavior:** `whale-radar.mjs`, `token-flow.mjs`, and `defi-analyst.mjs` each run **one bounded round** and **exit** (safe for cron). Use **`--daemon`** only if you want a local repeat loop (`heartbeat.interval_seconds` between rounds).

For **scheduled heartbeats**, prefer **`whale-cron.mjs`** (exits) or the recipe scripts **without** `--daemon`. This repo does not ship streaming trade consumption.

## Setup

```bash
cd powerloom-bds-univ3
npm install
export POWERLOOM_API_KEY=sk_live_...
node scripts/ensure-credits.mjs
```

**Where to get `POWERLOOM_API_KEY`:**

- **Free** (no wallet, 2 credits): `bds-agent signup` — browser device flow on the metering service. Same `sk_live_...` works against this skill, the hosted MCP server, and any pay-signup top-up later.
- **Wallet-funded** (10-credit plan): `node scripts/signup-pay.mjs --dry-run` (quote summary only), then **`node scripts/signup-pay.mjs --yes`** once after verifying stderr output — prefer argv over env so confirmation bypass is **not** persisted in OpenClaw skill config. CI may use [REDACTED_SECRET] for a single invocation only. **Native or ERC-20** per `quote.payment_kind` (POWER CGT on 7869 = native).
- **More credits, existing key**: `node scripts/credits-topup.mjs` — same `--dry-run` → **`--yes`** pattern (or one-shot env for CI only). See **`SKILL.md`**.

Optional: `POWERLOOM_TELEGRAM_BOT_TOKEN`, `POWERLOOM_TELEGRAM_CHAT_ID`, and `dispatch.channel: telegram` in `recipes/*.yaml`.

## Links (metering service)

One deploy (`npm run build` + `npm start` on **`bds-agenthub-billing-metering`**) serves both:

- **Agent signup (CLI / API)** — origin only: [bds-metering.powerloom.io](https://bds-metering.powerloom.io) (`BDS_AGENT_SIGNUP_URL` / `bds-agent signup --base-url …`).
- **Browser signup + billing UI** — [bds-metering.powerloom.io/metering](https://bds-metering.powerloom.io/metering)

- Hosted MCP SSE: `https://bds-mcp.powerloom.io/sse`

## Naming (ClawHub skill vs MCP tools)

| What | Name |
|------|------|
| ClawHub / OpenClaw skill folder & slug | **`powerloom-bds-univ3`** |
| MCP tools on the hosted server | **`bds_mpp_*`**, **`get_credit_balance`**, **`verify_data_provenance`** — there is **no** tool named `bds_univ3`. |

To print the live tool list from the API (same handshake as `callTool`):

```bash
export POWERLOOM_API_KEY=sk_live_...
node scripts/list-mcp-tools.mjs
```

## Test locally (without publishing to ClawHub)

Publishing is optional for trying the **scripts** and **SKILL.md** instructions:

1. **Scripts only** — From this directory, with `POWERLOOM_API_KEY` set, run `node scripts/ensure-credits.mjs`, `node scripts/list-mcp-tools.mjs` (proves tool names), `node scripts/powerloom-mcp-client.mjs get_credit_balance '{}'`, or a recipe (`whale-radar.mjs`, etc.). That validates MCP wiring end-to-end against the hosted server.

2. **OpenClaw / ClawHub** — If **`skills list`** and the **dashboard** show **`powerloom-bds-univ3`** as **ready**, the skill is **on disk and registered**. That is not the same as “the main chat always loads **`SKILL.md`** into the model on every turn.” If chat still acts blind, check your OpenClaw **agent** actually **uses** that skill (per-agent skill selection / defaults), then **new session** after changes. The reliable execution path is still **`node scripts/…`** with **`POWERLOOM_API_KEY`**; chat is best-effort unless you also wire **BDS MCP** for tools in the tool list.

   - **Registry:** `clawhub install powerloom-bds-univ3` only pulls **published** builds. **Local dev:** copy this repo’s root into **`…/workspace/skills/powerloom-bds-univ3/`** with **`SKILL.md`** at the folder root, set **`POWERLOOM_API_KEY`** in `openclaw.json` skill `entries`, restart the gateway.
   - **Compose / `OPENCLAW_WORKSPACE_DIR`:** The stack usually reads a **`.env` file next to `docker-compose.yml`**. [Docker Compose](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/) substitutes **`${OPENCLAW_WORKSPACE_DIR}`** from: that `.env` file, or **exported** variables in the shell you run `docker compose` from, or a **`.env` override** your vendor documents. It is not magic — if unset, the mount line can be wrong or empty. Set it to the **host** path that should map to `…/workspace` in the container (often your user’s `…/.openclaw/workspace` as an **absolute** path). Check `docker compose config` to see the resolved value.

   Docker bind mounts, **`ENOENT`**, symlinks, UI quirks: **`references/06-troubleshooting.md`**.

3. **After publish** — `clawhub install powerloom-bds-univ3` (or the slug you published).

## Publish (maintainers)

```bash
npx clawhub login
npx clawhub publish . --slug powerloom-bds-univ3 --version 0.1.0
```

## Repository

Source: [github.com/powerloom/powerloom-bds-univ3](https://github.com/powerloom/powerloom-bds-univ3) (mirror this folder into that org repo).


path: package-lock.json
content: {
  "name": "powerloom-bds-univ3",
  "version": "0.1.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "powerloom-bds-univ3",
      "version": "0.1.0",
      "dependencies": {
        "ethers": "^6.13.0",
        "yaml": "^2.6.0"
      },
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/@adraffy/ens-normalize": {
      "version": "1.10.1",
      "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@noble/curves": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@noble/hashes": "1.3.2"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@noble/hashes": {
      "version": "1.3.2",
      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@types/node": {
      "version": "22.7.5",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "undici-types": "~6.19.2"
      }
    },
    "node_modules/aes-js": {
      "version": "4.0.0-beta.5",
      "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/ethers": {
      "version": "6.16.0",
      "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "individual",
          "url": "https://github.com/sponsors/ethers-io/"
        },
        {
          "type": "individual",
          "url": "https://www.buymeacoffee.com/ricmoo"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "@adraffy/ens-normalize": "1.10.1",
        "@noble/curves": "1.2.0",
        "@noble/hashes": "1.3.2",
        "@types/node": "22.7.5",
        "aes-js": "4.0.0-beta.5",
        "tslib": "2.7.0",
        "ws": "8.17.1"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/tslib": {
      "version": "2.7.0",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "0BSD"
    },
    "node_modules/undici-types": {
      "version": "6.19.8",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/ws": {
      "version": "8.17.1",
      "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10.0.0"
      },
      "peerDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": ">=5.0.2"
      },
      "peerDependenciesMeta": {
        "bufferutil": {
          "optional": true
        },
        "utf-8-validate": {
          "optional": true
        }
      }
    },
    "node_modules/yaml": {
      "version": "2.8.3",
      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "bin": {
        "yaml": "bin.mjs"
      },
      "engines": {
        "node": ">= 14.6"
      },
      "funding": {
        "url": "https://github.com/sponsors/eemeli"
      }
    }
  }
}


path: package.json
content: {
  "name": "powerloom-bds-univ3",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "ClawHub skill — Powerloom BDS Uniswap V3 monitoring + on-chain verification",
  "engines": {
    "node": ">=20"
  },
  "dependencies": {
    "ethers": "^6.13.0",
    "yaml": "^2.6.0"
  }
}


path: scripts/whale-cron.mjs
content: #!/usr/bin/env node
/**
 * Whale Radar Cron — one-shot poll via bds_mpp_snapshot_allTrades.
 * Resolves pool token metadata via bds_mpp_pool_pool_address_metadata with on-disk cache.
 * Sends Telegram alerts with proper token names and verification provenance.
 */

import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { callTool } from "./lib/mcp.mjs";
import { defaultMcpCallTimeoutIfUnset, telegramBotToken, telegramChatId } from "./lib/powerloom-env.mjs";
import { loadState, saveState, fingerprintTrade, rememberFingerprint, wasEmitted } from "./lib/state.mjs";
import { flattenAllTradesFromSnapshot, tradeUsd, tradeDirectionLabel } from "./lib/trade-utils.mjs";

const THRESHOLD = parseFloat(process.env.WHALE_CRON_THRESHOLD || "10000");
const MAX_LOOPS = parseInt(process.env.WHALE_CRON_MAX_LOOPS || "10", 10);
const STATE_FILE = process.env.WHALE_CRON_STATE_FILE || ".powerloom/whale-cron-state.json";
const POOL_CACHE_FILE = process.env.WHALE_CRON_POOL_CACHE || ".powerloom/pool-metadata-cache.json";
defaultMcpCallTimeoutIfUnset(120000);

const TG_TOKEN = telegramBotToken();
const TG_CHAT = telegramChatId();

// ─── Pool metadata cache ───

function loadPoolCache() {
  try {
    if (existsSync(POOL_CACHE_FILE)) return JSON.parse(readFileSync(POOL_CACHE_FILE, "utf8"));
  } catch {}
  return {};
}

function savePoolCache(cache) {
  const dir = dirname(POOL_CACHE_FILE);
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  writeFileSync(POOL_CACHE_FILE, JSON.stringify(cache, null, 2));
}

async function resolvePool(poolAddress) {
  const cache = loadPoolCache();
  const key = poolAddress.toLowerCase();
  if (cache[key]) return cache[key];

  try {
    const result = await callTool("bds_mpp_pool_pool_address_metadata", { pool_address: poolAddress });
    const data = result?.data;
    if (data?.token0?.symbol && data?.token1?.symbol) {
      const feeBps = data.fee || 0;
      const feeStr = feeBps >= 10000 ? `${feeBps / 10000}%` : feeBps >= 100 ? `${feeBps / 100}%` : `${feeBps / 100}%`;
      const info = {
        t0: data.token0.symbol,
        t1: data.token1.symbol,
        t0addr: data.token0.address,
        t1addr: data.token1.address,
        fee: feeStr,
      };
      cache[key] = info;
      savePoolCache(cache);
      return info;
    }
  } catch (e) {
    console.error(`[whale-cron] metadata lookup failed for ${poolAddress}: ${e.message}`);
  }
  return null;
}

// ─── Telegram ───

function escMd(s) {
  return String(s).replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
}

function splitChunks(text, maxLen = 3900) {
  const sep = "\n━━━━━━━━━━━━━━━\n\n";
  const parts = text.split(sep);
  const out = []; let cur = "";
  for (const p of parts) {
    if ((cur + sep + p).length > maxLen) { if (cur) out.push(cur); cur = p; }
    else { cur = cur ? cur + sep + p : p; }
  }
  if (cur) out.push(cur);
  return out;
}

async function sendTelegram(text) {
  if (!TG_TOKEN || !TG_CHAT) { console.log(text); return; }
  const escaped = escMd(text);
  const chunks = escaped.length <= 4000 ? [escaped] : splitChunks(escaped);
  for (const chunk of chunks) {
    try {
      const r = await fetch(`https://api.telegram.org/bot${TG_TOKEN}/sendMessage`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ chat_id: TG_CHAT, text: chunk, parse_mode: "MarkdownV2", disable_web_page_preview: true }),
      });
      const d = await r.json();
      if (!d.ok) {
        console.error("TG err:", JSON.stringify(d));
        // Fallback: send as plain text
        const r2 = await fetch(`https://api.telegram.org/bot${TG_TOKEN}/sendMessage`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ chat_id: TG_CHAT, text: chunk, disable_web_page_preview: true }),
        });
        const d2 = await r2.json();
        if (!d2.ok) console.error("TG retry err:", JSON.stringify(d2));
      }
    } catch (e) { console.error("TG fail:", e.message); }
  }
}

// ─── Formatting ───

function fmtUsd(v) {
  if (v >= 1e6) return `$${(v / 1e6).toFixed(2)}M`;
  if (v >= 1e3) return `$${(v / 1e3).toFixed(1)}K`;
  return `$${v.toFixed(0)}`;
}

function fmtAmt(n) {
  const a = Math.abs(n);
  if (a >= 1e9) return (a / 1e9).toFixed(2) + "B";
  if (a >= 1e6) return (a / 1e6).toFixed(2) + "M";
  if (a >= 1) return a.toLocaleString(undefined, { maximumFractionDigits: 4 });
  return a.toFixed(8);
}

function formatAlert(tw, verification, poolInfo) {
  const t = tw.trade;
  const d = t.data || {};
  const log = t.log || {};
  const usd = tradeUsd(tw);
  const dir = tradeDirectionLabel(tw);
  const side = dir === "BUY" ? "🟢" : "🔴";

  const poolAddr = (tw.poolAddress || "").toLowerCase();
  let t0, t1, fee;
  if (poolInfo) {
    t0 = poolInfo.t0;
    t1 = poolInfo.t1;
    fee = poolInfo.fee;
  } else {
    // Show truncated addresses as fallback
    const addr = tw.poolAddress || "";
    t0 = addr ? `${addr.slice(0, 7)}…` : "???";
    t1 = "?";
    fee = "?";
  }

  const isBuy = dir === "BUY";
  const boughtToken = isBuy ? t0 : t1;
  const soldToken = isBuy ? t1 : t0;
  const a0 = Math.abs(d.calculated_token0_amount || 0);
  const a1 = Math.abs(d.calculated_token1_amount || 0);
  const boughtAmt = isBuy ? a0 : a1;
  const soldAmt = isBuy ? a1 : a0;

  const wallet = d.sender || d.recipient || "—";
  const shortWallet = wallet.length > 16 ? `${wallet.slice(0, 10)}…${wallet.slice(-6)}` : wallet;
  const txHash = log.transactionHash || "";
  const block = log.blockNumber || "";

  const lines = [
    `${side} 🐋 WHALE ALERT ${side}`,
    ``,
    `${side} ${dir} ${t0}/${t1} on Uniswap V3 (${fee})`,
    `💰 ${fmtUsd(usd)} swapped`,
    ``,
    `▸ ⇢ ${fmtAmt(boughtAmt)} ${boughtToken}`,
    `▸ ⇠ ${fmtAmt(soldAmt)} ${soldToken}`,
    `▸ 🦊 ${shortWallet}`,
    `▸ 📦 Block ${block}`,
  ];
  if (txHash) lines.push(`▸ 🔍 TX: https://etherscan.io/tx/${txHash}`);

  if (verification?.cid) {
    const cid = verification.cid;
    lines.push(``);
    lines.push(`✅ Verified on-chain:`);
    lines.push(`  ├ CID: ${cid.length > 28 ? cid.slice(0, 28) + "…" : cid}`);
    lines.push(`  ├ Epoch: ${verification.epochId || "—"}`);
    lines.push(`  └ Project: ${(verification.projectId || "—").split(":")[0]}`);
  }

  return lines;
}

// ─── Main ───

async function main() {
  const state = loadState(STATE_FILE);
  let lastEpoch = state.lastStreamEpoch ?? null;
  let newAlerts = 0;
  const allAlerts = [];
  const poolCache = loadPoolCache();

  for (let i = 0; i < MAX_LOOPS; i++) {
    console.error(`[whale-cron] poll ${i + 1}/${MAX_LOOPS}, from_epoch=${lastEpoch}`);

    const params = { max_events: 50 };
    if (lastEpoch != null) params.from_epoch = lastEpoch;

    let result;
    try {
      result = await callTool("bds_mpp_snapshot_allTrades", params);
    } catch (e) {
      console.error(`[whale-cron] MCP call failed: ${e.message}`);
      break;
    }

    const data = result?.data || result;
    if (!data) { console.error("[whale-cron] empty result"); break; }

    const verification = data.verification || null;
    const epochEnd = data.epoch?.end || data.epoch?.begin || null;

    const rows = flattenAllTradesFromSnapshot(data);

    // Collect unique pool addresses to resolve
    const unknownPools = new Set();
    for (const tw of rows) {
      const poolAddr = (tw.poolAddress || "").toLowerCase();
      if (poolAddr && !poolCache[poolAddr]) unknownPools.add(tw.poolAddress);
    }

    // Resolve unknown pools (batch — one call per pool)
    for (const poolAddr of unknownPools) {
      if (!poolAddr) continue;
      try {
        const meta = await callTool("bds_mpp_pool_pool_address_metadata", { pool_address: poolAddr });
        const md = meta?.data;
        if (md?.token0?.symbol) {
          const feeBps = md.fee || 0;
          poolCache[poolAddr.toLowerCase()] = {
            t0: md.token0.symbol,
            t1: md.token1.symbol,
            fee: feeBps >= 100 ? `${feeBps / 100}%` : `${feeBps}%`,
          };
        }
      } catch (e) {
        console.error(`[whale-cron] metadata failed for ${poolAddr}: ${e.message}`);
      }
    }
    savePoolCache(poolCache);

    let aboveThreshold = 0;
    for (const tw of rows) {
      const usd = tradeUsd(tw);
      if (usd < THRESHOLD) continue;
      aboveThreshold++;

      const fp = fingerprintTrade(tw.trade);
      if (wasEmitted(state, fp)) continue;

      const poolInfo = poolCache[(tw.poolAddress || "").toLowerCase()] || null;
      const lines = formatAlert(tw, verification, poolInfo);
      allAlerts.push(lines.join("\n"));
      rememberFingerprint(state, fp);
      newAlerts++;

      const bn = tw.trade?.log?.blockNumber ?? 0;
      if (bn > (state.lastEmittedBlock || 0)) state.lastEmittedBlock = bn;
    }

    console.error(`[whale-cron] epoch=${epochEnd} trades=${rows.length} above=$${THRESHOLD}:${aboveThreshold} new_whales=${newAlerts}`);

    if (epochEnd != null) {
      if (epochEnd > (lastEpoch ?? 0)) {
        lastEpoch = epochEnd;
      } else {
        lastEpoch = epochEnd + 1;
      }
    }

    if (rows.length === 0) break;
    if (rows.length < 50) break;
  }

  // Send all alerts in batch
  if (allAlerts.length > 0) {
    const msg = allAlerts.join("\n━━━━━━━━━━━━━━━\n\n");
    await sendTelegram(msg);
  }

  state.lastStreamEpoch = lastEpoch;
  saveState(STATE_FILE, state);
  console.log(`[whale-cron] done. ${newAlerts} alerts sent.`);
}

main().catch(e => {
  console.error(`[whale-cron] fatal: ${e.message}`);
  console.error(e.stack);
  process.exit(1);
});


path: scripts/powerloom-mcp-client.mjs
content: #!/usr/bin/env node
/**
 * Generic MCP tool invocation (for ad-hoc prompts and debugging).
 * Usage: POWERLOOM_API_KEY=... node scripts/powerloom-mcp-client.mjs <tool_name> '[json_params]'
 */

import { callTool } from "./lib/mcp.mjs";

const toolName = process.argv[2];
const params = process.argv[3] ? JSON.parse(process.argv[3]) : {};

if (!toolName) {
  console.error(
    'Usage: node scripts/powerloom-mcp-client.mjs <tool_name> \'{"k":"v"}\''
  );
  process.exit(1);
}

try {
  const out = await callTool(toolName, params);
  console.log(JSON.stringify(out, null, 2));
} catch (e) {
  console.error(e.message || e);
  process.exit(1);
}


path: scripts/ensure-credits.mjs
content: #!/usr/bin/env node
/**
 * Pre-flight: print credit balance and exit non-zero on auth failure or zero balance.
 * Usage: POWERLOOM_API_KEY=... node scripts/ensure-credits.mjs
 */

import { callTool } from "./lib/mcp.mjs";

async function main() {
  try {
    const out = await callTool("get_credit_balance", {});
    if (out.error) {
      console.error(String(out.error));
      process.exit(1);
    }
    const balance =
      out.balance ?? out.credits ?? out.credit_balance ?? out.remaining;
    const org = out.organization ?? out.org_id ?? out.org;
    console.log(
      JSON.stringify(
        {
          balance: balance ?? out,
          organization: org ?? null,
          rate_limits: out.rate_limits ?? out.rateLimits ?? null,
        },
        null,
        2
      )
    );
    const n = typeof balance === "number" ? balance : parseFloat(balance);
    if (Number.isFinite(n) && n <= 0) {
      console.error(
        "Zero credits — top up at https://bds-metering.powerloom.io/metering (free tier may still apply; check dashboard)."
      );
      process.exit(1);
    }
  } catch (e) {
    if (e.code === "HTTP_401" || e.code === "NO_API_KEY") {
      console.error(e.message);
      process.exit(1);
    }
    console.error(e.message || e);
    process.exit(1);
  }
}

main();


path: scripts/whale-radar.mjs
content: #!/usr/bin/env node
/**
 * Whale Radar — per-pool snapshot polls (`bds_mpp_snapshot_trades_pool_address`).
 * **Default:** one round over all configured pools, then **exit** (cron-friendly). Use **`--daemon`** to loop with `heartbeat.interval_seconds` between rounds.
 * For **all pools in one bounded batch**, use `scripts/whale-cron.mjs` (`bds_mpp_snapshot_allTrades`).
 */

import { callTool } from "./lib/mcp.mjs";
import { loadRecipe } from "./lib/recipe-config.mjs";
import {
  tradeUsd,
  tradeDirectionLabel,
} from "./lib/trade-utils.mjs";
import { loadState, saveState, fingerprintTrade, rememberFingerprint, wasEmitted } from "./lib/state.mjs";
import { dispatchLines } from "./lib/dispatch.mjs";
import { defaultMcpCallTimeoutIfUnset } from "./lib/powerloom-env.mjs";

const arg = (name) => {
  const i = process.argv.indexOf(name);
  return i >= 0 ? process.argv[i + 1] : undefined;
};

const defaults = {
  name: "whale-radar",
  heartbeat: { mode: "poll", interval_seconds: 30 },
  filters: { threshold_usd: 25000 },
  client: {
    call_timeout_ms: 60000,
    poll_fallback_pools: [
      [REDACTED_SECRET],
      [REDACTED_SECRET],
    ],
  },
  dispatch: { channel: "stdout" },
};

const cfg = loadRecipe("whale-radar.yaml", defaults);
const mode = (arg("--mode") || cfg.heartbeat?.mode || "poll").toLowerCase();
if (mode !== "poll") {
  console.error(
    "[whale-radar] Only poll mode is supported (`bds_mpp_snapshot_trades_pool_address`). For all-pool batches use `node scripts/whale-cron.mjs`.",
  );
  process.exit(2);
}

const threshold = parseFloat(
  arg("--threshold") || String(cfg.filters?.threshold_usd ?? 25000)
);
const stateFile =
  arg("--state-file") ||
  process.env.WHALE_RADAR_STATE_FILE ||
  ".powerloom/whale-radar-state.json";
const channel = cfg.dispatch?.channel || "stdout";
const daemon = process.argv.includes("--daemon");

function formatTradeAlert(tw, verification) {
  const t = tw.trade;
  const d = t.data || {};
  const log = t.log || {};
  const usd = tradeUsd(tw).toFixed(2);
  const dir = tradeDirectionLabel(tw);
  const t0 = Math.abs(parseFloat(String(d.calculated_token0_amount || 0))).toFixed(2);
  const t1 = Math.abs(parseFloat(String(d.calculated_token1_amount || 0))).toFixed(4);
  const ethPx = parseFloat(String(d.calculated_eth_price || 0)).toFixed(2);
  const pool = tw.poolAddress || "multi-pool";
  const tx = log.transactionHash || "";
  const block = log.blockNumber ?? "";
  const lines = [
    `WHALE | pool ${pool}`,
    `${dir}  $${usd}  (token0 ${t0} / token1 ${t1}, ETH @ $${ethPx})`,
    `tx  ${tx}`,
    `block  ${block}`,
  ];
  if (verification?.cid && verification.epochId != null && verification.projectId) {
    lines.push(
      `provenance  cid ${verification.cid}  epoch_id ${verification.epochId}  project_id ${verification.projectId}`
    );
  }
  lines.push("---");
  return lines;
}

async function runPoll() {
  const pools =
    cfg.client?.poll_fallback_pools ||
    cfg.client?.default_pools ||
    defaults.client.poll_fallback_pools;
  const intervalSec = cfg.heartbeat?.interval_seconds || 30;
  console.error(
    `[whale-radar] mode=poll pools=${pools.length} tool=bds_mpp_snapshot_trades_pool_address daemon=${daemon}`,
  );
  let state = loadState(stateFile);
  defaultMcpCallTimeoutIfUnset(cfg.client?.call_timeout_ms || 60000);

  async function oneRound() {
    for (const pool of pools) {
      let resp;
      try {
        resp = await callTool("bds_mpp_snapshot_trades_pool_address", {
          pool_address: pool,
        });
      } catch (e) {
        console.error("[whale-radar] poll failed:", pool, e.message);
        continue;
      }
      const data = resp.data;
      if (!data?.trades?.length) continue;
      const verification = data.verification || null;
      const rows = data.trades.map((t) => ({ poolAddress: pool, trade: t }));
      for (const tw of rows) {
        if (tradeUsd(tw) < threshold) continue;
        const fp = fingerprintTrade(tw.trade);
        if (wasEmitted(state, fp)) continue;
        await dispatchLines(formatTradeAlert(tw, verification), channel);
        rememberFingerprint(state, fp);
        const bn = tw.trade?.log?.blockNumber ?? 0;
        if (bn > (state.lastEmittedBlock || 0)) state.lastEmittedBlock = bn;
      }
    }
    saveState(stateFile, state);
  }

  await oneRound();
  if (!daemon) return;

  for (;;) {
    await new Promise((r) => setTimeout(r, intervalSec * 1000));
    await oneRound();
  }
}

runPoll().catch((e) => {
  console.error(e);
  process.exit(1);
});


path: scripts/list-mcp-tools.mjs
content: #!/usr/bin/env node
/**
 * Print MCP tool names from the live server (tools/list).
 * Proves valid names are bds_mpp_* / get_credit_balance / verify_data_provenance — not the ClawHub slug.
 *
 * Usage: POWERLOOM_API_KEY=sk_live_... node scripts/list-mcp-tools.mjs
 */

import { listMcpTools } from "./lib/mcp.mjs";

try {
  const names = await listMcpTools();
  for (const n of names.sort()) {
    console.log(n);
  }
} catch (e) {
  console.error(e.message || e);
  process.exit(1);
}


path: scripts/signup-pay.mjs
content: #!/usr/bin/env node
/**
 * Headless pay-signup: POST /signup/pay/quote → pay on-chain → POST /signup/pay/claim.
 * Uses quote.payment_kind: **erc20** = ERC-20 transfer; **native_value** = native/CGT value
 * send to recipient (e.g. POWER on chain 7869). Must match the plan (see GET /credits/plans).
 *
 * Required env (POWERLOOM_* only):
 *   POWERLOOM_EVM_PRIVATE_KEY — hex, optionally 0x-prefixed (funds the transfer)
 *   POWERLOOM_PLAN_ID — plan id from GET /credits/plans
 *   POWERLOOM_EVM_CHAIN_ID — EIP-155 chain id (must match the plan row)
 *   POWERLOOM_TOKEN_SYMBOL — must match plan.token_symbol for that chain
 *
 * Optional:
 *   POWERLOOM_METERING_BASE_URL — default https://bds-metering.powerloom.io
 *   POWERLOOM_EVM_RPC_URL — if unset, uses rpc_hint from the quote
 *   POWERLOOM_AGENT_NAME — default openclaw-pay-agent
 *   POWERLOOM_EMAIL — if set, must not already be registered
 *
 * Confirmation (broadcast protection — avoids silent spend after quote):
 *   Interactive TTY: prints quote summary; you must type CONFIRM before signing.
 *   Non-interactive (OpenClaw / CI): pass --yes OR set POWERLOOM_SIGNUP_PAY_CONFIRM=yes
 *   after manually verifying recipient, amount, chain, token, and plan on stderr output.
 *   --dry-run: fetch quote + print summary only; exits 0 without broadcasting (review step).
 *
 * On success, prints JSON with api_key — set POWERLOOM_API_KEY from the output.
 *
 * Usage:
 *   node scripts/signup-pay.mjs --dry-run
 *   node scripts/signup-pay.mjs
 *   POWERLOOM_SIGNUP_PAY_CONFIRM=yes node scripts/signup-pay.mjs
 *   node scripts/signup-pay.mjs --yes
 */

import { ethers } from "ethers";
import {
  meteringBaseUrl,
  evmPrivateKey,
  evmRpcUrl,
  planId,
  chainIdParsed,
  tokenSymbol,
  agentNameOr,
  signupEmail,
} from "./lib/powerloom-env.mjs";
import { confirmSpendBeforeBroadcast } from "./lib/confirm-spend.mjs";

const ERC20_ABI = ["function transfer(address to, uint256 amount) returns (bool)"];

async function main() {
  const base = meteringBaseUrl();
  const pk = evmPrivateKey();
  const pid = planId();
  const chainId = chainIdParsed();
  const sym = tokenSymbol();
  const agentName = agentNameOr("openclaw-pay-agent");
  const emailRaw = signupEmail();

  if (!pk) {
    console.error("Set POWERLOOM_EVM_PRIVATE_KEY");
    process.exit(1);
  }
  if (!pid || !Number.isFinite(chainId) || !sym) {
    console.error(
      "Set POWERLOOM_PLAN_ID, POWERLOOM_EVM_CHAIN_ID, and POWERLOOM_TOKEN_SYMBOL (see GET /credits/plans).",
    );
    process.exit(1);
  }

  const wallet = new ethers.Wallet(pk.startsWith("0x") ? pk : `0x${pk}`);
  const quoteBody = {
    agent_name: agentName,
    plan_id: pid,
    chain_id: chainId,
    token_symbol: sym,
    payer_address: wallet.address,
  };
  if (emailRaw) {
    quoteBody.email = emailRaw;
  }

  const qr = await fetch(`${base}/signup/pay/quote`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(quoteBody),
  });
  const quoteText = await qr.text();
  let quote;
  try {
    quote = JSON.parse(quoteText);
  } catch {
    console.error("Quote response not JSON:", quoteText.slice(0, 500));
    process.exit(1);
  }
  if (!qr.ok) {
    console.error(JSON.stringify(quote, null, 2));
    process.exit(1);
  }

  const rpcUrl = evmRpcUrl() || quote.rpc_hint;
  if (!rpcUrl) {
    console.error(
      "No RPC: set POWERLOOM_EVM_RPC_URL (quote.rpc_hint is null when metering has no public_rpc_url for that chain).",
    );
    process.exit(1);
  }

  const provider = new ethers.JsonRpcProvider(rpcUrl);
  const net = await provider.getNetwork();
  if (Number(net.chainId) !== Number(quote.chain_id)) {
    console.error(
      `RPC chainId ${net.chainId} does not match quote.chain_id ${quote.chain_id}. Fix POWERLOOM_EVM_RPC_URL.`,
    );
    process.exit(1);
  }

  const signer = wallet.connect(provider);
  const amount = BigInt(quote.amount_atomic);
  const isNative = quote.payment_kind === "native_value";

  const summaryLines = [
    "[signup-pay] Quote summary — verify before broadcasting:",
    `  plan_id       ${pid}`,
    `  chain_id      ${quote.chain_id}`,
    `  token_symbol  ${sym}`,
    `  payment_kind  ${quote.payment_kind}`,
    `  recipient     ${quote.recipient}`,
    `  amount_atomic ${quote.amount_atomic}`,
    `  payer         ${wallet.address}`,
    `  signup_nonce  ${quote.signup_nonce}`,
  ];

  if (process.argv.includes("--dry-run")) {
    for (const line of summaryLines) {
      console.error(line);
    }
    console.error("[signup-pay] --dry-run: no transaction broadcast. Re-run without --dry-run after confirming.");
    process.exit(0);
  }

  await confirmSpendBeforeBroadcast({
    scriptTag: "signup-pay",
    envConfirmVar: "POWERLOOM_SIGNUP_PAY_CONFIRM",
    summaryLines,
  });

  let tx;
  if (isNative) {
    console.error("[signup-pay] payment_kind=native_value → send native/CGT value to recipient");
    tx = await signer.sendTransaction({
      to: quote.recipient,
      value: amount,
    });
  } else {
    console.error("[signup-pay] payment_kind=erc20 → ERC-20 transfer to recipient");
    const token = new ethers.Contract(quote.token_contract, ERC20_ABI, signer);
    tx = await token.transfer(quote.recipient, amount);
  }
  console.error("Submitted tx", tx.hash);
  const receipt = await tx.wait();
  if (!receipt || receipt.status !== 1) {
    console.error("Transfer failed or reverted.");
    process.exit(1);
  }

  const cr = await fetch(`${base}/signup/pay/claim`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ signup_nonce: quote.signup_nonce, tx_hash: receipt.hash }),
  });
  const claimText = await cr.text();
  let claim;
  try {
    claim = JSON.parse(claimText);
  } catch {
    console.error("Claim response not JSON:", claimText.slice(0, 500));
    process.exit(1);
  }
  if (!cr.ok) {
    console.error(JSON.stringify(claim, null, 2));
    process.exit(1);
  }

  console.log(
    JSON.stringify(
      {
        [REDACTED_SECRET],
        org_id: claim.org_id,
        credit_balance: claim.credit_balance,
        plan_id: claim.plan_id,
        tx_hash: claim.tx_hash,
        chain_id: claim.chain_id,
        notice: "Export: export POWERLOOM_API_KEY=<api_key> (do not commit keys).",
      },
      null,
      2,
    ),
  );
}

main().catch((e) => {
  console.error(e.message || e);
  process.exit(1);
});


path: scripts/defi-analyst.mjs
content: #!/usr/bin/env node
/**
 * Autonomous DeFi Analyst — default: multi-pool via `bds_mpp_snapshot_allTrades` + token all-pools volume.
 * `filters.scope: single_pool` → one pool snapshots only. **Default:** one round then exit (cron). **`--daemon`** → repeat every `heartbeat.interval_seconds`.
 */

import { callTool } from "./lib/mcp.mjs";
import { loadRecipe } from "./lib/recipe-config.mjs";
import { tradeUsd, flattenAllTradesFromSnapshot } from "./lib/trade-utils.mjs";
import { loadState, saveState } from "./lib/state.mjs";
import { dispatchLines } from "./lib/dispatch.mjs";
import { defaultMcpCallTimeoutIfUnset } from "./lib/powerloom-env.mjs";

const USDC_MAINNET = [REDACTED_SECRET];

const arg = (name) => {
  const i = process.argv.indexOf(name);
  return i >= 0 ? process.argv[i + 1] : undefined;
};

const defaults = {
  name: "defi-analyst",
  heartbeat: { interval_seconds: 300 },
  filters: {
    scope: "multi",
    volume_token_address: USDC_MAINNET,
    pool_address: [REDACTED_SECRET],
    project_id: "uniswapv3.eth-usdc-0.05",
  },
  client: { call_timeout_ms: 90000, snapshot_max_events: 50 },
  verification: { mode: "sampled", sample_probability: 0.2 },
  dispatch: { channel: "stdout" },
};

const cfg = loadRecipe("defi-analyst.yaml", defaults);
const scope = (cfg.filters?.scope || "multi").toLowerCase();
const pool = cfg.filters?.pool_address || defaults.filters.pool_address;
const projectId = cfg.filters?.project_id || defaults.filters.project_id;
const volumeToken =
  (cfg.filters?.volume_token_address || USDC_MAINNET).toLowerCase();
const intervalSec = cfg.heartbeat?.interval_seconds || 300;
const pVerify = Math.min(
  1,
  Math.max(0, cfg.verification?.sample_probability ?? 0.2)
);
const channel = cfg.dispatch?.channel || "stdout";
const snapshotMaxEvents = cfg.client?.snapshot_max_events ?? 50;
const stateFile =
  arg("--state-file") ||
  process.env.DEFI_ANALYST_STATE_FILE ||
  ".powerloom/defi-analyst-state.json";

function epochIdFromSnapshot(data) {
  const e = data?.epoch;
  if (e && typeof e.end === "number") return e.end;
  if (e && typeof e.begin === "number") return e.begin;
  return null;
}

function pickTopTrade(trades) {
  let best = null;
  let bestUsd = -1;
  for (const t of trades || []) {
    const w = { trade: t };
    const u = tradeUsd(w);
    if (u > bestUsd) {
      bestUsd = u;
      best = t;
    }
  }
  return best;
}

function tradeDirection(t) {
  const a0 = parseFloat(String(t.data?.amount0 ?? "0"));
  return a0 < 0 ? "sell" : "buy";
}

async function oneRoundMulti() {
  defaultMcpCallTimeoutIfUnset(cfg.client?.call_timeout_ms || 90000);

  let vol;
  try {
    vol = await callTool("bds_mpp_tradeVolumeAllPools_token_address_time_interval", {
      token_address: volumeToken,
      time_interval: 3600,
    });
  } catch (e) {
    vol = { error: e.message };
  }

  const eth = await callTool("bds_mpp_ethPrice", {});
  let state = loadState(stateFile);
  const params = { max_events: snapshotMaxEvents };
  if (state.lastStreamEpoch != null) params.from_epoch = state.lastStreamEpoch;

  let result;
  try {
    result = await callTool("bds_mpp_snapshot_allTrades", params);
  } catch (e) {
    await dispatchLines(
      [
        `Powerloom DeFi Analyst (multi-pool) — ${new Date().toISOString()}`,
        `bds_mpp_snapshot_allTrades error: ${e.message}`,
      ],
      channel,
    );
    return;
  }

  const data = result?.data ?? result;
  if (!data) {
    await dispatchLines(
      [
        `Powerloom DeFi Analyst (multi-pool) — ${new Date().toISOString()}`,
        "empty snapshot",
      ],
      channel,
    );
    return;
  }

  const verificationTop = data.verification || null;
  const epochEnd = data.epoch?.end ?? data.epoch?.begin ?? null;
  const rows = flattenAllTradesFromSnapshot(data);

  let bestTw = null;
  let bestUsd = -1;
  for (const tw of rows) {
    const u = tradeUsd(tw);
    if (u > bestUsd) {
      bestUsd = u;
      bestTw = tw;
    }
  }

  if (epochEnd != null && typeof epochEnd === "number") {
    if (epochEnd > (state.lastStreamEpoch ?? 0)) {
      state.lastStreamEpoch = epochEnd;
    } else {
      state.lastStreamEpoch = epochEnd + 1;
    }
    saveState(stateFile, state);
  }

  const volData = vol?.data ?? vol;
  const ethData = eth.data || eth;
  const lines = [
    `Powerloom DeFi Analyst (multi-pool) — ${new Date().toISOString()}`,
    `scope  snapshot_allTrades  max_events=${snapshotMaxEvents}  (all indexed pools)`,
    `volume_token  ${volumeToken}  (1h all-pools)`,
    `volume_1h  ${JSON.stringify(volData?.tradeVolume ?? volData ?? {})}`,
    `eth_price  ${JSON.stringify(ethData?.price ?? ethData ?? {})}`,
  ];

  if (bestTw) {
    const t = bestTw.trade;
    const pAddr = bestTw.poolAddress || "?";
    lines.push(
      `top_trade_in_snapshot  pool ${pAddr}  ${tradeDirection(t)}  $${tradeUsd(bestTw).toFixed(2)}  tx ${t?.log?.transactionHash || ""}`
    );
  } else {
    lines.push("top_trade_in_snapshot  (no trades in this snapshot)");
  }

  const top = bestTw?.trade;
  const doVerify = Math.random() < pVerify && top;
  if (doVerify && top?.log?.cid) {
    const eid =
      verificationTop?.epochId ??
      epochIdFromSnapshot(data) ??
      null;
    if (eid != null) {
      try {
        const vr = await callTool("verify_data_provenance", {
          cid: top.log.cid,
          epoch_id: eid,
          project_id: projectId,
        });
        lines.push("verification_probe");
        lines.push(JSON.stringify(vr, null, 2));
      } catch (e) {
        lines.push(`verification_probe  error: ${e.message}`);
      }
    } else {
      lines.push(
        "verification_probe  skipped (could not derive epoch_id from snapshot)",
      );
    }
  } else if (doVerify) {
    lines.push(
      "verification_probe  skipped (no cid on trade log in snapshot)",
    );
  }

  await dispatchLines(lines, channel);
}

async function oneRoundSinglePool() {
  defaultMcpCallTimeoutIfUnset(90000);

  const vol = await callTool("bds_mpp_tradeVolume_pool_address_time_interval", {
    pool_address: pool,
    time_interval: 3600,
  });
  const eth = await callTool("bds_mpp_ethPrice", {});
  const snap = await callTool("bds_mpp_snapshot_trades_pool_address", {
    pool_address: pool,
  });

  const data = snap.data || snap;
  const trades = data.trades || [];
  const top = pickTopTrade(trades);
  const volData = vol.data || vol;
  const ethData = eth.data || eth;

  const lines = [
    `Powerloom DeFi Analyst (single-pool) — ${new Date().toISOString()}`,
    `pool       ${pool}`,
    `volume_1h  ${JSON.stringify(volData?.tradeVolume ?? volData ?? {})}`,
    `eth_price  ${JSON.stringify(ethData?.price ?? ethData ?? {})}`,
  ];
  if (top) {
    const d = top.data || {};
    lines.push(
      `top_trade  ${tradeDirection(top)}  $${tradeUsd({ trade: top }).toFixed(2)}  tx ${top.log?.transactionHash || ""}`
    );
  }

  const doVerify = Math.random() < pVerify;
  if (doVerify && top?.log?.cid) {
    const eid = epochIdFromSnapshot(data);
    if (eid != null) {
      try {
        const vr = await callTool("verify_data_provenance", {
          cid: top.log.cid,
          epoch_id: eid,
          project_id: projectId,
        });
        lines.push("verification_probe");
        lines.push(JSON.stringify(vr, null, 2));
      } catch (e) {
        lines.push(`verification_probe  error: ${e.message}`);
      }
    } else {
      lines.push(
        "verification_probe  skipped (could not derive epoch_id from snapshot; check pool snapshot shape)"
      );
    }
  } else if (doVerify) {
    lines.push(
      "verification_probe  skipped (no cid on trade log — upstream snapshot may omit it)"
    );
  }

  await dispatchLines(lines, channel);
}

async function oneRound() {
  if (scope === "single_pool") {
    await oneRoundSinglePool();
  } else {
    await oneRoundMulti();
  }
}

async function main() {
  const daemon = process.argv.includes("--daemon");
  await oneRound();
  if (!daemon) return;
  setInterval(() => {
    oneRound().catch((e) => console.error("[defi-analyst]", e.message));
  }, intervalSec * 1000);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});


path: scripts/token-flow.mjs
content: #!/usr/bin/env node
/**
 * Token-Flow — per-pool snapshot polls for all pools that list a token (`bds_mpp_snapshot_trades_pool_address`).
 * **Default:** one round over all pools for the token, then **exit** (cron-friendly). Use **`--daemon`** to loop with `heartbeat.interval_seconds` between rounds.
 */

import { callTool } from "./lib/mcp.mjs";
import { loadRecipe } from "./lib/recipe-config.mjs";
import {
  tradeUsd,
  tradeDirectionLabel,
  buildPoolAllowlistFromTokenPoolsResponse,
} from "./lib/trade-utils.mjs";
import {
  loadState,
  saveState,
  fingerprintTrade,
  rememberFingerprint,
  wasEmitted,
} from "./lib/state.mjs";
import { dispatchLines } from "./lib/dispatch.mjs";
import { defaultMcpCallTimeoutIfUnset } from "./lib/powerloom-env.mjs";

const USDC_MAINNET = [REDACTED_SECRET];

const arg = (name) => {
  const i = process.argv.indexOf(name);
  return i >= 0 ? process.argv[i + 1] : undefined;
};

const defaults = {
  name: "token-flow",
  heartbeat: { interval_seconds: 30 },
  filters: { token_address: USDC_MAINNET, min_usd: 0, pools: "auto" },
  client: { call_timeout_ms: 120000 },
  dispatch: { channel: "stdout" },
};

const cfg = loadRecipe("token-flow.yaml", defaults);
const token =
  (arg("--token") || cfg.filters?.token_address || USDC_MAINNET).toLowerCase();
const minUsd = parseFloat(String(cfg.filters?.min_usd ?? 0));
const stateFile =
  arg("--state-file") ||
  process.env.TOKEN_FLOW_STATE_FILE ||
  ".powerloom/token-flow-state.json";
const channel = cfg.dispatch?.channel || "stdout";
const daemon = process.argv.includes("--daemon");

function collectPoolAddresses(obj) {
  const raw = buildPoolAllowlistFromTokenPoolsResponse({ data: obj });
  if (raw.size) return raw;
  const set = new Set();
  const walk = (v) => {
    if (!v) return;
    if (typeof v === "string" && /^0x[a-fA-F]{40}$/.test(v)) {
      set.add(v.toLowerCase());
    } else if (Array.isArray(v)) v.forEach(walk);
    else if (typeof v === "object") Object.values(v).forEach(walk);
  };
  walk(obj);
  return set;
}

async function loadPoolsForToken() {
  const name = "bds_mpp_token_token_address_pools";
  const tryParams = [{ token_address: token }, { tokenAddress: token }];
  for (const p of tryParams) {
    try {
      const resp = await callTool(name, p);
      const body = resp?.data ?? resp;
      const set = collectPoolAddresses(body);
      if (set.size) return set;
    } catch {
      /* try next */
    }
  }
  return new Set();
}

function formatTokenAlert(tw, verification) {
  const t = tw.trade;
  const d = t.data || {};
  const log = t.log || {};
  const usd = tradeUsd(tw).toFixed(2);
  const dir = tradeDirectionLabel(tw);
  const t0 = Math.abs(parseFloat(String(d.calculated_token0_amount || 0))).toFixed(4);
  const t1 = Math.abs(parseFloat(String(d.calculated_token1_amount || 0))).toFixed(4);
  const pool = tw.poolAddress || "?";
  const tx = log.transactionHash || "";
  const block = log.blockNumber ?? "";
  const lines = [
    `TOKEN-FLOW | pool ${pool}`,
    `${dir}  $${usd}  (t0 ${t0} / t1 ${t1})`,
    `tx  ${tx}`,
    `block  ${block}`,
  ];
  if (verification?.cid && verification.epochId != null && verification.projectId) {
    lines.push(
      `provenance  cid ${verification.cid}  epoch_id ${verification.epochId}  project_id ${verification.projectId}`
    );
  }
  lines.push("---");
  return lines;
}

async function runPoll() {
  const poolSet = await loadPoolsForToken();
  if (!poolSet.size) {
    console.error(`[token-flow] No pools for token ${token}`);
    process.exit(2);
  }
  const intervalSec = cfg.heartbeat?.interval_seconds || 30;
  let state = loadState(stateFile);
  defaultMcpCallTimeoutIfUnset(cfg.client?.call_timeout_ms || 60000);
  console.error(`[token-flow] pools=${poolSet.size} [REDACTED_SECRET] daemon=${daemon}`);

  async function oneRound() {
    for (const pool of poolSet) {
      let resp;
      try {
        resp = await callTool("bds_mpp_snapshot_trades_pool_address", {
          pool_address: pool,
        });
      } catch (e) {
        console.error("[token-flow] poll error", pool, e.message);
        continue;
      }
      const data = resp.data || resp;
      const trades = data.trades || [];
      const verification = data.verification || null;
      const rows = trades.map((t) => ({ poolAddress: pool, trade: t }));
      for (const tw of rows) {
        if (tradeUsd(tw) < minUsd) continue;
        const fp = fingerprintTrade(tw.trade);
        if (wasEmitted(state, fp)) continue;
        await dispatchLines(formatTokenAlert(tw, verification), channel);
        rememberFingerprint(state, fp);
      }
    }
    saveState(stateFile, state);
  }

  await oneRound();
  if (!daemon) return;

  for (;;) {
    await new Promise((r) => setTimeout(r, intervalSec * 1000));
    await oneRound();
  }
}

runPoll().catch((e) => {
  console.error(e);
  process.exit(1);
});


path: scripts/credits-topup.mjs
content: #!/usr/bin/env node
/**
 * Existing API key: GET /credits/plans → pick plan → pay on-chain → POST /credits/topup
 * (same verification as bds-agenthub-billing-metering `POST /credits/topup`).
 *
 * Required env (POWERLOOM_* only):
 *   POWERLOOM_API_KEY — Bearer sk_live_…
 *   POWERLOOM_EVM_PRIVATE_KEY — hex; must fund the transfer
 *   POWERLOOM_PLAN_ID — must match a row in GET /credits/plans
 *   POWERLOOM_EVM_CHAIN_ID — must match that plan's chain_id
 *   POWERLOOM_TOKEN_SYMBOL — must match plan.token_symbol for that row
 *
 * Optional:
 *   POWERLOOM_METERING_BASE_URL — default https://bds-metering.powerloom.io
 *   POWERLOOM_EVM_RPC_URL — if unset, uses plan.rpc_url or chains[].rpc_url (public hints from
 *                        GET /credits/plans; either may be empty — then you must set this)
 *
 * Confirmation (broadcast protection):
 *   Interactive TTY: prints summary; type CONFIRM before signing.
 *   Non-interactive: --yes / -y OR POWERLOOM_CREDITS_TOPUP_CONFIRM=yes after verifying details.
 *   --dry-run: resolve plan + print summary only; exits 0 without broadcasting.
 *
 * Usage:
 *   node scripts/credits-topup.mjs --dry-run
 *   node scripts/credits-topup.mjs
 *   POWERLOOM_CREDITS_TOPUP_CONFIRM=yes node scripts/credits-topup.mjs
 *   node scripts/credits-topup.mjs --yes
 */

import { ethers } from "ethers";
import {
  meteringBaseUrl,
  powerloomApiKey,
  evmPrivateKey,
  evmRpcUrl,
  planId,
  chainIdParsed,
  tokenSymbol,
} from "./lib/powerloom-env.mjs";
import { confirmSpendBeforeBroadcast } from "./lib/confirm-spend.mjs";

const ERC20_ABI = ["function transfer(address to, uint256 amount) returns (bool)"];

function fail(msg) {
  console.error(`[credits-topup] ${msg}`);
  process.exit(1);
}

function chainMeta(chains, chainId) {
  return (chains || []).find((c) => Number(c.chain_id) === Number(chainId));
}

function resolveRpc(plan, chains, envRpc) {
  const e = (envRpc || "").trim();
  if (e) return e;
  if (plan.rpc_url && String(plan.rpc_url).trim()) return String(plan.rpc_url).trim();
  const m = chainMeta(chains, plan.chain_id);
  if (m && m.rpc_url && String(m.rpc_url).trim()) return String(m.rpc_url).trim();
  return "";
}

function resolveRecipient(plan, chains) {
  if (plan.recipient && String(plan.recipient).trim()) return String(plan.recipient).trim();
  const m = chainMeta(chains, plan.chain_id);
  if (m && m.recipient && String(m.recipient).trim()) return String(m.recipient).trim();
  return "";
}

async function main() {
  const base = meteringBaseUrl();
  const [REDACTED_SECRET]);
  const pk = evmPrivateKey();
  const pid = planId();
  const chainId = chainIdParsed();
  const symRaw = tokenSymbol();
  const rpcOverride = evmRpcUrl();

  if (!apiKey) fail("Set POWERLOOM_API_KEY");
  if (!pk) fail("Set POWERLOOM_EVM_PRIVATE_KEY");
  if (!pid || !Number.isFinite(chainId) || !symRaw) {
    fail("Set POWERLOOM_PLAN_ID, POWERLOOM_EVM_CHAIN_ID, and POWERLOOM_TOKEN_SYMBOL");
  }

  const pr = await fetch(`${base}/credits/plans`);
  if (!pr.ok) {
    console.error(await pr.text());
    fail(`GET /credits/plans failed (${pr.status})`);
  }
  const bundle = await pr.json();
  const plans = bundle.plans || [];
  const chains = bundle.chains || [];
  const sym = symRaw.toLowerCase();
  const plan = plans.find(
    (p) =>
      p.id === pid &&
      Number(p.chain_id) === chainId &&
      p.active !== false &&
      (p.token_symbol || "").toLowerCase() === sym,
  );
  if (!plan) {
    fail(
      `No matching active plan for id=${pid} chain_id=${chainId} token_symbol=${symRaw}. Run GET /credits/plans.`,
    );
  }

  const rpcUrl = resolveRpc(plan, chains, rpcOverride);
  if (!rpcUrl) {
    fail(
      "No RPC: set POWERLOOM_EVM_RPC_URL (metering may leave chains[].rpc_url empty when no public_rpc_url is configured)",
    );
  }

  const recipient = resolveRecipient(plan, chains);
  if (!recipient) fail("No recipient: plan.recipient and chains[].recipient are empty");

  const paymentKind = plan.payment_kind === "native_value" ? "native_value" : "erc20";
  const amount = ethers.parseUnits(String(plan.token_amount), Number(plan.token_decimals));

  const provider = new ethers.JsonRpcProvider(rpcUrl);
  const net = await provider.getNetwork();
  if (Number(net.chainId) !== chainId) {
    fail(`RPC chainId ${net.chainId} does not match POWERLOOM_EVM_CHAIN_ID ${chainId}. Fix POWERLOOM_EVM_RPC_URL.`);
  }

  const wallet = new ethers.Wallet(pk.startsWith("0x") ? pk : `0x${pk}`);
  const signer = wallet.connect(provider);

  const summaryLines = [
    "[credits-topup] Top-up summary — verify before broadcasting:",
    `  plan_id       ${pid}`,
    `  chain_id      ${chainId}`,
    `  token_symbol  ${symRaw}`,
    `  payment_kind  ${paymentKind}`,
    `  recipient     ${recipient}`,
    `  amount        ${amount.toString()} (atomic units, decimals=${plan.token_decimals})`,
    `  payer         ${wallet.address}`,
  ];

  if (process.argv.includes("--dry-run")) {
    for (const line of summaryLines) {
      console.error(line);
    }
    console.error("[credits-topup] --dry-run: no transaction broadcast. Re-run without --dry-run after confirming.");
    process.exit(0);
  }

  await confirmSpendBeforeBroadcast({
    scriptTag: "credits-topup",
    envConfirmVar: "POWERLOOM_CREDITS_TOPUP_CONFIRM",
    summaryLines,
  });

  let tx;
  if (paymentKind === "native_value") {
    tx = await signer.sendTransaction({
      to: recipient,
      value: amount,
    });
  } else {
    const token = new ethers.Contract(plan.token_contract, ERC20_ABI, signer);
    tx = await token.transfer(recipient, amount);
  }

  console.error("Submitted tx", tx.hash);
  const receipt = await tx.wait();
  if (!receipt || receipt.status !== 1) {
    fail("Transaction failed or reverted");
  }

  const reg = await fetch(`${base}/credits/topup`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      plan_id: pid,
      chain_id: chainId,
      tx_hash: receipt.hash,
    }),
  });
  const text = await reg.text();
  let data;
  try {
    data = JSON.parse(text);
  } catch {
    fail(`Top-up response not JSON: ${text.slice(0, 400)}`);
  }
  if (!reg.ok) {
    console.error(JSON.stringify(data, null, 2));
    process.exit(1);
  }

  console.log(JSON.stringify({ ...data, notice: "Credits added for this API key." }, null, 2));
}

main().catch((e) => {
  console.error(e.message || e);
  process.exit(1);
});


path: scripts/lib/trade-utils.mjs
content: /**
 * Normalize trades from all-pool snapshot (tradeData map) or pool snapshot (data.trades).
 */

export function flattenAllTradesFromSnapshot(snapshot) {
  if (!snapshot) return [];
  if (Array.isArray(snapshot.trades)) {
    return snapshot.trades.map((t) => ({ poolAddress: null, trade: t }));
  }
  const td = snapshot.tradeData;
  if (!td || typeof td !== "object") return [];
  const out = [];
  for (const [poolAddress, poolBlock] of Object.entries(td)) {
    const trades = poolBlock?.trades;
    if (!Array.isArray(trades)) continue;
    for (const t of trades) {
      out.push({ poolAddress, trade: t });
    }
  }
  return out;
}

export function tradeUsd(tradeWrapper) {
  const t = tradeWrapper.trade || tradeWrapper;
  const raw =
    t.data?.calculated_trade_amount_usd ??
    t.calculated_trade_amount_usd ??
    "0";
  const n = parseFloat(String(raw).replace(/,/g, ""));
  return Number.isFinite(n) ? n : 0;
}

export function tradeDirectionLabel(tradeWrapper) {
  const t = tradeWrapper.trade || tradeWrapper;
  const a0 = parseFloat(String(t.data?.amount0 ?? "0").replace(/,/g, ""));
  return a0 < 0 ? "SELL" : "BUY";
}

export function formatEtherscanTx(hash) {
  if (!hash) return "";
  return `https://etherscan.io/tx/${hash}`;
}

export function poolInAllowlist(poolAddress, allowSet) {
  if (!poolAddress) return false;
  return allowSet.has(String(poolAddress).toLowerCase());
}

/** Pools that list a token — keys are often pool addresses (UniswapTokenPoolsSnapshot). */
export function buildPoolAllowlistFromTokenPoolsResponse(resp) {
  const set = new Set();
  const data = resp?.data ?? resp;
  const pools = data?.pools;
  if (pools && typeof pools === "object" && !Array.isArray(pools)) {
    for (const k of Object.keys(pools)) {
      if (/^0x[a-fA-F]{40}$/.test(k)) set.add(k.toLowerCase());
    }
  }
  if (Array.isArray(pools)) {
    for (const p of pools) {
      if (typeof p === "string") set.add(p.toLowerCase());
      else if (p?.pool_address) set.add(String(p.pool_address).toLowerCase());
      else if (p?.address) set.add(String(p.address).toLowerCase());
    }
  }
  return set;
}


path: scripts/lib/state.mjs
content: import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { dirname } from "path";

/**
 * Shared on-disk state for recipe scripts. One schema per skill:
 * { lastStreamEpoch, lastEmittedBlock, emittedFingerprints: string[] }
 */
export function loadState(file) {
  if (!existsSync(file)) {
    return {
      lastStreamEpoch: null,
      lastEmittedBlock: 0,
      emittedFingerprints: [],
    };
  }
  try {
    const j = JSON.parse(readFileSync(file, "utf8"));
    return {
      lastStreamEpoch: j.lastStreamEpoch ?? null,
      lastEmittedBlock: j.lastEmittedBlock ?? 0,
      emittedFingerprints: Array.isArray(j.emittedFingerprints)
        ? j.emittedFingerprints
        : [],
    };
  } catch {
    return {
      lastStreamEpoch: null,
      lastEmittedBlock: 0,
      emittedFingerprints: [],
    };
  }
}

export function saveState(file, state) {
  const dir = dirname(file);
  try {
    mkdirSync(dir, { recursive: true });
  } catch {
    /* exists */
  }
  writeFileSync(file, JSON.stringify(state, null, 2));
}

export function fingerprintTrade(t) {
  const tx = t.log?.transactionHash || t.transactionHash || "";
  const bn = t.log?.blockNumber ?? t.blockNumber ?? "";
  return `${tx}:${bn}`;
}

export function rememberFingerprint(state, fp, max = 500) {
  const next = [...state.emittedFingerprints, fp];
  while (next.length > max) next.shift();
  state.emittedFingerprints = next;
}

export function wasEmitted(state, fp) {
  return state.emittedFingerprints.includes(fp);
}


path: scripts/lib/recipe-config.mjs
content: import { readFileSync, existsSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { parse } from "yaml";

const __dirname = dirname(fileURLToPath(import.meta.url));
const SKILL_ROOT = join(__dirname, "..", "..");

export function skillRoot() {
  return SKILL_ROOT;
}

/**
 * Load recipe yaml from path, or return defaults if missing / parse error.
 */
export function loadRecipe(fileName, defaults) {
  const path = join(SKILL_ROOT, "recipes", fileName);
  if (!existsSync(path)) return { ...defaults };
  const raw = readFileSync(path, "utf8");
  try {
    return { ...defaults, ...parse(raw) };
  } catch {
    return { ...defaults };
  }
}


path: scripts/lib/mcp.mjs
content: import { powerloomApiKey, mcpDebugEnabled } from "./powerloom-env.mjs";

/**
 * Powerloom BDS MCP over HTTP+SSE (same wire as Claude / OpenClaw remote MCP).
 * Env: POWERLOOM_API_KEY (required), POWERLOOM_MCP_URL (default https://bds-mcp.powerloom.io/sse),
 * POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS (default 60000; raise for large `bds_mpp_snapshot_allTrades` responses under backlog).
 *
 * Naming (do not confuse):
 * - **ClawHub skill slug:** `powerloom-bds-univ3` (folder + SKILL.md) — not an MCP tool.
 * - **MCP tool names:** `bds_mpp_*`, `get_credit_balance`, `verify_data_provenance` — from `tools/list` on the server.
 * - There is **no** tool called `bds_univ3` or `bds_univ3_*`.
 */

export function getMcpSseUrl() {
  const raw = process.env.POWERLOOM_MCP_URL || "https://bds-mcp.powerloom.io/sse";
  const trimmed = raw.replace(/\/$/, "");
  return trimmed.endsWith("/sse") ? trimmed : `${trimmed}/sse`;
}

export function getCallTimeoutMs() {
  const n = Number((process.env.POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS || "").trim());
  return Number.isFinite(n) && n > 0 ? n : 60000;
}

/**
 * Extract session id from MCP SSE bootstrap bytes.
 * The official transport sends an endpoint URL (often in `event: endpoint` / `data:`).
 * Session tokens may be hex, UUID (with hyphens), or URL-encoded — the old `[a-f0-9]+`-only
 * pattern failed for UUIDs and broke OpenClaw/Docker setups.
 */
export function extractMcpSseSessionId(text) {
  if (!text || typeof text !== "string") return null;
  const candidates = [
    /session_id=([^&\s"'<>]+)/i,
    /session_id%3D([^&\s"'<>%]+)/i,
    /\/messages\/\?session_id=([^&\s"'<>]+)/i,
  ];
  for (const re of candidates) {
    const m = text.match(re);
    if (!m?.[1]) continue;
    let raw = m[1].trim();
    try {
      raw = decodeURIComponent(raw);
    } catch {
      /* use raw */
    }
    if (raw.length > 0) return raw;
  }
  return null;
}

/**
 * Open GET /sse, read until session_id, POST initialize + notifications/initialized.
 * Caller must eventually cancel `reader` or call teardown.
 */
async function connectMcpSession(apiKey) {
  const sseUrl = getMcpSseUrl();
  const baseOrigin = sseUrl.replace(/\/sse\/?$/, "");
  const timeoutMs = getCallTimeoutMs();
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), timeoutMs);

  let sseResp;
  try {
    sseResp = await fetch(sseUrl, {
      headers: { Authorization: `Bearer ${apiKey}` },
      signal: ac.signal,
    });
  } catch (e) {
    clearTimeout(timer);
    if (e.name === "AbortError") {
      const err = new Error(
        `MCP SSE connection timed out after ${timeoutMs}ms (POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS).`
      );
      err.code = "TIMEOUT";
      throw err;
    }
    const err = new Error(`MCP SSE connection failed: ${e.message || e}`);
    err.code = "NETWORK";
    throw err;
  }

  if (sseResp.status === 401) {
    clearTimeout(timer);
    const err = new Error(
      "HTTP 401 — invalid or missing API key. Fix POWERLOOM_API_KEY (get a key via CLI at https://bds-metering.powerloom.io or browser at https://bds-metering.powerloom.io/metering)."
    );
    err.code = "HTTP_401";
    err.httpStatus = 401;
    throw err;
  }
  if (sseResp.status === 402) {
    clearTimeout(timer);
    const err = new Error(
      "HTTP 402 — credits exhausted. Top up at https://bds-metering.powerloom.io/metering"
    );
    err.code = "HTTP_402";
    err.httpStatus = 402;
    throw err;
  }
  if (sseResp.status === 429) {
    clearTimeout(timer);
    const err = new Error("HTTP 429 — rate limited. Back off and retry.");
    err.code = "HTTP_429";
    err.httpStatus = 429;
    throw err;
  }
  if (!sseResp.ok || !sseResp.body) {
    clearTimeout(timer);
    const txt = await sseResp.text().catch(() => "");
    const err = new Error(`MCP SSE HTTP ${sseResp.status}: ${txt.slice(0, 400)}`);
    err.code = "HTTP_ERROR";
    err.httpStatus = sseResp.status;
    throw err;
  }

  const reader = sseResp.body.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  const bootstrapMaxChunks = 500;

  let sessionId = null;
  for (let chunk = 0; chunk < bootstrapMaxChunks && !sessionId; chunk += 1) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    sessionId = extractMcpSseSessionId(buf);
  }

  if (!sessionId) {
    clearTimeout(timer);
    await reader.cancel().catch(() => {});
    const hint = mcpDebugEnabled()
        ? ` First bytes (debug): ${buf.slice(0, 800).replace(/\s+/g, " ")}`
        : "";
    const err = new Error(
      "MCP session_id never arrived on SSE — check POWERLOOM_MCP_URL, Authorization, and proxy. " +
        "If the server emits a non-URL session line, set POWERLOOM_BDS_MCP_DEBUG=1 and inspect hint in this message." +
        hint
    );
    err.code = "NO_SESSION";
    throw err;
  }

  const msgUrl = `${baseOrigin}/messages/?session_id=${sessionId}`;
  const headers = {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  };

  const rpc = async (id, method, params_) => {
    const r = await fetch(msgUrl, {
      method: "POST",
      headers,
      body: JSON.stringify({
        jsonrpc: "2.0",
        id,
        method,
        params: params_,
      }),
    });
    if (r.status === 401 || r.status === 402 || r.status === 429) {
      const txt = await r.text().catch(() => "");
      const err = new Error(`MCP POST HTTP ${r.status}: ${txt.slice(0, 300)}`);
      err.code = `HTTP_${r.status}`;
      err.httpStatus = r.status;
      throw err;
    }
    if (!r.ok) {
      const txt = await r.text().catch(() => "");
      const err = new Error(`MCP POST HTTP ${r.status}: ${txt.slice(0, 400)}`);
      err.code = "HTTP_ERROR";
      throw err;
    }
  };

  await rpc(1, "initialize", {
    protocolVersion: "2024-11-05",
    capabilities: {},
    clientInfo: { name: "powerloom-bds-univ3", version: "0.1.0" },
  });
  await new Promise((r) => setTimeout(r, 50));
  await fetch(msgUrl, {
    method: "POST",
    headers,
    body: JSON.stringify({
      jsonrpc: "2.0",
      method: "notifications/initialized",
    }),
  });
  await new Promise((r) => setTimeout(r, 50));

  async function teardown() {
    clearTimeout(timer);
    await reader.cancel().catch(() => {});
  }

  return { reader, decoder, buf, msgUrl, headers, rpc, timeoutMs, timer, teardown };
}

/**
 * Read SSE `data:` JSON lines until a JSON-RPC response with matching id (or timeout).
 */
async function readJsonRpcById(reader, decoder, bufRef, expectedId, timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  let buf = bufRef;

  while (Date.now() < deadline) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    const lines = buf.split("\n");
    buf = lines.pop() || "";

    for (const line of lines) {
      if (!line.startsWith("data: ")) continue;
      const raw = line.slice(6).trim();
      if (!raw || raw === "[DONE]") continue;
      let j;
      try {
        j = JSON.parse(raw);
      } catch {
        continue;
      }
      if (j.id == null) continue;
      if (j.id !== expectedId) continue;
      if (j.error) {
        const err = new Error(
          typeof j.error.message === "string"
            ? j.error.message
            : JSON.stringify(j.error)
        );
        err.code = "JSONRPC_ERROR";
        err.jsonRpc = j.error;
        throw err;
      }
      return { result: j.result, buf };
    }
  }
  return { result: null, buf };
}

/**
 * List tool names from the live MCP server (same handshake as `callTool`).
 * Use this to prove which `bds_mpp_*` names the endpoint exposes — not guess `bds_univ3`.
 */
export async function listMcpTools() {
  const [REDACTED_SECRET]);
  if (!apiKey) {
    const err = new Error(
      "POWERLOOM_API_KEY is not set. Sign up at https://bds-metering.powerloom.io (CLI) or https://bds-metering.powerloom.io/metering (browser) and export your API key."
    );
    err.code = "NO_API_KEY";
    throw err;
  }

  const { reader, decoder, buf: buf0, rpc, timeoutMs, teardown } =
    await connectMcpSession(apiKey);
  const listId = 2;
  await rpc(listId, "tools/list", {});

  try {
    const { result, buf } = await readJsonRpcById(
      reader,
      decoder,
      buf0,
      listId,
      timeoutMs
    );
    if (!result) {
      const err = new Error(
        "tools/list timed out — no JSON-RPC response with id 2 (check POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS)."
      );
      err.code = "TOOLS_LIST_TIMEOUT";
      throw err;
    }
    if (!Array.isArray(result.tools)) {
      const err = new Error(
        "tools/list returned unexpected shape — expected result.tools[]"
      );
      err.code = "TOOLS_LIST_SHAPE";
      throw err;
    }
    return result.tools.map((t) => (typeof t.name === "string" ? t.name : String(t?.name ?? "")));
  } finally {
    await teardown();
  }
}

/**
 * One MCP tools/call round-trip. Returns parsed JSON from tool result text (object).
 */
export async function callTool(toolName, params = {}) {
  const [REDACTED_SECRET]);
  if (!apiKey) {
    const err = new Error(
      "POWERLOOM_API_KEY is not set. Sign up at https://bds-metering.powerloom.io (CLI) or https://bds-metering.powerloom.io/metering (browser) and export your API key."
    );
    err.code = "NO_API_KEY";
    throw err;
  }

  const { reader, decoder, buf: buf0, rpc, timeoutMs, timer, teardown } =
    await connectMcpSession(apiKey);
  const callId = 2;
  await rpc(callId, "tools/call", {
    name: toolName,
    arguments: params,
  });

  const deadline = Date.now() + timeoutMs;
  let buf = buf0;

  try {
    while (Date.now() < deadline) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      const lines = buf.split("\n");
      buf = lines.pop() || "";

      for (const line of lines) {
        if (!line.startsWith("data: ")) continue;
        const raw = line.slice(6).trim();
        if (!raw || raw === "[DONE]") continue;
        let j;
        try {
          j = JSON.parse(raw);
        } catch {
          continue;
        }
        if (j.id == null) continue;
        if (j.id !== callId) continue;
        if (j.error) {
          const err = new Error(
            typeof j.error.message === "string"
              ? j.error.message
              : JSON.stringify(j.error)
          );
          err.code = "JSONRPC_ERROR";
          err.jsonRpc = j.error;
          throw err;
        }
        if (!j.result) continue;
        const r = j.result;
        if (r.isError && r.content && r.content[0]) {
          const err = new Error(String(r.content[0].text || "Tool error"));
          err.code = "TOOL_ERROR";
          err.isError = true;
          throw err;
        }
        const content = r.content;
        if (Array.isArray(content) && content[0]?.type === "text" && content[0].text) {
          const text = content[0].text.trim();
          try {
            clearTimeout(timer);
            return JSON.parse(text);
          } catch {
            clearTimeout(timer);
            return { _rawText: text };
          }
        }
        clearTimeout(timer);
        return r;
      }
    }
  } finally {
    clearTimeout(timer);
    await teardown();
  }

  const err = new Error(
    `Timeout waiting for MCP tools/call response after ${timeoutMs}ms (tool=${toolName}).`
  );
  err.code = "TOOL_TIMEOUT";
  throw err;
}


path: scripts/lib/powerloom-env.mjs
content: /**
 * Environment variables for this skill use the POWERLOOM_* prefix only (see SKILL.md).
 */

function env(name) {
  const v = process.env[name];
  if (v === undefined || v === null || String(v).trim() === "") return "";
  return String(v).trim();
}

export function meteringBaseUrl() {
  return (env("POWERLOOM_METERING_BASE_URL") || "https://bds-metering.powerloom.io").replace(
    /\/$/,
    "",
  );
}

export function evmPrivateKey() {
  return env("POWERLOOM_EVM_PRIVATE_KEY");
}

export function evmRpcUrl() {
  return env("POWERLOOM_EVM_RPC_URL");
}

export function planId() {
  return env("POWERLOOM_PLAN_ID");
}

export function tokenSymbol() {
  return env("POWERLOOM_TOKEN_SYMBOL");
}

export function chainIdParsed() {
  return parseInt(env("POWERLOOM_EVM_CHAIN_ID"), 10);
}

export function agentNameOr(defaultName) {
  return env("POWERLOOM_AGENT_NAME") || defaultName;
}

export function signupEmail() {
  return env("POWERLOOM_EMAIL");
}

export function powerloomApiKey() {
  return env("POWERLOOM_API_KEY");
}

export function telegramBotToken() {
  return env("POWERLOOM_TELEGRAM_BOT_TOKEN");
}

export function telegramChatId() {
  return env("POWERLOOM_TELEGRAM_CHAT_ID");
}

export function discordWebhookUrl() {
  return env("POWERLOOM_DISCORD_WEBHOOK_URL");
}

/** If POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS is unset, set it (ms) so MCP clients pick up the recipe/script default. */
export function defaultMcpCallTimeoutIfUnset(ms) {
  if (!env("POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS")) {
    process.env.POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS = String(ms);
  }
}

export function mcpDebugEnabled() {
  return env("POWERLOOM_BDS_MCP_DEBUG") === "1";
}


path: scripts/lib/dispatch.mjs
content: import {
  telegramBotToken,
  telegramChatId,
  discordWebhookUrl,
} from "./powerloom-env.mjs";

/**
 * Dispatch alert lines to Telegram, Discord webhook, or stdout.
 */

export async function dispatchLines(lines, channel) {
  const text = lines.filter(Boolean).join("\n");
  if (!text) return;

  if (channel === "telegram") {
    const [REDACTED_SECRET]);
    const chat = telegramChatId();
    if (!token || !chat) {
      console.log(text);
      return;
    }
    const u = `https://api.telegram.org/bot${token}/sendMessage`;
    const r = await fetch(u, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ chat_id: chat, text, disable_web_page_preview: true }),
    });
    if (!r.ok) {
      const errText = await r.text();
      console.error("Telegram dispatch failed:", r.status, errText.slice(0, 400));
      console.log(text);
    }
    return;
  }

  if (channel === "discord") {
    const url = discordWebhookUrl();
    if (!url) {
      console.log(text);
      return;
    }
    const r = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content: text.slice(0, 1900) }),
    });
    if (!r.ok) {
      console.error("Discord dispatch failed:", r.status, await r.text());
      console.log(text);
    }
    return;
  }

  console.log(text);
}


path: scripts/lib/confirm-spend.mjs
content: /**
 * Gate on-chain spends behind explicit human/agent acknowledgment.
 *
 * - Interactive (stdin + stdout are TTY): print summary, require typing CONFIRM.
 * - Non-interactive: require argv --yes/-y OR envVar set to yes|1|true (case-insensitive).
 *
 * Matches patterns ClawScan / auditors expect: no silent broadcast after quote fetch.
 */

import readline from "readline";

function truthyEnv(val) {
  const v = String(val ?? "").trim().toLowerCase();
  return v === "yes" || v === "1" || v === "true";
}

export function argvHasYesFlag() {
  return process.argv.includes("--yes") || process.argv.includes("-y");
}

export async function confirmSpendBeforeBroadcast({
  scriptTag,
  envConfirmVar,
  summaryLines,
}) {
  if (argvHasYesFlag() || truthyEnv(process.env[envConfirmVar])) {
    return;
  }

  const ttyIn = process.stdin.isTTY;
  const ttyOut = process.stdout.isTTY;

  for (const line of summaryLines) {
    console.error(line);
  }

  if (!ttyIn || !ttyOut) {
    console.error(
      `[${scriptTag}] Refusing to broadcast without explicit confirmation (non-interactive shell).`,
    );
    console.error(
      `  Inspect the quote details above, then either:` +
        ` (a) re-run with --yes (preferred for OpenClaw: ephemeral argv, do not persist in skill env), or (b) set ${envConfirmVar}=yes for a single CI invocation only`,
    );
    console.error(
      `  Autonomous agents (OpenClaw, CI) should only set ${envConfirmVar}=yes after the operator verified recipient, token, chain, and amount.`,
    );
    process.exit(1);
  }

  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const answer = await new Promise((resolve) => {
    rl.question(
      `[${scriptTag}] Irreversible on-chain payment will be broadcast. Type CONFIRM to proceed: `,
      resolve,
    );
  });
  rl.close();

  if (String(answer).trim() !== "CONFIRM") {
    console.error(`[${scriptTag}] Aborted — confirmation text did not match CONFIRM.`);
    process.exit(1);
  }
}


path: recipes/defi-analyst.yaml
content: # -----------------------------------------------------------------------------
# Default: one analytics round, then exit (cron-friendly). Use --daemon to repeat.
# scope multi → bds_mpp_snapshot_allTrades + all-pools volume; single_pool → one pool.
# -----------------------------------------------------------------------------
name: defi-analyst
description: Periodic analytics + random on-chain verification (multi-pool by default).
heartbeat:
  interval_seconds: 300
filters:
  scope: multi
  volume_token_address: [REDACTED_SECRET]
  project_id: "uniswapv3.eth-usdc-0.05"
  pool_address: [REDACTED_SECRET]
client:
  call_timeout_ms: 90000
  snapshot_max_events: 50
verification:
  mode: sampled
  sample_probability: 0.2
dispatch:
  channel: stdout


path: recipes/whale-radar.yaml
content: # -----------------------------------------------------------------------------
# Default: one poll round over all pools, then exit (cron-friendly). Use --daemon to repeat.
# For all pools in one bounded MCP batch, use scripts/whale-cron.mjs.
# -----------------------------------------------------------------------------
name: whale-radar
description: Whale swap alerts via per-pool snapshot polls (fixed pool list below).
heartbeat:
  mode: poll
  interval_seconds: 30
filters:
  threshold_usd: 25000
client:
  call_timeout_ms: 60000
  max_alerts_per_heartbeat: 10
  poll_fallback_pools:
    - [REDACTED_SECRET]
    - [REDACTED_SECRET]
verification:
  mode: passive
dispatch:
  channel: stdout


path: recipes/token-flow.yaml
content: # Default: one round over all pools for the token, then exit. Use --daemon to repeat.
name: token-flow
description: Every swap touching a token (default USDC) across indexed pools (poll).
heartbeat:
  interval_seconds: 30
filters:
  token_address: [REDACTED_SECRET]
  min_usd: 0
  pools: auto
client:
  call_timeout_ms: 120000
verification:
  mode: passive
dispatch:
  channel: stdout


path: references/06-troubleshooting.md
content: # Troubleshooting

| Symptom | Cause | Fix |
|---------|--------|-----|
| Pay-signup "recipient did not match" / wrong first tx (POWER 7869) | **`signup-pay.mjs` used ERC-20** while plan is **`payment_kind: native_value`** (CGT) | **Update** the script from the current skill repo, or use a flow that checks **`quote.payment_kind`**: native = `sendTransaction({ to, value })` only, not `token.transfer`. `credits-topup.mjs` already branches on `payment_kind`. |
| HTTP 401 | Bad or missing API key | Re-copy key from the metering dashboard ([bds-metering.powerloom.io/metering](https://bds-metering.powerloom.io/metering)) or your CLI profile; fix `POWERLOOM_API_KEY`. |
| HTTP 402 | Credits exhausted | Top up; reduce recipe cadence; run `ensure-credits.mjs` before crons. |
| HTTP 429 | Rate limit | Increase heartbeat interval; for **cron** schedules prefer **poll** (fewer parallel calls) instead of many snapshot fan-outs. |
| Tool timeout | Backlog / slow finalization | Raise `POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS`; reduce snapshot scope (`max_events` / fewer pools). |
| Empty snapshot | Idle chain / catch-up | Wait; check `from_epoch` in state file (cron / DeFi Analyst). |
| Wrong verify | Confused epoch vs block | Use `epoch_id` from snapshot / `verification` payload, not `blockNumber`. |
| Odd outputs after model swap | OpenClaw context mismatch | Restart OpenClaw; recipes are script-driven — state files live under `.powerloom/`. |
| Skill “ready” in `skills list` / dashboard but main chat ignores it | **Install ≠ injection.** The chat agent may not attach every skill to every session; per-agent config, a **new** session after enabling the skill, or product limits on which skills the model sees. | Enable the skill for **that** agent; start a new chat; add **BDS MCP** in config if you need tools in the tool list. For deterministic behavior, run **`node scripts/…`**. |
| `OPENCLAW_WORKSPACE_DIR` seems wrong in Docker | Compose interpolates `${VAR}` from a **`.env` next to `docker-compose.yml`**, the shell **environment**, or an `env_file` the compose file references — whatever OpenClaw’s install template ships. | Set **`OPENCLAW_WORKSPACE_DIR=`** in that `.env` to the **host** absolute path for workspace (e.g. `/Users/you/.openclaw/workspace`). Run **`docker compose config`** to verify substitution; **recreate** containers after changing `.env` (`up -d` / `up --force-recreate` as needed). |
| `NO_SESSION` / “session_id never arrived” | SSE bootstrap did not expose a parseable `session_id` (UUID vs hex, proxy, or stale client) | Confirm `POWERLOOM_MCP_URL` ends with `/sse` reachable from the runtime (e.g. Docker). Set **`POWERLOOM_BDS_MCP_DEBUG=1`** once to log the first bytes of the SSE stream in the error. |
| `ENOENT` on `.../skills/.../SKILL.md` (OpenClaw in Docker) | The **container** does not have that file at the path the process checks. Common: wrong **host** tree for **`OPENCLAW_WORKSPACE_DIR`** (compose often mounts `${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace` — skills belong under that dir’s `skills/` on the host, not a second guess at `~` if the env points elsewhere), nested slug folder, or symlink outside the mount. | **In the failing container:** `ls -la /home/node/.openclaw/workspace/skills/powerloom-bds-univ3/SKILL.md` (official compose) **or** `ls` under `/app/skills/...` if your error path uses `/app`. Copy/rsync the skill to the **host path** that maps to the shown prefix, with **`SKILL.md` at the slug root**; restart the gateway. `EACCES` (rare): `chmod -R a+rX` on the skill tree. |


path: references/01-quickstart.md
content: # Quickstart (~10 minutes)

1. **Get an API key** — **CLI / API:** metering origin [bds-metering.powerloom.io](https://bds-metering.powerloom.io) (`bds-agent signup`; see [agent guide](https://github.com/powerloom/bds-agent-py/blob/main/docs/USER_GUIDE.md)). **Browser:** signup and top-ups at [bds-metering.powerloom.io/metering](https://bds-metering.powerloom.io/metering).
2. **Export** `POWERLOOM_API_KEY=sk_live_...` in the environment OpenClaw uses (or your shell profile).
3. **Optional** — default MCP URL is `https://bds-mcp.powerloom.io/sse`. Override with `POWERLOOM_MCP_URL` if directed.
4. **Check credits**: `node scripts/ensure-credits.mjs` — should print balance JSON and exit 0.
5. **OpenClaw / cron** — for **one-shot schedulers** (recommended), use **`node scripts/whale-cron.mjs`** and the full copy-paste flow in **`references/08-openclaw-one-shot.md`**. For a **fixed pool list** and local poll loop, use **`whale-radar.mjs`** (per-pool snapshots only).
6. **Run a recipe** (stdout first):
   - Whale cron (bounded, all pools): `node scripts/whale-cron.mjs`
   - Whale Radar (fixed pools, poll): `node scripts/whale-radar.mjs`
   - Token-Flow: `node scripts/token-flow.mjs`
   - DeFi Analyst (one round, cron): `node scripts/defi-analyst.mjs`
7. **Telegram** — set `POWERLOOM_TELEGRAM_BOT_TOKEN` and `POWERLOOM_TELEGRAM_CHAT_ID`, set `dispatch.channel` to `telegram` in the recipe yaml.


path: references/08-openclaw-one-shot.md
content: # OpenClaw: pay-signup + whale cron (one-shot prompt)

Use this as a **single agent message** after installing the skill from ClawHub. It matches how the skill is meant to run: **bounded** `bds_mpp_snapshot_allTrades` via `scripts/whale-cron.mjs`. The skill does **not** use streaming trade tools.

---

## Copy-paste prompt

````
Install the skill "🦄 Powerloom Uniswap V3 timeseries data" (powerloom-bds-univ3) from ClawHub.
After install, run npm install in the skill directory.

**REQUIRED INPUTS — gather BOTH from me in chat BEFORE running `signup-pay.mjs` or creating any cron job. STOP and ask if either is missing. Do NOT proceed past this section with values unresolved. Asking me for any of these AFTER the cron job is created is a failure mode — re-ask BEFORE cron creation.**

a. **Payer wallet private key** — paste your EVM private key for the funded wallet that will pay 50 $POWER for the credit plan. **Use a burner wallet only** — never a wallet holding significant assets or with extensive transaction history. This key is consumed by `node scripts/signup-pay.mjs` and stored in OpenClaw env as `POWERLOOM_EVM_PRIVATE_KEY`.

b. **Telegram alerts** — ask explicitly: "Do you want Telegram alerts for whale swaps? If yes, paste your `POWERLOOM_TELEGRAM_BOT_TOKEN` and `POWERLOOM_TELEGRAM_CHAT_ID`. If no, type 'skip' and alerts will print to stdout only."
   - If I give you both tokens → use them in the cron env (step 5 below).
   - If I explicitly say "skip" / "no Telegram" / "stdout only" → proceed without TG envs and tell me clearly in the final cron summary that alerts will only print to stdout (the cron `--no-deliver` flag means OpenClaw's chat won't see them either).
   - Do NOT silently default to "stdout is fine." Either both TG values, or an explicit opt-out.
   - Do NOT look up TG credentials from any OpenClaw config source (`channels.telegram`, `openclaw.json`, etc.) — only use what I paste in this conversation.

Once (a) and (b) are resolved in chat, proceed with the steps below.

Then set up pay-signup and a whale radar cron. Details:

1. PLAN: launch_10_pl_power_cgt (POWER native on chain 7869, rpc-v2.powerloom.network)
2. Pay-signup using `scripts/signup-pay.mjs` with the private key from input (a):
   - **Recommended:** run `node scripts/signup-pay.mjs --dry-run` first — prints quote summary (recipient, amount_atomic, chain, payment_kind) to stderr; paste into chat so I can confirm values against metering UI / `/credits/plans`.
   - **Broadcast (non-interactive):** run `node scripts/signup-pay.mjs --yes` as a **one-off shell command** after review — **do not** persist `--yes` or any confirmation bypass inside OpenClaw long-lived skill env (`skills.entries.*.env`). CI operators may instead use `POWERLOOM_SIGNUP_PAY_CONFIRM=yes` for a single invocation; avoid leaving that variable set in autonomous sessions.
   - **TTY:** omit `--yes`; the script prompts `CONFIRM` interactively.
   Non-interactive shells refuse to broadcast without `--yes` / `POWERLOOM_SIGNUP_PAY_CONFIRM=yes` — this is intentional broadcast protection.
   `signup-pay.mjs` uses `quote.payment_kind`: `native_value` → `sendTransaction({ value })`; `erc20` → `token.transfer()`.
3. After signup, set the `sk_live_...` API key and all six env vars in OpenClaw under
   `skills.entries.powerloom-bds-univ3.env.*`:
   `POWERLOOM_EVM_PRIVATE_KEY` (from input a), `POWERLOOM_EVM_RPC_URL`, `POWERLOOM_EVM_CHAIN_ID`, `POWERLOOM_PLAN_ID`, `POWERLOOM_TOKEN_SYMBOL`, `POWERLOOM_API_KEY` (from `signup-pay.mjs` claim output).
   Plus, if input (b) yielded values: `POWERLOOM_TELEGRAM_BOT_TOKEN`, `POWERLOOM_TELEGRAM_CHAT_ID`.
   If the schema expects strings, pass `POWERLOOM_EVM_CHAIN_ID` as a quoted string (e.g. `"7869"`).
4. `scripts/whale-cron.mjs` should:
   - Use `lib/mcp.mjs` `callTool()` for all MCP calls (SSE handshake, not raw HTTP).
   - Use `lib/trade-utils.mjs` `flattenAllTradesFromSnapshot()` to parse the snapshot.
   - Response shape: `result.data.tradeData` = `{ poolAddr: { trades: [...] } }`.
   - Resolve pool token names with `bds_mpp_pool_pool_address_metadata` per unknown pool;
     cache in `.powerloom/pool-metadata-cache.json` (override with `WHALE_CRON_POOL_CACHE`).
   - Verification: `result.data.verification` = `{ cid, epochId, projectId }` — surface in alerts (e.g. ✅).
   - Telegram: `parse_mode: MarkdownV2` with full escaping, or plain-text fallback.
   - If the script is missing or broken, rebuild using the skill’s `lib/*` helpers only.
5. Create an OpenClaw cron job — **only after** REQUIRED INPUTS (a) and (b) above are fully resolved in chat AND `signup-pay.mjs` has produced the API key:
   - Name: e.g. "Whale Radar"
   - Schedule: e.g. every 15s (`--every 15s` with `openclaw cron add`)
   - Timeout: 90s (`--timeout 90000`)
   - Session: isolated (`--session isolated`)
   - Flags: `--no-deliver`, `--light-context`
   - Message: a shell command that `cd`s to the skill dir, sets env inline, then runs `node scripts/whale-cron.mjs`. Required env in the cron message:
     - `POWERLOOM_API_KEY=<value from signup-pay.mjs claim output>`
     - `POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS=120000`
     - `WHALE_CRON_THRESHOLD=10000` (default; only override if I asked for a different threshold)
   - Telegram env in the cron message — **only** if input (b) yielded both values:
     - `POWERLOOM_TELEGRAM_BOT_TOKEN=<value from input (b)>`
     - `POWERLOOM_TELEGRAM_CHAT_ID=<value from input (b)>`
   - Do NOT consult OpenClaw `channels.telegram`, `openclaw.json`, or any other config source for Telegram credentials — only use what I pasted in chat for input (b).
6. Before the first run: `rm -f .powerloom/whale-cron-state.json` if you need a clean epoch cursor.
   Keep `.powerloom/pool-metadata-cache.json` across reinstalls unless debugging metadata.
7. Set WHALE_CRON_STATE_FILE and WHALE_CRON_POOL_CACHE to paths outside the skill directory (e.g. in the workspace root) so they survive openclaw skills install --force.

Constraints:
- **Pay-signup broadcast:** run `node scripts/signup-pay.mjs --dry-run` first unless I explicitly waive dry-run in chat; paste stderr quote summary into chat for my review; then run **`node scripts/signup-pay.mjs --yes` once** as an ephemeral shell invocation (not saved in OpenClaw skill env). CI may use `POWERLOOM_SIGNUP_PAY_CONFIRM=yes` for a single job — do not leave confirmation bypass variables persisted alongside wallet keys. TTY sessions type `CONFIRM` instead.
- **Telegram credentials must be gathered from me in chat (or explicit opt-out received) BEFORE the cron job is created.** Asking me for `POWERLOOM_TELEGRAM_BOT_TOKEN` / `POWERLOOM_TELEGRAM_CHAT_ID` after running `openclaw cron add` and seeing the cron tick (with stdout-only alerts) is a known failure mode — fail the run and re-ask before cron creation.
- Do NOT use OpenClaw config (`channels.telegram`, `openclaw.json`, etc.) as a fallback Telegram source — chat input only.
- Use `bds_mpp_snapshot_allTrades` only (bounded batches). Do not add streaming trade tools.
- Do NOT run the tracker as a background process — use OpenClaw cron only.
- Do NOT show "???" for unknown tokens — resolve via the metadata tool or show the address.
````

---

## Verification provenance (cron script + one-shot)

**In `scripts/whale-cron.mjs` (already implemented):** each `bds_mpp_snapshot_allTrades` result carries `data.verification` (`cid`, `epochId`, `projectId`). The script reads that object once per poll and appends a **“Verified on-chain”** block (CID, epoch, project) to each formatted alert in `formatAlert` — it is not optional glue you add in the OpenClaw message; the one-shot above assumes this behavior.

**Independent check:** the MCP tool `verify_data_provenance` can confirm commitments using the same `cid` / `epoch_id` / `project_id` — see **`references/03-verification.md`** and the **Verify** row in `SKILL.md` (data table).

---

## Related files in this skill

| Item | Location |
|------|----------|
| Cron entrypoint (incl. verification in alerts) | `scripts/whale-cron.mjs` |
| Pay-signup | `scripts/signup-pay.mjs` |
| MCP + trade helpers | `lib/mcp.mjs`, `lib/trade-utils.mjs`, `lib/state.mjs` |
| On-chain verification details | `references/03-verification.md` |
| Integrator rules | `SKILL.md` → **Hosts & integrators** |

See also `references/01-quickstart.md` and `references/06-troubleshooting.md`.


path: references/02-tool-catalog.md
content: # BDS MCP tool catalog (representative)

All calls are metered unless noted. **Defaults:** [REDACTED_SECRET] (raise for large `bds_mpp_snapshot_allTrades` responses under backlog, e.g. `120000`).

**Skill note:** `powerloom-bds-univ3` recipes use **snapshot** tools only (`bds_mpp_snapshot_*`). Streaming catalog tools exist on the hosted MCP server for direct/advanced use but are **not** invoked by shipped scripts.

| Tool | What | Typical latency | Skill usage |
|------|------|-----------------|-------------|
| `bds_mpp_snapshot_allTrades` | One-shot all pools (bounded batch) | 8–45s | `whale-cron.mjs`, DeFi Analyst multi |
| [REDACTED_SECRET] | One pool snapshot | 2–15s | `whale-radar.mjs`, `token-flow.mjs` |
| `bds_mpp_stream_allTrades` | Streaming batches (long-lived upstream) | varies | **Not used by skill** |

See the live server’s `tools/list` for the full catalog.


path: references/04-credit-budget.md
content: # Credit budget (targets — validate in dry run)

| Recipe | Mode | Steady credits/hour (target) | Free tier (~100 cr) |
|--------|------|------------------------------|---------------------|
| Whale Radar (poll) | per-pool snapshots | TBD | TBD |
| Token-Flow | per-pool snapshots | TBD | TBD |
| Whale cron | bounded `snapshot_allTrades` | TBD | TBD |
| DeFi Analyst | 5m + p=0.2 verify | ~40 | ~2.5 h |

Update this table after measured burn from `get_credit_balance` deltas during a 1-hour run.


path: references/05-data-market-scope.md
content: # Data market scope (ETH mainnet Uniswap V3)

## Canonical worked example (single pool)

| Pool | Address | Fee tier |
|------|---------|----------|
| WETH/USDC | [REDACTED_SECRET] | 0.05% |

Some fee tiers (e.g. 0.3% WETH/USDC) may **not** be indexed in this data market — check `bds_mpp_pool_pool_address_metadata` / `bds_mpp_dailyActivePools` before assuming coverage.

## Multi-pool / token-first

- **All pools / all trades (bounded):** `bds_mpp_snapshot_allTrades` (skill + cron). Streaming variants may exist on MCP for direct use outside the skill.
- **Token-scoped:** `bds_mpp_token_token_address_pools`, `bds_mpp_tradeVolumeAllPools_token_address_time_interval`.
- **Single pool:** `bds_mpp_snapshot_trades_pool_address` — use only when the user explicitly wants one pool.


path: references/09-openclaw-one-shot-free-key.md
content: # OpenClaw: free-key whale cron (one-shot prompt)

Use this as a **single agent message** after running `bds-agent signup` to get a free `sk_live_...` API key (browser device flow, 2 free credits, no wallet). The skill itself only needs **`POWERLOOM_API_KEY`** at runtime — `whale-cron.mjs` dispatches alerts via the hosted MCP server using that key alone.

For the wallet-funded variant (autonomous on-chain pay-signup for a 10-credit plan in the same prompt), see [`references/08-openclaw-one-shot.md`](./08-openclaw-one-shot.md).

---

## Copy-paste prompt

````
Install the skill "🦄 Powerloom Uniswap V3 timeseries data" (powerloom-bds-univ3) from ClawHub.
After install, run npm install in the skill directory.

I already have a Powerloom API key (sk_live_...) from `bds-agent signup` — 2 free credits, no wallet needed. Use that key directly. Do NOT run `scripts/signup-pay.mjs`. Do NOT ask for a private key, RPC URL, plan id, chain id, or token symbol.

**REQUIRED INPUTS — gather BOTH from me in chat BEFORE you install the skill, wire any env, or create any cron job. STOP and ask if either is missing. Do NOT proceed to step 1 below until both are resolved. Asking me for any of these AFTER creating the cron is a failure mode — re-ask BEFORE cron creation.**

a. **Powerloom API key** — paste your `sk_live_...` from `bds-agent signup`. If you don't have one yet, run `bds-agent signup` first (free, 2 credits, browser device flow) and come back. Do not invent or guess a key.

b. **Telegram alerts** — ask explicitly: "Do you want Telegram alerts for whale swaps? If yes, paste your `POWERLOOM_TELEGRAM_BOT_TOKEN` and `POWERLOOM_TELEGRAM_CHAT_ID`. If no, type 'skip' and alerts will print to stdout only."
   - If I give you both tokens → use them in the cron env (steps 1 and 4 below).
   - If I explicitly say "skip" / "no Telegram" / "stdout only" → proceed without TG envs and tell me clearly in the final cron summary that alerts will only print to stdout (the cron `--no-deliver` flag means OpenClaw's chat won't see them either).
   - Do NOT silently default to "stdout is fine." Either both TG values, or an explicit opt-out.
   - Do NOT look up TG credentials from any OpenClaw config source (`channels.telegram`, `openclaw.json`, etc.) — only use what I paste in this conversation.

Once (a) and (b) are resolved in chat, proceed with the steps below.

Set up a whale radar cron with the existing key. Details:

1. Set the gathered envs in OpenClaw under `skills.entries.powerloom-bds-univ3.env.*`:
   - `POWERLOOM_API_KEY` — required, from input (a).
   - `POWERLOOM_TELEGRAM_BOT_TOKEN`, `POWERLOOM_TELEGRAM_CHAT_ID` — set both **only** if I gave you values in input (b). If I opted out, leave both unset; `whale-cron.mjs` prints alerts to stdout in that case (still includes the verification block).

2. Pre-flight: `node scripts/ensure-credits.mjs` to confirm the key is valid and balance is non-zero (free signup grants 2 credits).

3. `scripts/whale-cron.mjs` should:
   - Use `lib/mcp.mjs` `callTool()` for all MCP calls (SSE handshake, not raw HTTP).
   - Use `lib/trade-utils.mjs` `flattenAllTradesFromSnapshot()` to parse the snapshot.
   - Response shape: `result.data.tradeData` = `{ poolAddr: { trades: [...] } }`.
   - Resolve pool token names with `bds_mpp_pool_pool_address_metadata` per unknown pool;
     cache in `.powerloom/pool-metadata-cache.json` (override with `WHALE_CRON_POOL_CACHE`).
   - Verification: `result.data.verification` = `{ cid, epochId, projectId }` — surface in alerts (e.g. ✅).
   - Telegram: `parse_mode: MarkdownV2` with full escaping, or plain-text fallback.
   - If the script is missing or broken, rebuild using the skill’s `lib/*` helpers only.

4. Create an OpenClaw cron job — **only after** REQUIRED INPUTS (a) and (b) above are fully resolved in chat:
   - Name: e.g. "Whale Radar"
   - Schedule: e.g. every 15s (`--every 15s` with `openclaw cron add`)
   - Timeout: 90s (`--timeout 90000`)
   - Session: isolated (`--session isolated`)
   - Flags: `--no-deliver`, `--light-context`
   - Message: a shell command that `cd`s to the skill dir, sets env inline, then runs `node scripts/whale-cron.mjs`. Required env in the cron message:
     - `POWERLOOM_API_KEY=<value from input (a)>`
     - `POWERLOOM_BDS_MCP_CALL_TIMEOUT_MS=120000`
     - `WHALE_CRON_THRESHOLD=10000` (default; only override if I asked for a different threshold)
   - Telegram env in the cron message — **only** if input (b) yielded both values:
     - `POWERLOOM_TELEGRAM_BOT_TOKEN=<value from input (b)>`
     - `POWERLOOM_TELEGRAM_CHAT_ID=<value from input (b)>`
   - Do NOT consult OpenClaw `channels.telegram`, `openclaw.json`, or any other config source for Telegram credentials — only use what I pasted in chat for input (b).

5. Before the first run: `rm -f .powerloom/whale-cron-state.json` if you need a clean epoch cursor.
   Keep `.powerloom/pool-metadata-cache.json` across reinstalls unless debugging metadata.

6. Set WHALE_CRON_STATE_FILE and WHALE_CRON_POOL_CACHE to paths outside the skill directory (e.g. in the workspace root) so they survive `openclaw skills install --force`.

Constraints:
- This flow uses an **existing** `sk_live_...` key only. Do NOT initiate any on-chain payment, and do NOT prompt for wallet credentials.
- **Telegram credentials must be gathered from me in chat (or explicit opt-out received) BEFORE the cron job is created.** Asking me for `POWERLOOM_TELEGRAM_BOT_TOKEN` / `POWERLOOM_TELEGRAM_CHAT_ID` after running `openclaw cron add` and seeing the cron tick (with stdout-only alerts) is a known failure mode — fail the run and re-ask before cron creation.
- Do NOT use OpenClaw config (`channels.telegram`, `openclaw.json`, etc.) as a fallback Telegram source — chat input only.
- If the user later wants more credits, point them at `references/08-openclaw-one-shot.md` (wallet-funded pay-signup variant) or `node scripts/credits-topup.mjs` against the same key.
- Use `bds_mpp_snapshot_allTrades` only (bounded batches). Do not add streaming trade tools.
- Do NOT run the tracker as a background process — use OpenClaw cron only.
- Do NOT show "???" for unknown tokens — resolve via the metadata tool or show the address.
````

---

## Why this works without a wallet

`scripts/whale-cron.mjs` only consumes `POWERLOOM_API_KEY` (via `lib/mcp.mjs`) plus the optional Telegram envs (`lib/powerloom-env.mjs` `telegramBotToken()` / `telegramChatId()`). The wallet/plan env getters in `lib/powerloom-env.mjs` are **only** read by `scripts/signup-pay.mjs` and `scripts/credits-topup.mjs` — both of which this prompt explicitly skips. The skill's `metadata.openclaw.requires.env` schema in `SKILL.md` lists wallet vars as **optional** (`optional_env`) for this exact reason.

When the 2 free credits run out (~7200 epochs of data per credit on metered routes), upgrade in either of two ways without re-onboarding:

- **Wallet-funded plan, in the agent**: paste the [wallet-funded one-shot](./08-openclaw-one-shot.md) — runs `signup-pay.mjs` and refreshes the API key.
- **Top-up the existing key**: `node scripts/credits-topup.mjs` with `POWERLOOM_PLAN_ID`, `POWERLOOM_EVM_CHAIN_ID`, `POWERLOOM_TOKEN_SYMBOL`, and a funded wallet — adds credits to the same `sk_live_...`.

---

## Verification provenance (cron script + one-shot)

**In `scripts/whale-cron.mjs` (already implemented):** each `bds_mpp_snapshot_allTrades` result carries `data.verification` (`cid`, `epochId`, `projectId`). The script reads that object once per poll and appends a **"Verified on-chain"** block (CID, epoch, project) to each formatted alert in `formatAlert` — it is not optional glue you add in the OpenClaw message; the one-shot above assumes this behavior. Free-key alerts carry the same verification block as wallet-funded alerts.

**Independent check:** the MCP tool `verify_data_provenance` can confirm commitments using the same `cid` / `epoch_id` / `project_id` — see [`references/03-verification.md`](./03-verification.md) and the **Verify** row in `SKILL.md` (data table).

---

## Related files in this skill

| Item | Location |
|------|----------|
| Cron entrypoint (incl. verification in alerts) | `scripts/whale-cron.mjs` |
| Pre-flight credit check | `scripts/ensure-credits.mjs` |
| Pay-signup (wallet-funded variant) | `scripts/signup-pay.mjs` |
| Top-up (more credits, existing key) | `scripts/credits-topup.mjs` |
| MCP + trade helpers | `lib/mcp.mjs`, `lib/trade-utils.mjs`, `lib/state.mjs` |
| On-chain verification details | `references/03-verification.md` |
| Wallet-funded one-shot variant | `references/08-openclaw-one-shot.md` |
| Integrator rules | `SKILL.md` → **Hosts & integrators** |

See also [`references/01-quickstart.md`](./01-quickstart.md) and [`references/06-troubleshooting.md`](./06-troubleshooting.md).


path: references/07-prompt-patterns.md
content: # Prompt patterns → tools

| User intent | Primary path |
|-------------|--------------|
| “All USDC swaps”, “every trade for token X” | **Token-Flow** (`token-flow.yaml`) — per-pool snapshots; or `bds_mpp_snapshot_allTrades` via **`whale-cron.mjs`** for all pools in one batch. |
| “Whale”, “above $X” | **`whale-cron.mjs`** (all pools, bounded) or **`whale-radar.mjs`** (fixed pool list). |
| “One pool” | `bds_mpp_snapshot_trades_pool_address` after resolving the pool. |
| “Is this CID real?” | `verify_data_provenance` with `cid`, `epoch_id`, `project_id`. |

The skill does **not** ship streaming trade consumption; use snapshot tools only.


path: references/03-verification.md
content: # `verify_data_provenance`

Compares a snapshot **CID** to on-chain `maxSnapshotsCid` for `(data_market, project_id, epoch_id)` via the Powerloom protocol state contract.

**Inputs:** `cid` (string), `epoch_id` (integer), `project_id` (string). Optional `data_market` override.

**Hosted MCP** runs the `eth_call` on the server (configured RPC is **not** included in the tool response). If the server’s RPC is unset, the tool returns a clear configuration error — not a silent pass. For a **local** second check, use the same `cid` / `epoch_id` / `project_id` with your own provider **`POWERLOOM_EVM_RPC_URL`** (e.g. public Powerloom JSON-RPC) and the documented ProtocolState / DataMarket addresses — do not expect MCP to echo an RPC URL.

**Metering** `GET /credits/plans` exposes **`chains[].rpc_url`** only as an optional **public** hint; it can be empty.

**In alerts:** only print verification lines when `cid`, `epoch_id`, and `project_id` are all known from the API response. Do not substitute block numbers for epoch IDs.


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