Pinned Post
Wallet-Sync ohne fremde API: ein Electrum-Client in Elixir
- Link abrufen
- X
- Andere Apps
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:
Protocolist zustandslos und ohne Seiteneffekte. Es kodiert und dekodiert JSON-RPC und berechnet den Electrum-Scripthash. Trivial zu testen, von jedem Prozess aus aufrufbar.Connectionist 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.Clientist eine dünne Fassade darüber:get_history,list_unspent,subscribe_scripthashund so weiter. Lesbare Funktionen, die intern nurConnection.rpc/2aufrufen.
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.
- Link abrufen
- X
- Andere Apps

Kommentare