From a60a10b2998987a8792c1a2b0aa0e5f36a96be7d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 25 Mar 2026 14:38:44 +0100 Subject: [PATCH 1/2] ai: lsp rest api agent skill --- .../blocktank-api/.claude-plugin/plugin.json | 4 + .claude/plugins/blocktank-api/README.md | 29 ++ .../plugins/blocktank-api/skills/lsp/SKILL.md | 163 ++++++ .../skills/lsp/references/api-reference.md | 472 ++++++++++++++++++ .../blocktank-api/skills/lsp/scripts/lsp.sh | 59 +++ README.md | 8 +- lsp | 2 + 7 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 .claude/plugins/blocktank-api/.claude-plugin/plugin.json create mode 100644 .claude/plugins/blocktank-api/README.md create mode 100644 .claude/plugins/blocktank-api/skills/lsp/SKILL.md create mode 100644 .claude/plugins/blocktank-api/skills/lsp/references/api-reference.md create mode 100755 .claude/plugins/blocktank-api/skills/lsp/scripts/lsp.sh create mode 100755 lsp diff --git a/.claude/plugins/blocktank-api/.claude-plugin/plugin.json b/.claude/plugins/blocktank-api/.claude-plugin/plugin.json new file mode 100644 index 000000000..9cddb992e --- /dev/null +++ b/.claude/plugins/blocktank-api/.claude-plugin/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "blocktank-api", + "description": "Interact with the Blocktank LSP API for Lightning testing during bitkit development." +} diff --git a/.claude/plugins/blocktank-api/README.md b/.claude/plugins/blocktank-api/README.md new file mode 100644 index 000000000..e7ec6691e --- /dev/null +++ b/.claude/plugins/blocktank-api/README.md @@ -0,0 +1,29 @@ +# Blocktank API Plugin + +A Claude Code plugin that gives Claude knowledge of the full Blocktank LSP API, enabling it to autonomously create channels, fund them, mine blocks, pay invoices, and close channels during Blocktank LSP testing. + +## Usage + +Once installed, the skill auto-triggers when you mention things like: +- "mine blocks", "deposit sats", "pay invoice", "force close" +- "channel order", "CJIT", "blocktank", "LSP" + +Claude will use the `./lsp` wrapper at the repo root to make API calls directly. + +## Configuration + +The default API base URL is `https://api.stag0.blocktank.to/blocktank/api/v2` (staging). + +To override (e.g., for a local instance): + +```bash +export BLOCKTANK_API_URL=http://localhost:9000/api +``` + +## Cross-project reuse + +To use this plugin from another repo (e.g. bitkit-ios), symlink it into that project's `.claude/plugins/`: + +```bash +ln -s /path/to/bitkit-android/.claude/plugins/blocktank-api /path/to/other-repo/.claude/plugins/blocktank-api +``` diff --git a/.claude/plugins/blocktank-api/skills/lsp/SKILL.md b/.claude/plugins/blocktank-api/skills/lsp/SKILL.md new file mode 100644 index 000000000..1f625fe9b --- /dev/null +++ b/.claude/plugins/blocktank-api/skills/lsp/SKILL.md @@ -0,0 +1,163 @@ +--- +name: lsp +description: > + This skill should be used when the user asks to interact with the Blocktank LSP API, + "mine blocks", "deposit sats", "pay invoice", "force close a channel", "create a channel order", + "open a channel", "estimate fees", "create a CJIT channel", or mentions "blocktank", "regtest", + "LSP", or Lightning channel testing workflows during bitkit development. +version: 0.1.0 +--- + +# Blocktank LSP API + +Blocktank is the Lightning Service Provider (LSP) used by Bitkit. This skill provides full knowledge of its REST API and a utility script to call any endpoint from the command line. + +No authentication is required. All requests and responses use JSON. + +## Configuration + +**Default base URL:** `https://api.stag0.blocktank.to/blocktank/api/v2` (staging) + +Override with the `BLOCKTANK_API_URL` environment variable: +- Local instance: `http://localhost:9000/api` + +## API Script + +Call any endpoint using the `./lsp` wrapper at the repo root: + +```bash +./lsp [json_body] +``` + +Examples: + +```bash +# Get service info +./lsp GET /info + +# Create a channel order +./lsp POST /channels '{"lspBalanceSat":100000,"channelExpiryWeeks":12}' + +# Mine 6 blocks +./lsp POST /regtest/chain/mine '{"count":6}' + +# Deposit to an address +./lsp POST /regtest/chain/deposit '{"address":"bcrt1q...","amountSat":500000}' +``` + +The script outputs raw JSON. Pipe to `jq` for formatting if needed. + +On HTTP errors (4xx/5xx), the script prints the status code to stderr and the error response body to stdout, then exits with code 1. + +## Endpoint Quick Reference + +### Service Info + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/info` | Service info, LSP nodes, channel size limits, fee rates | + +### Channel Orders + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/channels` | Create a channel order | +| GET | `/channels/:id` | Get order by ID | +| GET | `/channels?ids[]=` | Get multiple orders (1-50 IDs) | +| POST | `/channels/:id/open` | Open a paid channel | +| GET | `/channels/:id/min-0conf-tx-fee` | Get 0-conf fee window | +| POST | `/channels/estimate-fee` | Estimate order fee | +| POST | `/channels/estimate-fee-full` | Estimate fee with breakdown | + +### CJIT (Just-In-Time Channels) + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/cjit` | Create a JIT channel entry | +| GET | `/cjit/:id` | Get CJIT entry status | + +### Gift + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/gift/pay` | Pay a gift invoice | +| POST | `/gift/order` | Create a gift order | +| GET | `/gift/:id` | Get gift info | + +### Regtest Tools (regtest only) + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/regtest/chain/mine` | Mine blocks (default: 1) | +| POST | `/regtest/chain/deposit` | Deposit sats to address (default: 100,000) | +| POST | `/regtest/channel/pay` | Pay a Lightning invoice | +| GET | `/regtest/channel/pay/:id` | Get payment status | +| POST | `/regtest/channel/close` | Force close a channel | + +## Common Workflows + +### Workflow A: Purchase a Channel + +1. **Get service info** — `GET /info` to retrieve LSP node pubkeys and channel size limits +2. **Create order** — `POST /channels` with `lspBalanceSat`, `channelExpiryWeeks`, and optional `clientBalanceSat` +3. **Extract payment info** — from response: `payment.onchain.address` (bitcoin address) and `feeSat` (amount to pay) +4. **Fund the order** — `POST /regtest/chain/deposit` with the payment address and fee amount +5. **Confirm payment** — `POST /regtest/chain/mine` with `count: 1` to mine a block +6. **Poll order status** — `GET /channels/:id` until `state2` becomes `paid` +7. **Open channel** — `POST /channels/:id/open` with the client's `connectionStringOrPubkey` +8. **Confirm channel** — `POST /regtest/chain/mine` with `count: 6` to fully confirm + +### Workflow B: CJIT Channel (Just-In-Time) + +1. **Get service info** — `GET /info` for node pubkeys and limits +2. **Create CJIT entry** — `POST /cjit` with `channelSizeSat`, `invoiceSat`, `nodeId`, `channelExpiryWeeks` +3. **Extract invoice** — from response: `invoice.request` (bolt11 invoice string) +4. **Client pays invoice** — the mobile app pays the invoice, triggering automatic channel opening +5. **Poll status** — `GET /cjit/:id` until `state` becomes `completed` + +### Workflow C: Force Close a Channel + +1. **Get order info** — `GET /channels/:id` to find `channel.fundingTx.id` and `channel.fundingTx.vout` +2. **Close channel** — `POST /regtest/channel/close` with `fundingTxId`, `vout`, and `forceCloseAfterSec: 0` for immediate close +3. **Mine blocks** — `POST /regtest/chain/mine` with `count: 6` to finalize the closure + +## State Machines + +### Order States (`state2`) + +``` +created → paid → executed + ↘ expired +``` + +- `created` — waiting for payment +- `paid` — payment confirmed, ready to open channel +- `executed` — channel opened successfully +- `expired` — order timed out + +### Payment States (`payment.state2`) + +``` +created → paid → refundAvailable → refunded + ↘ canceled +``` + +### Channel States (`channel.state`) + +``` +opening → open → closed +``` + +### CJIT States (`state`) + +``` +created → completed + ↘ expired + ↘ failed +``` + +## Detailed API Reference + +For full request/response schemas, field constraints, and error codes for every endpoint, consult: + +- **`references/api-reference.md`** — Complete API reference with all fields, types, defaults, and validation rules diff --git a/.claude/plugins/blocktank-api/skills/lsp/references/api-reference.md b/.claude/plugins/blocktank-api/skills/lsp/references/api-reference.md new file mode 100644 index 000000000..994c6ec54 --- /dev/null +++ b/.claude/plugins/blocktank-api/skills/lsp/references/api-reference.md @@ -0,0 +1,472 @@ +# Blocktank LSP API Reference + +Complete reference for all Blocktank LSP HTTP API endpoints. + +Base URL: `https://api.stag0.blocktank.to/blocktank/api/v2` (staging) + +--- + +## Service Info + +### GET /info + +General information about the service, LSP nodes, and channel configuration limits. + +**Response:** + +```json +{ + "version": 2, + "versions": { + "http": "2.5.1", + "btc": "1.4.0", + "ln2": "1.25.0" + }, + "nodes": [ + { + "alias": "Blocktank", + "pubkey": "0296b2db342fcf87ea94d981757fdf4d3e545bd5cef4919f58b5d38dfdd73bf5c9", + "connectionStrings": [ + "0296b2db342fcf87ea94d981757fdf4d3e545bd5cef4919f58b5d38dfdd73bf5c9@172.19.0.2:9735" + ] + } + ], + "options": { + "minChannelSizeSat": 1000, + "maxChannelSizeSat": 3170000, + "minExpiryWeeks": 1, + "maxExpiryWeeks": 53, + "minPaymentConfirmations": 0, + "minHighRiskPaymentConfirmations": 1, + "max0ConfClientBalanceSat": 317000 + }, + "onchain": { + "feeRates": { + "fast": 54, + "mid": 50, + "slow": 49 + } + } +} +``` + +--- + +## Channel Orders + +### POST /channels + +Create a new channel order. + +**Request body:** + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `lspBalanceSat` | integer | Yes | — | LSP-side balance. Min 20,000 sat. | +| `channelExpiryWeeks` | integer | Yes | — | Lease duration. Between minExpiryWeeks and maxExpiryWeeks from /info. | +| `clientBalanceSat` | integer | No | 0 | Client-side balance. Must be <= lspBalanceSat. | +| `zeroConf` | boolean | No | false | Turbo channel (0-conf channel open). | +| `zeroConfPayment` | boolean | No | null | Accept 0-conf onchain payment. Cannot use with clientBalanceSat. | +| `zeroReserve` | boolean | No | false | Zero channel reserve (dust limit). | +| `couponCode` | string | No | null | Discount code. Max 128 chars. | +| `discountCode` | string | No | null | Discount code (newer field). Max 128 chars. | +| `source` | string | No | null | Order source tracking. Max 128 chars. | +| `lspNodeId` | string | No | null | Specific LSP node pubkey. Must be from /info nodes list. | +| `clientNodeId` | string | No | null | Client node pubkey for compliance checks. | +| `signature` | string | No | null | Signature of `channelOpen-${timestamp}` by client node. | +| `timestamp` | string | No | null | ISO datetime used for signature. | +| `announceChannel` | boolean | No | false | Public channel. Cannot be true for zeroConf channels. | +| `refundOnchainAddress` | string | No | null | Refund address. Max 512 chars. | + +**Validation rules:** +- `lspBalanceSat + clientBalanceSat` must be between `minChannelSizeSat` and `maxChannelSizeSat` +- `zeroConfPayment` cannot be used when `clientBalanceSat > 0` +- If `signature` is provided, `clientNodeId` and `timestamp` must also be provided + +**Response (201):** Order object (see Order Schema below) + +**Errors:** 400 (validation error) + +### GET /channels/:id + +Get a single order by ID. + +**Path params:** `id` — UUID or 24-char hex legacy ID + +**Response (200):** Order object + +**Errors:** 404 (not found) + +### GET /channels?ids[]= + +Get multiple orders by IDs. + +**Query params:** `ids` — Array of UUIDs (1-50, no duplicates). Pass as `?ids[]=abc&ids[]=def` + +**Response (200):** Array of Order objects + +**Errors:** 404 (not found) + +### POST /channels/:id/open + +Open a channel for a paid order. + +**Path params:** `id` — UUID + +**Request body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `connectionStringOrPubkey` | string | Yes | `pubkey@host:port` or just `pubkey`. If pubkey only, client must have an active peer connection to the LSP. | +| `announceChannel` | boolean | No | Announce channel to network. Cannot be true for zeroConf. | + +**Response (200):** Updated Order object + +**Errors:** +- 400 — Order not in correct state +- 404 — Order not found +- 412 — Channel open failed (see ChannelOpenError) + +**Channel open error codes:** +- `WRONG_ORDER_STATE` — Order not in "paid" state +- `PEER_NOT_REACHABLE` — Cannot connect to peer +- `CHANNEL_REJECTED_BY_DESTINATION` — Peer rejected the channel +- `CHANNEL_REJECTED_BY_LSP` — LSP rejected the channel +- `BLOCKTANK_NOT_READY` — Service temporarily unavailable +- `UNKNOWN_ERROR` — Generic error + +### GET /channels/:id/min-0conf-tx-fee + +Get the minimum onchain fee for a 0-conf payment to be accepted. Valid for at least 2 minutes from time of calling. + +**Path params:** `id` — UUID + +**Response (200):** + +```json +{ + "id": "69ce39f6-4918-416e-9056-8dba678c8af2", + "satPerVByte": 24.3, + "validityEndsAt": "2023-07-28T07:39:00.342Z" +} +``` + +### POST /channels/estimate-fee + +Estimate channel order fee without creating an order. + +**Request body:** Same as POST /channels + +**Response (200):** + +```json +{ + "feeSat": 10192, + "min0ConfTxFee": { + "satPerVByte": 50.1, + "validityEndsAt": "2023-07-06T07:58:39.588Z" + } +} +``` + +### POST /channels/estimate-fee-full + +Estimate fee with network and service fee breakdown. + +**Request body:** Same as POST /channels + +**Response (200):** + +```json +{ + "feeSat": 10192, + "networkFeeSat": 5096, + "serviceFeeSat": 5096, + "min0ConfTxFee": { + "satPerVByte": 50.1, + "validityEndsAt": "2023-07-06T07:58:39.588Z" + } +} +``` + +--- + +## CJIT (Just-In-Time Channels) + +### POST /cjit + +Create a CJIT channel entry. The LSP creates a hold invoice; when paid, the channel opens automatically. + +**Request body:** + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `channelSizeSat` | integer | Yes | — | Channel size. Must be >= invoiceSat. Between min/max from /info. | +| `invoiceSat` | integer | Yes | — | Invoice amount. Min 1. | +| `invoiceDescription` | string | No | "" | Invoice description. | +| `channelExpiryWeeks` | integer | Yes | — | Lease duration. Between min/max from /info. | +| `nodeId` | string | Yes | — | Pubkey of the node to open the channel to. | +| `couponCode` | string | No | null | Discount code. Max 128 chars. | +| `source` | string | No | null | Order source tracking. Max 128 chars. | +| `discountCode` | string | No | null | Discount code. Max 128 chars. | +| `zeroReserve` | boolean | No | false | Zero channel reserve. | + +**Response (201):** CJitEntry object (see CJitEntry Schema below) + +**Errors:** 400 (validation or compliance check failure) + +### GET /cjit/:id + +Get CJIT entry by ID. + +**Path params:** `id` — UUID + +**Response (200):** CJitEntry object + +**Errors:** 404 (not found) + +--- + +## Gift + +### POST /gift/pay + +Pay a gift invoice. + +**Request body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `invoice` | string | Yes | Valid bolt11 invoice string. | + +**Response (200):** Gift object + +**Errors:** 400 + +### POST /gift/order + +Create a gift order. + +**Request body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `clientNodeId` | string | Yes | Client node ID. | +| `code` | string | Yes | Gift code. | + +**Response (200):** Gift object + +**Errors:** 400 + +### GET /gift/:id + +Get gift by ID. + +**Path params:** `id` — UUID + +**Response (200):** Gift object + +**Errors:** 400 + +--- + +## Regtest Tools + +These endpoints are only available when the service is running on regtest network. + +### POST /regtest/chain/mine + +Mine blocks on the regtest chain. + +**Request body:** + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `count` | integer | No | 1 | Number of blocks to mine. Min 1. | + +**Response (200):** Mining result + +### POST /regtest/chain/deposit + +Send satoshis to a regtest bitcoin address (faucet). + +**Request body:** + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `address` | string | Yes | — | Regtest bitcoin address (must be a valid regtest address). | +| `amountSat` | integer | No | 100000 | Amount in satoshis. Min 1. | + +**Response (200):** Transaction ID string + +### POST /regtest/channel/pay + +Pay a Lightning invoice on regtest. + +**Request body:** + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `invoice` | string | Yes | — | Valid regtest bolt11 invoice. | +| `amountSat` | integer | No | null | Amount for 0-amount invoices. Min 1. | + +**Response (200):** Payment ID string (UUID) + +### GET /regtest/channel/pay/:id + +Get payment status by ID. + +**Path params:** `id` — UUID (payment ID from POST /regtest/channel/pay) + +**Response (200):** Payment object with invoice state + +### POST /regtest/channel/close + +Force close a channel. + +**Request body:** + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `fundingTxId` | string | Yes | — | Funding transaction ID from `channel.fundingTx.id`. | +| `vout` | integer | Yes | — | Output index from `channel.fundingTx.vout`. Min 0. | +| `forceCloseAfterSec` | integer | No | 86400 | Seconds before force close. Use 0 for immediate force close. | + +**Response (200):** Closing transaction ID string + +--- + +## Response Schemas + +### Order + +```json +{ + "id": "fa7e6d29-a04d-47ea-8db4-ec05f6b8601c", + "state": "open", + "state2": "executed", + "orderExiresAt": "2023-07-06T07:58:39.588Z", + "feeSat": 19021, + "lspBalanceSat": 3000000, + "clientBalanceSat": 0, + "channelExpiryWeeks": 12, + "channelExpiresAt": "2023-10-06T07:58:39.588Z", + "couponCode": "", + "zeroConf": false, + "zeroReserve": false, + "discountPercent": 0, + "lspNode": { + "alias": "Blocktank", + "pubkey": "0296b2db...", + "connectionStrings": ["0296b2db...@172.19.0.2:9735"] + }, + "channel": { + "state": "open", + "lspNodePubkey": "0296b2db...", + "clientNodePubkey": "0386b2db...", + "announceChannel": false, + "shortChannelId": "792906x599x1", + "fundingTx": { + "id": "fa205519e0eb80f84a6c234b1c7f5a2cc6995eb4d84b6345fab214097d79b38d", + "vout": 1 + }, + "closingTxId": null, + "close": null + }, + "payment": { + "state": "paid", + "state2": "paid", + "paidSat": 19021, + "bolt11Invoice": { + "request": "lntb1u1pwz5w78pp5...", + "state": "paid", + "amountSat": 19021, + "expiresAt": "2023-07-06T07:58:39.588Z", + "updatedAt": "2023-07-06T07:58:39.588Z" + }, + "onchain": { + "requiredConfirmations": 1, + "address": "bcrt1q66jwcerttp8jcu43mlvz0y93v8pf6lxh5xztq3", + "confirmedSat": 19021, + "transactions": [ + { + "amountSat": 19021, + "txId": "fa205519...", + "vout": 0, + "blockHeight": 600100, + "blockConfirmations": 3, + "feeRateSatPerVbyte": 21.1, + "confirmed": true + } + ] + } + }, + "updatedAt": "2023-07-06T07:58:39.588Z", + "createdAt": "2023-07-06T07:58:39.588Z" +} +``` + +**Order states (`state2`):** `created`, `paid`, `executed`, `expired` + +**Payment states (`payment.state2`):** `created`, `paid`, `refunded`, `refundAvailable`, `canceled` + +**Channel states (`channel.state`):** `opening`, `open`, `closed` + +**Channel close types:** `cooporative`, `force`, `breach` + +**Channel close initiator:** `lsp`, `client` + +### CJitEntry + +```json +{ + "id": "fa7e6d29-a04d-47ea-8db4-ec05f6b8601c", + "state": "created", + "feeSat": 19021, + "channelSizeSat": 3000000, + "channelExpiryWeeks": 12, + "channelOpenError": null, + "nodeId": "03775370500b8c8642617bced873e7914eaec4f6a79c9ca99043224a1b28677082", + "invoice": { + "request": "lntb1u1pwz5w78pp5...", + "state": "pending", + "amountSat": 2000000, + "expiresAt": "2023-07-06T07:58:39.588Z", + "updatedAt": "2023-07-06T07:58:39.588Z" + }, + "channel": { + "state": "opening", + "lspNodePubkey": "0296b2db...", + "clientNodePubkey": "03775370...", + "announceChannel": false, + "fundingTx": null, + "close": null + }, + "lspNode": { + "alias": "Blocktank", + "pubkey": "0296b2db...", + "connectionStrings": ["0296b2db...@172.19.0.2:9735"] + }, + "couponCode": "", + "expiresAt": "2023-07-06T07:58:39.588Z", + "updatedAt": "2023-07-06T07:58:39.588Z", + "createdAt": "2023-07-06T07:58:39.588Z" +} +``` + +**CJIT states:** `created`, `completed`, `expired`, `failed` + +### ChannelOpenError + +Returned as HTTP 412 from POST /channels/:id/open: + +```json +{ + "message": "Channel has been rejected by the client.", + "code": "CHANNEL_REJECTED_BY_DESTINATION", + "details": {}, + "name": "ChannelOpenError" +} +``` + +**Error codes:** `WRONG_ORDER_STATE`, `PEER_NOT_REACHABLE`, `CHANNEL_REJECTED_BY_DESTINATION`, `CHANNEL_REJECTED_BY_LSP`, `BLOCKTANK_NOT_READY`, `UNKNOWN_ERROR` diff --git a/.claude/plugins/blocktank-api/skills/lsp/scripts/lsp.sh b/.claude/plugins/blocktank-api/skills/lsp/scripts/lsp.sh new file mode 100755 index 000000000..7e8fa5797 --- /dev/null +++ b/.claude/plugins/blocktank-api/skills/lsp/scripts/lsp.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Blocktank LSP API caller +# Usage: ./lsp [json_body] +# +# Examples: +# ./lsp GET /info +# ./lsp GET /channels/abc-123 +# ./lsp POST /channels '{"lspBalanceSat":100000,"channelExpiryWeeks":12}' +# ./lsp POST /regtest/chain/mine '{"count":6}' +# ./lsp POST /regtest/chain/deposit '{"address":"bcrt1q...","amountSat":500000}' +# +# Environment: +# BLOCKTANK_API_URL Override base URL (default: staging) + +BASE_URL="${BLOCKTANK_API_URL:-https://api.stag0.blocktank.to/blocktank/api/v2}" +METHOD="${1:?Usage: ./lsp [json_body]}" +API_PATH="${2:?Usage: ./lsp [json_body]}" +BODY="${3:-}" + +URL="${BASE_URL}${API_PATH}" + +tmpfile=$(mktemp) +trap 'rm -f "$tmpfile"' EXIT + +call_api() { + local http_code + local response + + if [ "$METHOD" = "GET" ]; then + http_code=$(curl -s -o "$tmpfile" -w "%{http_code}" "$URL") + elif [ "$METHOD" = "POST" ]; then + if [ -n "$BODY" ]; then + http_code=$(curl -s -o "$tmpfile" -w "%{http_code}" -X POST "$URL" \ + -H "Content-Type: application/json" \ + -d "$BODY") + else + http_code=$(curl -s -o "$tmpfile" -w "%{http_code}" -X POST "$URL" \ + -H "Content-Type: application/json" \ + -d '{}') + fi + else + echo "Error: Method must be GET or POST, got '$METHOD'" >&2 + exit 1 + fi + + response=$(cat "$tmpfile") + + if [ "$http_code" -ge 400 ]; then + echo "HTTP $http_code $METHOD $API_PATH" >&2 + echo "$response" + exit 1 + fi + + echo "$response" +} + +call_api diff --git a/README.md b/README.md index 35c28326f..15eace9fc 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ Please focus on: - Thread safety in coroutines ``` -#### Local Development Setup (YOLO Mode) +### Local Development Setup (YOLO Mode) To enable auto-approved permissions for Claude Code during local development: @@ -234,6 +234,12 @@ cp .claude/settings.local.template.json .claude/settings.local.json This reduces confirmation prompts for common operations (Bash, Read, Edit, Write, etc.). Destructive operations like `rm -rf`, `git commit`, and `git push` still require confirmation. +### AI Dev Plugins + +Claude Code plugins provide specialized skills for development workflows. See [`.claude/plugins/`](.claude/plugins/) for available plugins. + +- [blocktank-api](.claude/plugins/blocktank-api/README.md) — Blocktank LSP API for LN testing on regtest + ## License This project is licensed under the MIT License. diff --git a/lsp b/lsp new file mode 100755 index 000000000..5412a9314 --- /dev/null +++ b/lsp @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec "$(dirname "$0")/.claude/plugins/blocktank-api/skills/lsp/scripts/lsp.sh" "$@" From 142abce6f4a225039429598d3ed72c57b16248d9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 25 Mar 2026 14:39:15 +0100 Subject: [PATCH 2/2] ai: agent control interface --- .../plugins/blocktank-api/skills/lsp/SKILL.md | 28 ++++++ .../skills/lsp/scripts/pay-invoices.sh | 98 +++++++++++++++++++ app/src/debug/AndroidManifest.xml | 8 +- .../java/to/bitkit/dev/DevToolsProvider.kt | 87 ++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) create mode 100755 .claude/plugins/blocktank-api/skills/lsp/scripts/pay-invoices.sh create mode 100644 app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt diff --git a/.claude/plugins/blocktank-api/skills/lsp/SKILL.md b/.claude/plugins/blocktank-api/skills/lsp/SKILL.md index 1f625fe9b..8daf7b9e3 100644 --- a/.claude/plugins/blocktank-api/skills/lsp/SKILL.md +++ b/.claude/plugins/blocktank-api/skills/lsp/SKILL.md @@ -121,6 +121,34 @@ On HTTP errors (4xx/5xx), the script prints the status code to stderr and the er 2. **Close channel** — `POST /regtest/channel/close` with `fundingTxId`, `vout`, and `forceCloseAfterSec: 0` for immediate close 3. **Mine blocks** — `POST /regtest/chain/mine` with `count: 6` to finalize the closure +### Workflow D: Automated Invoice Payments + +Bulk-create and pay invoices to populate the app with payment activity. + +**Prerequisites:** Dev debug build installed, wallet set up, LDK node running, open channel with inbound capacity, ADB connected. + +**Run with defaults** (21 invoices of 1..21 sats, mine 150 blocks in batches of 10): + +```bash +"${CLAUDE_PLUGIN_ROOT}/skills/lsp/scripts/pay-invoices.sh" +``` + +**Custom parameters** via env vars: + +```bash +INVOICE_COUNT=10 MINE_TOTAL=60 MINE_BATCH=10 \ + "${CLAUDE_PLUGIN_ROOT}/skills/lsp/scripts/pay-invoices.sh" +``` + +The script uses the `DevToolsProvider` ContentProvider (dev builds only) to create invoices on the app's LDK node via `adb shell content call`, then pays each via the LSP's `POST /regtest/channel/pay` endpoint. + +**Create a single invoice manually:** + +```bash +adb shell "content call --uri content://to.bitkit.dev.devtools \ + --method createInvoice --arg '{\"amount\":1000,\"description\":\"test\"}'" +``` + ## State Machines ### Order States (`state2`) diff --git a/.claude/plugins/blocktank-api/skills/lsp/scripts/pay-invoices.sh b/.claude/plugins/blocktank-api/skills/lsp/scripts/pay-invoices.sh new file mode 100755 index 000000000..a448ae97a --- /dev/null +++ b/.claude/plugins/blocktank-api/skills/lsp/scripts/pay-invoices.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Automated LN invoice creation + payment via Blocktank LSP +# +# Prerequisites: +# - Dev debug build installed on device/emulator with wallet set up and LDK node running +# - ADB connected to the device +# - An open Lightning channel with sufficient inbound capacity +# +# Usage: +# pay-invoices.sh +# INVOICE_COUNT=10 pay-invoices.sh +# +# Each invoice amount equals its index (1 sat, 2 sats, ... N sats). +# Invoices are created via the DevToolsProvider ContentProvider (dev builds only). +# +# Environment: +# APP_ID App package (default: to.bitkit.dev) +# INVOICE_COUNT Number of invoices to create and pay (default: 21) +# MINE_TOTAL Total blocks to mine after payments (default: 150) +# MINE_BATCH Blocks per mining call (default: 10) +# PAY_DELAY Seconds between payments (default: 3) + +APP_ID="${APP_ID:-to.bitkit.dev}" +INVOICE_COUNT="${INVOICE_COUNT:-21}" +MINE_TOTAL="${MINE_TOTAL:-150}" +MINE_BATCH="${MINE_BATCH:-10}" +PAY_DELAY="${PAY_DELAY:-3}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LSP="$SCRIPT_DIR/lsp.sh" +AUTHORITY="$APP_ID.devtools" + +create_invoice() { + local amount="$1" + local index="$2" + local raw + raw=$(adb shell "content call --uri content://$AUTHORITY \ + --method createInvoice \ + --arg '{\"amount\":$amount,\"description\":\"dev-payment-$index\"}'") + + # Extract JSON from Bundle output: Result: Bundle[{result={"bolt11":"..."}}] + local json + json=$(echo "$raw" | sed -n 's/.*result=\({.*}\).*/\1/p') + + if [ -z "$json" ]; then + echo "ERROR: Failed to parse result from: $raw" >&2 + return 1 + fi + + local bolt11 + bolt11=$(echo "$json" | sed -n 's/.*"bolt11":"\([^"]*\)".*/\1/p') + + if [ -z "$bolt11" ]; then + local error + error=$(echo "$json" | sed -n 's/.*"message":"\([^"]*\)".*/\1/p') + echo "ERROR: ${error:-$json}" >&2 + return 1 + fi + + echo "$bolt11" +} + +# Phase 1: Create and pay invoices +echo "=== Creating and paying $INVOICE_COUNT invoices (1..$INVOICE_COUNT sats) ===" +for i in $(seq 1 "$INVOICE_COUNT"); do + echo "" + echo "--- Invoice $i/$INVOICE_COUNT ($i sats) ---" + + echo " Creating invoice..." + invoice=$(create_invoice "$i" "$i") || exit 1 + echo " Invoice: ${invoice:0:30}..." + + echo " Paying via LSP..." + "$LSP" POST /regtest/channel/pay "{\"invoice\":\"$invoice\"}" > /dev/null + echo " Paid." + + sleep "$PAY_DELAY" +done + +echo "" +echo "=== $INVOICE_COUNT invoices paid ===" + +# Phase 2: Mine blocks +batches=$((MINE_TOTAL / MINE_BATCH)) +if [ "$batches" -gt 0 ]; then + echo "" + echo "=== Mining $MINE_TOTAL blocks in $batches batches of $MINE_BATCH ===" + for i in $(seq 1 "$batches"); do + echo " Batch $i/$batches ($MINE_BATCH blocks)..." + "$LSP" POST /regtest/chain/mine "{\"count\":$MINE_BATCH}" > /dev/null + sleep 1 + done +fi + +echo "" +echo "=== Done: $INVOICE_COUNT invoices paid, $((batches * MINE_BATCH)) blocks mined ===" diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 15613a8d7..391358edf 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -4,5 +4,11 @@ + tools:ignore="MissingApplicationIcon"> + + + diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt new file mode 100644 index 000000000..faab81602 --- /dev/null +++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt @@ -0,0 +1,87 @@ +package to.bitkit.dev + +import android.content.ContentProvider +import android.content.ContentValues +import android.net.Uri +import android.os.Bundle +import androidx.core.os.bundleOf +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import to.bitkit.async.ServiceQueue +import to.bitkit.repositories.LightningRepo + +class DevToolsProvider : ContentProvider() { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Dependencies { + fun lightningRepo(): LightningRepo + } + + private val deps: Dependencies by lazy { + EntryPointAccessors.fromApplication(requireNotNull(context), Dependencies::class.java) + } + + override fun call(method: String, arg: String?, extras: Bundle?): Bundle = runCatching { + val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" } + ServiceQueue.LDK.blocking { command.execute(deps) } + }.getOrElse { + DevResult.Error(it.message) + }.toBundle() + + override fun onCreate() = true + override fun getType(uri: Uri): String? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, sel: String?, args: Array?) = 0 + override fun update(uri: Uri, values: ContentValues?, sel: String?, args: Array?) = 0 + override fun query(uri: Uri, proj: Array?, sel: String?, args: Array?, sort: String?) = null +} + +private sealed interface DevCommand { + + companion object { + fun parse(method: String, arg: String?): DevCommand? = when (method) { + CreateInvoice.METHOD -> CreateInvoice.parse(arg) + else -> null + } + } + + suspend fun execute(deps: DevToolsProvider.Dependencies): DevResult + + data class CreateInvoice(val args: Args) : DevCommand { + companion object { + const val METHOD = "createInvoice" + fun parse(arg: String?) = CreateInvoice(arg.deserialize()) + } + + @Serializable + data class Args(val amount: ULong? = null, val description: String = "dev-invoice") + + override suspend fun execute(deps: DevToolsProvider.Dependencies) = + deps.lightningRepo().createInvoice(args.amount, args.description).fold( + onSuccess = { DevResult.Invoice(it) }, + onFailure = { DevResult.Error(it.message) }, + ) + } +} + +@Serializable +private sealed interface DevResult { + + companion object { + private const val KEY_RESULT = "result" + } + + @Serializable data class Invoice(val bolt11: String) : DevResult + + @Serializable data class Error(val message: String? = null) : DevResult + + fun toBundle() = bundleOf(KEY_RESULT to Json.encodeToString(this)) +} + +private inline fun String?.deserialize(): T = + if (isNullOrBlank()) Json.decodeFromString("{}") else Json.decodeFromString(this)