Pinned Post

OpenSpec UI: ein Live-Dashboard für deine Specs

Bild
OpenSpec UI: ein Live-Dashboard für deine Specs Wer mit einem KI-Agenten und OpenSpec arbeitet, kennt das Bild. Im Projekt wächst ein Verzeichnis openspec/ heran: Changes mit proposal.md , design.md , tasks.md und ihren Delta-Specs, daneben die kanonischen Specs der einzelnen Capabilities. Die Absicht hinter dem Code steht damit endlich geschrieben, statt sich in ihm zu verstecken. Nur verteilt sie sich über Dutzende Markdown-Dateien, und der Editor zeigt eben Dateien. Was er nicht zeigt, ist der Zustand. Woran arbeitet der Agent gerade? Welcher Task ist der nächste offene? Und was hat sich verändert, während ich zehn Minuten woanders hingeschaut habe? Genau diese Lücke füllt OpenSpec UI , ein kleines Phoenix-LiveView-Dashboard, das lokal neben deinem Projekt läuft und den OpenSpec-Workspace im Browser zeigt — live, während gearbeitet wird. Der Code liegt offen auf GitLab: https://gitlab.com/public_elixir/openspec_ui Ein Blick statt Datei-Hopping Du startest den Server, gibst d...

Wallet-Sync ohne fremde API: ein Electrum-Client in Elixir

Bei Fiatbitcoin muss das Portfolio wissen, was auf den Bitcoin-Adressen der Nutzer passiert. Welche Zahlung ist eingegangen, was ist noch unbestätigt, wie sieht die Historie aus. Der bequeme Weg dorthin ist ein Block-Explorer: mempool.space, blockchain.info, ein REST-Aufruf, fertig. Nur lernt dieser Explorer damit, welche Adressen zu welchem Nutzer gehören. Für eine App, deren ganzer Anspruch Souveränität ist, ist das die falsche Abwägung.

Also reden wir direkt mit unserem eigenen Electrum-Server, der auf unserem eigenen Bitcoin-Node sitzt. Kein Dritter in der Schleife. Der Haken: Electrum spricht kein HTTP. Es spricht JSON-RPC über einen dauerhaft offenen TCP/SSL-Socket, mit Benachrichtigungen, die der Server von sich aus schickt. Genau an dieser Stelle hilft Req, unser sonstiger HTTP-Client, nicht weiter. Dieser Artikel handelt vom Kern des kleinen Clients, den ich dafür gebaut habe: wie man viele offene Anfragen über einen Socket auseinanderhält, Antworten von Server-Pushes unterscheidet, und die Pushes so in Phoenix PubSub übersetzt, dass eine LiveView in dem Moment aufwacht, in dem eine Zahlung eintrifft.

Warum selbst sprechen, und warum nicht über HTTP

Zwei Gründe, einer politisch, einer technisch.

Der politische: Ein Block-Explorer ist ein Beobachter. Jede Abfrage einer Adresse verrät ihm, dass diese Adresse jemanden interessiert, und über Zeit lässt sich daraus ein Adress-zu-Nutzer-Graph bauen. Ein eigener Electrum-Server, der nur unseren eigenen Node befragt, macht diesen Beobachter überflüssig.

Der technische: Das Electrum-Protokoll ist kein Request/Response über HTTP. Es ist ein zeilengetrennter JSON-RPC-2.0-Strom über eine persistente Verbindung. Man schickt eine Anfrage, irgendwann kommt eine Antwort, aber dazwischen und danach kann der Server jederzeit unaufgefordert Nachrichten pushen. Ein HTTP-Client kennt dieses Modell nicht. Hier braucht es einen Socket, einen Prozess, der ihn hält, und etwas Buchführung.

Drei Module, drei Verantwortungen

Ich habe die Sache in drei Teile geschnitten, klar nach Zustand getrennt:

  • Protocol ist zustandslos und ohne Seiteneffekte. Es kodiert und dekodiert JSON-RPC und berechnet den Electrum-Scripthash. Trivial zu testen, von jedem Prozess aus aufrufbar.
  • Connection ist der GenServer. Er hält den Socket, multiplext die Anfragen, kümmert sich um Reconnect und übersetzt Server-Pushes nach PubSub. Hier wohnt der gesamte unangenehme Zustand.
  • Client ist eine dünne Fassade darüber: get_history, list_unspent, subscribe_scripthash und so weiter. Lesbare Funktionen, die intern nur Connection.rpc/2 aufrufen.

Das Kernproblem: ein Socket, viele offene Anfragen

Ein TCP-Socket liefert keinen sauberen Strom von Nachrichten, sondern einen Strom von Bytes. Im Active-Mode kommen die Bytes als Chunks per handle_info herein, in beliebigen Stücken. Eine JSON-Zeile kann über zwei Chunks verteilt sein, oder zwei Zeilen kommen in einem. Also puffert man und schneidet an \n:

defp process_buffer(buffer, state) do
  parts = String.split(buffer, "\n")
  {complete, [remainder]} = Enum.split(parts, -1)

  state =
    Enum.reduce(complete, state, fn
      "", acc -> acc
      line, acc -> handle_json_line(line, acc)
    end)

  {remainder, state}
end

Alles vor dem letzten Zeilenumbruch ist vollständig, der Rest wandert zurück in den Puffer und wartet auf den nächsten Chunk.

Das eigentlich Interessante ist die Buchführung. Mehrere Aufrufer können gleichzeitig eine Anfrage offen haben, alle über denselben Socket. Jede Anfrage bekommt eine fortlaufende ID, und eine pending-Map merkt sich, welche ID zu welchem wartenden Aufrufer gehört:

def handle_call({:rpc, method, params}, from, state) do
  {id, state} = next_id(state)

  case send_encoded(state, Protocol.encode_request(id, method, params)) do
    {:ok, state} ->
      {:noreply, put_pending(state, id, {:call, from, method, t0})}

    {:error, reason} ->
      {:reply, {:error, reason}, disconnect(state)}
  end
end

Statt sofort zu antworten, parkt der GenServer das from unter der ID und gibt {:noreply, ...} zurück. Kommt die Antwort über den Socket, sucht er die ID heraus und antwortet dem richtigen Aufrufer mit GenServer.reply/2. So fühlt sich Connection.rpc/2 für den Aufrufer synchron an, obwohl unter der Haube ein Dutzend Anfragen gleichzeitig unterwegs sein können.

Die ganze Routing-Logik hängt an einer einzigen Unterscheidung im Protokoll. Eine Antwort trägt eine id. Eine vom Server gepushte Benachrichtigung trägt einen method-Namen und keine id. Das ist alles:

def parse_message(json_line) do
  case Jason.decode(json_line) do
    {:ok, %{"id" => id, "result" => result}} -> {:reply, id, result}
    {:ok, %{"id" => id, "error" => error}}   -> {:error_reply, id, error}
    {:ok, %{"method" => method, "params" => params}} -> {:notification, method, params}
    _ -> {:error, :parse_error}
  end
end

Subscriptions, oder: wie die Wallet von selbst aufwacht

Adressen im Sekundentakt abzufragen wäre verschwenderisch und langsam. Electrum bietet etwas Besseres: Man abonniert einen Scripthash, und sobald sich daran etwas ändert, eine eingehende Transaktion, eine Bestätigung, schickt der Server von sich aus eine Benachrichtigung.

Hier prallen zwei Welten aufeinander. Ein GenServer kann einen asynchronen Push nicht an irgendwen zurückgeben, der vor einer halben Minute mal gefragt hat. Niemand wartet mehr auf eine Antwort. Also übersetzt Connection jeden Push in einen Phoenix.PubSub-Broadcast auf ein Topic pro Scripthash. Wer auch immer dieses Topic abonniert hat, eine LiveView, ein Worker, wacht über handle_info auf:

defp broadcast_notification("blockchain.scripthash.subscribe", [hash, status]) do
  Phoenix.PubSub.broadcast(
    @pubsub,
    "electrum:scripthash:#{hash}",
    {:scripthash_status, hash, status}
  )
end

Das entkoppelt den Socket sauber von seinen Konsumenten. Connection weiß nichts über LiveViews, und die LiveView weiß nichts über Sockets. Dazwischen steht nur ein Topic-Name.

Eine Eigenheit hat dieses Modell, und sie ist ein echter Fallstrick: Man muss das PubSub-Topic abonnieren, bevor man die Electrum-Subscription abschickt. Sonst verpasst man den initialen Broadcast, den Connection feuert, sobald der Server die Subscription bestätigt. Im echten Einsatz sieht das so aus, hier im XpubScanner, der die Adressen aus einem xpub ableitet:

# Erst das Topic abonnieren, dann die Electrum-Subscription senden,
# damit der initiale Broadcast nicht verloren geht.
Phoenix.PubSub.subscribe(@pubsub, "electrum:scripthash:#{script_hash}")
Client.subscribe_scripthash(address)

Der Ablauf in der Praxis: Der Scanner leitet aus dem xpub die ersten zwanzig Adressen je Kette ab (das Gap Limit), abonniert jeden Scripthash und legt sich dann schlafen. Geht auf Adresse Nummer sieben eine Zahlung ein, pusht Electrum, Connection broadcastet, der Scanner reagiert, und das Portfolio aktualisiert sich. Kein Polling.

Die Bitcoin-Eigenheit: Scripthash statt Adresse

Electrum indiziert nicht nach Adresse, sondern nach dem Hash des Output-Skripts. Der Algorithmus ist eine kleine, leicht zu übersehende Konvention: SHA256 über das scriptPubKey, dann die Bytes umdrehen, dann hex-kodieren.

def script_to_hash(script) do
  :crypto.hash(:sha256, script)
  |> :binary.bin_to_list()
  |> Enum.reverse()
  |> :binary.list_to_bin()
  |> Base.encode16(case: :lower)
end

Das Umdrehen der Bytes ist der Teil, den man beim ersten Mal garantiert vergisst, und dann passt nichts zusammen. Davor steht die Umwandlung von der Adresse zum scriptPubKey, und die hängt vom Adresstyp ab. Eine Taproot-Adresse (bc1p…) ergibt ein anderes Skript als ein SegWit-v0-Output (bc1q…) oder eine alte 1…-Adresse. Für die Bech32-Fälle baue ich das Skript direkt aus dem Witness-Programm:

{:ok, {_network, 0, program}} when length(program) == 20 ->
  # P2WPKH: OP_0 <20-Byte-Keyhash>
  script = <<0x00, 0x14>> <> :binary.list_to_bin(program)
  {:ok, script_to_hash(script)}

{:ok, {_network, 1, program}} when length(program) == 32 ->
  # P2TR (Taproot): OP_1 <32-Byte-x-only-Pubkey>
  script = <<0x51, 0x20>> <> :binary.list_to_bin(program)
  {:ok, script_to_hash(script)}

Reconnect, ohne taub zu werden

Ein langlebiger Socket fällt irgendwann aus. Der Server startet neu, das Netz zuckt, eine Firewall räumt eine Idle-Verbindung ab. Wenn das passiert, darf zweierlei nicht geschehen: Die laufenden Aufrufer dürfen nicht bis zum Timeout hängen, und die Subscriptions dürfen nicht verloren gehen, sonst wird die Wallet taub.

Beim Trennen scheitert Connection deshalb sofort alle offenen Aufrufer, statt sie warten zu lassen:

Enum.each(state.pending, fn
  {_id, {:call, from, _method, _t0}} -> GenServer.reply(from, {:error, :disconnected})
  {_id, {:sub, from, _method, _params, _t0}} -> GenServer.reply(from, {:error, :disconnected})
  _ -> :ok
end)

Und alle Subscriptions liegen in einem MapSet aus {method, params}, das nach jedem Reconnect erneut abgespielt wird. Dabei wird auch der initiale Status wieder gebroadcastet, damit die Abonnenten sich neu synchronisieren. Dazu ein Ping alle sechzig Sekunden, damit die Verbindung nicht still wegbricht. Das sind keine Extras. Es ist der Unterschied zwischen einer Demo und etwas, das eine Nacht übersteht.

Lessons Learned

Drei Dinge, die sich bewährt haben, und eines, bei dem ich nachschärfen würde.

1. Den zustandslosen Teil herausziehen.

Protocol hat keinen State und keine Seiteneffekte. Kodieren, dekodieren, Scripthash rechnen, mehr nicht. Das macht genau den fummeligen Teil, das Byte-Umdrehen und die Adresstypen, ohne laufenden Server testbar. Der ganze unangenehme Zustand sitzt konzentriert in einem GenServer, und der ist dadurch überschaubar geblieben.

2. PubSub als Übersetzer zwischen Push und Request.

Der Bruch zwischen “der Server pusht, wann er will” und “ein Aufrufer will eine Antwort” lässt sich nicht wegdiskutieren. Ihn über einen Broadcast aufzulösen, statt zu versuchen, den Push an einen längst gegangenen Aufrufer zu hängen, hat den Socket von seinen Konsumenten entkoppelt. Neue Abnehmer kommen dazu, ohne dass Connection davon etwas mitbekommt.

3. Den Reconnect von Anfang an mitdenken.

Ich war versucht, erst die Happy-Path-Verbindung zu bauen und den Reconnect “später” nachzurüsten. Gut, dass ich es nicht getan habe. Re-Subscription und das sofortige Scheitern offener Aufrufer sind keine Politur, sie sind die halbe Substanz. Nachträglich hätte ich sie quer durch den schon gewachsenen Zustand fädeln müssen.

4. Was ich nachschärfen würde: das TLS und die eine Verbindung.

Zwei Stellen sind ehrlicherweise Kompromisse. Erstens verifiziere ich das Server-Zertifikat nicht (verify: :verify_none), weil es mein eigener, oft selbstsignierter Server ist. Für einen fremden Electrum-Server wäre das falsch, da gehört das Zertifikat gepinnt oder sauber geprüft. Zweitens ist die Subscription-Verbindung ein einzelner benannter GenServer, also bewusst ein Singleton für den Push-Strom. Der Bulk-Abgleich der Adressen läuft daneben über kurzlebige Verbindungen, und den musste ich auf etwa fünf parallele Refreshes drosseln, weil der Server darüber hinaus mit Timeouts antwortet. Ein richtiger Connection-Pool wäre hier die sauberere Antwort, steht aber noch nicht.

Wenn euch eine Stelle genauer interessiert, das Scripthash-Verfahren, der Reconnect mit Re-Subscription oder die Anbindung des XpubScanners, schreibt es in die Kommentare. Ich gehe gerne tiefer.

Kommentare

Beliebte Posts aus diesem Blog

Splitting an ML model and a web app across two BEAM nodes — the technical blueprint

Jido in Practice: Agents in Elixir as Composable Actions

A Foundation Model in the BEAM: On-Chain Anomalies with Google TimesFM in Elixir