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...

Vom xpub zur lebenden Wallet: HD-Ableitung und UTXO-Tracking in Elixir

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.

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