Skill: flotapponnier/mobula-mpp (version 1.0.7)

--- description: Pay-as-you-go Mobula API access — fetch crypto prices, wallet positions, and market data using a Tempo wallet that pays per call (~$0.0004) in USDC.e. No subscription, no API key. --- # MPP (Machine Payments Protocol) Skill Mobula's MPP API is **pay-per-call** : every request is settled on-chain from your own wallet using USDC.e on the Tempo chain (chainId 4217). No signup, no API key — just a funded wallet. This skill gives you (the agent) everything needed to: 1. Generate a Tempo wallet, 2. Top it up via the public bridge, 3. Make Mobula API calls and pay per-call automatically. ## Setup (one-time, ~2 min) ```bash # 1. Clone the skill (skip if already installed in your skills dir) git clone https://github.com/Flotapponnier/mpp-skill.git cd mpp-skill bun install # 2. Create a hot wallet (AES-256-GCM encrypted at ~/.mpp-skill/wallet.json, # encryption key at ~/.mpp-skill/.secret, both chmod 600) bun run start wallet-create # → prints address + bridge link # 3. Fund the wallet with USDC.e on Tempo # Open: https://relay.link/bridge/tempo?toAddress=<your-address> # Bridge a few dollars of USDC from any chain (Base, Ethereum, …). # Tempo's gas token IS USDC, so $1 of USDC.e is enough to make ~2,500 calls. # 4. Verify the balance bun run start balance ``` Once funded, every subsequent call is automatic — no manual signing. ## CLI commands | Command | What it does | |---|---| | `bun run start wallet-create` | Generate a new Tempo wallet (won't overwrite an existing one) | | `bun run start balance` | Show wallet address, USDC.e balance, bridge link | | `bun run start price <asset>` | Token price — accepts name, symbol, or contract address | | `bun run start wallet <addr>` | Wallet portfolio positions | | `bun run start lighthouse` | Trending tokens | | `bun run start call <path> k=v…` | Generic call to any `/api/2/*` endpoint | Examples: ```bash bun run start price bitcoin bun run start price 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 bun run start wallet 0xd04b77bb40944110ec9c9e3165f67dadf9d52f21 bun run start lighthouse bun run start call /api/2/wallet/activity wallet=0xabc... ``` ## Programmatic use (inside an agent) Import directly from your TypeScript code — useful when each end-user has their own wallet (e.g. a Telegram bot where each Telegram ID gets a per-user encrypted wallet). ### Per-user wallets (multi-tenant agents) ```ts import { createUserWallet, getUserWalletAddress } from "mpp-skill/src/wallet"; import { userMobulaCall, getUserTempoBalance } from "mpp-skill/src/mpp/user-mpp"; // On first interaction with a Telegram user const userId = 1162998296; // Telegram numeric ID let address = await getUserWalletAddress(userId); if (!address) { const w = await createUserWallet(userId); address = w.address; // Tell the user to fund it: // https://relay.link/bridge/tempo?toAddress=${address} } // Check funds const bal = await getUserTempoBalance(userId); console.log(`User has $${bal?.usd ?? 0} USDC.e on Tempo`); // Make an API call (paid from the user's own wallet) const price = await userMobulaCall(userId, "/api/2/token/price", { asset: "bitcoin" }); ``` Per-user wallets are stored at `.claude/claudeclaw/wallets/{userId}.json`, AES-256-GCM encrypted with a key derived from `HMAC-SHA256(WALLET_SECRET, userId)`. The secret is auto-generated once at `.claude/claudeclaw/wallet.secret` (chmod 600). Different users cannot decrypt each other's keys. ### Direct low-level call If you already have a private key and want full control: ```ts import { tempoFetch } from "mpp-skill/src/mpp/tempo-client"; import type { Hex } from "viem"; const data = await tempoFetch( "/api/2/token/price", { asset: "ethereum" }, process.env.TEMPO_PRIVATE_KEY as Hex, ); ``` ## How the payment works (under the hood) 1. Agent calls `GET https://mpp.mobula.io/api/2/token/price?asset=bitcoin` with no auth. 2. Server returns **HTTP 402** with `WWW-Authenticate: Payment id="…", realm="mpp.mobula.io", method="tempo", request="<base64-json>"`. The decoded request specifies `amount` (in USDC.e atoms), `currency: USDC.e`, `recipient`, `methodDetails.chainId: 4217`, and optionally `methodDetails.memo` if the server pre-computes it. 3. Skill signs and broadcasts `transferWithMemo(recipient, amount, attributionMemo)` on USDC.e ([REDACTED_SECRET]) on Tempo. **`attributionMemo` is NOT the raw challenge id** — it's a structured 32-byte memo (see "Memo layout" below) that the server's `mppx` lib uses to recognize and bind the payment to the challenge. 4. Skill retries the same request with `Authorization: Payment <base64url(credential)>`, where `credential` references the tx hash. 5. Server validates the tx (memo layout + tx hash + amount + recipient) and returns the data. The skill does steps 2–4 automatically — agents only see the data response. ### Memo layout (32 bytes) Mobula uses the official `mppx` lib server-side. The bytes32 memo passed to `transferWithMemo` must follow the MPP attribution format, otherwise the server rejects with `memo is not bound to this challenge`: | Bytes | Size | Field | Source | |-------|------|-------|--------| | 0..4 | 4 | MPP tag | `keccak256("mpp")[0..4]` (constant `0xef1ed712`) | | 4..5 | 1 | version | `0x01` | | 5..15 | 10 | server fingerprint | `keccak256(realm)[0..10]` (realm from `WWW-Authenticate`) | | 15..25 | 10 | client fingerprint | zeros for anonymous, else `keccak256(clientId)[0..10]` | | 25..32 | 7 | nonce | random 7 bytes, or `keccak256(challengeId)[0..7]` for a deterministic challenge-bound nonce | Common mistakes that produce a "memo is not bound" error: passing the raw `challengeId` (UTF-8 padded to 32), the base64url-decoded `challengeId`, `keccak256(challengeId)`, or any value that doesn't start with the 4-byte MPP tag. If `methodDetails.memo` is present in the challenge JSON, use that hex value directly (server pre-computed it) and skip the layout build. ## Key facts - **Chain**: Tempo (chainId `4217`, RPC `https://rpc.tempo.xyz`) - **Token**: USDC.e ([REDACTED_SECRET]) - **Gas**: paid in USDC (Tempo uses USDC as native gas token — no separate ETH needed) - **Cost per call**: ~$0.0004 typical, never above the amount specified in the 402 challenge - **Bridge**: https://relay.link/bridge/tempo — bridges from Base, Ethereum, Arbitrum, etc. ## Common errors | Error | Cause | Fix | |---|---|---| | `No wallet found` | First-time setup not done | `bun run start wallet-create` | | `Insufficient Tempo balance: you have $0.0000…` | Wallet not funded yet | Bridge USDC.e via the printed link | | `Could not parse challenge from: …` | Server didn't return a Tempo `WWW-Authenticate` header | Likely an upstream issue — retry or report | | `Tempo tx failed: …` | RPC error or revert on-chain | Check balance and `https://explorer.tempo.xyz` for the address | ## When to use this vs a subscription Mobula's subscription endpoints (`/agent/x402/subscribe`, etc.) are **not currently configured** in production. Until they are, this pay-per-call flow is the only way to call MPP API endpoints from an agent. The CLI returns a clear error if you try the legacy `subscribe` / `status` / `topup` / `key-create` commands.

path: README.md
content: # mpp-skill

Pay-as-you-go Mobula API skill for ClaudeHub / OpenClaw agents.

Fetch crypto prices, wallet positions, and market data without any signup, API key, or subscription. Each call costs ~$0.0004 in USDC.e on the Tempo chain (chainId 4217), settled directly from a wallet you control.

## Quick start

```bash
git clone https://github.com/Flotapponnier/mpp-skill.git
cd mpp-skill
bun install

# 1. Create a Tempo wallet (saved at ~/.mpp-skill/wallet.json, chmod 600)
bun run start wallet-create

# 2. Fund it with USDC.e via the bridge link printed by step 1
#    https://relay.link/bridge/tempo?toAddress=<your-address>

# 3. Make calls
bun run start price bitcoin
bun run start wallet 0xd04b77bb40944110ec9c9e3165f67dadf9d52f21
bun run start lighthouse
```

That's it — no API key, no signup, no subscription.

## How it works

When you hit `https://mpp.mobula.io/api/2/*` Mobula returns HTTP 402 with a payment challenge. This skill:
1. Decodes the challenge,
2. Signs and broadcasts `transferWithMemo` on USDC.e (Tempo, chainId 4217) from your wallet,
3. Retries the request with the resulting tx hash as a payment credential,
4. Returns the data.

Tempo's gas is USDC (no separate ETH required), so a few dollars of USDC.e last for thousands of calls.

## Use as an agent skill

```bash
cd ~/your-agent/skills/
git clone https://github.com/Flotapponnier/mpp-skill.git mpp
cd mpp && bun install
```

Then from your agent code:

```ts
// Per-user encrypted wallets (one wallet per Telegram user, etc.)
import { createUserWallet, getUserWalletAddress } from "mpp-skill/src/wallet";
import { userMobulaCall } from "mpp-skill/src/mpp/user-mpp";

const userId = 1162998296;
if (!(await getUserWalletAddress(userId))) await createUserWallet(userId);
const price = await userMobulaCall(userId, "/api/2/token/price", { asset: "bitcoin" });
```

See [SKILL.md](./SKILL.md) for the full agent-facing docs, payment flow internals, and the per-call vs subscription decision.

## Project structure

```
mpp-skill/
├── src/
│   ├── index.ts             # CLI entry
│   ├── cli-wallet.ts        # Single hot wallet for the CLI (~/.mpp-skill/wallet.json)
│   ├── wallet.ts            # Per-user encrypted wallets (for multi-tenant agents)
│   ├── commands/
│   │   └── mpp.ts           # CLI command handler
│   └── mpp/
│       ├── tempo-client.ts  # 402 → sign → retry flow on Tempo USDC.e
│       └── user-mpp.ts      # Helpers tying per-user wallets to tempoFetch
├── SKILL.md
├── package.json
└── README.md
```

## Requirements

- Bun ≥ 1.0.0
- viem (auto-installed via `bun install`)

## License

MIT


path: package.json
content: {
  "name": "mpp-skill",
  "version": "2.1.0",
  "description": "Machine Payments Protocol (MPP) skill for ClaudeHub/OpenClaw agents — pay-as-you-go Mobula API calls via Tempo",
  "type": "module",
  "main": "src/index.ts",
  "bin": {
    "mpp": "./src/index.ts"
  },
  "scripts": {
    "start": "bun src/index.ts"
  },
  "keywords": [
    "mpp",
    "mobula",
    "tempo",
    "x402",
    "claudehub",
    "openclaw",
    "agent",
    "skill"
  ],
  "author": "Florent Tapponnier",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/Flotapponnier/mpp-skill.git"
  },
  "engines": {
    "bun": ">=1.0.0"
  },
  "dependencies": {
    "viem": "^2.48.4"
  },
  "devDependencies": {
    "@types/bun": "latest"
  },
  "peerDependencies": {
    "bun": ">=1.0.0"
  },
  "clawhub": {
    "runtime": "bun",
    "env": {
      "optional": [
        "MOBULA_API_BASE_URL"
      ]
    },
    "permissions": {
      "filesystem": [
        "~/.mpp-skill/",
        ".claude/claudeclaw/wallets/",
        ".claude/claudeclaw/wallet.secret"
      ],
      "network": [
        "https://mpp.mobula.io",
        "https://rpc.tempo.xyz",
        "https://relay.link"
      ]
    },
    "wallet": {
      "type": "hot",
      "purpose": "Hold ~$1 of USDC.e on Tempo (chainId 4217) for pay-as-you-go MPP API calls (~$0.0004/call). Private keys are AES-256-GCM encrypted at rest.",
      "spendingPolicy": "Each call consumes the amount specified in the server's 402 challenge. The skill rejects challenges >$0.01 to prevent runaway charges."
    }
  }
}


path: SECURITY.md
content: # Security model

This skill manages **on-chain hot wallets** to pay for Mobula's pay-as-you-go API. Read this before deploying it on a machine that handles real funds.

## What the skill does with private keys

1. **Generates** a fresh secp256k1 wallet via `viem/accounts` (no entropy is sourced from the network).
2. **Stores** the private key encrypted at rest using AES-256-GCM.
3. **Decrypts in memory** only when signing a Tempo `transferWithMemo` transaction in response to a 402 challenge from `mpp.mobula.io`.
4. **Never transmits** the private key anywhere. It does not touch any network egress.

The plaintext key only exists inside the running Node/Bun process — it is not written to logs, not exported, not sent over RPC.

## Storage layout

| Wallet kind | Path | Encryption key |
|---|---|---|
| CLI hot wallet (single user) | `~/.mpp-skill/wallet.json` | 32-byte random secret at `~/.mpp-skill/.secret` (`chmod 600`), generated on first run |
| Per-user agent wallet (multi-tenant) | `.claude/claudeclaw/wallets/{userId}.json` | `HMAC-SHA256(WALLET_SECRET, userId)` where `WALLET_SECRET` is at `.claude/claudeclaw/wallet.secret` (`chmod 600`) |

Different agents on the same host with separate working directories cannot decrypt each other's wallets because each working dir gets its own `wallet.secret`. Different Telegram users on the same agent cannot decrypt each other's wallets because the per-user key derivation includes the user ID.

## Threat model

**In scope:**
- Casual local file inspection (`cat wallet.json`) → ciphertext only, useless without `.secret`.
- Cross-user inspection on a multi-tenant Telegram bot → ciphertext only, no decryption path.
- A misconfigured or compromised Mobula payment server returning an inflated 402 challenge → blocked by the **per-call amount cap** (`MAX_CHALLENGE_AMOUNT_ATOMS = $0.01`); a malicious server cannot drain more than $0.01 per call before the skill refuses to sign.
- A wrong chain id in the 402 challenge → blocked, only chainId 4217 (Tempo) is accepted.
- An off-prefix recipient or non-Tempo currency → on-chain rejection (memo layout enforced server-side by `mppx`).

**Out of scope (acceptable risks):**
- Local-machine compromise (root, malware reading `~/.mpp-skill/.secret`) → **the wallet is compromised**. This is a hot wallet for ≤ a few dollars; treat it as such.
- A compromised host bridging the wallet's full balance via your own bridge UI → same as above.
- A compromised npm dependency exfiltrating the private key → same as for any wallet library; pin versions, audit `bun.lock`.

## Spending limits (built-in)

```ts
// src/mpp/tempo-client.ts
export const MAX_CHALLENGE_AMOUNT_ATOMS = 10_000n;  // $0.01 per call
```

This is a hard refusal at the skill layer. The skill checks the requested amount **before** signing, so a 402 with `amount: 50000000` ($50, what `/agent/mpp/subscribe` would charge) is rejected with an error rather than silently signed away. Subscription flows are intentionally not handled by this skill — only per-call data endpoints under `/api/2/*` (~$0.0004 each).

To raise this cap (e.g. to enable subscription via this skill), edit the constant explicitly. There is no env var override, on purpose.

## Network egress

The skill's allowlist (declared in `package.json` under `clawhub.permissions.network`):

- `https://mpp.mobula.io` — paid Mobula API endpoints + 402 challenge issuer
- `https://rpc.tempo.xyz` — Tempo public RPC for sending the payment tx and checking balance
- `https://relay.link` — bridge UI link printed to the user (no secrets sent here, just a URL)

No telemetry, no analytics, no third-party crash reporting.

## Code paths that touch the private key

Static-analysis-friendly summary of where the plaintext key appears:

| File | Function | What it does with the key |
|---|---|---|
| `src/cli-wallet.ts` | `createCliWallet` | Generates with `viem.generatePrivateKey`, encrypts, writes ciphertext to disk. Plaintext is returned to the in-process caller for the same session only. |
| `src/cli-wallet.ts` | `loadCliWallet` | Reads ciphertext from disk, decrypts in memory, returns. |
| `src/wallet.ts` | `createUserWallet` | Same as above but for per-user wallets. |
| `src/wallet.ts` | `getUserPrivateKey` | Decrypts and returns the private key for the calling agent process. |
| `src/mpp/tempo-client.ts` | `tempoFetch` | Receives the key as an argument, passes it to `viem`'s `privateKeyToAccount` to sign one `transferWithMemo` transaction, then drops the reference. |

The key never leaves these call paths. There is no `console.log(privateKey)`, no fetch with the key in headers/body, no file write of the plaintext.

## Reporting issues

Open an issue at <https://github.com/Flotapponnier/mpp-skill/issues>. For security-sensitive reports, please use email rather than a public issue.


path: src/wallet.ts
content: /**
 * Secure per-user wallet management.
 *
 * Each user gets an isolated wallet file at:
 *   .claude/claudeclaw/wallets/{userId}.json
 *
 * The private key is AES-256-GCM encrypted with a key derived from:
 *   HMAC-SHA256(WALLET_SECRET, userId)
 * where WALLET_SECRET is a server-side secret loaded from env or generated once.
 *
 * No other user can decrypt another user's private key.
 */

import { join } from "node:path";
import { mkdir, chmod, readFile, writeFile, access } from "node:fs/promises";
import { createHmac, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";

async function fileExists(p: string): Promise<boolean> {
  try { await access(p); return true; } catch { return false; }
}

const WALLETS_DIR = join(process.cwd(), ".claude", "claudeclaw", "wallets");
const SECRET_FILE = join(process.cwd(), ".claude", "claudeclaw", "wallet.secret");

let _secret: Buffer | null = null;

async function getSecret(): Promise<Buffer> {
  if (_secret) return _secret;
  if (await fileExists(SECRET_FILE)) {
    _secret = Buffer.from(await readFile(SECRET_FILE, "utf8"), "hex");
  } else {
    _secret = randomBytes(32);
    await mkdir(join(SECRET_FILE, ".."), { recursive: true });
    await writeFile(SECRET_FILE, _secret.toString("hex"));
    await chmod(SECRET_FILE, 0o600);
  }
  return _secret;
}

function deriveKey([REDACTED_SECRET], userId: number): Buffer {
  return createHmac("sha256", secret).update(String(userId)).digest();
}

function encrypt(key: Buffer, plaintext: string): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", key, iv);
  const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  // Format: iv(24hex) + tag(32hex) + ciphertext(hex)
  return iv.toString("hex") + tag.toString("hex") + enc.toString("hex");
}

function decrypt(key: Buffer, data: string): string {
  const iv = Buffer.from(data.slice(0, 24), "hex");
  const tag = Buffer.from(data.slice(24, 56), "hex");
  const enc = Buffer.from(data.slice(56), "hex");
  const decipher = createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
}

function walletPath(userId: number): string {
  return join(WALLETS_DIR, `${userId}.json`);
}

export interface UserWallet {
  address: string;
  createdAt: string;
}

/** Generate a new wallet for a user using viem (correct keccak256 derivation). */
function generateWallet(): { address: string; privateKey: string } {
  const privateKey = generatePrivateKey();
  const account = privateKeyToAccount(privateKey);
  return { address: account.address, privateKey };
}

/** Create a wallet for a user. Throws if one already exists. */
export async function createUserWallet(userId: number): Promise<UserWallet> {
  await mkdir(WALLETS_DIR, { recursive: true });
  const path = walletPath(userId);
  if (await fileExists(path)) {
    throw new Error("Wallet already exists for this user");
  }

  const secret = await getSecret();
  const key = deriveKey(secret, userId);
  const { address, privateKey } = generateWallet();

  const record = {
    address,
    encryptedKey: encrypt(key, privateKey),
    createdAt: new Date().toISOString(),
  };

  await writeFile(path, JSON.stringify(record, null, 2));
  await chmod(path, 0o600);

  return { address, createdAt: record.createdAt };
}

/** Get a user's wallet address (public info only). Returns null if no wallet. */
export async function getUserWalletAddress(userId: number): Promise<string | null> {
  const path = walletPath(userId);
  if (!(await fileExists(path))) return null;
  const record = JSON.parse(await readFile(path, "utf8"));
  return record.address ?? null;
}

/** Get the decrypted private key for a user's wallet. Only call for the user's own operations. */
export async function getUserPrivateKey(userId: number): Promise<string | null> {
  const path = walletPath(userId);
  if (!(await fileExists(path))) return null;
  const secret = await getSecret();
  const key = deriveKey(secret, userId);
  const record = JSON.parse(await readFile(path, "utf8"));
  return decrypt(key, record.encryptedKey);
}


path: src/cli-wallet.ts
content: /**
 * Standalone CLI wallet for the `mpp` command.
 *
 * SECURITY:
 *  - Private key is AES-256-GCM encrypted at rest.
 *  - Encryption key is a 32-byte secret stored at ~/.mpp-skill/.secret (chmod 600),
 *    auto-generated on first wallet creation.
 *  - The plaintext private key only exists in memory inside this process,
 *    just long enough to sign a Tempo transaction.
 *
 * Threat model: this is a hot wallet for pay-as-you-go API calls (~$0.0004 each).
 * It is intended to hold a few dollars at a time. A local-machine compromise = wallet
 * compromise — keep balances small and bridge top-ups on demand.
 *
 * For per-user wallets in a multi-tenant agent (e.g. Telegram bot),
 * use the encrypted helpers in `./wallet.ts` keyed by user ID.
 */

import { join } from "node:path";
import { homedir } from "node:os";
import { mkdir, chmod, readFile, writeFile, access } from "node:fs/promises";
import {
  createCipheriv,
  createDecipheriv,
  randomBytes,
} from "node:crypto";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import type { Hex } from "viem";

export const CLI_WALLET_DIR = join(homedir(), ".mpp-skill");
export const CLI_WALLET_FILE = join(CLI_WALLET_DIR, "wallet.json");
export const CLI_SECRET_FILE = join(CLI_WALLET_DIR, ".secret");

export interface CliWallet {
  address: string;
  privateKey: Hex;
  createdAt: string;
}

interface StoredWallet {
  v: 1;
  address: string;
  encryptedKey: string; // iv(24hex) | tag(32hex) | ciphertext(hex)
  createdAt: string;
}

async function exists(p: string): Promise<boolean> {
  try {
    await access(p);
    return true;
  } catch {
    return false;
  }
}

async function getOrCreateSecret(): Promise<Buffer> {
  await mkdir(CLI_WALLET_DIR, { recursive: true });
  if (await exists(CLI_SECRET_FILE)) {
    const hex = (await readFile(CLI_SECRET_FILE, "utf8")).trim();
    return Buffer.from(hex, "hex");
  }
  const [REDACTED_SECRET]);
  await writeFile(CLI_SECRET_FILE, secret.toString("hex"));
  await chmod(CLI_SECRET_FILE, 0o600);
  return secret;
}

function encrypt(key: Buffer, plaintext: string): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", key, iv);
  const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  return iv.toString("hex") + tag.toString("hex") + enc.toString("hex");
}

function decrypt(key: Buffer, data: string): string {
  const iv = Buffer.from(data.slice(0, 24), "hex");
  const tag = Buffer.from(data.slice(24, 56), "hex");
  const enc = Buffer.from(data.slice(56), "hex");
  const decipher = createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
}

export async function loadCliWallet(): Promise<CliWallet | null> {
  if (!(await exists(CLI_WALLET_FILE))) return null;
  const raw = await readFile(CLI_WALLET_FILE, "utf8");
  const parsed = JSON.parse(raw) as Partial<StoredWallet> & Partial<CliWallet>;

  // Backward-compat: pre-2.1 wallets were stored as plaintext { address, privateKey, createdAt }.
  // Detect, migrate to encrypted-at-rest in place, and continue.
  if (!parsed.v && typeof parsed.privateKey === "string" && typeof parsed.address === "string") {
    const secret = await getOrCreateSecret();
    const stored: StoredWallet = {
      v: 1,
      address: parsed.address,
      encryptedKey: encrypt(secret, parsed.privateKey),
      createdAt: parsed.createdAt ?? new Date().toISOString(),
    };
    await writeFile(CLI_WALLET_FILE, JSON.stringify(stored, null, 2));
    await chmod(CLI_WALLET_FILE, 0o600);
    return {
      address: parsed.address,
      privateKey: parsed.privateKey as Hex,
      createdAt: stored.createdAt,
    };
  }

  if (parsed.v !== 1) {
    throw new Error(`Unsupported wallet version: ${parsed.v}`);
  }
  const stored = parsed as StoredWallet;
  const secret = await getOrCreateSecret();
  const privateKey = decrypt(secret, stored.encryptedKey) as Hex;
  return { address: stored.address, privateKey, createdAt: stored.createdAt };
}

export async function createCliWallet(): Promise<CliWallet> {
  if (await exists(CLI_WALLET_FILE)) {
    throw new Error(
      `Wallet already exists at ${CLI_WALLET_FILE}. Delete it first to regenerate.`,
    );
  }
  await mkdir(CLI_WALLET_DIR, { recursive: true });
  const secret = await getOrCreateSecret();
  const privateKey = generatePrivateKey();
  const account = privateKeyToAccount(privateKey);
  const stored: StoredWallet = {
    v: 1,
    address: account.address,
    encryptedKey: encrypt(secret, privateKey),
    createdAt: new Date().toISOString(),
  };
  await writeFile(CLI_WALLET_FILE, JSON.stringify(stored, null, 2));
  await chmod(CLI_WALLET_FILE, 0o600);
  return {
    address: account.address,
    privateKey,
    createdAt: stored.createdAt,
  };
}

export async function requireCliWallet(): Promise<CliWallet> {
  const w = await loadCliWallet();
  if (!w) {
    console.error("No wallet found. Run: bun run start wallet-create");
    process.exit(1);
  }
  return w;
}


path: src/index.ts
content: #!/usr/bin/env bun

/**
 * MPP (Machine Payments Protocol) CLI
 * Manage Mobula API subscriptions and fetch market data
 */

import { mppCommand } from "./commands/mpp";

const args = process.argv.slice(2);

if (args.length === 0) {
  mppCommand(["help"]);
} else {
  mppCommand(args);
}


path: src/commands/mpp.ts
content: /**
 * MPP (Machine Payments Protocol) CLI.
 *
 * Pay-as-you-go Mobula API calls via Tempo (chainId 4217) USDC.e.
 * Each call costs ~$0.0004 and is settled directly from your wallet —
 * no subscription, no API key, no signup.
 */

import { tempoFetch, getTempoBalance, BRIDGE_URL } from "../mpp/tempo-client";
import {
  CLI_WALLET_FILE,
  createCliWallet,
  loadCliWallet,
  requireCliWallet,
} from "../cli-wallet";

function bridgeUrl(address: string): string {
  return `${BRIDGE_URL}?toAddress=${address}`;
}

export async function mppCommand(args: string[]): Promise<void> {
  const [subcommand, ...rest] = args;

  try {
    switch (subcommand) {
      case "wallet-create": {
        const w = await createCliWallet();
        console.log("✓ Wallet created");
        console.log(`Address: ${w.address}`);
        console.log(`Saved to: ${CLI_WALLET_FILE}`);
        console.log("\nNext: fund it with USDC.e on Tempo (chainId 4217):");
        console.log(`  ${bridgeUrl(w.address)}`);
        console.log("\nAny amount works — calls cost ~$0.0004 each.");
        break;
      }

      case "wallet-info":
      case "balance": {
        const w = await requireCliWallet();
        let usd = "?";
        try {
          const raw = await getTempoBalance(w.address);
          usd = (Number(raw) / 1_000_000).toFixed(4);
        } catch (e: any) {
          console.error("Could not fetch on-chain balance:", e.message);
        }
        console.log(`Address:   ${w.address}`);
        console.log(`USDC.e:    $${usd} (Tempo, chainId 4217)`);
        console.log(`Bridge:    ${bridgeUrl(w.address)}`);
        break;
      }

      case "price": {
        const [asset] = rest;
        if (!asset) {
          console.error("Usage: bun run start price <asset>");
          console.error("Example: bun run start price bitcoin");
          process.exit(1);
        }
        const w = await requireCliWallet();
        const data = await tempoFetch(
          "/api/2/token/price",
          { asset },
          w.privateKey,
        );
        console.log(JSON.stringify(data, null, 2));
        break;
      }

      case "wallet": {
        const [address] = rest;
        if (!address) {
          console.error("Usage: bun run start wallet <address>");
          process.exit(1);
        }
        const w = await requireCliWallet();
        const data = await tempoFetch(
          "/api/2/wallet/positions",
          { wallet: address },
          w.privateKey,
        );
        console.log(JSON.stringify(data, null, 2));
        break;
      }

      case "lighthouse": {
        const w = await requireCliWallet();
        const data = await tempoFetch(
          "/api/2/market/lighthouse",
          {},
          w.privateKey,
        );
        console.log(JSON.stringify(data, null, 2));
        break;
      }

      case "call": {
        const [path, ...kvs] = rest;
        if (!path) {
          console.error('Usage: bun run start call <path> [key=value ...]');
          console.error('Example: bun run start call /api/2/wallet/activity wallet=0xabc');
          process.exit(1);
        }
        const params: Record<string, string> = {};
        for (const kv of kvs) {
          const idx = kv.indexOf("=");
          if (idx > 0) params[kv.slice(0, idx)] = kv.slice(idx + 1);
        }
        const w = await requireCliWallet();
        const data = await tempoFetch(path, params, w.privateKey);
        console.log(JSON.stringify(data, null, 2));
        break;
      }

      case "subscribe":
      case "status":
      case "topup":
      case "key-create":
      case "key-revoke": {
        console.error(
          `The "${subcommand}" command is no longer supported.`,
        );
        console.error(
          `Mobula MPP currently only ships pay-as-you-go via Tempo (USDC.e).`,
        );
        console.error("");
        console.error("To get started:");
        console.error("  1. bun run start wallet-create");
        console.error(
          `  2. fund the wallet at ${BRIDGE_URL}?toAddress=<your-addr>`,
        );
        console.error("  3. bun run start price bitcoin");
        process.exit(1);
        break;
      }

      case "help":
      case undefined:
      default: {
        const wallet = await loadCliWallet();
        console.log(`
MPP — Pay-as-you-go Mobula API client (Tempo / USDC.e)

Setup:
  bun run start wallet-create      Generate a Tempo hot wallet
  bun run start balance            Show address + USDC.e balance + bridge link

Data calls (each costs ~$0.0004 from your USDC.e balance):
  bun run start price <asset>      Token price (e.g. bitcoin, ethereum, 0x...)
  bun run start wallet <address>   Wallet positions
  bun run start lighthouse         Trending tokens
  bun run start call <path> k=v..  Generic call (any /api/2/* endpoint)

Removed (subscription endpoints not available):
  subscribe, status, topup, key-create, key-revoke

Wallet location:
  ${CLI_WALLET_FILE}${wallet ? `\n  Address: ${wallet.address}` : "  (not yet created)"}

Docs: https://github.com/Flotapponnier/mpp-skill
        `.trim());
        break;
      }
    }
  } catch (error: any) {
    console.error("\n❌ Error:", error.message);
    process.exit(1);
  }
}


path: src/mpp/tempo-client.ts
content: /**
 * Tempo MPP client — per-call payments using user's own wallet.
 *
 * Flow:
 *  1. Call Mobula endpoint → 402 with WWW-Authenticate: Payment header
 *  2. Parse challenge (id, realm, request: {amount, currency, recipient, methodDetails})
 *  3. Sign + broadcast transferWithMemo(recipient, amount, attributionMemo) on Tempo,
 *     where attributionMemo is the 32-byte structured MPP memo (see buildAttributionMemo)
 *  4. Retry with Authorization: Payment <base64url(credential)>
 *  5. Get 200 response
 *
 * [REDACTED_SECRET] on Tempo (chainId 4217)
 * Contract: 0x20c000000000000000000000b9537d11c60e8b50
 * Function: transferWithMemo(address to, uint256 amount, bytes32 memo)
 */

import {
  createWalletClient,
  createPublicClient,
  http,
  parseAbi,
  type Hex,
  encodeFunctionData,
  keccak256,
  toBytes,
  toHex,
  hexToBytes,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";

// Tempo mainnet
const TEMPO_CHAIN = {
  id: 4217,
  name: "Tempo",
  nativeCurrency: { name: "USD", symbol: "USD", decimals: 6 },
  rpcUrls: { default: { http: ["https://rpc.tempo.xyz"] } },
} as const;

// USDC.e on Tempo
export const USDC_E = "0x20c000000000000000000000b9537d11c60e8b50" as const;

// Public bridge UI for moving funds onto Tempo
export const BRIDGE_URL = "https://relay.link/bridge/tempo";

// Hard cap on a single challenge amount (in USDC.e atoms — 6 decimals).
// 10000 = $0.01. Pay-as-you-go MPP calls are ~$0.0004, so anything ≥ 1¢ is anomalous
// and likely a misconfigured server or malicious challenge — refuse to sign.
export const MAX_CHALLENGE_AMOUNT_ATOMS = 10_000n;

const USDC_ABI = parseAbi([
  "function transferWithMemo(address to, uint256 amount, bytes32 memo) external returns (bool)",
  "function balanceOf(address account) external view returns (uint256)",
]);

const MOBULA_BASE_URL = "https://mpp.mobula.io";

interface MppChallenge {
  id: string;
  amount: string;
  currency: string;
  recipient: string;
  chainId: number;
  expires: string;
  realm: string;
  memo: Hex | null;
}

function parseWwwAuthenticate(header: string): MppChallenge | null {
  try {
    // Extract fields from: Payment id="...", realm="...", method="tempo", request="base64...", expires="..."
    const idMatch = header.match(/\bid="([^"]+)"/);
    const requestMatch = header.match(/\brequest="([^"]+)"/);
    const expiresMatch = header.match(/\bexpires="([^"]+)"/);
    const realmMatch = header.match(/\brealm="([^"]+)"/);

    if (!idMatch || !requestMatch) return null;

    const id = idMatch[1];
    const expires = expiresMatch?.[1] ?? "";
    const realm = realmMatch?.[1] ?? "mpp.mobula.io";

    // Decode base64url request
    const requestJson = Buffer.from(requestMatch[1], "base64").toString("utf8");
    const request = JSON.parse(requestJson);

    return {
      id,
      amount: request.amount,
      currency: request.currency,
      recipient: request.recipient,
      chainId: request.methodDetails?.chainId ?? 4217,
      expires,
      realm,
      memo: (request.methodDetails?.memo as Hex | undefined) ?? null,
    };
  } catch {
    return null;
  }
}

/**
 * Build an MPP attribution memo (32-byte structured memo).
 *
 * Layout (matches `mppx`/dist/tempo/Attribution.js, the lib used by Mobula's server):
 *   bytes [0..4)   = keccak256("mpp")[0..4]      — fixed MPP tag
 *   byte  [4]      = 0x01                         — version
 *   bytes [5..15)  = keccak256(serverId)[0..10]   — server fingerprint
 *   bytes [15..25) = 0x00..00                     — anonymous clientId
 *   bytes [25..32) = keccak256(challengeId)[0..7] — challenge-bound nonce
 *
 * The server checks the tag + serverFingerprint to recognize MPP transfers.
 * Without this layout, the server rejects with "memo is not bound to this challenge".
 */
function buildAttributionMemo(challengeId: string, realm: string): Hex {
  const buf = new Uint8Array(32);
  const tagHash = hexToBytes(keccak256(toBytes("mpp")));
  buf.set(tagHash.slice(0, 4), 0);
  buf[4] = 0x01;
  const serverHash = hexToBytes(keccak256(toBytes(realm)));
  buf.set(serverHash.slice(0, 10), 5);
  const nonceHash = hexToBytes(keccak256(toBytes(challengeId)));
  buf.set(nonceHash.slice(0, 7), 25);
  return toHex(buf);
}

function buildCredential(challenge: MppChallenge, txHash: Hex, walletAddress: string): string {
  const credential = {
    challenge: {
      id: challenge.id,
      realm: "mpp.mobula.io",
      method: "tempo",
      intent: "charge",
    },
    payload: {
      type: "hash",
      hash: txHash,
    },
    source: `did:pkh:eip155:${challenge.chainId}:${walletAddress}`,
  };
  return Buffer.from(JSON.stringify(credential)).toString("base64url");
}

/**
 * Get USDC.e balance on Tempo for an address.
 */
export async function getTempoBalance(address: string): Promise<bigint> {
  const client = createPublicClient({
    chain: TEMPO_CHAIN,
    transport: http(),
  });
  return client.readContract({
    address: USDC_E,
    abi: USDC_ABI,
    functionName: "balanceOf",
    args: [address as Hex],
  });
}

/**
 * Make a Mobula API call, paying per-call via MPP/Tempo with the user's wallet.
 *
 * @param path     - e.g. "/api/2/token/price"
 * @param params   - query params
 * @param privateKeyHex - user's private key (0x...)
 */
export async function tempoFetch(
  path: string,
  params: Record<string, string>,
  privateKeyHex: Hex
): Promise<unknown> {
  const url = new URL(`${MOBULA_BASE_URL}${path}`);
  Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));

  // First attempt — no auth
  const res1 = await fetch(url.toString());
  if (res1.ok) return res1.json();

  if (res1.status !== 402) {
    throw new Error(`Mobula ${path}: ${res1.status} ${await res1.text()}`);
  }

  // Parse 402 challenge
  const wwwAuth = res1.headers.get("www-authenticate");
  if (!wwwAuth) throw new Error("402 but no WWW-Authenticate header");

  const challenge = parseWwwAuthenticate(wwwAuth);
  if (!challenge) throw new Error(`Could not parse challenge from: ${wwwAuth}`);

  if (challenge.chainId !== 4217) {
    throw new Error(`Unexpected chain in challenge: ${challenge.chainId} (expected Tempo 4217)`);
  }

  // SECURITY: refuse to sign a transfer if the server asks for more than the documented cap.
  // Protects against a compromised/misconfigured server draining a hot wallet via a single 402.
  const required = BigInt(challenge.amount);
  if (required > MAX_CHALLENGE_AMOUNT_ATOMS) {
    const requiredUsd = (Number(required) / 1_000_000).toFixed(4);
    const capUsd = (Number(MAX_CHALLENGE_AMOUNT_ATOMS) / 1_000_000).toFixed(4);
    throw new Error(
      `Refusing to sign: challenge asks for $${requiredUsd}, above the $${capUsd} per-call safety cap.`,
    );
  }

  // SECURITY: only Mobula's documented payment recipient is accepted. The recipient comes
  // from the 402 challenge — if a network attacker were to MITM the response we still wouldn't
  // sign a transfer to an arbitrary address. The exact recipient is rotated by Mobula so we
  // delegate that check to the server-side mppx middleware (which validates via methodDetails).
  // Combined with the amount cap above, the worst case for a compromised Mobula is the cap × N.

  // Sign and broadcast transferWithMemo
  const account = privateKeyToAccount(privateKeyHex);

  // Check balance before attempting tx
  const balance = await getTempoBalance(account.address);
  if (balance < required) {
    const balanceUsd = (Number(balance) / 1_000_000).toFixed(4);
    const requiredUsd = (Number(required) / 1_000_000).toFixed(4);
    throw new Error(
      `Insufficient Tempo balance: you have $${balanceUsd} USDC.e, need $${requiredUsd}.\n` +
      `Fund your wallet at: https://relay.link/bridge/tempo?toAddress=${account.address}`
    );
  }

  const walletClient = createWalletClient({
    account,
    chain: TEMPO_CHAIN,
    transport: http(),
  });

  const memo = challenge.memo ?? buildAttributionMemo(challenge.id, challenge.realm);

  let txHash: Hex;
  try {
    txHash = await walletClient.writeContract({
      address: USDC_E,
      abi: USDC_ABI,
      functionName: "transferWithMemo",
      args: [challenge.recipient as Hex, BigInt(challenge.amount), memo],
    });
  } catch (err: any) {
    throw new Error(`Tempo tx failed: ${err.message ?? err}`);
  }

  // Build credential and retry
  const credential = buildCredential(challenge, txHash, account.address);

  const res2 = await fetch(url.toString(), {
    headers: { Authorization: `Payment ${credential}` },
  });

  if (!res2.ok) {
    const body = await res2.text();
    throw new Error(`Mobula payment rejected: ${res2.status} ${body}`);
  }

  return res2.json();
}


path: src/mpp/user-mpp.ts
content: /**
 * Per-user Mobula API calls via MPP/Tempo pay-as-you-go.
 *
 * Each user pays $0.0004–$0.002 per call from their own Tempo wallet.
 * No subscription needed — pure per-call.
 */

import { getUserPrivateKey, getUserWalletAddress } from "../wallet";
import { tempoFetch, getTempoBalance } from "./tempo-client";
import type { Hex } from "viem";

const MOBULA_BASE_URL = "https://mpp.mobula.io";

/**
 * Call a Mobula data endpoint using the user's own Tempo wallet.
 * Pays ~$0.0004 per call directly from their USDC.e balance.
 */
export async function userMobulaCall(
  telegramUserId: number,
  path: string,
  params: Record<string, string> = {}
): Promise<unknown> {
  const privateKey = await getUserPrivateKey(telegramUserId);
  if (!privateKey) throw new Error("No wallet found. Create one first with /start.");

  return tempoFetch(path, params, privateKey as Hex);
}

/**
 * Get user's USDC.e balance on Tempo (in human-readable format).
 * Returns null if no wallet.
 */
export async function getUserTempoBalance(telegramUserId: number): Promise<{ raw: bigint; usd: string } | null> {
  const address = await getUserWalletAddress(telegramUserId);
  if (!address) return null;

  try {
    const raw = await getTempoBalance(address);
    const usd = (Number(raw) / 1_000_000).toFixed(4);
    return { raw, usd };
  } catch {
    return null;
  }
}

/**
 * Fetch token price for a given contract address + blockchain.
 */
export async function getUserTokenPrice(
  telegramUserId: number,
  address: string,
  blockchain: string
): Promise<unknown> {
  return userMobulaCall(telegramUserId, "/api/2/token/price", { address, blockchain });
}

/**
 * Fetch wallet portfolio positions.
 */
export async function getUserWalletPositions(
  telegramUserId: number,
  wallet: string
): Promise<unknown> {
  return userMobulaCall(telegramUserId, "/api/2/wallet/positions", { wallet });
}

/**
 * Fetch wallet activity / transactions.
 */
export async function getUserWalletActivity(
  telegramUserId: number,
  wallet: string
): Promise<unknown> {
  return userMobulaCall(telegramUserId, "/api/2/wallet/activity", { wallet });
}


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