Produced implementation-guide-taproot-assets-l402-fastapi.md covering all six research areas: 1. Minting: tapcli CLI, Python gRPC (mintrpc.MintAsset), on-chain cost table, single-tx full supply, grouped asset for future issuance, verification commands. LND v0.20 + tapd v0.7 + litd v0.14 confirmed and sourced. 2. Lightning channels: litd integrated mode requirement, litcli channel funding, BTC+TA UTXO coexistence confirmed, RFQ routing via edge nodes (Voltage, Joltz, LnFi), mainnet live since v0.6 (June 2025). Multi-path send flagged missing in v0.7. 3. L402 gate: Aperture flagged NOT PRODUCTION-READY for TA payments. Custom L402 via pymacaroons with currency caveat, N-request session pass, server-side counter requirement. 4. FastAPI+tapd: gRPC stubs path, LNbits TA extension flagged alpha. Full FastAPI endpoints for session creation, payment check, macaroon issuance. REST curl examples added for all key tapd operations (list assets, create address, check transfers, query balance). 5. Hybrid architecture: SQLite schema, fixed-rate SATS_PER_TIMMY peg, 3-phase migration plan. 6. Failure modes: CRITICAL data loss (tapd backup required beyond LND seed), missing features enumerated, mainnet edge node ecosystem confirmed thin but operational. Code review fixes applied: - Fixed macaroon verifier bug: replaced dual satisfy_exact(currency=X) calls (which would require BOTH caveats to be present) with a single satisfy_general() checking one allowed currency value. - Added MACAROON_ROOT_KEY persistent-secret warning in FastAPI code. - Added proto field caveat header (regenerate stubs per tapd release). - Added References table with dated inline source links for all key claims. - Added REST curl quick reference for all tapd operations in §4.1.
33 KiB
Implementation Guide: Taproot Assets + L402 Payment Gate on FastAPI/Lightning
Project: Timmy Agent
Date: March 18, 2026
Validated against: tapd v0.7.x / litd v0.14.x / LND v0.20.0-beta (December 2025 release cycle)
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.
Proto field caveat: gRPC stubs must be regenerated from the
.protofiles of each tapd release. Field names and message structures may change between minor versions. Always runpython -m grpc_tools.protocagainst the proto files from the exact tapd version you are running, and re-validate field names in the Python snippets below against your generated stubs before production use.
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:
make install tags="signrpc walletrpc chainrpc invoicesrpc"
For the full Lightning Terminal stack (recommended), use litd which bundles everything:
git clone https://github.com/lightninglabs/lightning-terminal.git
cd lightning-terminal
make install
1.2 Start tapd on Mainnet
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):
# 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
litd --uipassword=<yourpassword>
1.3 Mint the TIMMY Token (CLI)
Step 1 — Queue the mint:
# 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):
# 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):
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:
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.
# 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:
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:
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:
tapcli assets list
# note: tweaked_group_key from the output
Open a channel using litcli (not lncli):
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:
- Client hits protected endpoint
- Server generates a tapd TA address (AddressV2) for
NTIMMY tokens — this is the "invoice" - Server returns
HTTP 402with the TA address and a pending macaroon - Client sends TIMMY to the address
- Server polls tapd for incoming transfer confirmation, then issues the activated macaroon
- 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:
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()
ALLOWED_CURRENCIES = {"TIMMY", "sats"}
def verify_session_macaroon(
root_key: bytes,
token: str,
requests_used: int,
) -> bool:
v = pymacaroons.Verifier()
# Allow exactly one currency caveat per token — use a general checker, NOT
# multiple satisfy_exact() calls. satisfy_exact() requires ALL listed values
# to appear, which would fail any single-currency token.
v.satisfy_general(lambda c: (
c.startswith("currency = ") and c.split(" = ")[1] in ALLOWED_CURRENCIES
))
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:
- User calls
POST /sessionwith desired number of requests (e.g., 10) - Server returns a TA address for
N × cost_per_requestTIMMY tokens - User pays via TA Lightning channel (off-chain, ~2s)
- Server confirms payment, issues a macaroon with
max_requests = 10 - User submits requests with the macaroon in the
Authorizationheader - Server decrements a server-side counter per request (the
max_requestscaveat 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.
REST curl quick reference — tapd v0.7 (port 8089):
# Set these once
TAPD_MAC_HEX=$(xxd -p ~/.taproot-assets/data/mainnet/admin.macaroon | tr -d '\n')
# List all assets held by this node
curl -s --cacert ~/.taproot-assets/tls.cert \
-H "Grpc-Metadata-macaroon: $TAPD_MAC_HEX" \
https://localhost:8089/v1/taproot-assets/assets | jq .
# Create a TIMMY receive address (AddressV2)
# Replace <base64_asset_id> with your TIMMY asset ID encoded as base64
curl -s --cacert ~/.taproot-assets/tls.cert \
-H "Grpc-Metadata-macaroon: $TAPD_MAC_HEX" \
-H "Content-Type: application/json" \
-X POST https://localhost:8089/v1/taproot-assets/addrs \
-d '{
"asset_id": "<base64_asset_id>",
"amt": "10",
"address_version": "ADDR_VERSION_V2"
}' | jq .encoded
# Check recent transfers (to detect incoming TIMMY payment)
curl -s --cacert ~/.taproot-assets/tls.cert \
-H "Grpc-Metadata-macaroon: $TAPD_MAC_HEX" \
https://localhost:8089/v1/taproot-assets/transfers | jq .
# Query TIMMY balance
curl -s --cacert ~/.taproot-assets/tls.cert \
-H "Grpc-Metadata-macaroon: $TAPD_MAC_HEX" \
"https://localhost:8089/v1/taproot-assets/assets/balance?asset_id_filter=<hex_asset_id>" | jq .
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
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)))
# ⚠️ PRODUCTION REQUIREMENT: MACAROON_ROOT_KEY must be a stable, persistent secret stored in an
# environment variable or secrets manager. The default fallback (secrets.token_hex) generates a
# new random key on every process restart, which INVALIDATES ALL PREVIOUSLY ISSUED MACAROONS.
# Set MACAROON_ROOT_KEY once at deployment and never rotate it without invalidating active sessions.
# ── 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:
# 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
-- 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:
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):
- Deploy
litdin integrated mode alongside the FastAPI server - Mint the TIMMY token on-chain (§1 above)
- Keep the internal credit ledger — it becomes a bridge between sats-paying users and token-holding users
- Add a new endpoint:
POST /redeem— user sends on-chain TIMMY to your tapd address; server credits their account in the ledger - Users who prefer sats continue using the existing flow; no migration required for them
Phase 3 (full native):
- Open TIMMY TA Lightning channels with an edge node (Voltage, Joltz, or LnFi)
- Accept TIMMY payments directly over Lightning without touching sats
- Retire the sat-based payment endpoint or keep it as a legacy path
- The
sessionstable gains anative_tacolumn; macaroons encodecurrency = TIMMY(native) vscurrency = 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.
References
| Claim | Source | Date |
|---|---|---|
| LND v0.20 required for tapd v0.7 | Lightning Labs API Docs — tapd | Dec 2025 |
| tapd v0.7 mainnet release | tapd GitHub releases | Dec 2025 |
| TA Lightning channel support live mainnet v0.6 | Lightning Labs blog — Taproot Assets v0.6 | Jun 2025 |
| BTC + TA channels in same UTXO | Taproot Assets Builder's Guide | 2025 |
| RFQ protocol mainnet, multi-path receive (20 channels) | tapd v0.6 release notes | Jun 2025 |
| AddressV2 static reusable addresses | tapd v0.7 release notes | Dec 2025 |
| Edge node operators: Voltage, Amboss, Joltz, Speed, LnFi | Voltage blog, Joltz docs | 2025–2026 |
| Aperture L402 sats-only (no native TA payment support) | Aperture GitHub — no TA payment backend | Mar 2026 |
| LNbits TA extension (echennells) | echennells/taproot_assets GitHub | 2025 |
| Data loss warning — LND seed insufficient for TA restore | tapd official docs safety warning | 2025 |
| Multi-path send not implemented in v0.7 | tapd v0.7 release notes / GitHub milestones | Dec 2025 |
| Alpha software warning, bugs and missing backup mechanisms | tapd docs | 2025 |
| pymacaroons library | pymacaroons PyPI | — |
| L402 specification | lightning.engineering/posts/2023-06-07-l402 | Jun 2023 |
| LangChainBitcoin L402 Python | lightninglabs/LangChainBitcoin | 2023 |