@clawhub-powerloom-bot-5907a95310
Autonomous Uniswap V3 monitoring on consensus-backed data. Every data point is finalized on-chain by Powerloom's decentralized sequencer-validator network (D...
---
name: powerloom-bds-univ3
description: |
Autonomous Uniswap V3 monitoring on consensus-backed data. Every data point is
finalized on-chain by Powerloom's decentralized sequencer-validator network (DSV)
and independently verifiable via verify_data_provenance. Ships with Whale Radar,
Token-Flow, and Autonomous DeFi Analyst recipes. Billing: metering service HTTP APIs; optional bds-agent CLI. Agent-first: plan + wallet then pay-signup, then top-up.
Triggers on phrases like "whale alert", "track trades", "all trades for", "by token",
"ERC20", "ERC20 token swaps", "Powerloom", "verify on-chain", "verified data".
version: 0.0.8
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:
- EVM_PRIVATE_KEY
- EVM_RPC_URL
- EVM_CHAIN_ID
- PLAN_ID
- TOKEN_SYMBOL
- POWERLOOM_API_KEY
optional_env:
- POWERLOOM_MCP_URL
- TELEGRAM_BOT_TOKEN
- TELEGRAM_CHAT_ID
- DISCORD_WEBHOOK_URL
- BDS_MCP_CALL_TIMEOUT_MS
- METERING_BASE_URL
- AGENT_NAME
- EMAIL
---
# Powerloom BDS — Uniswap V3
## Install
**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 **`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` (same service). |
| 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 **`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 `requires.env` (mirrors a pay-signup row + wallet + key)
| Field | Role |
|-------|------|
| `EVM_PRIVATE_KEY` | Payer wallet |
| `EVM_RPC_URL` | JSON-RPC for that chain |
| `EVM_CHAIN_ID` | Must match the plan’s `chain_id` |
| `PLAN_ID` | e.g. `launch_10_pl_power_cgt` from `GET /credits/plans` |
| `TOKEN_SYMBOL` | e.g. `POWER` (must match that row) |
| `POWERLOOM_API_KEY` | After claim (or set after device signup) |
**Path A (browser) only** usually needs `POWERLOOM_API_KEY` in practice. If the host enforces the full list, set wallet + plan to the row you will use, or adjust host policy.
### 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, this must be **native** — do not run the ERC-20 path. |
| `node scripts/credits-topup.mjs` | **More** credits: uses existing **`POWERLOOM_API_KEY`**, fetches `GET /credits/plans`, matches **`PLAN_ID` + `EVM_CHAIN_ID` + `TOKEN_SYMBOL`**, sends **ERC-20** or **native** per `payment_kind`, then **`POST /credits/topup`**. Set **`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):** `METERING_BASE_URL`, `AGENT_NAME`, `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) | `bds_mpp_stream_allTrades` / `bds_mpp_snapshot_allTrades` + **Token-Flow** recipe |
| **Whale** / USD threshold | `bds_mpp_stream_allTrades` + filters, or **Whale Radar** recipe |
| **One pool only** | `bds_mpp_snapshot_trades_pool_address` after `bds_mpp_token_token_address_pools` or `bds_mpp_dailyActivePools` |
| **Streaming** live | `bds_mpp_stream_allTrades` with `from_epoch` checkpoint (see `scripts/whale-radar.mjs`) |
| **Verify** on-chain | `verify_data_provenance` with `cid`, `epoch_id`, `project_id` from API — never substitute block for epoch |
**Timeouts:** default `BDS_MCP_CALL_TIMEOUT_MS=60000`. Use **120000** for `bds_mpp_stream_allTrades` with `max_events=50` if you see timeouts under backlog.
## Recipes (supported surface)
Pre-built scripts + `recipes/*.yaml` defaults — prefer these over ad-hoc scripts on weaker models.
| Recipe / entrypoint | Script |
|---------------------|--------|
| Whale Radar (stream / per-pool poll) | `node scripts/whale-radar.mjs` — default **stream = all pools**; `--mode poll` uses `poll_fallback_pools` (per-pool snapshot), not `snapshot_allTrades` |
| 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...`) |
| DeFi Analyst | `node scripts/defi-analyst.mjs` — default **multi-pool** (`bds_mpp_stream_allTrades` + all-pools volume); `filters.scope: single_pool` for one-pool only (`--once` = one shot) |
## 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” setup (install → pay-signup → cron message):** use the copy-paste prompt in **`references/08-openclaw-one-shot.md`** so agents get a single, repeatable instruction block without hunting daily notes.
**Scheduled / cron-style runs** (short heartbeat, one shot per tick): use **`node scripts/whale-cron.mjs`** (all-pool `bds_mpp_snapshot_allTrades`, exits after `MAX_LOOPS`) or, for a **fixed** pool set only, `bds_mpp_snapshot_trades_pool_address` / `whale-radar.mjs --mode poll`. **Do not** use `whale-radar` default **stream** for crons. Each one-shot run stays a **bounded** call; easier on credits and timeouts.
**Stream tools** (`bds_mpp_stream_allTrades`, SSE catalog routes): use only when the **end user** wants a **long-running background** data consumer, deployed **outside** a typical cron “wake up → one batch → exit” model. **Do not** default generated skill glue to streams for cron: streams open a different metering/session pattern and are a poor fit for start-stop heartbeats. This repo’s recipes still **default to stream** for interactive demos; integrators should override to **poll** in `recipes/*.yaml` or script flags for production crons.
## 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.
FILE:README.md
# 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: long-running default **`bds_mpp_stream_allTrades`**; **`--mode poll`** + `poll_fallback_pools` = per-pool polls only. For **cron / OpenClaw heartbeats** over all pools, use **`node scripts/whale-cron.mjs`** (bounded `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** stream batch + all-pools token volume; set **`filters.scope: single_pool`** in `recipes/defi-analyst.yaml` for legacy single-pool snapshots only.
## Integrators (OpenClaw, cron)
**End-to-end one-shot** (install, signup, env, `whale-cron` job): **`references/08-openclaw-one-shot.md`**.
For **scheduled heartbeats**, prefer **`whale-cron.mjs`** or snapshot MCP tools — not stream tools. Streams suit **long-running background** services; see **Hosts & integrators** in `SKILL.md`.
## Setup
```bash
cd powerloom-bds-univ3
npm install
export POWERLOOM_API_KEY=sk_live_...
node scripts/ensure-credits.mjs
```
**Metering (no `bds-agent` required):** `scripts/signup-pay.mjs` (new key, pay-signup; **native or ERC-20** per `quote.payment_kind` — e.g. POWER CGT = native) and `scripts/credits-topup.mjs` (more credits, existing key). See **`SKILL.md`**.
Optional: `TELEGRAM_BOT_TOKEN`, `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).
FILE:package-lock.json
{
"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": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
"license": "MIT"
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"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": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"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": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"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": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT"
},
"node_modules/ethers": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
"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": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"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": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
FILE:package.json
{
"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"
}
}
FILE:scripts/whale-cron.mjs
#!/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 { 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";
process.env.BDS_MCP_CALL_TIMEOUT_MS = process.env.BDS_MCP_CALL_TIMEOUT_MS || "120000";
const TG_TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
const TG_CHAT = process.env.TELEGRAM_CHAT_ID || "";
// ─── 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/botTG_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/botTG_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`);
lines.push(` ├ Epoch: verification.epochId || "—"`);
lines.push(` └ Project: ")[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);
});
FILE:scripts/powerloom-mcp-client.mjs
#!/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);
}
FILE:scripts/ensure-credits.mjs
#!/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();
FILE:scripts/whale-radar.mjs
#!/usr/bin/env node
/**
* Whale Radar — long-running: default **stream** (`bds_mpp_stream_allTrades`); or `--mode poll` for a
* fixed list of pools via `bds_mpp_snapshot_trades_pool_address` (see recipe yaml).
* For **scheduled / one-shot** runs over **all** pools, use `scripts/whale-cron.mjs` (snapshot all-trades, bounded loops).
*/
import { callTool } from "./lib/mcp.mjs";
import { loadRecipe } from "./lib/recipe-config.mjs";
import {
flattenAllTradesFromSnapshot,
tradeUsd,
tradeDirectionLabel,
formatEtherscanTx,
} from "./lib/trade-utils.mjs";
import { loadState, saveState, fingerprintTrade, rememberFingerprint, wasEmitted } from "./lib/state.mjs";
import { dispatchLines } from "./lib/dispatch.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: "stream", interval_seconds: 30 },
filters: { threshold_usd: 25000 },
client: {
call_timeout_ms: 60000,
poll_fallback_pools: [
"0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640",
"0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8",
],
},
dispatch: { channel: "stdout" },
};
const cfg = loadRecipe("whale-radar.yaml", defaults);
const mode = arg("--mode") || cfg.heartbeat?.mode || "stream";
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";
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 runStream() {
let state = loadState(stateFile);
process.env.BDS_MCP_CALL_TIMEOUT_MS =
process.env.BDS_MCP_CALL_TIMEOUT_MS || String(cfg.client?.call_timeout_ms || 120000);
console.error(
"[whale-radar] mode=stream tool=bds_mpp_stream_allTrades (all indexed pools; poll_fallback_pools unused)"
);
for (;;) {
const params = { max_events: 50 };
if (state.lastStreamEpoch != null) {
params.from_epoch = state.lastStreamEpoch + 1;
}
let result;
try {
result = await callTool("bds_mpp_stream_allTrades", params);
} catch (e) {
console.error("[whale-radar] stream batch failed:", e.message);
await new Promise((r) => setTimeout(r, 5000));
continue;
}
const events = result.events || [];
let maxEpoch = state.lastStreamEpoch ?? 0;
for (const ev of events) {
if (ev.skipped) {
if (typeof ev.epoch === "number") maxEpoch = Math.max(maxEpoch, ev.epoch);
continue;
}
const verification = ev.verification || null;
const snap = ev.snapshot;
const epochNum = ev.epoch ?? verification?.epochId;
if (typeof epochNum === "number") maxEpoch = Math.max(maxEpoch, epochNum);
const rows = flattenAllTradesFromSnapshot(snap);
for (const tw of rows) {
if (tradeUsd(tw) < threshold) continue;
const fp = fingerprintTrade(tw.trade);
if (wasEmitted(state, fp)) continue;
const lines = formatTradeAlert(tw, verification);
await dispatchLines(lines, channel);
rememberFingerprint(state, fp);
const bn = tw.trade?.log?.blockNumber ?? 0;
if (bn > (state.lastEmittedBlock || 0)) state.lastEmittedBlock = bn;
}
}
if (events.length === 0) {
await new Promise((r) => setTimeout(r, 2000));
} else {
state.lastStreamEpoch = maxEpoch;
saveState(stateFile, state);
}
}
}
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`
);
let state = loadState(stateFile);
process.env.BDS_MCP_CALL_TIMEOUT_MS =
process.env.BDS_MCP_CALL_TIMEOUT_MS || String(cfg.client?.call_timeout_ms || 60000);
for (;;) {
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 new Promise((r) => setTimeout(r, intervalSec * 1000));
}
}
if (mode === "poll") {
runPoll().catch((e) => {
console.error(e);
process.exit(1);
});
} else {
runStream().catch((e) => {
console.error(e);
process.exit(1);
});
}
FILE:scripts/list-mcp-tools.mjs
#!/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);
}
FILE:scripts/signup-pay.mjs
#!/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:
* EVM_PRIVATE_KEY — hex, optionally 0x-prefixed (funds the transfer; becomes the account’s payer address)
* PLAN_ID — plan id from GET /credits/plans
* CHAIN_ID — EIP-155 chain id (must match the plan row)
* EVM_CHAIN_ID — same as CHAIN_ID (optional alias; used by bds-agent profile `.evm.env`)
* TOKEN_SYMBOL — must match plan.token_symbol for that chain
*
* Optional:
* METERING_BASE_URL — default https://bds-metering.powerloom.io
* EVM_RPC_URL — if unset, uses rpc_hint from the quote (public RPC from metering;
* may be null if no public hint — then set this)
* AGENT_NAME — default openclaw-pay-agent
* EMAIL — if set, must not already be registered
*
* On success, prints JSON with api_key — set POWERLOOM_API_KEY from the output.
*
* Usage:
* node scripts/signup-pay.mjs
*/
import { ethers } from "ethers";
const ERC20_ABI = ["function transfer(address to, uint256 amount) returns (bool)"];
async function main() {
const base = (process.env.METERING_BASE_URL || "https://bds-metering.powerloom.io").replace(
/\/$/,
"",
);
const pk = (process.env.EVM_PRIVATE_KEY || "").trim();
const planId = (process.env.PLAN_ID || "").trim();
const chainId = parseInt(process.env.CHAIN_ID || process.env.EVM_CHAIN_ID || "", 10);
const tokenSymbol = (process.env.TOKEN_SYMBOL || "").trim();
const agentName = (process.env.AGENT_NAME || "openclaw-pay-agent").trim();
const emailRaw = (process.env.EMAIL || "").trim();
if (!pk) {
console.error("Set EVM_PRIVATE_KEY");
process.exit(1);
}
if (!planId || !Number.isFinite(chainId) || !tokenSymbol) {
console.error("Set PLAN_ID, CHAIN_ID, and TOKEN_SYMBOL (see GET /credits/plans on the metering origin).");
process.exit(1);
}
const wallet = new ethers.Wallet(pk.startsWith("0x") ? pk : `0xpk`);
const quoteBody = {
agent_name: agentName,
plan_id: planId,
chain_id: chainId,
token_symbol: tokenSymbol,
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 = (process.env.EVM_RPC_URL || "").trim() || quote.rpc_hint;
if (!rpcUrl) {
console.error(
"No RPC: set 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 EVM_RPC_URL.`,
);
process.exit(1);
}
const signer = wallet.connect(provider);
const amount = BigInt(quote.amount_atomic);
const isNative = quote.payment_kind === "native_value";
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(
{
api_key: claim.api_key,
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);
});
FILE:scripts/defi-analyst.mjs
#!/usr/bin/env node
/**
* Autonomous DeFi Analyst — default: multi-pool via bds_mpp_stream_allTrades + token all-pools volume.
* Legacy: filters.scope: single_pool → one pool snapshots only.
*/
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";
const USDC_MAINNET = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
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: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640",
project_id: "uniswapv3.eth-usdc-0.05",
},
client: { call_timeout_ms: 90000, stream_max_events: 12 },
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 streamMaxEvents = cfg.client?.stream_max_events ?? 12;
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() {
process.env.BDS_MCP_CALL_TIMEOUT_MS =
process.env.BDS_MCP_CALL_TIMEOUT_MS ||
String(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: streamMaxEvents };
if (state.lastStreamEpoch != null) params.from_epoch = state.lastStreamEpoch + 1;
const result = await callTool("bds_mpp_stream_allTrades", params);
const events = result.events || [];
let maxEpoch = state.lastStreamEpoch ?? 0;
let bestTw = null;
let bestUsd = -1;
let bestSnap = null;
let bestEv = null;
for (const ev of events) {
if (ev.skipped) {
if (typeof ev.epoch === "number") maxEpoch = Math.max(maxEpoch, ev.epoch);
continue;
}
const snap = ev.snapshot;
const epochNum = ev.epoch ?? ev.verification?.epochId;
if (typeof epochNum === "number") maxEpoch = Math.max(maxEpoch, epochNum);
const rows = flattenAllTradesFromSnapshot(snap);
for (const tw of rows) {
const u = tradeUsd(tw);
if (u > bestUsd) {
bestUsd = u;
bestTw = tw;
bestSnap = snap;
bestEv = ev;
}
}
}
if (events.length > 0) {
state.lastStreamEpoch = maxEpoch;
saveState(stateFile, state);
}
const volData = vol?.data ?? vol;
const ethData = eth.data || eth;
const lines = [
`Powerloom DeFi Analyst (multi-pool) — new Date().toISOString()`,
`scope stream batch max_events=streamMaxEvents (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_batch pool pAddr tradeDirection(t) $tradeUsd(bestTw).toFixed(2) tx t?.log?.transactionHash || ""`
);
} else {
lines.push("top_trade_in_batch (no trades in this stream window)");
}
const top = bestTw?.trade;
const doVerify = Math.random() < pVerify && top;
if (doVerify && top?.log?.cid) {
const eid =
bestEv?.verification?.epochId ??
epochIdFromSnapshot(bestSnap?.data ?? bestSnap) ??
(typeof bestEv?.epoch === "number" ? bestEv.epoch : 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 stream snapshot)"
);
}
} else if (doVerify) {
lines.push(
"verification_probe skipped (no cid on trade log in batch)"
);
}
await dispatchLines(lines, channel);
}
async function oneRoundSinglePool() {
process.env.BDS_MCP_CALL_TIMEOUT_MS =
process.env.BDS_MCP_CALL_TIMEOUT_MS || "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) $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 once = arg("--once");
if (once) {
await oneRound();
return;
}
await oneRound();
setInterval(() => {
oneRound().catch((e) => console.error("[defi-analyst]", e.message));
}, intervalSec * 1000);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
FILE:scripts/token-flow.mjs
#!/usr/bin/env node
/**
* Token-Flow — all swaps touching a token across indexed pools (stream default).
*/
import { callTool } from "./lib/mcp.mjs";
import { loadRecipe } from "./lib/recipe-config.mjs";
import {
flattenAllTradesFromSnapshot,
tradeUsd,
tradeDirectionLabel,
poolInAllowlist,
buildPoolAllowlistFromTokenPoolsResponse,
} from "./lib/trade-utils.mjs";
import {
loadState,
saveState,
fingerprintTrade,
rememberFingerprint,
wasEmitted,
} from "./lib/state.mjs";
import { dispatchLines } from "./lib/dispatch.mjs";
const USDC_MAINNET = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const arg = (name) => {
const i = process.argv.indexOf(name);
return i >= 0 ? process.argv[i + 1] : undefined;
};
const defaults = {
name: "token-flow",
heartbeat: { mode: "stream", 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 mode = arg("--mode") || cfg.heartbeat?.mode || "stream";
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";
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 runStream() {
const poolSet = await loadPoolsForToken();
if (!poolSet.size) {
console.error(
`[token-flow] No indexed pools found for token token. Check bds_mpp_dailyActiveTokens / token list.`
);
process.exit(2);
}
let state = loadState(stateFile);
process.env.BDS_MCP_CALL_TIMEOUT_MS =
process.env.BDS_MCP_CALL_TIMEOUT_MS || String(cfg.client?.call_timeout_ms || 120000);
for (;;) {
const params = { max_events: 50 };
if (state.lastStreamEpoch != null) params.from_epoch = state.lastStreamEpoch + 1;
let result;
try {
result = await callTool("bds_mpp_stream_allTrades", params);
} catch (e) {
console.error("[token-flow] stream batch failed:", e.message);
await new Promise((r) => setTimeout(r, 5000));
continue;
}
const events = result.events || [];
let maxEpoch = state.lastStreamEpoch ?? 0;
for (const ev of events) {
if (ev.skipped) {
if (typeof ev.epoch === "number") maxEpoch = Math.max(maxEpoch, ev.epoch);
continue;
}
const verification = ev.verification || null;
const snap = ev.snapshot;
const epochNum = ev.epoch ?? verification?.epochId;
if (typeof epochNum === "number") maxEpoch = Math.max(maxEpoch, epochNum);
const rows = flattenAllTradesFromSnapshot(snap).filter((tw) =>
poolInAllowlist(tw.poolAddress, poolSet)
);
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);
const bn = tw.trade?.log?.blockNumber ?? 0;
if (bn > (state.lastEmittedBlock || 0)) state.lastEmittedBlock = bn;
}
}
if (events.length === 0) {
await new Promise((r) => setTimeout(r, 2000));
} else {
state.lastStreamEpoch = maxEpoch;
saveState(stateFile, state);
}
}
}
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);
process.env.BDS_MCP_CALL_TIMEOUT_MS =
process.env.BDS_MCP_CALL_TIMEOUT_MS || String(cfg.client?.call_timeout_ms || 60000);
for (;;) {
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 new Promise((r) => setTimeout(r, intervalSec * 1000));
}
}
if (mode === "poll") {
runPoll().catch((e) => {
console.error(e);
process.exit(1);
});
} else {
runStream().catch((e) => {
console.error(e);
process.exit(1);
});
}
FILE:scripts/credits-topup.mjs
#!/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_API_KEY — Bearer sk_live_… (or BDS_API_KEY)
* EVM_PRIVATE_KEY — hex; must fund the transfer
* PLAN_ID — must match a row in GET /credits/plans
* CHAIN_ID or EVM_CHAIN_ID — must match that plan’s chain_id
* TOKEN_SYMBOL — must match plan.token_symbol for that row
*
* Optional:
* METERING_BASE_URL — default https://bds-metering.powerloom.io
* 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)
*
* Usage:
* node scripts/credits-topup.mjs
*/
import { ethers } from "ethers";
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 = (process.env.METERING_BASE_URL || "https://bds-metering.powerloom.io").replace(
/\/$/,
"",
);
const apiKey = (process.env.POWERLOOM_API_KEY || process.env.BDS_API_KEY || "").trim();
const pk = (process.env.EVM_PRIVATE_KEY || "").trim();
const planId = (process.env.PLAN_ID || "").trim();
const chainId = parseInt(process.env.CHAIN_ID || process.env.EVM_CHAIN_ID || "", 10);
const tokenSymbol = (process.env.TOKEN_SYMBOL || "").trim();
const rpcOverride = (process.env.EVM_RPC_URL || "").trim();
if (!apiKey) fail("Set POWERLOOM_API_KEY (or BDS_API_KEY)");
if (!pk) fail("Set EVM_PRIVATE_KEY");
if (!planId || !Number.isFinite(chainId) || !tokenSymbol) {
fail("Set PLAN_ID, CHAIN_ID (or EVM_CHAIN_ID), and 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 = tokenSymbol.toLowerCase();
const plan = plans.find(
(p) =>
p.id === planId &&
Number(p.chain_id) === chainId &&
p.active !== false &&
(p.token_symbol || "").toLowerCase() === sym,
);
if (!plan) {
fail(
`No matching active plan for id=planId chain_id=chainId token_symbol=tokenSymbol. Run GET /credits/plans.`,
);
}
const rpcUrl = resolveRpc(plan, chains, rpcOverride);
if (!rpcUrl) {
fail(
"No RPC: set 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 EVM_CHAIN_ID chainId. Fix EVM_RPC_URL.`);
}
const wallet = new ethers.Wallet(pk.startsWith("0x") ? pk : `0xpk`);
const signer = wallet.connect(provider);
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: planId,
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);
});
FILE:scripts/lib/trade-utils.mjs
/**
* 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;
}
FILE:scripts/lib/state.mjs
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);
}
FILE:scripts/lib/recipe-config.mjs
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 };
}
}
FILE:scripts/lib/mcp.mjs
/**
* Powerloom BDS MCP over HTTP+SSE (same wire as Claude / OpenClaw remote MCP).
* Env: POWERLOOM_API_KEY (required), POWERLOOM_MCP_URL or BDS_MCP_URL (default https://bds-mcp.powerloom.io/sse),
* BDS_MCP_CALL_TIMEOUT_MS (default 60000; raise for bds_mpp_stream_allTrades with max_events=50).
*
* 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 ||
process.env.BDS_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.BDS_MCP_CALL_TIMEOUT_MS);
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 timeoutMsms (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 =
process.env.BDS_MCP_DEBUG === "1"
? ` 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 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 apiKey = process.env.POWERLOOM_API_KEY;
if (!apiKey || !String(apiKey).trim()) {
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 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 apiKey = process.env.POWERLOOM_API_KEY;
if (!apiKey || !String(apiKey).trim()) {
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 timeoutMsms (tool=toolName).`
);
err.code = "TOOL_TIMEOUT";
throw err;
}
FILE:scripts/lib/dispatch.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 token = process.env.TELEGRAM_BOT_TOKEN;
const chat = process.env.TELEGRAM_CHAT_ID;
if (!token || !chat) {
console.log(text);
return;
}
const u = `https://api.telegram.org/bottoken/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 = process.env.DISCORD_WEBHOOK_URL;
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);
}
FILE:recipes/defi-analyst.yaml
# -----------------------------------------------------------------------------
# Autonomous DeFi Analyst — default: MULTI-POOL (stream + token-wide volume)
#
# OpenClaw / cron: this recipe is stream-first; for scheduled jobs prefer
# `node scripts/defi-analyst.mjs --once` or a poll-based script — see SKILL.md.
#
# filters.scope: multi (default) → `bds_mpp_stream_allTrades` for cross-pool
# trade batch + `bds_mpp_tradeVolumeAllPools_token_address_time_interval` for
# 1h volume on the token (default USDC mainnet). Aligns with SKILL.md / token-first.
#
# filters.scope: single_pool → legacy v1: one pool snapshot + pool volume only.
# -----------------------------------------------------------------------------
name: defi-analyst
description: Periodic analytics + random on-chain verification (multi-pool by default).
heartbeat:
interval_seconds: 300
filters:
scope: multi
volume_token_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
project_id: "uniswapv3.eth-usdc-0.05"
pool_address: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"
client:
call_timeout_ms: 90000
stream_max_events: 12
verification:
mode: sampled
sample_probability: 0.2
dispatch:
channel: stdout
FILE:recipes/whale-radar.yaml
# -----------------------------------------------------------------------------
# Whale Radar — default path is MULTI-POOL
#
# OpenClaw / cron: set heartbeat.mode: poll (or --mode poll) for scheduled
# heartbeats; reserve stream for long-running services — see SKILL.md.
#
# heartbeat.mode: stream → MCP tool `bds_mpp_stream_allTrades` (entire indexed
# market: all pools / all trades in one stream). `client.poll_fallback_pools`
# is IGNORED for stream mode — see `scripts/whale-radar.mjs` → `runStream()`.
#
# Only if you run: node scripts/whale-radar.mjs --mode poll
# does the script use per-pool snapshot tools and the list below.
# -----------------------------------------------------------------------------
name: whale-radar
description: Whale swap alerts across indexed Uniswap V3 pools (stream default).
heartbeat:
mode: stream
interval_seconds: 30
filters:
threshold_usd: 25000
client:
call_timeout_ms: 120000
max_alerts_per_heartbeat: 10
poll_fallback_pools:
- "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"
- "0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8"
verification:
mode: passive
dispatch:
channel: stdout
FILE:recipes/token-flow.yaml
# For cron / OpenClaw: prefer mode: poll — see SKILL.md (Hosts & integrators).
name: token-flow
description: Every swap touching a token (default USDC) across indexed pools.
heartbeat:
mode: stream
interval_seconds: 30
filters:
token_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
min_usd: 0
pools: auto
client:
call_timeout_ms: 120000
verification:
mode: passive
dispatch:
channel: stdout
FILE:references/06-troubleshooting.md
# 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 `BDS_MCP_CALL_TIMEOUT_MS`; reduce `max_events` on streams, or use **poll** / smaller snapshot scope. |
| Empty stream | Idle chain / catch-up | Wait; check `from_epoch` in state file. |
| 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 **`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. |
FILE:references/01-quickstart.md
# 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 interactive stream or per-pool poll daemons, see **Hosts & integrators** in `SKILL.md` (`whale-radar.mjs`).
6. **Run a recipe** (stdout first):
- Whale cron (bounded, all pools): `node scripts/whale-cron.mjs`
- Whale Radar (stream / yaml poll): `node scripts/whale-radar.mjs`
- Token-Flow: `node scripts/token-flow.mjs`
- DeFi Analyst (one shot): `node scripts/defi-analyst.mjs --once`
7. **Telegram** — set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`, set `dispatch.channel` to `telegram` in the recipe yaml.
FILE:references/08-openclaw-one-shot.md
# 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`, **not** streaming or long-lived background processes.
---
## 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.
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. The user provides a private key for the payer wallet — run `node scripts/signup-pay.mjs` for pay-signup.
`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.*`:
`EVM_PRIVATE_KEY`, `EVM_RPC_URL`, `EVM_CHAIN_ID`, `PLAN_ID`, `TOKEN_SYMBOL`, `POWERLOOM_API_KEY`.
If the schema expects strings, pass `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:
- 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
(`POWERLOOM_API_KEY`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `BDS_MCP_CALL_TIMEOUT_MS=120000`,
`WHALE_CRON_THRESHOLD=10000`), then runs `node scripts/whale-cron.mjs`.
- Telegram: read `botToken` from OpenClaw `channels.telegram` / config; chat id from user or config.
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:
- Do NOT use `bds_mpp_stream_allTrades` for this cron — use `bds_mpp_snapshot_allTrades` only.
- 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`.
FILE:references/02-tool-catalog.md
# MCP tool catalog (cost / latency)
All calls are metered unless noted. **Defaults:** `BDS_MCP_CALL_TIMEOUT_MS=60000` (raise to 120000 for `bds_mpp_stream_allTrades` with `max_events=50`).
**Cron / OpenClaw:** prefer **snapshot** rows below for heartbeat jobs; reserve **stream** for dedicated long-running consumers (see `SKILL.md` → Hosts & integrators).
| Tool | Role | p95 latency (steady / backlog) | Notes |
|------|------|----------------------------------|--------|
| `bds_mpp_stream_allTrades` | Stream batch up to 50 epochs | ~2s connect + per-epoch upstream | Default Whale Radar / Token-Flow. |
| `bds_mpp_snapshot_trades_pool_address` | Pool snapshot | 5–35s variable | Poll fallback. |
| `bds_mpp_snapshot_allTrades` | One-shot all pools | 8–45s | Alternative to stream. |
| `bds_mpp_token_token_address_pools` | Pools for token | 1–5s | Token-Flow allowlist. |
| `bds_mpp_tradeVolume_pool_address_time_interval` | Volume | 1–5s | DeFi Analyst. |
| `bds_mpp_ethPrice` | ETH USD | 0.5–2s | Cached in recipes when possible. |
| `verify_data_provenance` | On-chain CID check | 0.5–2s | Server-side `eth_call` only; response does not include configured RPC. |
| `get_credit_balance` | Metering | 0.2–1s | Pre-flight. |
Replace placeholders after a 1-hour dry run on mainnet MCP.
FILE:references/04-credit-budget.md
# Credit budget (targets — validate in dry run)
| Recipe | Mode | Steady credits/hour (target) | Free tier (~100 cr) |
|--------|------|------------------------------|---------------------|
| Whale Radar | stream | ~300 | ~20 min |
| Token-Flow | stream | ~300 (+ optional cross-check) | ~19 min |
| 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.
FILE:references/05-data-market-scope.md
# Data market scope (ETH mainnet Uniswap V3)
## Canonical worked example (single pool)
| Pool | Address | Fee tier |
|------|---------|----------|
| WETH/USDC | `0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640` | 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:** `bds_mpp_snapshot_allTrades`, `bds_mpp_stream_allTrades`.
- **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.
FILE:references/07-prompt-patterns.md
# Prompt → tool mapping
| User intent | Prefer |
|-------------|--------|
| “All USDC swaps”, “every trade for token X” | `bds_mpp_stream_allTrades` or `bds_mpp_snapshot_allTrades` + **Token-Flow** recipe (`token-flow.yaml`). |
| “Watch one pool only” | `bds_mpp_snapshot_trades_pool_address` + pool address from discovery. |
| “Whale / large USD” | **Whale Radar** recipe + `threshold_usd`. |
| “Prove on-chain” / “verify CID” | `verify_data_provenance` with **exact** `epoch_id`, `project_id`, `cid` from API. |
| “Streaming live” | `bds_mpp_stream_allTrades` with `from_epoch` checkpoint. |
Avoid leading with a single pool address table in ad-hoc prompts — it biases weak models to one pool. Use this skill’s **task table** in `SKILL.md` instead.
FILE:references/03-verification.md
# `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 **`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.