704 lines
29 KiB
Markdown
704 lines
29 KiB
Markdown
|
|
# Implementation Guide: Taproot Assets + L402 Payment Gate on FastAPI/Lightning
|
|||
|
|
|
|||
|
|
**Project:** Timmy Agent
|
|||
|
|
**Date:** March 18, 2026
|
|||
|
|
**Scope:** Concrete architecture and code paths for TIMMY token minting, Lightning channel integration, L402 payment gating, and FastAPI/Python wiring
|
|||
|
|
**Convention:** `⚠️ NOT PRODUCTION-READY` marks anything that is alpha, testnet-only, or has a known breaking limitation. `✅ MAINNET` marks confirmed production-viable paths.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Prerequisites and Stack Versions
|
|||
|
|
|
|||
|
|
| Component | Required Version | Status |
|
|||
|
|
|-----------|-----------------|--------|
|
|||
|
|
| LND | v0.20.0-beta or later | ✅ MAINNET |
|
|||
|
|
| tapd (Taproot Assets daemon) | v0.7.x (Dec 2025) | ✅ MAINNET (with caveats — see §6) |
|
|||
|
|
| litd (Lightning Terminal) | v0.14.x | ✅ MAINNET — required for TA channels |
|
|||
|
|
| Python | 3.11+ | ✅ |
|
|||
|
|
| grpcio / grpcio-tools | 1.62+ | ✅ |
|
|||
|
|
| FastAPI | 0.110+ | ✅ |
|
|||
|
|
|
|||
|
|
**Critical note:** `litd` must be run in **integrated mode** — LND, tapd, and litd run as a single binary. You cannot run tapd standalone alongside a separately managed LND if you want Lightning channel support.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Taproot Asset Minting
|
|||
|
|
|
|||
|
|
### 1.1 LND Compile Requirements
|
|||
|
|
|
|||
|
|
LND must be compiled with the following RPC tags. Pre-built binaries from the official releases include these; if building from source:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
make install tags="signrpc walletrpc chainrpc invoicesrpc"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
For the full Lightning Terminal stack (recommended), use litd which bundles everything:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git clone https://github.com/lightninglabs/lightning-terminal.git
|
|||
|
|
cd lightning-terminal
|
|||
|
|
make install
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.2 Start tapd on Mainnet
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
tapd \
|
|||
|
|
--network=mainnet \
|
|||
|
|
--lnd.host=localhost:10009 \
|
|||
|
|
--lnd.macaroonpath=~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon \
|
|||
|
|
--lnd.tlspath=~/.lnd/tls.cert \
|
|||
|
|
--rpclisten=127.0.0.1:10029 \
|
|||
|
|
--restlisten=127.0.0.1:8089
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Or, using `litd` in integrated mode (required for Lightning channel support):
|
|||
|
|
|
|||
|
|
```ini
|
|||
|
|
# lit.conf
|
|||
|
|
lnd.protocol.option-scid-alias=true
|
|||
|
|
lnd.protocol.zero-conf=true
|
|||
|
|
lnd.protocol.simple-taproot-chans=true
|
|||
|
|
lnd.protocol.simple-taproot-overlay-chans=true
|
|||
|
|
lnd.protocol.custom-message=17
|
|||
|
|
lnd.accept-keysend=true
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
litd --uipassword=<yourpassword>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.3 Mint the TIMMY Token (CLI)
|
|||
|
|
|
|||
|
|
**Step 1 — Queue the mint:**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# Fixed supply (cannot issue more later)
|
|||
|
|
tapcli assets mint \
|
|||
|
|
--type normal \
|
|||
|
|
--name "TIMMY" \
|
|||
|
|
--supply 1000000 \
|
|||
|
|
--decimal_display 0 \
|
|||
|
|
--meta_bytes "TIMMY: one unit of Timmy agent labor credit"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Or with a group key to allow future issuance (recommended for a labor credit that may need top-ups):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# Grouped asset — allows future tranches of TIMMY
|
|||
|
|
tapcli assets mint \
|
|||
|
|
--type normal \
|
|||
|
|
--name "TIMMY" \
|
|||
|
|
--supply 1000000 \
|
|||
|
|
--decimal_display 0 \
|
|||
|
|
--new_grouped_asset \
|
|||
|
|
--meta_bytes "TIMMY: one unit of Timmy agent labor credit"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2 — Finalize (broadcast the Bitcoin transaction):**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
tapcli assets mint finalize
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
This publishes a single on-chain Bitcoin transaction that anchors the entire supply. The entire supply is minted in one transaction. You do not need multiple transactions for different amounts.
|
|||
|
|
|
|||
|
|
**Step 3 — Verify:**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
tapcli assets list
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Look for `"name": "TIMMY"` in the output. Note the `asset_id` (a 32-byte hash) and, if grouped, the `tweaked_group_key`. Save both — you will need them for all subsequent operations.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# Verify a specific asset
|
|||
|
|
tapcli assets list --asset_id <hex_asset_id>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.4 gRPC Mint Call (Python)
|
|||
|
|
|
|||
|
|
Generate stubs once from the tapd proto files:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
pip install grpcio grpcio-tools
|
|||
|
|
|
|||
|
|
git clone https://github.com/lightninglabs/taproot-assets.git
|
|||
|
|
cd taproot-assets
|
|||
|
|
|
|||
|
|
mkdir -p python_stubs
|
|||
|
|
python -m grpc_tools.protoc \
|
|||
|
|
-I. \
|
|||
|
|
--python_out=./python_stubs \
|
|||
|
|
--grpc_python_out=./python_stubs \
|
|||
|
|
taprpc/*.proto \
|
|||
|
|
mintrpc/*.proto \
|
|||
|
|
assetwalletrpc/*.proto \
|
|||
|
|
rfqrpc/*.proto
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Mint via Python gRPC:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import grpc
|
|||
|
|
import codecs
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
# Generated stubs (from proto generation above)
|
|||
|
|
import mintrpc.mint_pb2 as mintrpc
|
|||
|
|
import mintrpc.mint_pb2_grpc as mintstub
|
|||
|
|
|
|||
|
|
TAPD_GRPC = "localhost:10029"
|
|||
|
|
TLS_PATH = Path.home() / ".taproot-assets/tls.cert"
|
|||
|
|
MAC_PATH = Path.home() / ".taproot-assets/data/mainnet/admin.macaroon"
|
|||
|
|
|
|||
|
|
def tapd_channel():
|
|||
|
|
cert = open(TLS_PATH, "rb").read()
|
|||
|
|
mac = codecs.encode(open(MAC_PATH, "rb").read(), "hex")
|
|||
|
|
def _mac_cb(context, callback):
|
|||
|
|
callback([("macaroon", mac)], None)
|
|||
|
|
ssl = grpc.ssl_channel_credentials(cert)
|
|||
|
|
auth = grpc.metadata_call_credentials(_mac_cb)
|
|||
|
|
creds = grpc.composite_channel_credentials(ssl, auth)
|
|||
|
|
return grpc.secure_channel(TAPD_GRPC, creds)
|
|||
|
|
|
|||
|
|
def mint_timmy(supply: int = 1_000_000):
|
|||
|
|
with tapd_channel() as ch:
|
|||
|
|
stub = mintstub.MintStub(ch)
|
|||
|
|
req = mintrpc.MintAssetRequest(
|
|||
|
|
asset=mintrpc.MintAsset(
|
|||
|
|
asset_type=mintrpc.NORMAL, # fungible
|
|||
|
|
name="TIMMY",
|
|||
|
|
asset_meta=mintrpc.AssetMeta(
|
|||
|
|
data=b"TIMMY: one unit of Timmy agent labor credit"
|
|||
|
|
),
|
|||
|
|
amount=supply,
|
|||
|
|
new_grouped_asset=True, # allows future issuance
|
|||
|
|
decimal_display=0,
|
|||
|
|
),
|
|||
|
|
short_response=False,
|
|||
|
|
)
|
|||
|
|
resp = stub.MintAsset(req)
|
|||
|
|
print(resp)
|
|||
|
|
|
|||
|
|
def finalize_mint():
|
|||
|
|
with tapd_channel() as ch:
|
|||
|
|
stub = mintstub.MintStub(ch)
|
|||
|
|
resp = stub.FinalizeBatch(mintrpc.FinalizeBatchRequest())
|
|||
|
|
print(resp)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.5 On-Chain Cost
|
|||
|
|
|
|||
|
|
The minting transaction is a standard Bitcoin Taproot transaction. Cost depends entirely on the current fee market:
|
|||
|
|
|
|||
|
|
| Network congestion | Approximate cost |
|
|||
|
|
|-------------------|-----------------|
|
|||
|
|
| Low (<5 sat/vB) | $3–$10 |
|
|||
|
|
| Normal (10–30 sat/vB) | $10–$40 |
|
|||
|
|
| High (>100 sat/vB) | $40–$200+ |
|
|||
|
|
|
|||
|
|
The mint is a **single transaction regardless of supply size**. Minting 1 token or 1,000,000 tokens costs the same — it is just a Bitcoin Taproot output with a commitment in the witness.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Taproot Assets on Lightning Channels
|
|||
|
|
|
|||
|
|
### 2.1 Fund a Lightning Channel with TIMMY Tokens
|
|||
|
|
|
|||
|
|
Get your asset's group key first:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
tapcli assets list
|
|||
|
|
# note: tweaked_group_key from the output
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Open a channel using `litcli` (not `lncli`):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
litcli ln fundchannel \
|
|||
|
|
--node_pubkey <peer_pubkey> \
|
|||
|
|
--local_sat_amount 100000 \
|
|||
|
|
--push_sat 0 \
|
|||
|
|
--asset_id <hex_asset_id> \
|
|||
|
|
--asset_amount 10000
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The `local_sat_amount` is the BTC reserve deposited alongside the asset — the channel requires both a BTC component and the asset component. This BTC functions as the fee buffer for routing and channel closure.
|
|||
|
|
|
|||
|
|
### 2.2 BTC Channel Coexistence
|
|||
|
|
|
|||
|
|
✅ **Yes, TA channels can coexist alongside BTC channels in the same UTXO.** The protocol specifically supports this: "Taproot Assets channels can be created alongside BTC channels in the same UTXO, allowing Taproot Assets to exist in the Lightning Network without consuming additional resources."
|
|||
|
|
|
|||
|
|
A single channel funding transaction can carry both BTC and Taproot Asset commitments. This means no extra on-chain transaction overhead once you are already opening a channel with a peer.
|
|||
|
|
|
|||
|
|
### 2.3 Routing: How TIMMY Payments Route Over Lightning
|
|||
|
|
|
|||
|
|
The RFQ (Request for Quote) protocol, live on mainnet since **v0.6 (June 2025)**, is the routing mechanism:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
[User: holds TIMMY in TA channel with Timmy node]
|
|||
|
|
│
|
|||
|
|
▼ TA channel
|
|||
|
|
[Timmy Node / Edge Node]
|
|||
|
|
│ RFQ: TIMMY → sats (rate locked, time-limited)
|
|||
|
|
▼ Standard BTC Lightning
|
|||
|
|
[Rest of Lightning Network]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**When no direct TIMMY-to-TIMMY route exists (which is almost always the case initially):**
|
|||
|
|
|
|||
|
|
- Edge nodes perform the swap automatically. The edge node holds TIMMY in a private TA channel to the user and BTC channels to the rest of the network.
|
|||
|
|
- The swap is atomic via HTLC. The user's TIMMY is debited, the recipient receives BTC sats (or another TA asset if they also have an edge node).
|
|||
|
|
- **This means TIMMY can pay any standard Lightning invoice**, even nodes that have never heard of Taproot Assets, as long as an edge node is in the path.
|
|||
|
|
|
|||
|
|
**v0.7 improvement:** AddressV2 (static, reusable receive addresses) simplifies the receive side — users don't need to generate a fresh address per invoice.
|
|||
|
|
|
|||
|
|
### 2.4 Current LND v0.20+ TA Channel Status
|
|||
|
|
|
|||
|
|
✅ **Lightning channel support is live on mainnet as of v0.6 (June 2025) / v0.7 (December 2025).**
|
|||
|
|
|
|||
|
|
Production edge nodes confirmed operating as of early 2026: Voltage, Amboss, Joltz, LnFi Network, Speed.
|
|||
|
|
|
|||
|
|
⚠️ **Multi-path sending** (splitting an outbound TIMMY payment across multiple channels) is **not yet implemented** in v0.7. Multi-path receive was added in v0.6 (up to 20 inbound channels). Multi-path send is planned for a future release.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. L402 Payment Gate with Taproot Assets
|
|||
|
|
|
|||
|
|
### 3.1 Can Aperture Gate Using TIMMY Tokens?
|
|||
|
|
|
|||
|
|
⚠️ **NOT PRODUCTION-READY.** As of March 2026, Aperture (Lightning Labs' L402 reverse proxy) natively supports **BTC satoshi payments only**. It generates standard BOLT-11 Lightning invoices and validates payment via preimage. There is no built-in Aperture support for Taproot Asset invoices.
|
|||
|
|
|
|||
|
|
**Interim path: build a thin custom L402 layer on top of tapd.**
|
|||
|
|
|
|||
|
|
### 3.2 Custom L402 Implementation for Taproot Asset Payments
|
|||
|
|
|
|||
|
|
The standard L402 flow adapted for TIMMY:
|
|||
|
|
|
|||
|
|
1. Client hits protected endpoint
|
|||
|
|
2. Server generates a tapd TA address (AddressV2) for `N` TIMMY tokens — this is the "invoice"
|
|||
|
|
3. Server returns `HTTP 402` with the TA address and a pending macaroon
|
|||
|
|
4. Client sends TIMMY to the address
|
|||
|
|
5. Server polls tapd for incoming transfer confirmation, then issues the activated macaroon
|
|||
|
|
6. Client includes `Authorization: L402 <macaroon>:<transfer_txid>` on the next request
|
|||
|
|
|
|||
|
|
The key difference from standard L402: Lightning BOLT-11 invoices confirm in 1–3 seconds with an atomic preimage. Taproot Asset on-chain transfers confirm in ~10 minutes (1 block). **For the eval-fee pattern, this means on-chain TA transfers are not usable for real-time gating.** TA Lightning channel payments (off-chain) are the only viable path for sub-second confirmation.
|
|||
|
|
|
|||
|
|
For TA Lightning channel payments, the flow uses `tapcli assets send --addr <ta_address>` or the equivalent gRPC call, which generates an off-chain HTLC that settles in 1–3 seconds, matching standard Lightning behavior.
|
|||
|
|
|
|||
|
|
### 3.3 Macaroon Caveat Encoding for TIMMY vs Sats
|
|||
|
|
|
|||
|
|
Macaroons are issued by your server, not by LND. Add a caveat identifying the payment currency:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import pymacaroons # pip install pymacaroons
|
|||
|
|
|
|||
|
|
def issue_session_macaroon(
|
|||
|
|
root_key: bytes,
|
|||
|
|
user_id: str,
|
|||
|
|
payment_hash: str,
|
|||
|
|
currency: str, # "TIMMY" or "sats"
|
|||
|
|
amount: int,
|
|||
|
|
max_requests: int,
|
|||
|
|
expires_at: str, # ISO 8601
|
|||
|
|
) -> str:
|
|||
|
|
m = pymacaroons.Macaroon(
|
|||
|
|
location="https://timmy.agent",
|
|||
|
|
identifier=f"user:{user_id}:ph:{payment_hash}",
|
|||
|
|
key=root_key,
|
|||
|
|
)
|
|||
|
|
m.add_first_party_caveat(f"currency = {currency}")
|
|||
|
|
m.add_first_party_caveat(f"amount_paid = {amount}")
|
|||
|
|
m.add_first_party_caveat(f"max_requests = {max_requests}")
|
|||
|
|
m.add_first_party_caveat(f"expires_at = {expires_at}")
|
|||
|
|
m.add_first_party_caveat(f"payment_hash = {payment_hash}")
|
|||
|
|
return m.serialize()
|
|||
|
|
|
|||
|
|
def verify_session_macaroon(
|
|||
|
|
root_key: bytes,
|
|||
|
|
token: str,
|
|||
|
|
requests_used: int,
|
|||
|
|
) -> bool:
|
|||
|
|
v = pymacaroons.Verifier()
|
|||
|
|
v.satisfy_exact("currency = TIMMY")
|
|||
|
|
v.satisfy_exact("currency = sats")
|
|||
|
|
v.satisfy_general(lambda c: c.startswith("amount_paid = "))
|
|||
|
|
v.satisfy_general(lambda c: c.startswith("payment_hash = "))
|
|||
|
|
v.satisfy_general(lambda c: _check_requests(c, requests_used))
|
|||
|
|
v.satisfy_general(lambda c: _check_expiry(c))
|
|||
|
|
m = pymacaroons.Macaroon.deserialize(token)
|
|||
|
|
try:
|
|||
|
|
v.verify(m, root_key)
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def _check_requests(caveat: str, used: int) -> bool:
|
|||
|
|
if caveat.startswith("max_requests = "):
|
|||
|
|
max_r = int(caveat.split(" = ")[1])
|
|||
|
|
return used < max_r
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def _check_expiry(caveat: str) -> bool:
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
if caveat.startswith("expires_at = "):
|
|||
|
|
exp = datetime.fromisoformat(caveat.split(" = ")[1])
|
|||
|
|
return datetime.now(timezone.utc) < exp
|
|||
|
|
return True
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.4 Session Pass Pattern (Prepay N Requests)
|
|||
|
|
|
|||
|
|
The session pass flow using TIMMY:
|
|||
|
|
|
|||
|
|
1. User calls `POST /session` with desired number of requests (e.g., 10)
|
|||
|
|
2. Server returns a TA address for `N × cost_per_request` TIMMY tokens
|
|||
|
|
3. User pays via TA Lightning channel (off-chain, ~2s)
|
|||
|
|
4. Server confirms payment, issues a macaroon with `max_requests = 10`
|
|||
|
|
5. User submits requests with the macaroon in the `Authorization` header
|
|||
|
|
6. Server decrements a server-side counter per request (the `max_requests` caveat is a hint, server-side tracking is authoritative)
|
|||
|
|
|
|||
|
|
⚠️ The `max_requests` caveat cannot enforce itself — macaroons are bearer tokens, not stateful counters. The server **must** maintain a counter in its database keyed on the payment hash. The caveat is a hint for the client; the server enforces the limit.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. FastAPI Integration
|
|||
|
|
|
|||
|
|
### 4.1 Python Path to tapd: gRPC vs REST vs LNbits
|
|||
|
|
|
|||
|
|
| Path | Maturity | When to use |
|
|||
|
|
|------|----------|-------------|
|
|||
|
|
| **gRPC stubs (generated from .proto)** | ✅ Official, documented | Production; full access to all tapd APIs |
|
|||
|
|
| **tapd REST API (port 8089)** | ✅ Works today | Simpler integration; slightly less coverage than gRPC |
|
|||
|
|
| **LNbits Taproot Assets extension** | ⚠️ Community; alpha | Rapid prototyping only; not battle-tested for production |
|
|||
|
|
|
|||
|
|
**Recommendation:** Use the tapd REST API for simplicity during development; migrate to gRPC for production to access features like RFQ negotiation that are gRPC-only.
|
|||
|
|
|
|||
|
|
### 4.2 LNbits Taproot Assets Extension Status (March 2026)
|
|||
|
|
|
|||
|
|
The community extension (`echennells/taproot_assets`) exists and connects LNbits to `litd` via gRPC. It supports asset listing, send/receive, channel viewing, and balance tracking with WebSocket updates. It bundles its own LND and tapd protobuf stubs (`lnd_grpc_files.tar.gz`, `tapd_grpc_files.tar.gz`).
|
|||
|
|
|
|||
|
|
⚠️ **Production status: Alpha/community.** Use it for local development and testing. For a production service handling real funds, wire FastAPI directly to tapd gRPC.
|
|||
|
|
|
|||
|
|
### 4.3 Minimal FastAPI Endpoint: Create TA Address, Check Payment, Issue Macaroon
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import asyncio
|
|||
|
|
import codecs
|
|||
|
|
import os
|
|||
|
|
import secrets
|
|||
|
|
from datetime import datetime, timedelta, timezone
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import grpc
|
|||
|
|
import httpx
|
|||
|
|
import pymacaroons
|
|||
|
|
from fastapi import FastAPI, HTTPException
|
|||
|
|
from pydantic import BaseModel
|
|||
|
|
|
|||
|
|
# ── Generated stubs ─────────────────────────────────────────────────────────
|
|||
|
|
# Generate with: python -m grpc_tools.protoc -I. --python_out=./stubs
|
|||
|
|
# --grpc_python_out=./stubs taprpc/*.proto
|
|||
|
|
import stubs.taprootassets_pb2 as taprpc
|
|||
|
|
import stubs.taprootassets_pb2_grpc as tapstub
|
|||
|
|
|
|||
|
|
app = FastAPI()
|
|||
|
|
|
|||
|
|
TAPD_GRPC = os.getenv("TAPD_GRPC_HOST", "localhost:10029")
|
|||
|
|
TAPD_TLS = Path(os.getenv("TAPD_TLS_PATH", "~/.taproot-assets/tls.cert")).expanduser()
|
|||
|
|
TAPD_MAC = Path(os.getenv("TAPD_MAC_PATH", "~/.taproot-assets/data/mainnet/admin.macaroon")).expanduser()
|
|||
|
|
TIMMY_ID = bytes.fromhex(os.getenv("TIMMY_ASSET_ID", "")) # 32-byte asset id hex
|
|||
|
|
MACAROON_ROOT_KEY = bytes.fromhex(os.getenv("MACAROON_ROOT_KEY", secrets.token_hex(32)))
|
|||
|
|
|
|||
|
|
# ── tapd gRPC channel ────────────────────────────────────────────────────────
|
|||
|
|
def _tapd_creds():
|
|||
|
|
cert = TAPD_TLS.read_bytes()
|
|||
|
|
mac = codecs.encode(TAPD_MAC.read_bytes(), "hex")
|
|||
|
|
def _cb(context, callback):
|
|||
|
|
callback([("macaroon", mac)], None)
|
|||
|
|
return grpc.composite_channel_credentials(
|
|||
|
|
grpc.ssl_channel_credentials(cert),
|
|||
|
|
grpc.metadata_call_credentials(_cb),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_channel = grpc.secure_channel(TAPD_GRPC, _tapd_creds())
|
|||
|
|
_tap = tapstub.TaprootAssetsStub(_channel)
|
|||
|
|
|
|||
|
|
# ── In-memory session store (replace with SQLite/Redis in prod) ──────────────
|
|||
|
|
_sessions: dict[str, dict] = {}
|
|||
|
|
|
|||
|
|
# ── Models ───────────────────────────────────────────────────────────────────
|
|||
|
|
class SessionRequest(BaseModel):
|
|||
|
|
num_requests: int = 10
|
|||
|
|
|
|||
|
|
class SessionResponse(BaseModel):
|
|||
|
|
ta_address: str # user sends TIMMY here
|
|||
|
|
timmy_amount: int # how many TIMMY to send
|
|||
|
|
session_id: str # poll /session/{session_id} to check payment
|
|||
|
|
|
|||
|
|
class SessionStatus(BaseModel):
|
|||
|
|
paid: bool
|
|||
|
|
macaroon: str | None = None
|
|||
|
|
|
|||
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|||
|
|
TIMMY_PER_REQUEST = int(os.getenv("TIMMY_PER_REQUEST", "1"))
|
|||
|
|
|
|||
|
|
def _new_ta_address(amount: int) -> str:
|
|||
|
|
"""Create a tapd AddressV2 to receive `amount` TIMMY tokens."""
|
|||
|
|
req = taprpc.NewAddrRequest(
|
|||
|
|
asset_id=TIMMY_ID,
|
|||
|
|
amt=amount,
|
|||
|
|
address_version=taprpc.ADDR_VERSION_V2,
|
|||
|
|
)
|
|||
|
|
resp = _tap.NewAddr(req)
|
|||
|
|
return resp.encoded # bech32m ta1... address
|
|||
|
|
|
|||
|
|
def _check_recent_receives(ta_address: str) -> bool:
|
|||
|
|
"""Check if TIMMY has been received to the given address."""
|
|||
|
|
req = taprpc.ListTransfersRequest()
|
|||
|
|
resp = _tap.ListTransfers(req)
|
|||
|
|
for transfer in resp.transfers:
|
|||
|
|
for output in transfer.outputs:
|
|||
|
|
if output.address == ta_address and output.status == taprpc.OUTPUT_STATUS_COMPLETED:
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def _issue_macaroon(session_id: str, num_requests: int) -> str:
|
|||
|
|
expires = (datetime.now(timezone.utc) + timedelta(hours=24)).isoformat()
|
|||
|
|
return issue_session_macaroon(
|
|||
|
|
root_key=MACAROON_ROOT_KEY,
|
|||
|
|
user_id=session_id,
|
|||
|
|
payment_hash=session_id,
|
|||
|
|
currency="TIMMY",
|
|||
|
|
amount=num_requests * TIMMY_PER_REQUEST,
|
|||
|
|
max_requests=num_requests,
|
|||
|
|
expires_at=expires,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Reuse the macaroon helpers from §3.3
|
|||
|
|
def issue_session_macaroon(root_key, user_id, payment_hash,
|
|||
|
|
currency, amount, max_requests, expires_at) -> str:
|
|||
|
|
m = pymacaroons.Macaroon(
|
|||
|
|
location="https://timmy.agent",
|
|||
|
|
identifier=f"user:{user_id}:ph:{payment_hash}",
|
|||
|
|
key=root_key,
|
|||
|
|
)
|
|||
|
|
m.add_first_party_caveat(f"currency = {currency}")
|
|||
|
|
m.add_first_party_caveat(f"amount_paid = {amount}")
|
|||
|
|
m.add_first_party_caveat(f"max_requests = {max_requests}")
|
|||
|
|
m.add_first_party_caveat(f"expires_at = {expires_at}")
|
|||
|
|
return m.serialize()
|
|||
|
|
|
|||
|
|
# ── Routes ───────────────────────────────────────────────────────────────────
|
|||
|
|
@app.post("/session", response_model=SessionResponse)
|
|||
|
|
async def create_session(req: SessionRequest):
|
|||
|
|
if req.num_requests < 1 or req.num_requests > 100:
|
|||
|
|
raise HTTPException(400, "num_requests must be 1–100")
|
|||
|
|
|
|||
|
|
timmy_amount = req.num_requests * TIMMY_PER_REQUEST
|
|||
|
|
ta_address = _new_ta_address(timmy_amount)
|
|||
|
|
session_id = secrets.token_hex(16)
|
|||
|
|
|
|||
|
|
_sessions[session_id] = {
|
|||
|
|
"ta_address": ta_address,
|
|||
|
|
"timmy_amount": timmy_amount,
|
|||
|
|
"num_requests": req.num_requests,
|
|||
|
|
"paid": False,
|
|||
|
|
"requests_used": 0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return SessionResponse(
|
|||
|
|
ta_address=ta_address,
|
|||
|
|
timmy_amount=timmy_amount,
|
|||
|
|
session_id=session_id,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@app.get("/session/{session_id}", response_model=SessionStatus)
|
|||
|
|
async def check_session(session_id: str):
|
|||
|
|
s = _sessions.get(session_id)
|
|||
|
|
if not s:
|
|||
|
|
raise HTTPException(404, "Session not found")
|
|||
|
|
|
|||
|
|
if not s["paid"]:
|
|||
|
|
# Poll tapd for incoming transfer
|
|||
|
|
paid = await asyncio.to_thread(_check_recent_receives, s["ta_address"])
|
|||
|
|
if paid:
|
|||
|
|
s["paid"] = True
|
|||
|
|
s["macaroon"] = _issue_macaroon(session_id, s["num_requests"])
|
|||
|
|
|
|||
|
|
return SessionStatus(
|
|||
|
|
paid=s["paid"],
|
|||
|
|
macaroon=s.get("macaroon"),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@app.get("/session/{session_id}/balance")
|
|||
|
|
async def query_timmy_balance(session_id: str):
|
|||
|
|
"""
|
|||
|
|
Query TIMMY balance held by this node across all channels/UTXOs.
|
|||
|
|
Replace asset_id filter with your TIMMY_ID.
|
|||
|
|
"""
|
|||
|
|
req = taprpc.ListBalancesRequest(asset_id=TIMMY_ID)
|
|||
|
|
resp = _tap.ListBalances(req)
|
|||
|
|
return {"balance": resp.asset_balances.get(TIMMY_ID.hex(), {}).get("balance", 0)}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.4 Query a User's TIMMY Balance
|
|||
|
|
|
|||
|
|
To check how many TIMMY a **remote address** (user) holds, query the tapd universe — the public registry of asset ownership proofs:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# Check universe for asset transfers associated with an address
|
|||
|
|
tapcli universe leaves --asset_id <hex_asset_id> --proof_type issuance
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
For an application-level balance check (tracking credits against a session), maintain the balance in your own database. tapd does not expose third-party address balances — only your own node's holdings.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. Hybrid Architecture: Sats + TIMMY Credits
|
|||
|
|
|
|||
|
|
### 5.1 Why the Hybrid Is Necessary Now
|
|||
|
|
|
|||
|
|
⚠️ **As of March 2026**, the full Taproot Asset Lightning stack requires running `litd` in integrated mode with LND + tapd. This is non-trivial for a solo developer shipping a first version. Additionally, multi-path send is missing, and the alpha warnings are real (see §6).
|
|||
|
|
|
|||
|
|
The practical production path today:
|
|||
|
|
|
|||
|
|
- **Users pay in sats** via standard Lightning (BOLT-11 invoice, LNbits, any wallet)
|
|||
|
|
- **Internal ledger tracks TIMMY credits** in your database
|
|||
|
|
- TIMMY credits are not on-chain tokens yet — they are your application's internal accounting unit
|
|||
|
|
|
|||
|
|
This lets you ship the payment gate today using standard, battle-tested Lightning tooling.
|
|||
|
|
|
|||
|
|
### 5.2 Internal Credit Ledger Model
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- Minimal schema (SQLite or PostgreSQL)
|
|||
|
|
CREATE TABLE sessions (
|
|||
|
|
id TEXT PRIMARY KEY, -- session UUID
|
|||
|
|
user_id TEXT NOT NULL,
|
|||
|
|
currency TEXT NOT NULL, -- 'sats' or 'TIMMY'
|
|||
|
|
amount_paid INTEGER NOT NULL, -- sats paid or TIMMY credited
|
|||
|
|
timmy_credits INTEGER NOT NULL, -- TIMMY credit balance
|
|||
|
|
requests_used INTEGER DEFAULT 0,
|
|||
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|||
|
|
expires_at TEXT NOT NULL,
|
|||
|
|
paid INTEGER DEFAULT 0 -- 0=pending, 1=confirmed
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE TABLE requests (
|
|||
|
|
id TEXT PRIMARY KEY,
|
|||
|
|
session_id TEXT REFERENCES sessions(id),
|
|||
|
|
eval_fee_paid INTEGER DEFAULT 0, -- 1=eval invoice paid
|
|||
|
|
work_fee_paid INTEGER DEFAULT 0, -- 1=work invoice paid
|
|||
|
|
timmy_debited INTEGER DEFAULT 0, -- TIMMY credits consumed
|
|||
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.3 TIMMY Credit Pegging Strategy
|
|||
|
|
|
|||
|
|
**Option A: Fixed operator rate (recommended for v1)**
|
|||
|
|
|
|||
|
|
Set a fixed rate at deployment:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
SATS_PER_TIMMY = int(os.getenv("SATS_PER_TIMMY", "1000")) # 1 TIMMY = 1000 sats
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- Operator controls the rate via env var
|
|||
|
|
- Simple, predictable for users
|
|||
|
|
- Adjust the rate when relaunching; existing sessions keep their purchased credits
|
|||
|
|
- Risk: if sat/USD moves significantly, the labor price in USD drifts
|
|||
|
|
|
|||
|
|
**Option B: Floating rate (future)**
|
|||
|
|
|
|||
|
|
Derive `SATS_PER_TIMMY` from a price oracle (e.g., CoinGecko USD/BTC feed) to keep TIMMY's labor value stable in USD terms. Implement this only after the fixed-rate model is validated.
|
|||
|
|
|
|||
|
|
### 5.4 Migration Path to Native TA Payments
|
|||
|
|
|
|||
|
|
When you are ready to move from the sats + internal ledger hybrid to native Taproot Asset Lightning payments:
|
|||
|
|
|
|||
|
|
**Phase 1 (current):** Sats in → internal TIMMY credits (database)
|
|||
|
|
|
|||
|
|
**Phase 2 (when TA Lightning is stable for your use case):**
|
|||
|
|
1. Deploy `litd` in integrated mode alongside the FastAPI server
|
|||
|
|
2. Mint the TIMMY token on-chain (§1 above)
|
|||
|
|
3. Keep the internal credit ledger — it becomes a bridge between sats-paying users and token-holding users
|
|||
|
|
4. Add a new endpoint: `POST /redeem` — user sends on-chain TIMMY to your tapd address; server credits their account in the ledger
|
|||
|
|
5. Users who prefer sats continue using the existing flow; no migration required for them
|
|||
|
|
|
|||
|
|
**Phase 3 (full native):**
|
|||
|
|
1. Open TIMMY TA Lightning channels with an edge node (Voltage, Joltz, or LnFi)
|
|||
|
|
2. Accept TIMMY payments directly over Lightning without touching sats
|
|||
|
|
3. Retire the sat-based payment endpoint or keep it as a legacy path
|
|||
|
|
4. The `sessions` table gains a `native_ta` column; macaroons encode `currency = TIMMY` (native) vs `currency = sats` (legacy credit)
|
|||
|
|
|
|||
|
|
The database schema is the same across all phases. The payment confirmation logic changes (LNbits sat invoice → tapd TA transfer), but the session/macaroon layer is untouched.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. What Breaks
|
|||
|
|
|
|||
|
|
### 6.1 Known Failure Modes (March 2026)
|
|||
|
|
|
|||
|
|
**⚠️ ALPHA — FUNDS AT RISK**
|
|||
|
|
|
|||
|
|
The official tapd documentation states verbatim: *"there can still be bugs and not all desired data safety and backup mechanisms have been implemented yet."*
|
|||
|
|
|
|||
|
|
| Failure mode | Severity | Detail |
|
|||
|
|
|-------------|----------|--------|
|
|||
|
|
| **Local data loss → asset destruction** | CRITICAL | If the tapd data directory is lost without a proper backup, assets are permanently destroyed. The LND seed phrase alone is NOT sufficient to restore Taproot Assets. `tapd` holds the taproot tweak separately from `lnd`'s key; both are required. |
|
|||
|
|
| **Uninstalling litd without backup** | CRITICAL | Applies to Umbrel and similar node-in-a-box setups. Uninstall deletes application data, destroying assets. |
|
|||
|
|
| **No confidential asset amounts** | HIGH | All asset balances in TA channels are public to channel peers. Planned but not implemented. |
|
|||
|
|
| **No smart contracts** | MEDIUM | TA channels support payments only. No programmable conditions (escrow, time-locks beyond channel mechanics). |
|
|||
|
|
| **Edge node requirement** | MEDIUM | Sending TIMMY to a non-TA Lightning node requires routing through an edge node. If no edge node is in the path, payment fails. With limited edge node availability (Voltage, Joltz, LnFi), routing reliability depends on a small set of operators. |
|
|||
|
|
| **Multi-path send not implemented** | MEDIUM | Large TIMMY sends cannot be split across multiple outbound channels. Only single-path outbound. Multi-path receive works (up to 20 channels). |
|
|||
|
|
| **Forced litd integrated mode** | LOW-MEDIUM | You cannot run tapd as a standalone sidecar next to a pre-existing LND. You must run litd. This is an ops constraint, not a protocol flaw. |
|
|||
|
|
|
|||
|
|
### 6.2 What Is NOT Yet Implemented in tapd (v0.7)
|
|||
|
|
|
|||
|
|
| Feature | Status |
|
|||
|
|
|---------|--------|
|
|||
|
|
| Multi-path sending (outbound) | Planned, not in v0.7 |
|
|||
|
|
| Confidential asset amounts | Planned |
|
|||
|
|
| Full smart contract support | Not planned for TA (protocol constraint) |
|
|||
|
|
| Native Aperture L402 support for TA payments | Not implemented in Aperture |
|
|||
|
|
| Python SDK (official) | Not available — use gRPC stubs or REST |
|
|||
|
|
| tapd standalone mode (without litd) for Lightning channels | Not supported |
|
|||
|
|
|
|||
|
|
### 6.3 Mainnet TA Channels: Production or Testnet?
|
|||
|
|
|
|||
|
|
✅ **Mainnet production as of v0.6 (June 2025).** Confirmed operators: Voltage, Amboss, Joltz, Speed, LnFi Network.
|
|||
|
|
|
|||
|
|
⚠️ The production ecosystem is small. As of March 2026, the Taproot Assets Lightning channel network is real but thin. Routing reliability is lower than the broader BTC Lightning Network. Do not assume your TIMMY payment will route successfully without a direct edge node relationship.
|
|||
|
|
|
|||
|
|
### 6.4 Protocol Evolution Risk
|
|||
|
|
|
|||
|
|
The Lightning Labs team declared **forward compatibility** starting with the v0.3 mainnet alpha (Oct 2023): assets issued on mainnet will not be broken by future protocol versions. The protocol API and message formats, however, are still stabilizing. gRPC stubs generated from v0.7 proto files may need regeneration for v0.8+.
|
|||
|
|
|
|||
|
|
**Mitigation:** Abstract all tapd calls behind a single `TapdService` class. When the proto changes, update the stubs and the service class in one place. The FastAPI routes and business logic remain stable.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Summary: What Works Today vs What to Wait On
|
|||
|
|
|
|||
|
|
| Feature | Today's path | Wait for |
|
|||
|
|
|---------|-------------|----------|
|
|||
|
|
| Mint TIMMY on-chain | ✅ `tapcli assets mint` — single tx, mainnet | — |
|
|||
|
|
| TIMMY in Lightning channels | ✅ `litd` integrated mode, v0.7 | — |
|
|||
|
|
| BTC + TIMMY in same UTXO | ✅ Supported | — |
|
|||
|
|
| Route TIMMY over BTC Lightning | ✅ Via edge nodes (Voltage, Joltz) | Multi-path send |
|
|||
|
|
| L402 gate with TIMMY | ⚠️ Custom implementation required | Native Aperture TA support |
|
|||
|
|
| Session pass macaroon (N requests) | ✅ `pymacaroons` + server-side counter | — |
|
|||
|
|
| FastAPI + tapd Python | ✅ gRPC stubs from proto files | Official Python SDK |
|
|||
|
|
| LNbits TA extension | ⚠️ Alpha/community | Stable release |
|
|||
|
|
| Hybrid sats → TIMMY credit ledger | ✅ Standard Lightning + SQLite | — |
|
|||
|
|
| Migration to native TA payments | ✅ Architecture is clear | Multi-path send stabilization |
|
|||
|
|
|
|||
|
|
**Recommended starting point:** Implement the hybrid architecture (§5) using LNbits for sats payments and an internal TIMMY credit ledger. Wire tapd for the on-chain mint only (§1). Add native TA Lightning payments (§2–4) once the edge node ecosystem matures further and you have validated demand.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
*Sources: Lightning Labs official tapd documentation and API reference (lightning.engineering), tapd GitHub repository (v0.7), LND v0.20 release notes, Taproot Assets Builder's Guide, echennells/taproot_assets LNbits extension, pymacaroons library documentation, L402 specification (lightning.engineering/posts), Voltage/Joltz/LnFi edge node announcements.*
|