Eine Produktionsarchitektur für eine STONfi-gestützte Swap-App: Frontend, Quote Layer, Wallet, Indexer und Monitoring

Einen Swap zum Laufen zu bringen, dauert auf deinem Laptop einen Nachmittag. Die Doku von STONfi ist gut, die SDKs übernehmen vieles, und es gibt sogar eine öffentliche Demo-App, die du Zeile für Zeile lesen kannst. Damit der Swap für echte Nutzer auch dann weiterläuft, wenn es mal einen schlechten Netzwerktag gibt und Support-Tickets reinkommen, ist das ein anderes Projekt – und fast nichts davon hat mit dem Swap selbst zu tun.

Dieser Artikel handelt von diesem zweiten Job. Er führt durch die fünf Schichten, die ich um einen Swap herum anbringen würde, der auf STONfi und Omniston basiert: das Frontend, die Quote-Layer, das Wallet, der Indexer und das Monitoring. Für jede Schicht sage ich, wofür sie ist, was kaputtgeht, wenn man sie weglässt, und wo STONfi endet und deine eigene Arbeit beginnt. Ich halte die Sprache einfach und definiere die Fachbegriffe, sobald sie auftauchen.

Zuerst zwei ehrliche Hinweise. Wenn du nur einen Swap-Button brauchst, solltest du wahrscheinlich nichts davon bauen: STONfi bietet ein fertiges Swap-Widget an, und das ist eine völlig richtige Antwort. Und der Code unten ist eine Skizze, kein Drop-in. Er folgt den Namen in den aktuellen Omniston-Docs, die in v1beta8 ein expliziteres API-Modell mit nativer Cross-Chain-Unterstützung eingeführt haben, und die Event-Formen des SDK haben sich zwischen Versionen geändert. Pinne deine Version und prüfe die Types, die du tatsächlich installiert hast.

🗺️ Das große Ganze: Wer besitzt was

Starte mit der Aufteilung der Verantwortlichkeiten, denn alles andere folgt daraus. STONfi und Omniston geben dir Liquidity, Routing, Quotes und Execution: der Teil, in dem eine Anfrage nach dem besten Preis von konkurrierenden Quellen beantwortet und dann on-chain finalisiert wird. Das baust du nicht. Was du baust, ist alles, was es in deinem Produkt nutzbar und vertrauenswürdig macht.

Verfolge einen einzigen Swap durch das System, dann erscheinen die fünf Layer in dieser Reihenfolge:

  1. Frontend: Der Nutzer wählt Assets und einen Betrag und sieht, was er bekäme.

  2. Quote-Layer: Ein Live-Stream an Quotes wird in etwas umgewandelt, das stabil genug ist, um es anzuzeigen und zu entscheiden.

  3. Wallet: Der Nutzer überprüft die exakte Transaktion und signiert sie mit seinen eigenen Keys.

  4. Tracking und Indexer: Die App folgt dem Swap bis zum Ende und behält einen dauerhaften Datensatz dessen, was passiert ist.

  5. Monitoring: Du erfährst, dass etwas nicht stimmt, bevor deine Nutzer es dir sagen.

Eine Regel steht über allen fünf: Deine Server halten niemals Keys oder Funds. Die Wallet signiert, die Chain finalisiert, und alles, was du baust, ist ein Fenster darauf – kein Ersatz dafür. Das ist das, was „non-custodial“ für deine Architektur bedeutet, und deshalb geht ein großer Teil der Arbeit unten eher um Records und Visibility als darum, Geld zu bewegen.

Omniston bietet drei Wege in. Die SDKs (Node.js und React) sind der empfohlene Weg und übernehmen für dich die WebSocket-Verbindung, den Quote-Stream, das Bauen der Transaktionen und das Error-Handling. Eine Low-Level WebSocket JSON-RPC API existiert für Custom Clients, und gRPC über TLS ist die primäre Low-Level Option für Backend-Integrationen. Für die meisten Teams ist das SDK die richtige Wahl, und der Rest dieses Artikels geht davon aus.

🖥️ Frontend und Quote-Layer: Quotes sind ein Stream, keine Antwort

Der häufigste konzeptionelle Fehler in einer ersten Swap-UI ist, eine Quote wie die Antwort auf einen normalen API-Call zu behandeln: du fragst, du bekommst eine Zahl, du zeigst sie. Omniston funktioniert anders. Du sendest eine Quote-Anfrage, und Quotes treffen weiter ein – während Resolver und Liquidity-Quellen antworten und aktualisieren. Das beste, was sich ändern kann, während der Nutzer noch auf dem Bildschirm schaut. Das ist gut für den Preis, aber eine Design-Aufgabe für die Oberfläche.

Deshalb braucht das Frontend eine kleine Layer in der Mitte, die einen sich bewegenden Stream in etwas verwandelt, das eine Person sicher verarbeiten kann. Ich nenne sie Quote-Layer, und ihre Aufgabe ist absichtlich langweilig: Nimm alles, was das SDK ausgibt, verwandle es in eine interne einheitliche Form und stempel es mit dem Zeitpunkt, zu dem du es empfangen hast. Wenn die UI SDK-Objekte direkt rendert, wird jedes SDK-Upgrade zu einer Änderung für jede Komponente. Wenn die UI deine eigene QuoteView rendert, betrifft ein Upgrade nur noch eine Mapping-Funktion.

import { Omniston, useRfq, type QuoteRequest } from "@ston-fi/omniston-sdk-react"; // Sandbox für Entwicklung und CI. Production nur für echten Traffic. const OMNISTON_URL = import.meta.env.VITE_ENV === "production" ? "wss://omni-ws.ston.fi" : "wss://omni-ws-sandbox.ston.fi"; export const omniston = new Omniston({ apiUrl: OMNISTON_URL }); // Das UI rendert das, niemals das rohe SDK-Objekt. interface QuoteView { quoteId: string; receivedAt: number; // wann WIR es gesehen haben, genutzt für stale-Checks estimatedOut: string; // die Zahl, auf die die Leute hoffen minimumOut: string; // die Zahl, die wir tatsächlich versprechen können } function QuotePanel({ request }: { request: QuoteRequest }) { // useRfq streamt Events; Quotes aktualisieren sich weiter, bis du aufhörst zu lauschen. const { event, error } = useRfq(request); if (error) return <NoQuote reason="connection" /\u003e; if (!event) return <Loading /\u003e; // Event-Formate unterscheiden sich zwischen SDK-Versionen: prüfe deine installierten Types. if (event.$case === "quoteUpdated") { return <QuoteCard quote={toQuoteView(event.value, Date.now())} /\u003e; } // Jedes andere Event (z. B. keine Quote verfügbar) wird explizit behandelt, // nicht als Crash behandelt. return <NoQuote reason="unavailable" /\u003e; }

Wofür sollte diese Layer tatsächlich verantwortlich sein? Eine kurze Liste:

  • Normalisiere die Quote des SDK in deine eigene Form, damit Upgrades lokal bleiben.

  • Frische (Freshness): Wenn für eine festgelegte Anzahl an Sekunden kein Update angekommen ist, markiere die Quote als veraltet (stale) und deaktiviere den Bestätigungs-Button, statt jemandem zu erlauben, gegen eine Zahl zu signieren, die sich inzwischen geändert haben könnte.

  • Schutzgeländer (Guardrails): Warnen oder blockieren, wenn der Price Impact eine von dir gewählte Schwelle überschreitet, und Mindestbeträge erzwingen.

  • Logging dessen, was angezeigt wurde: Quote-ID, geschätzte und minimale Ausgabe, Zeitstempel. Wenn ein Nutzer später sagt: „Es hat mir gesagt, ich bekomme mehr“, dann ist das der Datensatz, der das belegt.

  • Ein Kill Switch: ein Konfigurations-Flag, das ein Routing deaktiviert, z. B. Cross-Chain, ohne einen Redeploy.

Wenn du aus dem Flow Geld verdienen willst, ist das auch der Ort, an dem Referral Fees leben. Omniston erlaubt dir, Referral-Daten an eine Quote-Anfrage zu hängen, sodass ein Anteil an der Swap-Gebühr an deine Adresse geht – konfigurierbar von 0,01% bis 1% pro Swap und on-chain in derselben Transaktion bezahlt. Es gibt die flexible_referrer_fee Option, mit der das Protokoll deinen Anteil senken kann, wenn das dem Nutzer einen besseren Kurs bringt – und ich denke, das ist der richtige Default für eine App, die möchte, dass Nutzer wiederkommen.

👛 Die Wallet-Layer: Wo der Nutzer – nicht du – die Entscheidung trifft

Die Wallet-Layer ist im Code die kleinste und in den Konsequenzen die größte. Auf TON ist der übliche Weg TON Connect, wobei @tonconnect/ui-react dir den Connect-Button und das Session-Handling gibt. Der Flow hat zwei getrennte Phasen, und deine UI sollte diese Trennung respektieren: Das Verbinden teilt eine Adresse und erlaubt deiner App Transaktionen vorzuschlagen, und das Signieren ist der einzige Moment, in dem sich wirklich etwas bewegt. Nichts von dem, was du schreibst, sollte diese beiden Momente verwischen.

Ein paar Dinge gehören hierher, die Leute oft überspringen.

Baue die Transaktion aus der Quote, die du angezeigt hast. Das React SDK stellt dafür Build-Helper bereit, z. B. useTonBuildSwap, sodass die Transaktion, die die Wallet zum Signieren bekommt, aus der exakten Quote auf dem Bildschirm abgeleitet wird – nicht aus einer frischen Anfrage, die ggf. einen anderen Preis hat. Zeige dem Nutzer die gleichen Zahlen, insbesondere das Minimum, das garantiert ist, direkt bevor die Wallet-Prompt erscheint.

Behandle eine abgelehnte Signatur als Ergebnis, nicht als Fehler. Der Nutzer hat sich die Bedingungen angesehen und gesagt: nein. Das sollte ihn zurück auf den Swap-Screen bringen – mit intakten Eingaben und ohne rotes Banner. Echte Fehler, wie ein Netzwerkfehler oder eine Transaktion, die die Chain abgelehnt hat, verdienen eine andere Nachricht und einen anderen Log-Eintrag, weil du sie später getrennt zählen willst.

Ich behandle eine abgelehnte Signatur als Ergebnis, dass die App korrekt funktioniert. Jemand hat die Bedingungen gelesen und entschieden: nein. Das ist der beste Fehlschlag, den man haben kann – also verkleide ihn nicht als Crash.

Sei streng bei Adressen. Für Cross-Chain Swaps braucht der STONfi-Flow nur die verbundene Source-Wallet und bietet eine Option, an eine andere Destination-Adresse zu senden. Wenn deine App das unterstützt, ist die Destination ein manuell eingegebener Wert, ohne verbundene Wallet, um ihn zu sanity-checken. Validieren das Format für die Destination-Chain (eine Adresse, die auf einer Chain gültig ist, ist auf einer anderen nicht gültig) und zeige die vollständige Adresse dem Nutzer zurück, bevor er signiert. Die neuere Omniston-API modelliert Source- und Destination-Chains genau aus diesem Grund als getrennte Felder, und deine UI sollte sie ebenfalls getrennt behandeln.

🔎 Tracking und der Indexer: zwei Uhren, zwei Arten von Speicher

Sobald der Nutzer signiert, tauchen zwei unterschiedliche Bedürfnisse auf, und die brauchen unterschiedliche Tools.

Das Erste ist Live: Die Person starrt auf deinen Bildschirm und will wissen, was gerade passiert. Omnistons SDK deckt das mit einem Tracking-Stream ab. Du gibst ihm die Quote-ID, die Adresse des Traders und einen Bezeichner für die ausgehende Transaktion (in den Docs heißt das outgoingTxQuery), und es sendet Events, während der Swap voranschreitet. Modelliere diese Events als kleinen Zustandsautomaten statt als Haufen von Booleans:

type SwapStage = | { kind: "awaiting-transfer" } | { kind: "in-progress"; status: string } | { kind: "closed" }; // der Stream ist beendet: konsolidieren, nicht Erfolg annehmen async function watchSwap(omniston, quote, traderAddress, outgoingTxQuery, onStage) { const stream = await omniston.swapTrack({ quoteId: quote.quoteId, traderAddress, outgoingTxQuery, // identifiziert die Transaktion, die die Wallet gerade gesendet hat }); const sub = stream.subscribe({ next(event) { switch (event?.$case) { case "awaitingTransfer": onStage({ kind: "awaiting-transfer" }); break; case "progress": onStage({ kind: "in-progress", status: String(event.value.status) }); break; case "unsubscribed": onStage({ kind: "closed" }); break; } // Speichere jede Stage-Änderung in deinem eigenen Store, nicht nur in React State. }, }); return () =\u003e sub.unsubscribe(); }

Beachte den Kommentar zu „closed“. Ein Stream-Ende bedeutet: Tracking ist beendet, nicht: Der Swap war erfolgreich. Behandle es als Grund, das Ergebnis zu prüfen – niemals als gutes Zeichen.

Das zweite Bedürfnis ist dauerhaft, und hier kommt der Indexer ins Spiel. Ein Indexer ist im Grunde ein Dienst, der Chain-Daten liest und eine durchsuchbare Kopie der Events vorhält, die dich interessieren. Du musst nicht die ganze Chain indexieren – nur die Swaps deiner eigenen Nutzer und, wenn du sie nutzt, deine Referral-Earnings. Warum den Aufwand? Weil Nutzer Tabs schließen, Handys mitten im Swap das Signal verlieren und Support irgendwann die Frage kommt: „Wo ist mein Swap?“ React State kann das nicht beantworten. Eine Zeile in deiner Datenbank kann es.

CREATE TABLE swap_attempts ( id UUID PRIMARY KEY, -- idempotency key, wird erstellt, wenn der Nutzer die Quote bestätigt quote_id TEXT NOT NULL, wallet_address TEXT NOT NULL, route_kind TEXT NOT NULL, -- 'ton-swap' oder 'cross-chain' bid_asset TEXT NOT NULL, ask_asset TEXT NOT NULL, bid_amount NUMERIC NOT NULL, quoted_out NUMERIC NOT NULL, minimum_out NUMERIC NOT NULL, final_out NUMERIC, -- wird während der Konsolidierung gefüllt, nie aus der UI stage TEXT NOT NULL, -- letzte Stage, die wir beobachtet haben sdk_version TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), reconciled_at TIMESTAMPTZ -- gesetzt, nachdem gegen die Quelle der Wahrheit geprüft wurde );

Zwei Begriffe darin verdienen eine einfache Erklärung. Idempotenz bedeutet: Dasselbe zweimal hat die gleiche Wirkung wie einmal – die ID wird erzeugt, wenn der Nutzer bestätigt, also erstellt ein doppelt getippter Button genau einen Datensatz, nicht zwei. Konsolidierung (Reconciliation) bedeutet: Deine Datensätze mit einer Quelle der Wahrheit vergleichen und Abweichungen korrigieren. Für die endgültige Order-History, Konsolidierung und Aggregat-Reports stellt Omniston eine eigene History-API bereit, sodass ein nächtlicher Job deine recent swap_attempts durchgehen, sie mit ihr und mit der Chain vergleichen und final_out mit der echten Zahl befüllt. Die Ansicht der UI davon, was passiert ist, ist nur eine Vermutung. Der konsolidierte Wert ist der Datensatz.

Cross-Chain Swaps fügen noch etwas hinzu, das du verfolgen musst. Wie im früheren Beitrag zu HTLC-Timelocks beschrieben: Ein Cross-Chain Swap, der festhängt, löst sich selbst bei einer Frist (Deadline) auf. Daher sollten deine Records zwischen „langsam“ und „festgefahren“ unterscheiden, und deine Oberfläche sollte einem wartenden Nutzer mitteilen, dass seine Mittel gemäß Regel zurückkommen, wenn der Swap nicht fertig wird – statt ihn dazu zu drängen, es erneut zu versuchen.

📡 Monitoring: Wissen, bevor deine Nutzer es wissen

Die letzte Layer ist die, die Teams nach dem ersten schlimmen Vorfall hinzufügen. Ich würde sie vorher einbauen. Das Ziel sind keine Dashboards um ihrer selbst willen; es geht darum, vier Fragen schnell zu beantworten: Ist der Quote-Stream gesund? Werden Swaps abgeschlossen? Steigen die Fehlerfälle? Und ist irgendetwas festgefahren?

Hier drauf würde ich Alerts setzen:

  • Gesundheit des Quote-Streams: WebSocket-Disconnects, Reconnect-Loops und Zeit bis zur ersten Quote (time-to-first-quote). Wenn Quotes nicht mehr ankommen, ist dein Swap-Screen still kaputt, obwohl nichts „crashed“ ist.

  • Drop-off im Funnel: Quote angezeigt, Confirm geklickt, Signatur genehmigt, Swap abgeschlossen. Ein plötzlicher Abfall an einer Stelle zeigt direkt auf die Ursache.

  • Fehler-Taxonomie: Nutzer-Ablehnungen, Wallet-Fehler, Fehler auf Chain-Ebene und Swaps, die nie fertig wurden – jeweils separat gezählt. Eine gemischte „failure rate“ versteckt jedes nützliche Signal.

  • Tail der Abschlusszeit: Der Median ist weniger wichtig als die langsamsten ein paar Prozent. Ein gesunder Swap ist in Sekunden fertig, daher ist ein wachsender Tail eine frühe Warnung.

  • Steckengebliebene Orders: Alles, was nach der Zeit liegt, die du erwarten würdest – besonders bei Cross-Chain – gegen die Refund-Deadline prüfen.

  • SDK-Version Drift: Starte die Sandbox in deinem CI, sodass ein Dependency-Update, das Event-Formate ändert, statt bei einem Kunden in einem Test auffällt.

„Keine Quote“ ist eine Antwort, kein Ausfall. Apps, die eine rote Fehlermeldung aufblitzen lassen, wenn kein Resolver antwortet, trainieren Nutzer dazu, dem einen Moment nicht zu vertrauen, in dem das System ihnen ehrlich gegenübersteht.

Dieser Unterschied ist auch wichtig für deine Alerts. Bei einem dünnen Pair zu einer unpassenden Stunde ist keine Quote ein normaler Marktzustand, also zeige das ruhig und ohne Engineer-Page. Hebe die lauten Alarme für die Dinge auf, die darauf hinweisen, dass dein System ungesund ist. Und halte eine Statusseite oder zumindest ein Feature-Flag bereit: Wenn eine Route sich falsch verhält, kannst du sie gezielt für Nutzer abschalten, statt ihnen zu überlassen, das Problem selbst zu entdecken.

Noch eine Notiz zum Frontend selbst, denn nur dahin können Angreifer überhaupt gelangen. Fixiere deine Abhängigkeiten, nutze eine strikte Content-Security-Policy und lass deine Oberfläche niemals nach einer Seed-Phrase oder nach einer Signatur fragen, die dem Nutzer nicht angezeigt wurde. Eine Swap-App, die hinter den Kulissen perfekt architektonisch gebaut ist, aber mit einem kompromittierten Script ausgeliefert wird, bleibt eine kompromittierte Swap-App.

🧭 Was ich zuerst bauen würde – und was warten kann

Wenn ich diese Woche damit anfangen würde, würde ich in dieser Reihenfolge bauen: die Quote-Layer mit der eigenen QuoteView, der Wallet-Flow mit sauberer Trennung zwischen Ablehnung (Rejection) und Fehler (Failure), dann die Tabelle swap_attempts, die ab dem ersten Tag geschrieben wird – und erst danach der Reconciliation-Job und Alerts. Referral Fees, Custom Destinations und Cross-Chain-Routen können später einzeln dazukommen, jeweils hinter einem Flag. Die Reihenfolge spiegelt eine einfache Idee wider: Baue den Datensatz dessen, was passiert ist, bevor du irgendetwas „Fancy“ darauf aufsetzt.

Wenn ich nur eine Sache zusätzlich zum Happy Path bauen könnte, dann wäre es der Datensatz dessen, was dem Nutzer angezeigt wurde. Fast jede Support-Frage, die ich mir vorstellen kann, beginnt mit: „Was hat es gesagt?“

STONfi hat den harten Teil dieses Stacks gemacht – Routing und Settlement. Die Architektur darum herum ist der Punkt, an dem ein Swap zu einem Produkt wird, und nichts davon ist besonders glamourös: die Quote normalisieren, die Signatur respektieren, Dinge aufschreiben, dem Tail nachgehen. Wenn du diese vier Dinge richtig machst, bleibt der Großteil dessen, was schiefgeht, klein und erklärbar.

❓ FAQ

Soll die Quote-Layer im Browser laufen oder auf meinem Backend? Der Browser mit dem React SDK ist der einfachste Start und so funktioniert auch die öffentliche Demo-App. Eine Backend-Layer hat ihren Platz, wenn du z. B. gemeinsames Logging, Rate Limiting, Kill Switches oder Analytics brauchst – und du kannst sie später ergänzen, ohne den SDK-Vertrag zu ändern.

Brauche ich meinen eigenen Indexer oder reicht die History API? Die History API ist die Quelle für die finalisierte Order History und für Reconciliation, aber du willst trotzdem deine eigene Tabelle der Versuche, weil sie enthält, was dem Nutzer angezeigt wurde und wo deine App den Swap beobachtet hat – das kann keine externe Quelle wissen.

Wie vermeide ich doppelte Swaps, wenn ein Nutzer doppelt auf „Confirm“ tippt? Erstelle eine Idempotency-Key, wenn der Nutzer bestätigt, verwende ihn als ID des Records und ignoriere wiederholte Submit-Versuche mit demselben Key.

Kann ich für Tests die Sandbox verwenden? Ja, und du solltest sie für Entwicklung und CI nutzen. Die Doku beschreibt sie nur für Entwicklung und Testing, also halte Production-Traffic auf dem Production-Endpoint.


\u003cc-238/\u003e

ZEC
ZECUSDT
1,397.73
-9.66%