Skip to content

Commit 9daface

Browse files
committed
ai: agent control interface
1 parent a60a10b commit 9daface

4 files changed

Lines changed: 246 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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
desc="${desc//\'/\'\\\'\'}"
47+
local raw
48+
raw=$(adb shell "content call --uri content://$AUTHORITY \
49+
--method createInvoice \
50+
--arg '{\"amount\":$amount,\"description\":\"$desc\"}'")
51+
52+
# Extract JSON from Bundle output: Result: Bundle[{result={"bolt11":"..."}}]
53+
local json
54+
json=$(echo "$raw" | sed -n 's/.*result=\({.*}\).*/\1/p')
55+
56+
if [ -z "$json" ]; then
57+
echo "ERROR: Failed to parse result from: $raw" >&2
58+
return 1
59+
fi
60+
61+
local bolt11
62+
bolt11=$(echo "$json" | sed -n 's/.*"bolt11":"\([^"]*\)".*/\1/p')
63+
64+
if [ -z "$bolt11" ]; then
65+
local error
66+
error=$(echo "$json" | sed -n 's/.*"message":"\([^"]*\)".*/\1/p')
67+
echo "ERROR: ${error:-$json}" >&2
68+
return 1
69+
fi
70+
71+
echo "$bolt11"
72+
}
73+
74+
# Phase 1: Create and pay invoices
75+
echo "=== Creating and paying $INVOICE_COUNT invoices (1..$INVOICE_COUNT sats) ==="
76+
for i in $(seq 1 "$INVOICE_COUNT"); do
77+
echo ""
78+
echo "--- Invoice $i/$INVOICE_COUNT ($i sats) ---"
79+
80+
echo " Creating invoice..."
81+
invoice=$(create_invoice "$i" "$i") || exit 1
82+
echo " Invoice: ${invoice:0:30}..."
83+
84+
echo " Paying via LSP..."
85+
"$LSP" POST /regtest/channel/pay "{\"invoice\":\"$invoice\"}" > /dev/null
86+
echo " Paid."
87+
88+
sleep "$PAY_DELAY"
89+
done
90+
91+
echo ""
92+
echo "=== $INVOICE_COUNT invoices paid ==="
93+
94+
# Phase 2: Mine blocks
95+
batches=$((MINE_TOTAL / MINE_BATCH))
96+
remainder=$((MINE_TOTAL % MINE_BATCH))
97+
if [ "$batches" -gt 0 ]; then
98+
echo ""
99+
echo "=== Mining $MINE_TOTAL blocks in $batches batches of $MINE_BATCH ==="
100+
for i in $(seq 1 "$batches"); do
101+
echo " Batch $i/$batches ($MINE_BATCH blocks)..."
102+
"$LSP" POST /regtest/chain/mine "{\"count\":$MINE_BATCH}" > /dev/null
103+
sleep 1
104+
done
105+
fi
106+
if [ "$remainder" -gt 0 ]; then
107+
echo " Remainder ($remainder blocks)..."
108+
"$LSP" POST /regtest/chain/mine "{\"count\":$remainder}" > /dev/null
109+
fi
110+
111+
echo ""
112+
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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
private const val TAG = "DevToolsProvider"
21+
22+
class DevToolsProvider : ContentProvider() {
23+
24+
@EntryPoint
25+
@InstallIn(SingletonComponent::class)
26+
interface Dependencies {
27+
fun lightningRepo(): LightningRepo
28+
}
29+
30+
private val deps: Dependencies by lazy {
31+
EntryPointAccessors.fromApplication(requireNotNull(context) { "DevToolsProvider context is null" }, Dependencies::class.java)
32+
}
33+
34+
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
35+
check(Binder.getCallingUid() == Process.SHELL_UID) { "Only ADB shell callers are allowed" }
36+
return runCatching {
37+
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
38+
ServiceQueue.LDK.blocking { command.execute(deps) }
39+
}.getOrElse {
40+
Logger.error("Failed to execute command '$method'", it, context = TAG)
41+
DevResult.Error(it.message)
42+
}.toBundle()
43+
}
44+
45+
override fun onCreate() = true
46+
override fun getType(uri: Uri): String? = null
47+
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
48+
override fun delete(uri: Uri, sel: String?, args: Array<String>?) = 0
49+
override fun update(uri: Uri, values: ContentValues?, sel: String?, args: Array<String>?) = 0
50+
override fun query(uri: Uri, proj: Array<String>?, sel: String?, args: Array<String>?, sort: String?) = null
51+
}
52+
53+
private sealed interface DevCommand {
54+
55+
companion object {
56+
fun parse(method: String, arg: String?): DevCommand? = when (method) {
57+
CreateInvoice.METHOD -> CreateInvoice.parse(arg)
58+
else -> null
59+
}
60+
}
61+
62+
suspend fun execute(deps: DevToolsProvider.Dependencies): DevResult
63+
64+
data class CreateInvoice(val args: Args) : DevCommand {
65+
companion object {
66+
const val METHOD = "createInvoice"
67+
fun parse(arg: String?) = CreateInvoice(arg.deserialize<Args>())
68+
}
69+
70+
@Serializable
71+
data class Args(val amount: ULong? = null, val description: String = "dev-invoice")
72+
73+
override suspend fun execute(deps: DevToolsProvider.Dependencies) =
74+
deps.lightningRepo().createInvoice(args.amount, args.description).fold(
75+
onSuccess = { DevResult.Invoice(it) },
76+
onFailure = { DevResult.Error(it.message) },
77+
)
78+
}
79+
}
80+
81+
@Serializable
82+
private sealed interface DevResult {
83+
84+
companion object {
85+
private const val KEY_RESULT = "result"
86+
}
87+
88+
@Serializable data class Invoice(val bolt11: String) : DevResult
89+
90+
@Serializable data class Error(val message: String? = null) : DevResult
91+
92+
fun toBundle() = bundleOf(KEY_RESULT to Json.encodeToString(this))
93+
}
94+
95+
private inline fun <reified T> String?.deserialize(): T =
96+
if (isNullOrBlank()) Json.decodeFromString("{}") else Json.decodeFromString(this)

0 commit comments

Comments
 (0)