Skip to content

Commit 47ca5eb

Browse files
committed
ai: agent control interface
1 parent a60a10b commit 47ca5eb

4 files changed

Lines changed: 251 additions & 1 deletion

File tree

.claude/plugins/blocktank-api/skills/lsp/SKILL.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,36 @@ On HTTP errors (4xx/5xx), the script prints the status code to stderr and the er
121121
2. **Close channel**`POST /regtest/channel/close` with `fundingTxId`, `vout`, and `forceCloseAfterSec: 0` for immediate close
122122
3. **Mine blocks**`POST /regtest/chain/mine` with `count: 6` to finalize the closure
123123

124+
### Workflow D: Automated Invoice Payments
125+
126+
Bulk-create and pay invoices to populate the app with payment activity.
127+
128+
**Prerequisites:** Dev debug build installed, wallet set up, LDK node running, open channel with inbound capacity, ADB connected.
129+
130+
**Run with defaults** (21 invoices of 1..21 sats, mine 150 blocks in batches of 10):
131+
132+
```bash
133+
"${CLAUDE_PLUGIN_ROOT}/skills/lsp/scripts/pay-invoices.sh"
134+
```
135+
136+
**Custom parameters** via env vars:
137+
138+
```bash
139+
INVOICE_COUNT=10 DESCRIPTION="test-ovi-{i}" MINE_TOTAL=60 MINE_BATCH=10 \
140+
"${CLAUDE_PLUGIN_ROOT}/skills/lsp/scripts/pay-invoices.sh"
141+
```
142+
143+
- `DESCRIPTION` — invoice description; `{i}` is replaced with the invoice index (default: `dev-payment-{i}`)
144+
145+
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.
146+
147+
**Create a single invoice manually:**
148+
149+
```bash
150+
adb shell "content call --uri content://to.bitkit.dev.devtools \
151+
--method createInvoice --arg '{\"amount\":1000,\"description\":\"test\"}'"
152+
```
153+
124154
## State Machines
125155

126156
### Order States (`state2`)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Automated LN invoice creation + payment via Blocktank LSP
5+
#
6+
# Prerequisites:
7+
# - Dev debug build installed on device/emulator with wallet set up and LDK node running
8+
# - ADB connected to the device
9+
# - An open Lightning channel with sufficient inbound capacity
10+
#
11+
# Usage:
12+
# pay-invoices.sh
13+
# INVOICE_COUNT=10 pay-invoices.sh
14+
#
15+
# Each invoice amount equals its index (1 sat, 2 sats, ... N sats).
16+
# Invoices are created via the DevToolsProvider ContentProvider (dev builds only).
17+
#
18+
# Environment:
19+
# APP_ID App package (default: to.bitkit.dev)
20+
# INVOICE_COUNT Number of invoices to create and pay (default: 21)
21+
# DESCRIPTION Invoice description. Use {i} as index placeholder (default: dev-payment-{i})
22+
# MINE_TOTAL Total blocks to mine after payments (default: 150)
23+
# MINE_BATCH Blocks per mining call (default: 10)
24+
# PAY_DELAY Seconds between payments (default: 3)
25+
26+
APP_ID="${APP_ID:-to.bitkit.dev}"
27+
INVOICE_COUNT="${INVOICE_COUNT:-21}"
28+
DESCRIPTION="${DESCRIPTION:-dev-payment-{i\}}"
29+
MINE_TOTAL="${MINE_TOTAL:-150}"
30+
MINE_BATCH="${MINE_BATCH:-10}"
31+
PAY_DELAY="${PAY_DELAY:-3}"
32+
33+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34+
LSP="$SCRIPT_DIR/lsp.sh"
35+
AUTHORITY="$APP_ID.devtools"
36+
37+
create_invoice() {
38+
local amount="$1"
39+
local index="$2"
40+
local desc="${DESCRIPTION//\{i\}/$index}"
41+
# Escape characters that could cause shell injection when passed through adb shell
42+
desc="${desc//\\/\\\\}"
43+
desc="${desc//\$/\\$}"
44+
desc="${desc//\`/\\\`}"
45+
desc="${desc//\"/\\\"}"
46+
local raw
47+
raw=$(adb shell "content call --uri content://$AUTHORITY \
48+
--method createInvoice \
49+
--arg '{\"amount\":$amount,\"description\":\"$desc\"}'")
50+
51+
# Extract JSON from Bundle output: Result: Bundle[{result={"bolt11":"..."}}]
52+
local json
53+
json=$(echo "$raw" | sed -n 's/.*result=\({.*}\).*/\1/p')
54+
55+
if [ -z "$json" ]; then
56+
echo "ERROR: Failed to parse result from: $raw" >&2
57+
return 1
58+
fi
59+
60+
local bolt11
61+
bolt11=$(echo "$json" | sed -n 's/.*"bolt11":"\([^"]*\)".*/\1/p')
62+
63+
if [ -z "$bolt11" ]; then
64+
local error
65+
error=$(echo "$json" | sed -n 's/.*"message":"\([^"]*\)".*/\1/p')
66+
echo "ERROR: ${error:-$json}" >&2
67+
return 1
68+
fi
69+
70+
echo "$bolt11"
71+
}
72+
73+
# Phase 1: Create and pay invoices
74+
echo "=== Creating and paying $INVOICE_COUNT invoices (1..$INVOICE_COUNT sats) ==="
75+
for i in $(seq 1 "$INVOICE_COUNT"); do
76+
echo ""
77+
echo "--- Invoice $i/$INVOICE_COUNT ($i sats) ---"
78+
79+
echo " Creating invoice..."
80+
invoice=$(create_invoice "$i" "$i") || exit 1
81+
echo " Invoice: ${invoice:0:30}..."
82+
83+
echo " Paying via LSP..."
84+
"$LSP" POST /regtest/channel/pay "{\"invoice\":\"$invoice\"}" > /dev/null
85+
echo " Paid."
86+
87+
sleep "$PAY_DELAY"
88+
done
89+
90+
echo ""
91+
echo "=== $INVOICE_COUNT invoices paid ==="
92+
93+
# Phase 2: Mine blocks
94+
batches=$((MINE_TOTAL / MINE_BATCH))
95+
remainder=$((MINE_TOTAL % MINE_BATCH))
96+
if [ "$batches" -gt 0 ]; then
97+
echo ""
98+
echo "=== Mining $MINE_TOTAL blocks in $batches batches of $MINE_BATCH ==="
99+
for i in $(seq 1 "$batches"); do
100+
echo " Batch $i/$batches ($MINE_BATCH blocks)..."
101+
"$LSP" POST /regtest/chain/mine "{\"count\":$MINE_BATCH}" > /dev/null
102+
sleep 1
103+
done
104+
fi
105+
if [ "$remainder" -gt 0 ]; then
106+
echo " Remainder ($remainder blocks)..."
107+
"$LSP" POST /regtest/chain/mine "{\"count\":$remainder}" > /dev/null
108+
fi
109+
110+
echo ""
111+
echo "=== Done: $INVOICE_COUNT invoices paid, $MINE_TOTAL blocks mined ==="

app/src/debug/AndroidManifest.xml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,12 @@
44

55
<application
66
android:usesCleartextTraffic="true"
7-
tools:ignore="MissingApplicationIcon" />
7+
tools:ignore="MissingApplicationIcon">
8+
9+
<provider
10+
android:name="to.bitkit.dev.DevToolsProvider"
11+
android:authorities="${applicationId}.devtools"
12+
android:exported="true"
13+
tools:ignore="ExportedContentProvider" />
14+
</application>
815
</manifest>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package to.bitkit.dev
2+
3+
import android.content.ContentProvider
4+
import android.content.ContentValues
5+
import android.net.Uri
6+
import android.os.Binder
7+
import android.os.Bundle
8+
import android.os.Process
9+
import androidx.core.os.bundleOf
10+
import dagger.hilt.EntryPoint
11+
import dagger.hilt.InstallIn
12+
import dagger.hilt.android.EntryPointAccessors
13+
import dagger.hilt.components.SingletonComponent
14+
import kotlinx.serialization.Serializable
15+
import kotlinx.serialization.json.Json
16+
import to.bitkit.async.ServiceQueue
17+
import to.bitkit.repositories.LightningRepo
18+
import to.bitkit.utils.Logger
19+
20+
class DevToolsProvider : ContentProvider() {
21+
22+
companion object {
23+
private const val TAG = "DevToolsProvider"
24+
}
25+
26+
@EntryPoint
27+
@InstallIn(SingletonComponent::class)
28+
interface Dependencies {
29+
fun lightningRepo(): LightningRepo
30+
}
31+
32+
private val deps: Dependencies by lazy {
33+
EntryPointAccessors.fromApplication(requireNotNull(context) { "DevToolsProvider context is null" }, Dependencies::class.java)
34+
}
35+
36+
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
37+
check(Binder.getCallingUid() == Process.SHELL_UID) { "Only ADB shell callers are allowed" }
38+
return runCatching {
39+
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
40+
ServiceQueue.LDK.blocking { command.execute(deps) }
41+
}.getOrElse {
42+
Logger.error("Failed to execute command '$method'", it, context = TAG)
43+
DevResult.Error(it.message)
44+
}.toBundle()
45+
}
46+
47+
override fun onCreate() = true
48+
override fun getType(uri: Uri): String? = null
49+
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
50+
override fun delete(uri: Uri, sel: String?, args: Array<String>?) = 0
51+
override fun update(uri: Uri, values: ContentValues?, sel: String?, args: Array<String>?) = 0
52+
override fun query(uri: Uri, proj: Array<String>?, sel: String?, args: Array<String>?, sort: String?) = null
53+
}
54+
55+
private sealed interface DevCommand {
56+
57+
companion object {
58+
private const val TAG = "DevToolsProvider"
59+
fun parse(method: String, arg: String?): DevCommand? = when (method) {
60+
CreateInvoice.METHOD -> CreateInvoice.parse(arg)
61+
else -> null
62+
}
63+
}
64+
65+
suspend fun execute(deps: DevToolsProvider.Dependencies): DevResult
66+
67+
data class CreateInvoice(val args: Args) : DevCommand {
68+
companion object {
69+
const val METHOD = "createInvoice"
70+
fun parse(arg: String?) = CreateInvoice(arg.deserialize<Args>())
71+
}
72+
73+
@Serializable
74+
data class Args(val amount: ULong? = null, val description: String = "dev-invoice")
75+
76+
override suspend fun execute(deps: DevToolsProvider.Dependencies) =
77+
deps.lightningRepo().createInvoice(args.amount, args.description).fold(
78+
onSuccess = { DevResult.Invoice(it) },
79+
onFailure = {
80+
Logger.error("Failed to execute dev command '$METHOD'", it, context = TAG)
81+
DevResult.Error(it.message)
82+
},
83+
)
84+
}
85+
}
86+
87+
@Serializable
88+
private sealed interface DevResult {
89+
90+
companion object {
91+
private const val KEY_RESULT = "result"
92+
}
93+
94+
@Serializable data class Invoice(val bolt11: String) : DevResult
95+
96+
@Serializable data class Error(val message: String? = null) : DevResult
97+
98+
fun toBundle() = bundleOf(KEY_RESULT to Json.encodeToString(this))
99+
}
100+
101+
private inline fun <reified T> String?.deserialize(): T =
102+
if (isNullOrBlank()) Json.decodeFromString("{}") else Json.decodeFromString(this)

0 commit comments

Comments
 (0)