fix(realunit): make register/wallet idempotent on signature match#3731
Conversation
`completeRegistrationForWalletAddress` (and the same-wallet branch of
`completeRegistration`) threw `BadRequestException("RealUnit registration
already exists for this wallet")` whenever a KycStep already existed for
the current wallet. That breaks legitimate client retries after a lost
response: the first call succeeds server-side, the network drops the
reply, the client re-sends with the same EIP-712 signature and gets a
hard 400 — surfaced in the realunit-app KYC merge flow (DFXswiss/realunit-app#466)
as a user-stuck failure with no recovery path.
Replace the hard throw with a signature-aware idempotency check:
- Same wallet + same signature → return the existing registration's
status (`COMPLETED` if the KycStep is completed, otherwise
`FORWARDING_FAILED`). No new KycStep, no re-forward to Aktionariat.
- Same wallet + different signature → keep the 400, but with the more
precise message "RealUnit registration already exists for this wallet
with a different signature". A fresh sign over the same wallet means
the client produced a new payload — that is a real conflict, not a
retry.
Tests cover all three branches and assert that `createCustomKycStep` is
never called on the idempotent paths.
🧭 Vollständiges Why-Briefing — warum diese Idempotenz-Änderung notwendig istDieser Kommentar dokumentiert ausführlich den Kontext und die Begründung, weil der Fix selbst klein ist (21 Zeilen Logik), aber die Auswirkungen auf das User-Erlebnis und die Datenintegrität signifikant. Wer den Diff alleine liest, sieht nicht, warum die strikte 1. Wo das Problem aufgetaucht istBei einem kritischen Code-Review von DFXswiss/realunit-app#466 (Frontend, KYC-Merge-Flow). Der PR fixt zwei Bugs im Wallet-Onboarding für existierende DFX-Kunden:
Während des Reviews ist aufgefallen, dass selbst nach Bug-B-Fix der Retry-Pfad für den User in eine Sackgasse läuft — und der Grund liegt backend-seitig. 2. Der vollständige End-to-End-Flow eines existierenden DFX-KundenDamit nachvollziehbar wird, an welcher Stelle der Bug zuschlägt, hier die komplette Kette: EIP-712-Signatur in Schritt 6 wird im App-Code aus dem gespeicherten User-Data des bestehenden DFX-Accounts (Adresse, Name, Geburtstag, …) plus der neuen Wallet-Adresse plus dem heutigen Datum zusammengesetzt und mit dem Private Key der neuen Wallet signiert. Deterministisch: gleiche Eingaben ⇒ gleiche Signatur. 3. Das "lost response" Szenario im DetailMobile Netzwerke verlieren Responses regelmäßig — TCP-Connection-Reset, kurzer LTE-Drop nach Antenna-Handover, App im Background, etc. Genau das Szenario, das hier durchschlägt: Der entscheidende Punkt: Der erste Call ist erfolgreich durchgelaufen. Die Wallet ist registriert, der KycStep ist 4. Wie sich das vor PR #466 für den User dargestellt hatPre-#466 war es noch perfider: Bug B ( Damit war der Bug unsichtbar für den User, was ihn so lange unentdeckt hat überleben lassen. 5. Wie sich das nach PR #466 darstellt (Stand jetzt, ohne diesen Backend-Fix)Nach dem Frontend-Fix in #466 (gemerged in
Ergebnis: User sieht zwar jetzt korrekt die Failure-Snackbar (gut), aber sein Retry läuft direkt in den Das ist genau das Verhalten, das wir mit diesem PR fixen. Frontend war nur der halbe Fix. 6. Warum genau Idempotenz und nicht "wallet-only check"Naive Lösung wäre: "wenn
Deswegen signature-aware:
Das ist die Mindest-Sicherheitsschwelle, die wir nicht aufweichen dürfen. 7. Warum beide Endpoints betroffen sindEs gibt zwei verwandte Endpoints im selben Service:
Beide hatten dieselbe Stelle: if (this.hasRegistrationForWallet(...)) { // bzw. isForCurrentWallet
throw new BadRequestException('RealUnit registration already exists for this wallet');
}Das Frontend ruft je nach Pfad einen anderen Endpoint auf, aber das Stuck-Verhalten ist in beiden Pfaden identisch. Deshalb wird dieselbe Helper-Methode 8. Warum die Helper-Methode
|
| Bereich | Status | Begründung |
|---|---|---|
Signatur-Verifikation (verifyRealUnitRegistrationSignature) |
unverändert | Wir validieren die Signatur in completeRegistrationForWalletAddress weiterhin nur, wenn wir tatsächlich einen neuen KycStep anlegen. Auf dem idempotenten Pfad wäre eine erneute Verifikation redundant — die Signatur wurde beim ersten Call schon geprüft und ist (per definition match) dieselbe. |
hasRegistrationForWallet-Methode |
unverändert | Wird noch an drei weiteren Stellen genutzt (controllers/realunit.controller.ts:499, sowie zwei weitere Service-interne Calls). completeRegistration musste auf den direkten findRegistrationStep-Call umgestellt werden, damit der step auch verfügbar ist — hasRegistrationForWallet returned nur boolean. |
validateRegistrationDto für den /register/wallet-Pfad |
unverändert | Existierte vorher schon nicht (nur Signatur-Check). Ist eine separate Frage und wird unten als Follow-up erwähnt. |
| Aktionariat-Forward bei Retry | unverändert | Wir machen ihn nicht erneut. Siehe oben — Idempotenz heisst, dasselbe Resultat zu liefern, nicht den fehlgeschlagenen Forward zu retryen. |
| Existierende KycStep-Daten | unverändert | Auf dem idempotenten Pfad wird nichts modifiziert — kein neuer KycStep, kein Update des bestehenden, keine User-Data-Änderung. Vollständig read-only-Response. |
10. Sicherheitsbetrachtung — wird hier eine Lücke geöffnet?
Nein. Konkret durchgespielt:
- Replay durch einen anderen Account: blockiert durch
userData.id !== userDataIdCheck (Zeile 665 unverändert) — ein anderer Account kann nicht über die Wallet-Adresse einer fremden Wallet einen 200 erschleichen. - Replay mit gestohlener Signatur: blockiert durch den
userData-Lookup übergetUserByAddress(walletAddress)— diewalletAddressaus dem DTO muss zum gleichen Account gehören, der das JWT mitgesendet hat. Eine gestohlene Signatur eines anderen Users hat keinen Effekt. - Replay vom gleichen User mit alter Signatur: technisch sind alle Signaturen "alt" (auch der erste Call war "alt" als er ankam) — der Schutz besteht ausschliesslich darin, dass nur jemand mit dem Wallet-Private-Key die Signatur produzieren kann. Das ändert sich durch diesen PR nicht.
- Datenmanipulation: ausgeschlossen, weil der idempotente Pfad nichts schreibt. Keine personalData, kein kycStep, kein Forward.
Die Sicherheits-Garantien sind also exakt dieselben wie vorher. Was sich ändert ist nur, dass ein legitimer Retry nicht mehr in eine User-Sackgasse läuft.
11. Test-Strategie
Drei Test-Cases im neuen describe('completeRegistrationForWalletAddress (idempotency)') Block:
| # | Setup | Expectation |
|---|---|---|
| 1 | KycStep existiert mit gleicher Signatur, isCompleted=true |
Return COMPLETED, createCustomKycStep nicht aufgerufen |
| 2 | KycStep existiert mit gleicher Signatur, isCompleted=false |
Return FORWARDING_FAILED, createCustomKycStep nicht aufgerufen |
| 3 | KycStep existiert mit anderer Signatur | Wirft BadRequestException, createCustomKycStep nicht aufgerufen |
Wichtig ist die Zusatz-Assertion expect(kycService.createCustomKycStep).not.toHaveBeenCalled() — sie sichert ab, dass auf dem idempotenten Pfad kein neues KycStep angelegt wird. Ohne die Assertion könnte ein späteres Refactoring den Helper falsch implementieren, die ersten zwei Tests würden trotzdem grün bleiben.
completeRegistration (der andere Endpoint) ist über denselben Helper abgedeckt — die spezifischen Pfade dort haben mehr Pre-Conditions (Email-Match, KycLevel-Check, etc.) und werden über das vorhandene Test-Setup nicht trivial isolierbar. Der Helper ist die Single Source of Truth.
12. Real-World-Frequenz — wie oft passiert sowas?
Mobile Network Drops zwischen Backend-Write und Client-Receipt sind ein Klassiker. Realistische Schätzungen aus Mobile-Backend-Literatur:
- ~0.1 % bis 1 % aller mobile API-Calls fallen ins "Server hat geschrieben, Client hat keine Response gesehen"-Fenster, abhängig von Netzwerk-Qualität und Request-Latenz.
- Bei einem Onboarding-Flow mit ~1 kritischem Wallet-Registration-Call pro neuem User: bei 1000 neuen DFX-Kunden ⇒ ~1-10 stuck users pro Onboarding-Welle.
Das ist die Größenordnung, in der man Support-Tickets bekommt mit der Beschreibung "ich hab Wallet hinzugefügt, aber die App sagt Fehler und ich kann nicht weitermachen". Die User wären dann tatsächlich schon registriert, der Support müsste das manuell verifizieren und den User durchschalten. Diese Tickets verschwinden mit diesem Fix.
13. Follow-ups, die in eigene PRs gehören
validateRegistrationDtoauch für/register/wallet-Pfad: Der Endpoint validiert aktuell nur die Signatur, nichtregistrationDate(muss heute sein), Birthday/Age, Personal-Data-Format. BeicompleteRegistrationläuftvalidateRegistrationDtoals erstes — beicompleteRegistrationForWalletAddressnicht. Unklar, ob bewusst (Daten kommen aus dem bestehenden KycStep, müssten dort schon validiert sein), aber dasregistrationDateaus dem DTO sollte wahrscheinlich auf "heute" geprüft werden.- Aktionariat-Forward Retry-Policy: Wenn ein KycStep auf
FORWARDING_FAILEDsteht, gibt es keinen automatischen Retry. Der Idempotenz-Fix macht den User darauf aufmerksam (Frontend zeigt jetzt die korrekte Failure-Snackbar), aber der eigentliche Recovery-Pfad fehlt. Heute überadmin/registration/:kycStepId/forward(manuell). - Beobachtung des stuck-Patterns vor Fix: Wäre interessant, ein paar Wochen Daten aus den Logs zu ziehen, wie oft der "400 already exists"-Response in Production tatsächlich aufgetreten ist — das wäre die Validierung der Real-World-Frequenz-Schätzung in Punkt 12.
14. Zusammenfassung in einem Satz
Wir tauschen einen unbedingten 400 Bad Request gegen eine signature-verifizierte Idempotenz-Antwort, damit legitimen Client-Retries nach Netzwerk-Verlust nicht mehr in eine User-Sackgasse laufen, ohne die Sicherheits-Garantien zu schwächen.
…dempotent retries - Use destructuring at both `findRegistrationStep` call sites so the register/complete and register/wallet paths read identically. - Pass `userData` explicitly into `idempotentRegistrationResult` rather than relying on TypeORM's back-population of `step.userData`, which is not guaranteed for steps loaded via the `userData.kycSteps` relation. - Emit an info log when the idempotent branch fires so production retry frequency is observable and stuck-user reports can be correlated to a kycStep id. - Document the three KycStep statuses REALUNIT_REGISTRATION actually reaches (INTERNAL_REVIEW / MANUAL_REVIEW / COMPLETED) so the binary `isCompleted` mapping does not silently break if the lifecycle is ever extended.
…rides The previous comment claimed the step is in one of three states (INTERNAL_REVIEW / MANUAL_REVIEW / COMPLETED). That is true under the normal service flow, but the generic `kyc-admin.updateKycStep` endpoint can push REALUNIT_REGISTRATION into any non-failed/non-canceled status (ON_HOLD, OUTDATED, etc.) — and `findRegistrationStep` only filters FAILED and CANCELED. Runtime behavior is unchanged (anything that is not `isCompleted` maps to FORWARDING_FAILED, which is the safe default), but the comment now accurately reflects the reachable state set.
| if (existingData?.signature !== incomingSignature) { | ||
| throw new BadRequestException('RealUnit registration already exists for this wallet with a different signature'); | ||
| } |
There was a problem hiding this comment.
Use case insensitive check? Util.equalsIgnoreCase
EIP-712 signatures are 0x-prefixed hex strings. Lower-case and upper-case representations of the same signature are semantically identical, but the previous strict `!==` would have treated them as different and rejected legitimate retries that happen to flip hex case (can occur when a wallet library updates its hex-encoding default). Use `Util.equalsIgnoreCase` for the comparison, matching the convention already used for wallet-address comparisons in `findRegistrationStep`. Added a unit test that stores an upper-case signature and retries with a lower-case one — it now returns COMPLETED instead of throwing 400.
…dit alignment The first revision shipped the rule and the inventory but left the plan and audit unable to be cross-referenced: the plan mentioned V1–V19 IDs that the audit never assigned, and the summary tables miscounted. Several real violations were also missing from the audit, and the plan didn't close all audit items. Audit changes (`docs/api-authority-audit.md`) - Assign explicit V<N> anchors to every bulleted finding (V1–V40). The plan cites these. Composite items use V6a–d / V10a–c / V13b/c. - Add four previously-missed grep targets: `_changeStepNames` in `settings_user_data_cubit.dart:18-22` (V6d); the inReview interpretation in `settings_edit_address_cubit.dart:22` (V6c); `Currency.values` in `settings_currencies_page.dart:26` (V10c); `Language.values` in `settings_languages_page.dart:24` (V13c). - Add V11 (bank-account default selection in `sell_bank_account_field.dart`) and V34 (`softwareTermsAccepted` boot-time gate in `main.dart:120`), which the plan already references but the audit had not enumerated. - Tag the four boundary cases (V13b BitBox backup, V25 401-refresh, V28 network mode, V30 default assets, V33 BIP-39) as documented exceptions in-line so reviewers don't try to "fix" them. - Replace the Summary table with canonical counts derived from a recount of the file itself: 15 P0 + 10 P1 + 16 P2 = 41 distinct P0–P2 violations, 36 actionable after subtracting the 5 boundary cases. - Use `DFXswiss#466` and `DFXswiss/api#3731` consistently in P4 instead of mixing `PR DFXswiss#466` / `#3731` short forms. Plan changes (`docs/api-authority-plan.md`) - Rewrite the Executive summary table with per-wave V-ID lists. Totals add up: 11 + 6 + 7 + 4 + 8 = 36 actionable. - Wave 1 description: replace the inconsistent "8 violations" / "5 P0/P1 items" with "6 items closing 11 audit findings", matching W1.* heading count + each W1.x's Closes line. - W1.2: drop the false claim that `BankAccountDto` parsing needs adjusting — the app's DTO already exposes `isDefault` (verified in `lib/packages/service/dfx/models/bank_account/dto/bank_account_dto.dart`). Selector switch is now a one-line change. - W1.3, W1.4, W1.6: extend Closes lines so the V-IDs added to the audit are actually covered. W1.6 now explicitly references V34 (drops "tail item from Wallet/Services scan" wording). - W2.1/W2.2 Closes: add V5 (routing chain), V21 + V22 (session-gate positions). W3.1/W3.2 Closes: add V6c (edit-address) and V6d (`_changeStepNames`) + the new `canEditAddress` capability + `category` field on `KycStepDto`. - Add a new Wave 5 that closes V20 (server-side auto-register), V23 (JWT merge), V24 (transaction statusLabel), V26+V27 (polling orchestration), V29 (asset config endpoint), V31+V32 (account-bounds for date pickers). Wave 5 also documents which audit items remain explicitly out of scope (V13b, V25, V28, V30, V33) and why. - Redraw the Sequencing diagram so the wave-internal API→App arrow and the "waves are independent of each other" property are unambiguous (no more vertical connectors that imply sequential). CONTRIBUTING.md - Rename the rule-test heading to "### The test (Wer entscheidet?)" so the German phrase referenced from the PR body and the plan footer actually appears in the file.
…GISTERED status Wave 3 of the realunit-app API-as-Decision-Authority plan (`DFXswiss/realunit-app:docs/api-authority-plan.md`). Two changes that let the realunit-app stop interpreting backend state into UI affordances: UserV2Dto.capabilities ---------------------- New `UserCapabilitiesDto` field on the `/v2/user` response. Surfaces per-action flags the realunit-app cubits were re-deriving locally from KYC step status (`settings_user_data_page.dart:239`, `settings_edit_name_cubit.dart:22`, `settings_contact_page.dart:54-67`): - `canEditName` / `canEditAddress`: false once PersonalData is in any review or completed state (data is locked to keep client and KYC attestation aligned). - `canEditMail` / `canEditPhone`: false only on KYC-terminated accounts. - `supportAvailable`: requires a verified mail. `UserDtoMapper.computeCapabilities` mirrors the rules the cubits encode today. A separate app-side PR consumes the field and drops the local interpretation. RealUnitRegistrationStatus.ALREADY_REGISTERED --------------------------------------------- New enum value. `completeRegistration` and `completeRegistrationForWalletAddress` return it instead of throwing `BadRequestException` when the wallet is already registered for the user. The realunit-app currently catches the 400 and treats it as a success — surfacing the success as a structured status removes the "papering over an error" pattern and lets the app distinguish the merge / retry path cleanly from other 400s. Backwards compatibility ----------------------- Both changes are additive. Old clients ignore the new `capabilities` field and continue to derive editability locally. The `ALREADY_REGISTERED` status is a new enum value — existing clients that switch-fall-through will treat it the same as `FORWARDING_FAILED` (no behaviour change worse than a generic failure). Tests ----- - `user-dto.mapper.spec.ts` adds a `mapUser: capabilities` block covering all five flags across happy path, PersonalData-locked, KYC-terminated, and no-mail fixtures. - Existing tests cover the two `return RealUnitRegistrationStatus.ALREADY_REGISTERED` call sites by absence of any new throw (`completeRegistration` / `completeRegistrationForWalletAddress` did not have happy-path tests for the already-registered branch; PR #3731 addresses the idempotency semantics in the same area and adds dedicated coverage). Local verification ------------------ - `npm run type-check` — clean - `npm run lint` — clean - `npm test` — **943 / 943 passing**
Summary
Surface a real client retry-stuck bug spotted while reviewing DFXswiss/realunit-app#466 (KYC merge flow). `POST /v1/realunit/register/wallet` (and the same-wallet branch of `POST /v1/realunit/register/complete`) was non-idempotent: if the first call succeeded server-side but the response was lost (mobile network drop is the realistic case), the client's automatic retry with the same EIP-712 signature got a hard `400 Bad Request` — "RealUnit registration already exists for this wallet" — with no recovery path. The user ends up stuck on a failure snackbar even though the registration is in fact complete.
Fix
Replace the hard throw with a signature-aware idempotency check in `RealUnitService` (new private helper `idempotentRegistrationResult`):
Signature comparison is case-insensitive (`Util.equalsIgnoreCase`): EIP-712 signatures are 0x-prefixed hex strings and lower/upper case representations are semantically identical. Matches the convention already used for wallet-address comparisons in `findRegistrationStep`.
Both endpoints share the helper; `completeRegistration` is refactored to call `findRegistrationStep` directly instead of the boolean-only `hasRegistrationForWallet`, so it has access to the `KycStep` for the idempotency check. `hasRegistrationForWallet` itself is preserved (still used by `realunit.controller.ts` and two other call-sites).
The helper takes `userData` as an explicit parameter rather than reading `step.userData` so the call does not depend on TypeORM back-populating the inverse relation on steps loaded via `userData.kycSteps`. Each idempotent hit is logged at `info` level (`userData.id`, `kycStep.id`, resulting status) so production retry frequency is observable and stuck-user reports can be correlated to a specific step.
Tests
`describe('completeRegistrationForWalletAddress (idempotency)', ...)` block in `realunit.service.spec.ts` covers all branches:
Test setup expands the `UserService` and `KycService` mock providers (previously empty objects) with the methods needed by these tests. The `Util.equalsIgnoreCase` jest mock was missing from the existing setup — added here too, since both `findRegistrationStep` and the new signature comparison rely on it.
Local verification
Test plan
Known limitations
Out of scope / follow-ups