Pinned Post
Vom xpub zur lebenden Wallet: HD-Ableitung und UTXO-Tracking in Elixir
- Link abrufen
- X
- Andere Apps
Bei Fiatbitcoin kann ein Nutzer einen xpub einfügen, den Extended Public Key aus seiner Hardware-Wallet, und sieht danach jede Adresse, jeden Zahlungseingang und seinen vollen Kontostand, in Echtzeit. Ohne dass die App jemals einen privaten Schlüssel zu Gesicht bekommt. Eine Watch-only-Wallet eben.
Dahinter stecken drei Probleme. Erstens muss man aus dem xpub die Adressen ableiten, und das ist reine Kryptografie. Zweitens müssen diese Adressen synchron bleiben, und zwar in dem Moment, in dem sich on-chain etwas bewegt, nicht erst beim nächsten Polling. Drittens, als Bonus, sollte das System erkennen, wenn der Nutzer nur Coins zwischen seinen eigenen Adressen umgeschichtet hat, damit das Steuermodul das nicht für einen Verkauf hält. Dieser Artikel geht die drei der Reihe nach durch.
Was ein xpub hergibt
Ein Extended Public Key ist ein öffentlicher Schlüssel plus ein Chain Code. Diese Kombination erlaubt etwas, das auf den ersten Blick paradox wirkt: Aus ihm lassen sich beliebig viele Kind-Adressen ableiten, ganz ohne den privaten Schlüssel. Das ist die Grundlage von BIP32 und der Grund, warum Watch-only-Wallets überhaupt funktionieren.
Die Konvention dahinter ist BIP44. Die Empfangsadressen liegen unter
dem Pfad m/0/*, die Wechselgeld-Adressen unter
m/1/*. Beide Ketten werden getrennt durchnummeriert. Wer
den xpub hat, kann jede dieser Adressen berechnen, ihre Salden
beobachten, aber nichts ausgeben.
HD-Ableitung von Hand: secp256k1 ohne Library
Hier wird es interessant. Die Ableitung einer Kind-Adresse ist nicht bloß ein Hash. Sie ist echte Elliptische-Kurven-Arithmetik. Der neue öffentliche Schlüssel ergibt sich aus dem alten plus einem Punkt auf der Kurve secp256k1:
defp derive_child({_x, _y} = pubkey, chain_code, index) when index < 0x80000000 do
data = compress_pubkey(pubkey) <> <<index::big-32>>
<<il::binary-32, ir::binary-32>> = :crypto.mac(:hmac, :sha512, chain_code, data)
il_int = decode_uint(il)
# child_pubkey = il_int * G + parent_pubkey
child_point = point_add(scalar_mul(il_int), pubkey)
{child_point, ir}
end
Ein HMAC-SHA512 über den komprimierten Elternschlüssel und den Index
liefert 64 Bytes. Die linken 32 werden als Zahl interpretiert und mit
dem Generatorpunkt G multipliziert, die rechten 32 werden
der neue Chain Code. Das il_int * G ist dabei eine
Skalarmultiplikation auf der Kurve, und point_add addiert
zwei Kurvenpunkte.
Das Bemerkenswerte: Diese Kurvenarithmetik ist komplett von Hand
implementiert, ohne Krypto-Library. point_add,
point_double, die modulare Inverse über den kleinen Satz
von Fermat, alles steht im Modul. Dazu kommen Base58Check für die
Legacy-Adressen und Bech32 für die SegWit-Adressen, ebenfalls selbst
geschrieben. Die Skalarmultiplikation ist der klassische
Double-and-Add:
defp point_mul(0, _p, acc), do: acc
defp point_mul(k, p, acc) do
acc = if rem(k, 2) == 1, do: point_add(acc, p), else: acc
point_mul(div(k, 2), point_double(p), acc)
end
Es ist ein gutes Gefühl, die ganze Ableitung ohne Abhängigkeit dastehen zu haben. Es ist aber auch genau die Art Code, in der man keinen subtilen Fehler haben will. Dazu in den Lessons mehr.
Das Gap Limit: wann höre ich auf abzuleiten
Wenn man beliebig viele Adressen ableiten kann, stellt sich die Frage, wo man aufhört. Eine Wallet könnte ihre fünfte Adresse benutzt haben oder ihre fünfhundertste. BIP44 beantwortet das mit dem Gap Limit: Solange auf einer Strecke aufeinanderfolgender Adressen nichts passiert ist, hört man nach einer festen Zahl leerer Adressen auf zu suchen. Üblich sind zwanzig.
Der Scanner macht das eifrig statt faul. Sobald eine Adresse mit
Index i Aktivität zeigt, leitet er sofort bis
i + gap_limit + 1 ab, damit immer ein ausreichendes Polster
unbenutzter Adressen vorausläuft:
defp maybe_extend_scan(state, change, active_index) do
required_head = active_index + state.gap_limit + 1
current_head = Map.fetch!(state.scan_head, change)
if required_head > current_head do
new_count = required_head - current_head
Logger.debug(
"[XpubScanner] Extending chain=#{change} from=#{current_head} to=#{required_head - 1}" <>
" (+#{new_count} addresses)"
)
derive_and_subscribe(state, change, current_head, required_head)
else
state
end
end
So wandert das Fenster mit der Nutzung mit, ohne dass man von vornherein tausend Adressen ableiten muss.
Ein Prozess pro Wallet, der nie pollt
Jede überwachte Wallet bekommt ihren eigenen GenServer, den
XpubScanner, gestartet unter einem
DynamicSupervisor und auffindbar über eine
Registry unter dem Schlüssel {user_id, xpub}.
Das isoliert die Wallets voneinander: Stolpert ein Scan, reißt er die
anderen nicht mit, und eine neue Wallet kostet nur einen Prozess.
Der State des Scanners hält die abgeleiteten Adressen, einen Rückwärtsindex von Scripthash zu Adresse und den Kopf jeder Kette:
state = %{
xpub: xpub,
user_id: user_id,
gap_limit: gap_limit,
# %{address => %{change: 0|1, index: non_neg_integer(), script_hash: String.t()}}
addresses: %{},
# %{script_hash => address} — reverse lookup for incoming notifications
by_script_hash: %{},
# Next index to derive for each chain (0=external, 1=change)
scan_head: %{0 => 0, 1 => 0},
tip_height: nil,
refresh_queue: [],
refreshing: MapSet.new()
}
Statt die Adressen im Sekundentakt abzufragen, abonniert der Scanner jeden Scripthash beim Electrum-Server und legt sich dann schlafen. Geht eine Zahlung ein, pusht Electrum eine Benachrichtigung, die über Phoenix PubSub beim Scanner ankommt, und der reagiert. Ein Detail dabei ist heikel: Man muss das PubSub-Topic abonnieren, bevor man die Electrum-Subscription abschickt, sonst verpasst man den initialen Broadcast.
# Subscribe to PubSub *before* sending Electrum subscription so we do not miss
# the initial broadcast that Connection emits when Electrum confirms.
Phoenix.PubSub.subscribe(@pubsub, "electrum:scripthash:#{script_hash}")
# Electrum subscription is sent asynchronously to avoid blocking the scanner
# while waiting for network round-trips during the initial bulk subscription.
Task.start(fn -> Client.subscribe_scripthash(address) end)
Wie diese Socket-Verbindung zu Electrum darunter funktioniert, also das Multiplexing und die Übersetzung der Server-Pushes nach PubSub, habe ich in einem eigenen Artikel beschrieben. Hier ist der Scanner nur ein Abnehmer dieser Topics.
Der Push-Weg ist elegant, aber nicht garantiert. Während eines Reconnects können Benachrichtigungen verloren gehen. Deshalb gibt es als Sicherheitsnetz alle zwanzig Minuten einen vollständigen Abgleich der aktiven Adressen. Dieser Refresh läuft über eine Queue mit begrenzter Parallelität, weil jeder einzelne Abgleich mehrere frische Verbindungen zu Electrum öffnet:
defp drain_refresh_queue(state) do
available = @max_concurrent_refreshes - MapSet.size(state.refreshing)
{to_run, remaining} = Enum.split(state.refresh_queue, available)
scanner_pid = self()
user_id = state.user_id
xpub_id = state.xpub_id
Enum.each(to_run, fn address ->
Task.start(fn ->
refresh_address(address, user_id, xpub_id)
send(scanner_pid, {:refresh_done, address})
end)
end)
%{state | refresh_queue: remaining,
refreshing: Enum.reduce(to_run, state.refreshing, &MapSet.put(&2, &1))}
end
Mehr als fünf gleichzeitige Abgleiche verträgt der Server nicht, darüber kommen Timeouts. Also wird gedrosselt, und der Rest wartet in der Queue.
Consolidation erkennen: alle Outputs gehören mir
Wer viele kleine UTXOs zu einem großen zusammenfasst, schickt eine Transaktion, deren Outputs alle auf eigenen Adressen landen. Das ist kein Verkauf und keine Zahlung, nur ein internes Aufräumen. Fürs Steuermodul ist die Unterscheidung wichtig, denn eine Consolidation darf die Kostenbasis nicht zurücksetzen.
Die Heuristik ist bewusst simpel und scharf umrissen. Sie gruppiert alle ausgehenden Transfers nach ihrer Transaktion und behält nur die Transaktionen, bei denen jede Ziel-Adresse dem Nutzer selbst gehört:
def run(user_id) do
user_addresses = load_user_addresses(user_id)
address_set = MapSet.new(user_addresses, & &1.address)
forwards = load_forwards(user_id)
# Group forwards by tx_hash to identify consolidation transactions:
# a tx where ALL outputs land on own addresses
consolidation_txs =
forwards
|> Enum.group_by(& &1.tx_hash)
|> Enum.filter(fn {_tx_hash, fwds} ->
Enum.all?(fwds, &MapSet.member?(address_set, &1.to_address))
end)
count =
Enum.reduce(consolidation_txs, 0, fn {tx_hash, fwds}, acc ->
acc + process_consolidation(tx_hash, fwds, user_id, user_addresses)
end)
{:ok, count}
end
Bleibt noch, den zusammengeführten Ziel-UTXO den Quell-UTXOs zuzuordnen. Gibt es nur einen Kandidaten, ist die Sache klar. Bei mehreren wird der genommen, dessen Wert der Summe der eingehenden Beträge am nächsten kommt, mit einer Toleranz für die Miner-Gebühr:
defp find_matching_target(_source_utxo_id, [single], _fwds), do: single
defp find_matching_target(_source_utxo_id, targets, fwds) do
# When multiple target UTXOs exist, use value proximity to the forwarded amounts
total_forwarded = fwds |> Enum.map(& &1.value_satoshi) |> Enum.sum()
Enum.find(targets, fn t ->
abs(t.value_satoshi - total_forwarded) <= @fee_tolerance_sat
end)
end
Die Toleranz liegt bei 10.000 Satoshi, also etwa 0,0001 BTC.
Lessons Learned
Drei Dinge, die sich bewährt haben, und eines, bei dem ich anders einsteigen würde.
1. Watch-only ist reine Public-Key-Arithmetik.
Es ist ein erstaunlich beruhigender Gedanke, dass die ganze Wallet ohne ein einziges Geheimnis auskommt. Kein privater Schlüssel, keine Signaturen, kein Material, das man verlieren könnte. Aus einem öffentlichen Schlüssel und einem Chain Code wird durch Ableitung ein vollständiges Bild der Wallet, und beobachten ist alles, was die App tut.
2. Push schlägt Poll, aber nur mit Sicherheitsnetz.
Auf Server-Pushes zu reagieren statt zu pollen ist der Unterschied zwischen einer Wallet, die sofort aufwacht, und einer, die im Minutentakt nachschaut. Aber der Push-Weg ist verlustbehaftet, sobald die Verbindung wackelt. Der periodische Abgleich ist deshalb keine Bequemlichkeit, sondern die Bedingung dafür, dass nichts dauerhaft durchrutscht.
3. Die Consolidation-Heuristik ist absichtlich eng.
“Alle Outputs gehören mir” erkennt nur die saubere, vollständige Consolidation. Eine Transaktion, die nebenbei noch einen externen Output hat, fällt durch das Raster. Das ist eine bewusste Entscheidung für wenige Fehlalarme statt für hohe Trefferquote. Für die Steuer ist das die sichere Richtung: lieber eine Consolidation übersehen, als eine Zahlung fälschlich als internes Umschichten abtun.
4. Was ich anders machen würde: secp256k1 von Hand.
Die selbstgeschriebene Kurvenarithmetik ist abhängigkeitsfrei und hat
Spaß gemacht, aber sie ist die riskanteste Stelle im ganzen Code. Ein
subtiler Fehler in point_add oder in der Bech32-Kodierung
leitet stillschweigend falsche Adressen ab, und das merkt man erst, wenn
Geld nicht auftaucht. Dazu kommt der Adresstyp: Fällt die
Versionserkennung auf den Default :p2pkh zurück, leitet sie
Legacy-Adressen ab, obwohl manche Hardware-Wallets wie Ledger native
SegWit-Konten mit xpub-Versionsbytes exportieren. Es gibt
eine Funktion, das zu korrigieren, aber sie greift nicht automatisch.
Für den Kern der Kurvenmathematik wäre eine geprüfte Bibliothek die
vernünftigere Wahl, zumal wir für das Bech32-Dekodieren an anderer
Stelle ohnehin schon eine nutzen.
Wenn euch eine Stelle genauer interessiert, die Skalarmultiplikation auf der Kurve, das Gap-Limit-Fenster oder wie aus den UTXOs am Ende Käufe rekonstruiert werden, schreibt es in die Kommentare. Ich gehe gerne tiefer.
- Link abrufen
- X
- Andere Apps

Kommentare