Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Bankr Skills equip builders with plug-and-play tools to build more powerful agen
| yoink | [yoink](yoink/) | Social on-chain game. "Yoink" a token from the current holder. Uses Bankr for transaction execution. |
| [Neynar](https://neynar.com) | [neynar](neynar/) | Full Farcaster API integration. Post casts, like, recast, follow users, search content, and manage Farcaster identities. |
| [Nookplot](https://nookplot.com) | [nookplot](nookplot/) | Decentralized agent coordination on Base. On-chain identity, messaging, bounties, marketplace escrow, knowledge mining, reputation, guilds, and 410 MCP tools via gasless meta-transactions. |
| [Obol](https://obol.org/stack) | [obol](obol/) | Buy services from Obol Agents such as RPC queries, specialised smart contract indices, work from agents with proprietary data and skills, and more. |
| [Quicknode](https://www.quicknode.com) | [quicknode](quicknode/) | Blockchain RPC and data access for all supported chains. Native/token balances, gas estimation, transaction status, and onchain queries for Base, Ethereum, Polygon, Solana, and Unichain. Supports API key and x402 pay-per-request access. |
| [Hydrex](https://hydrex.fi) | [hydrex](hydrex/) | Liquidity pools on Base. Lock HYDX for voting power, vote on pool strategies, deposit single-sided liquidity into auto-managed vaults, and claim oHYDX rewards. |
| [Helixa](https://helixa.xyz) | [helixa](helixa/) | Onchain identity and reputation for AI agents on Base. Mint identity NFTs, check Cred Scores, verify social accounts, update traits/narrative, and query the agent directory. Supports SIWA auth and x402 micropayments. |
Expand Down
110 changes: 110 additions & 0 deletions obol/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
name: obol
description: Pay for and call x402-monetized APIs sold by Obol Stack agents. Use when the user wants to call an HTTPS endpoint that returns `402 Payment Required` (typically `https://<host>/services/<name>/*` on an Obol Stack tunnel) and pay in USDC or $OBOL via the Bankr Wallet API. Signs EIP-3009 `TransferWithAuthorization` for USDC and, for $OBOL on mainnet, a Permit2 `PermitWitnessTransferFrom` plus a gasless EIP-2612 permit settled by the Obol facilitator (`x402.gcp.obol.tech`). Reads asset decimals so a 1 OBOL price isn't misread as a trillion-dollar USDC ask. Buy-side only.
metadata:
{
"clawdbot":
{
"emoji": "♾️",
"homepage": "https://obol.org/stack",
"requires": { "bins": ["bun"] },
},
}
---

# Obol (x402 buy-side)

Call paid APIs hosted by Obol Stack agents. A seller exposes services behind `/services/<name>/*` on a public tunnel; hitting one without payment returns HTTP 402 with a JSON payment challenge. This skill signs the challenge with the Bankr Wallet API and retries — the agent gets the response, the seller gets paid.

**Buy-side only.** Not for installing the Obol Stack, running validators, or selling your own endpoints.

Scripts are TypeScript on `bun` (already in the Bankr sandbox), no extra deps:
- `scripts/obol-x402-call.ts` — probe / pay / call (the one you run).
- `scripts/obol-skill-list.ts` — fetch a host's free `/skill.md` catalogue.
- `scripts/x402.ts` — protocol library (EIP-712 schemas, domains, envelope) imported by the call script; the source of truth for wire details. Don't run it directly.

## When to use

- The user wants to call an x402-protected URL (any HTTPS endpoint returning 402).
- They want to pay in USDC (Base, Base Sepolia, or mainnet) or $OBOL on mainnet (gasless via the Obol facilitator).
- They want to discover what an Obol Stack host sells (fetch `<host>/skill.md`).

## Setup

Relies on the Bankr skill's config at `~/.clawdbot/skills/bankr/config.json`. The API key must have **Wallet API** access and not be read-only — signing typed data is blocked otherwise.

```bash
test -f ~/.clawdbot/skills/bankr/config.json && echo OK || echo "set up the bankr skill first"
```

The buyer EVM address comes from `GET /wallet/me` automatically. Pass `--from 0x...` to pin a specific address.

## Quick start

```bash
# Probe a service — show price / network / asset / signing path, do NOT pay
bun ~/.clawdbot/skills/obol/scripts/obol-x402-call.ts --probe \
https://example.trycloudflare.com/services/hello

# Pay and call
bun ~/.clawdbot/skills/obol/scripts/obol-x402-call.ts \
https://example.trycloudflare.com/services/hello

# POST a JSON body; cap the spend (base units) and pin the chain
bun ~/.clawdbot/skills/obol/scripts/obol-x402-call.ts -X POST -d '{"prompt":"hi"}' \
--max-amount 1000000 --network base https://example.trycloudflare.com/services/quant
```

The script prints the parsed challenge (price + network + asset + signing path) on stderr before signing; the paid response body goes to stdout. `-v` prints intermediate state.

**Always `--probe` first on a URL you haven't called** — the seller posts the price, and `--probe` shows it without committing.

## How the flow works

1. Unpaid request → `402` with `accepts[]` (scheme, network, asset, `amount`, `payTo`, `extra`) and optional top-level `extensions`.
2. The script takes `accepts[0]`, resolves decimals (built-in registry for USDC/OBOL, else on-chain `decimals()`), and prints a human-readable price.
3. It signs the path from `extra.assetTransferMethod`:
- **`eip3009`** → EIP-3009 `TransferWithAuthorization` (USDC).
- **`permit2`** → Permit2 `PermitWitnessTransferFrom`; if the seller advertises `extensions.eip2612GasSponsoring` (OBOL on mainnet) it *also* signs an EIP-2612 permit the Obol facilitator submits gaslessly.
4. `POST /wallet/sign` (Bankr, `eth_signTypedData_v4`) for each signature.
5. Base64-encodes the `{x402Version, accepted, payload, extensions?}` envelope and retries with the `X-PAYMENT` header (`redirect: "manual"` — a signed voucher is never replayed to a redirected host).
6. `200` → prints body + decodes the `X-PAYMENT-RESPONSE` settlement receipt.

**Critical**: `amount` is in base units, not USD. OBOL = 18 decimals; USDC = 6. Misreading OBOL as USDC overshoots by 10^12 — the script reads decimals and shows the formatted price so this can't happen.

## Payment rails

| Asset | Network | `assetTransferMethod` | Buyer gas? |
|-------|---------|-----------------------|------------|
| USDC | base, base-sepolia, mainnet | `eip3009` | No — facilitator settles |
| **$OBOL** | mainnet | `permit2` + `eip2612GasSponsoring` | **No** — Obol facilitator submits the permit |

The seller picks the network and method; the script picks the signing path. Known chains (signing domains + USDC addresses): mainnet, base, base-sepolia. Other `eip155:<id>` chains work for `eip3009` if the seller advertises `extra.eip712Domain` and you pass `--rpc-url`.

## Discovery: the seller's `/skill.md`

Every Obol Stack tunnel publishes a free catalogue at `<host>/skill.md` (also `<host>/api/services.json`) listing service names, prices, and URLs. Only the `/services/<name>/*` URLs cost money.

```bash
bun ~/.clawdbot/skills/obol/scripts/obol-skill-list.ts https://example.trycloudflare.com
```

## Troubleshooting

- **Wildly wrong price (trillions)**: raw base units without decimals — route through `--probe`, which formats correctly.
- **Paid request 402'd again**: envelope rejected. Re-run `--probe`; if `payTo`/`asset`/`extra` changed the seller rotated, just re-run. Otherwise the signature didn't verify (wrong domain `name`/`version`, chainId, or expired deadline) — re-running signs fresh. For USDC the EIP-712 domain isn't always `extra.name` (e.g. base-sepolia is "USDC"); `x402.ts` resolves it per chain.
- **`403` from `/wallet/sign`**: Bankr key is read-only or lacks Wallet API access. New key at [bankr.bot/api](https://bankr.bot/api).
- **`unknown x402 network`** or **new token shows wrong decimals**: extend `NET_ALIAS`/`CHAINS`/`tokenMeta` in `scripts/x402.ts` (unknown tokens fall back to on-chain `decimals()`).
- **permit2 without gas sponsoring**: needs a one-time `approve(Permit2, …)` on the token, which this client doesn't do — the script warns. Use a sponsored (OBOL) seller or approve Permit2 separately.
- **`scheme` is not `exact`**: out of scope — show the user the full 402 body.

## Security

- The `X-PAYMENT` value authorises a specific amount, recipient, and expiry; facilitators record nonces so replay is rejected. The voucher deadline is short and used immediately.
- The retry uses `redirect: "manual"` so a redirecting seller can't replay your voucher to another host. Never log the `X-PAYMENT` header persistently.
- The Bankr API key is the sensitive bit — it lives in `~/.clawdbot/skills/bankr/config.json` (gitignored by Bankr). Never echo it.

## Resources

- Obol Stack docs: <https://docs.obol.org/obol-stack/> · facilitator: <https://x402.gcp.obol.tech>
- x402 protocol: <https://www.x402.org/> · Bankr signing: <https://docs.bankr.bot/wallet-api/sign>
39 changes: 39 additions & 0 deletions obol/scripts/obol-skill-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bun
// @ts-nocheck — runs under bun; IDE doesn't have @types/node loaded.
/**
* obol-skill-list.ts — fetch the `/skill.md` catalogue from an Obol Stack host.
*
* Every Obol Stack tunnel publishes a `/skill.md` at the tunnel root listing the services
* it sells. This route is unauthenticated and free — only the `/services/<name>/*` URLs
* below it cost money.
*
* Usage:
* bun obol-skill-list.ts HOST
*
* Examples:
* bun obol-skill-list.ts https://example.trycloudflare.com
* bun obol-skill-list.ts https://my-stack.example.com
*/

async function main() {
const host = process.argv[2];
if (!host) {
console.error("Usage: bun obol-skill-list.ts HOST");
process.exit(2);
}
const url = host.replace(/\/$/, "") + "/skill.md";
const r = await fetch(url);
const text = await r.text();
if (!r.ok) {
console.error(`GET ${url} → HTTP ${r.status}`);
if (text) console.error(text);
console.error("(no /skill.md catalogue here — host may not be an Obol Stack tunnel, or the route isn't published)");
process.exit(1);
}
process.stdout.write(text);
}

main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
143 changes: 143 additions & 0 deletions obol/scripts/obol-x402-call.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env bun
// @ts-nocheck — runs under bun.
// obol-x402-call.ts — pay an x402-protected URL via the Bankr Wallet API (buy-side).
// probe 402 → sign ONE voucher → retry with X-PAYMENT. EIP-712 lives in x402.ts;
// wire details in ../references/x402-protocol.md

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { CHAINS, UA, resolveChain, chainCfg, assetMeta, fmt, buildEnvelope } from "./x402.ts";

const USAGE = `bun obol-x402-call.ts [options] URL
-X, --method M HTTP method (default GET)
-d, --data BODY request body (sent on probe + paid request)
-H, --header H extra "Key: value" header (repeatable)
--probe show the 402 challenge and exit; do NOT pay
--max-amount N refuse to sign if price exceeds N base units
--network NET abort unless the seller's chain matches NET
--from 0x.. buyer address (default: from /wallet/me)
--rpc-url URL RPC for on-chain reads (decimals, permit nonce)
-v, --verbose log to stderr -h, --help this message`;

const die = (m) => { console.error(`ERROR: ${m}`); process.exit(1); };

function parseArgs(argv) {
const a = { method: "GET", headers: {}, probe: false, verbose: false };
for (let i = 0; i < argv.length; i++) {
const t = argv[i];
if (t === "-X" || t === "--method") a.method = argv[++i];
else if (t === "-d" || t === "--data") a.body = argv[++i];
else if (t === "-H" || t === "--header") {
const h = argv[++i], k = h.indexOf(":");
if (k < 0) die(`bad header (need 'Key: value'): ${h}`);
a.headers[h.slice(0, k).trim()] = h.slice(k + 1).trim();
} else if (t === "--probe") a.probe = true;
else if (t === "--max-amount") a.maxAmount = BigInt(argv[++i]);
else if (t === "--network") a.network = argv[++i];
else if (t === "--from") a.from = argv[++i];
else if (t === "--rpc-url") a.rpcUrl = argv[++i];
else if (t === "-v" || t === "--verbose") a.verbose = true;
else if (t === "-h" || t === "--help") { process.stdout.write(USAGE + "\n"); process.exit(0); }
else if (t.startsWith("-")) die(`unknown flag: ${t}`);
else a.url = t;
}
if (!a.url) die("URL is required (use -h for usage).");
return a;
}

function loadBankrConfig() {
const path = join(process.env.CLAWDBOT_HOME ?? join(homedir(), ".clawdbot"), "skills", "bankr", "config.json");
if (!existsSync(path)) die(`missing ${path} — set up the bankr skill first.`);
const cfg = JSON.parse(readFileSync(path, "utf8"));
if (!cfg.apiKey) die(`apiKey missing in ${path}`);
return { apiKey: cfg.apiKey, apiUrl: cfg.apiUrl ?? "https://api.bankr.bot" };
}

async function bankr(cfg, path, init) {
const r = await fetch(`${cfg.apiUrl}${path}`, init);
const text = await r.text();
if (!r.ok) die(`${path} returned ${r.status}: ${text}`);
try { return JSON.parse(text); } catch { die(`${path} returned non-JSON: ${text}`); }
}
async function walletAddress(cfg, override) {
if (override) return override;
const b = await bankr(cfg, "/wallet/me", { headers: { "X-API-Key": cfg.apiKey } });
for (const c of [b?.address, b?.wallet?.address, b?.evmAddress, ...(b?.addresses ?? []), ...((b?.wallets ?? []).map((w) => w?.address))])
if (typeof c === "string" && /^0x[0-9a-fA-F]{40}$/.test(c)) return c;
die(`no EVM address in /wallet/me — pass --from 0x...`);
}
function signer(cfg, log) {
return async (typedData) => {
log(`sign ${typedData.primaryType}`);
const b = await bankr(cfg, "/wallet/sign", {
method: "POST", headers: { "X-API-Key": cfg.apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ signatureType: "eth_signTypedData_v4", typedData }),
});
if (typeof b?.signature !== "string") die(`no signature from /wallet/sign — key may be read-only or lack Wallet API access.`);
return b.signature;
};
}

async function main() {
const args = parseArgs(process.argv.slice(2));
const log = (m) => args.verbose && console.error(`» ${m}`);

const headers = { "User-Agent": UA, ...args.headers };
if (args.body && !("Content-Type" in headers)) headers["Content-Type"] = "application/json";
log(`unpaid ${args.method} ${args.url}`);
const first = await fetch(args.url, { method: args.method, headers, body: args.body });
const firstText = await first.text();
if (first.status === 200) { process.stdout.write(firstText); return; }
if (first.status !== 402) { process.stderr.write(firstText + "\n"); die(`expected 200 or 402, got ${first.status}`); }

let ch; // parse the 402 challenge
try { ch = JSON.parse(firstText); } catch { die(`402 body is not JSON:\n${firstText}`); }
const accept = ch?.accepts?.[0];
if (!accept) die(`no payment challenge in 402 response`);
const extensions = ch.extensions ?? {}, extra = accept.extra ?? {};
const network = accept.network, chainId = resolveChain(network);
const rpc = args.rpcUrl ?? chainCfg(chainId).rpc;
const scheme = accept.scheme ?? "exact";
const asset = accept.asset ?? chainCfg(chainId).usdc;
if (!asset) die(`402 has no asset and no canonical USDC for ${chainCfg(chainId).name}`);
const payTo = accept.payTo; if (!payTo) die(`402 missing payTo`);
const amount = String(accept.amount ?? accept.maxAmountRequired ?? die(`402 has no amount`));
const method = extra.assetTransferMethod ?? "eip3009";
const { symbol, decimals } = await assetMeta(chainId, asset, rpc, extra.name);
const amountBI = BigInt(amount), human = fmt(amountBI, decimals, symbol);
const sponsored = "eip2612GasSponsoring" in extensions;
const q = { scheme, network, chainId, asset, payTo, amount, method, symbol, sponsored };

console.error(`x402 challenge:
version: ${ch.x402Version ?? 1} scheme: ${scheme}
network: ${network} (chainId ${chainId})
asset: ${asset} (${symbol}, ${decimals} dec)
payTo: ${payTo}
price: ${human} (= ${amount} base units)
path: ${method === "permit2" ? `Permit2 witness${sponsored ? " + EIP-2612 sponsoring" : ""}` : "EIP-3009"}`);
if (args.probe) return;

if (args.network && resolveChain(args.network) !== chainId) die(`seller is on ${chainCfg(chainId).name} but --network ${args.network} requested.`);
if (args.maxAmount != null && amountBI > args.maxAmount) die(`price ${amount} base units (${human}) exceeds --max-amount ${args.maxAmount} — refusing.`);
if (scheme !== "exact") die(`unsupported scheme: ${scheme} (only 'exact')`);

// sign the voucher
const cfg = loadBankrConfig();
const from = await walletAddress(cfg, args.from);
log(`buyer wallet: ${from}`);
const deadline = String(Math.floor(Date.now() / 1000) + Math.max(3600, (accept.maxTimeoutSeconds ?? 60) + 600)); // outlive the seller's settle window
const { env, b64 } = await buildEnvelope({ q, accept, extra, extensions, from, rpc, deadline, sign: signer(cfg, log) });
log(`envelope: ${JSON.stringify(env)}`);

// retry; redirect:"manual" stops a signed voucher being replayed to a redirected host.
log(`retry with X-PAYMENT`);
const second = await fetch(args.url, { method: args.method, headers: { ...headers, "X-PAYMENT": b64 }, body: args.body, redirect: "manual" });
const secondText = await second.text();
const receipt = second.headers.get("x-payment-response");
if (receipt) try { console.error(`payment receipt: ${JSON.stringify(JSON.parse(Buffer.from(receipt, "base64").toString("utf8")))}`); } catch {}
if (second.status !== 200) { process.stderr.write(secondText + "\n"); die(`paid request returned ${second.status}. Check chain/asset and that ${from} holds ≥ ${human}.`); }
process.stdout.write(secondText);
}

main().catch((e) => die(e instanceof Error ? e.message : String(e)));
Loading