Skip to content

feat(user): TravelRule-Signatur-PDF-Cron als Ablöse des Apps-Script-Sheets#3975

Open
TaprootFreak wants to merge 13 commits into
developfrom
feat/user-travel-rule-pdf-cron
Open

feat(user): TravelRule-Signatur-PDF-Cron als Ablöse des Apps-Script-Sheets#3975
TaprootFreak wants to merge 13 commits into
developfrom
feat/user-travel-rule-pdf-cron

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Was

Ersetzt den TravelRule-Signatur-Pfad des Apps-Script-Sheets durch einen nativen API-Cron:

  • TravelRulePdfService — rendert das PDF als 1:1-Replik der editPDF-Sheet-Vorlage (per echtem PDF-Export verifiziert + visuell gegengeprüft): DFX-Logo oben rechts, deutscher Titel „Travel Rule / Digitale Signatur Kontrolle“, Metadaten-Tabelle (Id / Date in UTC / User Data ID), umrahmte „Kontrolle“-Box (Address / Signatur Text = signMessageGeneral + Adresse verbatim mit Unterstrichen / Signatur verbatim inkl. ;key, kein split / Kontrolle = verifycryptomessage.com-Link). Signatur in derselben Schrift (Helvetica) wie die übrigen Felder; der grüne Haken der Vorlage wurde bewusst entfernt (nicht benötigt).
  • TravelRuleJobService@DfxCron(EVERY_MINUTE, { process: TRAVEL_RULE_PDF, timeout: 1800 }): Query (signature NOT NULL, travelRulePdfDate IS NULL, kycLevel >= 40, kein Custody), fail-closed Signatur-Validierung, PDF erzeugen, als kyc_file (UserNotes / AddressSignature) anhängen, travelRulePdfDate setzen.
  • TravelRuleSignature — geteilte, fail-closed Signatur-Format-Allowlist (isValid), einzige Quelle der Wahrheit, von Job und Observer identisch genutzt (DRY).
  • TravelRuleObserver — Stuck-Detektor (MetricObserver, EVERY_10_MINUTES): Backlog-Count nur über verarbeitbare Kandidaten, separate Metrik skippedUnrecognised, Alter des ältesten offenen Kandidaten (approximativ) und „geclaimt-aber-blob-los".
  • KycFileService.invalidateKycFile — setzt eine verwaiste kyc_file-Row auf valid = false.
  • Process.TRAVEL_RULE_PDF — neuer Prozess fürs Gating.

Warum

Der Sheet-Auto-Pfad zeigte auf die abgebaute Azure-App und scheiterte seit 18.06. still mit 403 — niemand bemerkte es, weil der Fehler nur in ein ungelesenes Log ging. Die Migration löst diesen Pfad nativ ab und schließt zugleich die eigentliche Lücke: das stille Scheitern. Datengrundlage ist der empirische Voll-Scan der Sheet-Daten (~27k archiv-Zeilen, 28.9k BatchArchiv) plus Repo-Verifikation (siehe open-questions.md).

Kernpunkte

  • Signatur verbatim (Q2 RESOLVED): Der ;<key>-Suffix (Cardano CIP-30 COSE, 23/27298 Fälle) ist verifikationsrelevant und wird 1:1 ins PDF übernommen. user.signature wird im Cron via userRepo.find roh/unmaskiert gelesen (nicht der maskierte /gs-Pfad), also keine RESTRICTED-Maskierung.
  • Fail-closed Signatur-Validierung (Q1): Vor der PDF-Erzeugung wird jeder Kandidat geprüft:
    • UUID-Ausschluss (^[0-9a-fA-F]{8}-…-[0-9a-fA-F]{12}$) — eine masterKey-UUID kann Adress-Ownership kryptographisch nie beweisen (historisch 9 wertlose UUID-Pseudo-Belege mit Status TRUE).
    • Konservative Allowlist bekannter Formate: EVM-Hex (0x + 130/146 hex), Monero base58, Bitcoin base64 (H…, len ~88), Cardano COSE (hex 8458, optional mit ;key). Unbekannte Formate werden übersprungen + logger.warn, travelRulePdfDate bleibt ungesetzt (Kandidat bleibt sichtbar). Bewusst konservativ, um valide Signaturen nicht fälschlich abzulehnen.
  • Claim-first-CAS gegen Doppel-Upload: Da LockClass nur prozessweit greift und uploadUserFile nicht transaktional ist, wird travelRulePdfDate zuerst atomar via UPDATE … WHERE travelRulePdfDate IS NULL gesetzt. Nur bei affected === 1 wird verarbeitet.
  • Observer-Monitoring-Kohärenz (DRY, geteilte Allowlist): Der Job überspringt Signaturen, die die Allowlist nicht matchen (z.B. EVM-Signaturen ohne 0x-Prefix; DeFiChain/Bitcoin werden erkannt), und setzt für sie travelRulePdfDate nie → sie bleiben dauerhaft offen. Würde der Observer dieselbe WHERE-Bedingung ohne Format-Filter zählen, meldete er diese dauerhaft-übersprungenen Kandidaten als Backlog → dauerhafte Falsch-Positiv-Stuck-Alerts. Fix: dieselbe TravelRuleSignature.isValid in Job und Observer. Der Observer holt die Kandidaten-Signaturen als Spalte und teilt in-Code auf — backlog/oldestAgeHours messen nur verarbeitbare Arbeit, die separate Metrik skippedUnrecognised führt die nicht-allowlist-fähigen Fälle.
  • Kein stiller Ausfall mehr: Der TravelRuleObserver macht einen hängenden Job sichtbar — backlog (wachsend, robuster Primärindikator), oldestAgeHours (approximativ, siehe unten) und neu claimedWithoutFile (travelRulePdfDate IS NOT NULL ohne gültige AddressSignature-kycFile). Letzteres fängt genau den Fall, gegen den der PR antritt: Rollback-fehlgeschlagen (Claim-Leak / Orphan-Blob).
  • oldestAgeHours-Semantik ehrlich: Basis ist user.updated, eine @UpdateDateColumn, die bei jedem Save bumpt (nicht nur bei Signatur/Claim). oldestAgeHours ist daher „Zeit seit letzter Änderung, approximativ" — eine untere Schranke, kein exaktes Stuck-Alter. Der Backlog-Count ist der robuste, autoritative Stuck-Indikator; keine erfundene Genauigkeit.
  • Keine Waisen: Schlägt der Upload nach Anlage der kyc_file-Row fehl, wird travelRulePdfDate zurückgesetzt und die Row auf valid = false gesetzt. Rollback- und Invalidations-Fehler werden jetzt logger.error-geloggt statt still verschluckt.
  • originalName-Konvention: YYYYMMDD-AddressSignature-0-<userId>-HHMMSS.pdf; split('-')[0] === YYYYMMDD ist per Test gegen den Download-Filter (config id 15) abgesichert.

Geklärte Fragen (RESOLVED)

  • Q2 — ;<key>-Suffix: Verbatim rendern, kein split. Genau 23/27298 Cardano-CIP-30-COSE-Fälle, part1 immer non-empty. Jetzt umgesetzt (verbatim).
  • Q3 — Manueller vs Auto-Pfad: Kein eigener Ersatz-Endpoint nötig. Beide Pfade nutzen dieselbe Engine; der idempotente Service deckt den manuellen Pfad ab. Upload-Endpoint (POST userData/:id/kycFile, COMPLIANCE-Guard) existiert bereits.
  • Q4 — createOrUpdate() / Spalten B/C: Toter Code. B/C in allen 28.9k Batch- + 27.5k archiv-Datenzeilen leer → No-Op-Return. Nicht portiert.
  • Q6 — txId = user.id im Dateinamen: Das -0-<userId>--Segment der originalName-Konvention kodiert user.id als „txId"-Platzhalter (das 0-Feld ist der historische, hier konstante txId-Slot). Empirisch aus dem Archiv-Sheet bestätigt: open-questions.md belegt user.id als Spalte A des archiv-Tabs (A=user.id, 100% befüllt), genau die ID, die der Sheet-Pfad pro Adressbeweis führte. Jetzt umgesetzt (hartkodiert -0-${userId}-, per Test gegen den Download-Filter id 15 abgesichert). Annahme empirisch gestützt — formale Tech-Lead-Bestätigung der txId-Semantik vor PRD-Aktivierung erbeten.

Bewusst zurückgestellt (DECISION_NEEDED)

  • Q1 — Custody/UUID-Ausschluss + Allowlist-Scope (Compliance-Sign-off pending): UUID-Ausschluss + fail-closed Allowlist sind jetzt umgesetzt (siehe oben). Der exakte Allowlist-Umfang ist eine Compliance-Entscheidung: welche Blockchains/Messages der native PDF-Pfad abdeckt = Compliance-Scope. Empirisch sind 9 historische UUID-Falschbelege (Status TRUE) dokumentiert; aktueller Kandidaten-Pool hat 0 Custody-User. Compliance muss bestätigen, dass die Allowlist vollständig ist und die 9 Altfälle akzeptiert/nachbehandelt werden.
    • DeFiChain-Message-Wortlaut (Minor, Teil von Q1): Das PDF rendert signMessageGeneral (defaultMessage). Nutzer, deren Signatur gegen fallbackMessage (signMessage, DeFiChain) verifiziert wurde (auth.service.ts:502-504), haben de facto einen abweichenden Statement-Wortlaut unterschrieben, als das PDF zeigt. Da der native Pfad heute signMessageGeneral als einheitliches Statement rendert, ist dies an die Q1-Allowlist-/Blockchain-Scope-Entscheidung gebunden (welche Chains/Messages der Pfad abdeckt). Nicht in diesem PR — Compliance/Produkt entscheidet, ob DeFiChain-Belege mit ihrem eigenen signMessage-Wortlaut gerendert werden müssen.
  • Q5 — Multi-Adress-Download + Backfill (Produkt/Compliance): 3.990/10.878 userDataIds (36,68 %) haben >1 Adresse; der Download-Filter (config id 15, selectAll/Sort-Richtung) kollabiert pro userData auf das jeweils älteste PDF und macht 13.262 Adressbeweise unsichtbar. Selektions-/Sort-Änderung (neueste vs. älteste = Compliance-Definition) + einmaliger Backfill der 13.262 Fälle wären nötig. Nicht in diesem PR — Produkt/Compliance muss Multi-Adress-Vollständigkeit und Sort-Semantik entscheiden.
  • Q7 — Branding (Produkt): Korrektur: Die Sheet-Vorlage ist nicht markenlos — sie hat ein DFX-Logo (+ grünen Haken); die API repliziert das Layout (DFX-Logo oben rechts; Haken bewusst weggelassen). Offen bleibt pro-User-RealUnit-Branding: API hat kanonische RealUnit-Erkennung (user.wallet?.name === REALUNIT_WALLET_NAME) + beide Logo-Assets (PdfBrand), wäre ~1 Zeile. DFX-only ist funktional ok. Nicht in diesem PR — Produkt entscheidet, ob RealUnit-Dokumente RealUnit-gebrandet sein müssen.
  • Q8 — Spreadsheet-Stilllegung / Aufbewahrung (Compliance): Nur archiv (~27.255 Audit-Zeilen seit Jan 2025) ist persistenter Store; die 15 übrigen Tabs sind regenerierbar. Aufbewahrungspflicht des Alt-Trails ist eine Compliance-Entscheidung (Export vs. ab-jetzt-Logging in der API). Nicht in diesem PR.

Tests / Gates

  • format:check, lint, type-check: grün (0 Fehler).
  • jest travel-rule: 22/22 grün (3 Suites):
    • Job — Query-Filter, Claim-first-CAS (affected === 0 → skip), catch-Rollback inkl. Waisen-Invalidierung, distinct originalName pro User mit split('-')[0] === YYYYMMDD; fail-closed Validierung (UUID → skip, Artefakt → skip, alle Allowlist-Formate → pass, Cardano ;key verbatim durchgereicht).
    • PDF-Service — valides base64 (%PDF-Magic), Signatur verbatim inkl. ;key (kein split), M5-Kontiguität (statement+address als ein zusammenhängender String, kein getrenntes Adressfeld).
    • Observerbacklog (nur verarbeitbare), skippedUnrecognised (Format-Randfälle/UUID separat), oldestAgeHours aus ältestem verarbeitbaren Kandidaten (skipped-Kandidaten verfälschen das Alter nicht), claimedWithoutFile, Emit an Subscriber.

Rollout (DEV vor PRD, mit Notbremse)

Gemäß Design-Abschnitt 5:

  1. PR → develop, DEV-Deploy mit TravelRulePdf in den DISABLED_PROCESSES (Job aus).
  2. DEV verifizieren (1 PDF/Kandidat, korrekter Pfad/Name, travelRulePdfDate gesetzt, Download-Filter id 15 liefert das PDF, Observer-Metriken plausibel).
  3. developmain (Auto-Release-PR, User merged), PRD-Deploy mit Job in DISABLED_PROCESSES (aus).
  4. Sheet-Auto-Pfad entschärfen (autoOn = false), Trigger als Fallback nicht löschen.
  5. PRD-Job aktivieren (aus DISABLED_PROCESSES entfernen).
  6. Erst nach mehrtägiger PRD-Bewährung: Sheet read-only, Trigger löschen.

Notbremse: DISABLED_PROCESSES += TravelRulePdf greift in ~30s (resyncDisabledProcesses) — kein Redeploy nötig.

Backlog seit 18.06.: kein Sonder-Backfill — alle offenen Kandidaten haben travelRulePdfDate IS NULL und werden mit take: 100/Minute, order: id ASC abgearbeitet. Vor PRD-Aktivierung Pflicht-Check, ob in der Lücke manuell erledigte Fälle bereits eine AddressSignature-Row haben (sonst Doppel-PDF) → deren travelRulePdfDate nachsetzen.


Status: Draft — DEV-Verifikation steht aus, nicht mergen.

Bekannte Einschränkung (vor Alert-/PRD-Aktivierung zu klären)

  • Head-of-Line-Blocking (latent, verteilungsabhängig): Nicht-allowlist-fähige Signaturen (z.B. EVM ohne 0x-Prefix) werden ohne travelRulePdfDate-Marker übersprungen und belegen Slots im festen take:100/id ASC-Fenster. Bei ≥100 unverarbeitbaren Kandidaten mit niedrigeren IDs käme ein verarbeitbarer Kandidat nicht ins Fenster. Aktuell überwacht der Observer diese Fälle aktiv via skippedUnrecognised (kein stiller Verlust). Falls die reale Verteilung das Fenster je füllt: Cursor/Offset oder persistenter Skip-Marker nachrüsten. Zusammen mit der Q1-Allowlist-Scope-Entscheidung zu klären.

Bekannte Minors (Verify-Loop, kein Merge-Gate)

  • claimedWithoutFile zählt Alt-/admin-gesetzte Daten als Sockel (kein kycLevel/Custody-Filter wie die Job-Query) — Monitoring-Tuning, Follow-up vor PRD.
  • Bitcoin-Allowlist deckt uncompressed-key recId-0 (Header-Byte 27, base64 G) nicht ab — sichtbar via skippedUnrecognised, kein stiller Verlust; Widening zu [G-K] ist Q1-Entscheidung.

Idempotenz (Update) — der Cron ersetzt BEIDE Sheet-Stufen

Die Sheet-Pipeline war zweistufig: ein Generator-Sheet erzeugte das AddressSignature-PDF, ein separates vollautomatisches Verify-Stamp-Sheet (1w0hQk) stempelte travelRulePdfDate mit dem Datum aus dem PDF-Dateinamen, sobald das PDF im Storage lag. Gegen PRD-DB belegt: 200 von 399 Kandidaten haben bereits ein gültiges AddressSignature-PDF (nur nie gestempelt). Der Cron ist daher idempotent: existiert ein valides AddressSignature-0-<userId>-PDF, wird travelRulePdfDate mit dem Datum aus dem Dateinamen gestempelt (kein Duplikat, kein now-Datum); sonst wird erzeugt. Dateinamen-Muster gegen 1188/1188 echte Backlog-Dateien bestätigt; malformierte/unmögliche Datums-Namen sind fail-safe (kein Stempel, kein Rateversuch).

@TaprootFreak TaprootFreak marked this pull request as ready for review June 25, 2026 15:15
@TaprootFreak TaprootFreak force-pushed the feat/user-travel-rule-pdf-cron branch from b0e4f2e to e78ac98 Compare June 25, 2026 17:24
…heets

Ersetzt den seit 18.06. still mit 403 scheiternden Sheet-Auto-Pfad durch
einen nativen API-Job:

- TravelRulePdfService: reines PDF-Rendering der signierten Adressnachricht
- TravelRuleJobService: Minuten-Cron mit Claim-first-CAS gegen Multi-Replica-
  Doppel-Upload, Rollback des travelRulePdfDate und Invalidierung verwaister
  kyc_file-Rows bei Upload-Fehler
- TravelRuleObserver: Stuck-Detektor (Backlog + Alter ältester Kandidat) als
  Lehre aus dem stillen Sheet-Ausfall
- KycFileService.invalidateKycFile für die Waisen-Bereinigung
- Prozess-Gating via Process.TRAVEL_RULE_PDF (DISABLED_PROCESSES-Notbremse)
- Signatur verbatim ins PDF rendern (kein split(';')); der ;<key>-Suffix
  der Cardano-CIP-30-Faelle ist verifikationsrelevant und wird 1:1 uebernommen.
  rawSignature-Helper entfernt, Spec auf verbatim-Assert inkl. Cardano-Fall.
- Fail-closed Signatur-Validierung vor PDF-Erzeugung: UUID-Ausschluss plus
  konservative Allowlist bekannter Formate (EVM-Hex, Monero-base58,
  Bitcoin-base64, Cardano-COSE inkl. ;key). Unbekannte Formate werden mit
  logger.warn uebersprungen, travelRulePdfDate bleibt ungesetzt.
- Observer: zweite Metrik claimedWithoutFile (geclaimt ohne gueltige
  AddressSignature-kycFile) fuer den Rollback-fehlgeschlagen-Fall;
  oldestAgeHours kandidaten-nah ueber user.updated statt MIN(user.created);
  Rollback- und Orphan-Invalidation-Fehler werden geloggt statt verschluckt.
…n beschraenken

Der Job ueberspringt Signaturen, die die Format-Allowlist nicht matchen
(z.B. Lightning/DeFiChain), und setzt fuer sie travelRulePdfDate nie. Der
Stuck-Observer zaehlte dieselbe WHERE-Bedingung ohne Format-Filter und meldete
diese dauerhaft-uebersprungenen Kandidaten als Backlog -> dauerhafte
Falsch-Positiv-Stuck-Alerts.

- Signatur-Format-Pruefung als geteilte Klasse TravelRuleSignature.isValid
  ausgelagert (DRY), in Job und Observer identisch genutzt.
- Observer holt die Kandidaten-Signaturen als Spalte und teilt in-Code via
  derselben isValid auf: backlog/oldestAgeHours nur ueber verarbeitbare
  Kandidaten, separate Metrik skippedUnrecognised fuer nicht-allowlist-faehige.
- oldestAgeHours ehrlich als approximativ dokumentiert (user.updated ist
  @UpdateDateColumn, kein echtes Stuck-Alter); backlog als robuster
  Primaerindikator.
- travel-rule.observer.spec.ts ergaenzt (backlog/oldestAge/skippedUnrecognised/
  claimedWithoutFile). M5-Kontiguitaets-Assertion im pdf.service.spec
  (statement+address als ein zusammenhaengender String, kein getrenntes
  Adressfeld).
- dedizierte travel-rule-signature.spec.ts: testet TravelRuleSignature.isValid
  direkt ueber alle Pfade (leere Eingabe, je ein Treffer pro Allowlist-Format,
  masterKey-UUID mit/ohne ;<key>-Suffix, Lightning, Artefakte) -> Branch 100%
- travel-rule-job.service.spec.ts: Rollback-update-Fehler (Claim-Leak) und
  Orphan-invalidateKycFile-Fehler decken die fail-closed logger.error-Pfade ab
- travel-rule-pdf.service.spec.ts: wirft beim Rendern -> Promise rejected
@TaprootFreak TaprootFreak force-pushed the feat/user-travel-rule-pdf-cron branch from e78ac98 to 8fbbeff Compare June 25, 2026 20:13
@DFXswiss DFXswiss deleted a comment from github-actions Bot Jun 25, 2026
Das bisherige PDF war frei erfunden. Der TravelRulePdfService rendert nun
exakt die editPDF-Vorlage: grüner Haken oben links, DFX-Logo rechts,
zweizeiliger Titel, Metadaten-Tabelle (Id/Date/User Data ID) und Kontrolle-Box
(Address/Signatur Text/Signatur/Kontrolle-Link).

Der Signatur-Text ist signMessageGeneral + Adresse verbatim (Unterstriche
bleiben erhalten), die Signatur wird unverändert inkl. ;key-Suffix gerendert,
der Kontrolle-Link zeigt auf verifycryptomessage.com mit url-kodierter
Nachricht und Signatur.

Neue Service-Signatur generatePdf(input) mit id/userDataId/address/signature/
date; Job übergibt diese Felder. drawLogo erhält eine optionale x-Override für
die Rechts-Positionierung. Tests komplett neu.
Der `kf`-Alias im NOT-EXISTS-Subquery der claimedWithoutFile-Metrik ist
kein TypeORM-registrierter Alias, daher quotet TypeORM die Identifier nicht.
PostgreSQL faltet ungequotete Identifier zu lowercase, wodurch `kf.userDataId`
und `kf.subType` zu nicht existierenden Spalten werden und der Observer alle
10 Minuten mit "column kf.userdataid does not exist" zur Laufzeit abbricht.

Die camelCase-Identifier werden jetzt manuell doppelt gequotet, damit sie
verbatim erhalten bleiben. Der bisherige Voll-Mock im Observer-Spec hat den
Fehler verdeckt; ein neuer Regressionstest prueft den generierten SQL und
schlaegt fehl, falls die Identifier nicht gequotet sind.
…ure-PDFs

Vor dem Claim/Generate wird je Kandidat ein bereits vorhandenes, gueltiges
AddressSignature-PDF gesucht (valid, subType ADDRESS_SIGNATURE, Name-Infix
"AddressSignature-0-<userId>-" mit Trailing-Hyphen gegen Multi-Adress-Kollision).

Gefunden: Datum aus dem YYYYMMDD-Dateinamen-Prefix wird als UTC-Mitternacht
geparst und per CAS in travelRulePdfDate gestempelt (repliziert die Verify-Stamp-
Stufe des alten Sheets), keine Neuerzeugung. Malformter Name wird als Fehler
geloggt und ohne Stempel uebersprungen (fail-safe, kein geratenes Datum, kein
Fallback auf now). Nicht gefunden: unveraendertes Claim-first-Verhalten.

Verhindert Duplikat-PDFs mit falschem (now) Datum fuer den Sheet-Altbestand.
…l: Format-Randfälle wie EVM ohne 0x-Prefix; DeFiChain/Bitcoin werden erkannt)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant