Skip to content

RealUnit: model wallet-scoped registration as a first-class entity (FK), not a JSON blob in kyc_step.result #3785

@TaprootFreak

Description

@TaprootFreak

Problem

The RealUnit-Aktionariat registration is per-wallet, not per-account: one user_data can hold multiple user rows (one per wallet), and the entry into the Aktionariat share registry has to happen per wallet. Today this per-wallet relation is squeezed into a generic kyc_step row whose result column holds the full RealUnitRegistrationDto JSON, with the wallet address as a string field inside that blob.

Concretely, RealUnitService.findRegistrationStep(userData, walletAddress) (realunit.service.ts:865) implements the lookup like this:

```ts
const allSteps = userData.getStepsWith(KycStepName.REALUNIT_REGISTRATION);
const currentWalletStep = allSteps
.filter((s) => !(s.isFailed || s.isCanceled))
.find((s) => {
const result = s.getResult();
return result?.walletAddress && Util.equalsIgnoreCase(result.walletAddress, walletAddress);
});
```

Why this is wrong

  1. No referential integrity. If a `user` row is deleted or migrated, the JSON-encoded `walletAddress` inside the blob becomes a dangling pointer. The DB has no idea it ever pointed at anything.
  2. No DB-level index. Every wallet-scoped lookup loads all `kyc_step` rows of a `user_data`, deserialises the JSON, and string-compares in application code. O(n) instead of indexed equality.
  3. Case-insensitive string match. `Util.equalsIgnoreCase(result.walletAddress, walletAddress)` only exists because Ethereum addresses arrive in mixed-case checksum form and the JSON keeps the string the registration was originally submitted with. A numeric FK to `user.id` would eliminate this entire class of bug.
  4. Schema-blind. Nothing in the DB schema reveals that this step subtype is bound to a specific wallet. Migration tooling, ORM relations, query planners are all blind to it.
  5. Joins impossible. "All RealUnit registrations grouped by wallet" or "all wallets of user X that are registered" cannot be expressed as SQL — they require application-side iteration.
  6. Lifecycle mismatch. RealUnit registrations are forwarded to an external party (Aktionariat) and have a different status lifecycle (`COMPLETED` / `PENDING_REVIEW` / `FORWARDING_FAILED` / `ALREADY_REGISTERED`) than the generic kyc_step ReviewStatus enum. Squeezing the two into one column produces ad-hoc state translations everywhere.

Proposed direction

Promote RealUnit registration to a first-class entity with a proper relational shape. Sketch:

```
realunit_registration
id PK
userId FK → user.id NOT NULL (= the registered wallet)
userDataId FK → user_data.id NOT NULL (= the natural person / account)
signature text NOT NULL
registrationDate date NOT NULL
status enum (COMPLETED, PENDING_REVIEW, FORWARDING_FAILED, …)
aktionariatRequestId / externalRef -- for forwarding-failed retry, audit
signedPayload jsonb -- the kycData/Aktionariat fields, for compliance audit
created / updated
```

`findRegistrationStep` becomes a single `SELECT … WHERE userId = ?`. The case-insensitive string compare disappears. The KycStep-side of things either:

  • (a) keeps a generic `RealUnitRegistration` KycStep row whose `result` only carries a foreign key `{ realunitRegistrationId: number }` — for backward compatibility with the existing KYC step-iteration plumbing, or
  • (b) removes the per-wallet `RealUnitRegistration` KycStep entirely and lets the `user_data` declare "fully KYC'd" without it; the realunit-buy gate (`hasRegistrationForWallet`) reads from the new table directly.

Either way, the `AktionariatRegistrationDto` JSON inside `kyc_step.result` goes away as the source of truth for "is this wallet registered".

Why this is its own issue / PR

The pre-fill work currently in #3782 (server) + RealUnitCH/app#600 (client) is a tactical UX fix on top of the existing structure. The structural change proposed here is a separate concern — it would interact with the migration backlog, with the realunit-app's `KycRegistrationView` flow, and with how the `/v1/realunit/wallet/status` and `/v1/realunit/register/status` endpoints answer "is THIS wallet registered". Doing both in one PR muddies both reviews.

Out of scope

  • Renaming `KycStepName.REALUNIT_REGISTRATION` (the user-facing step concept can stay)
  • Aktionariat forwarding semantics (no change to the external API contract)
  • Other KycStep subtypes that legitimately are per-userData (ContactData, PersonalData, NationalityData, Ident, FinancialData, DfxApproval). Those stay JSON-result-based — they are per-account by nature.

Acceptance criteria

  • New `realunit_registration` entity + table + migration
  • `findRegistrationStep` / `hasRegistrationForWallet` / `getAddressWalletStatus` rewired to use the new table; no JSON parsing in the lookup path
  • Backfill migration that converts existing `kyc_step` rows with `name = 'RealUnitRegistration'` into rows in the new table
  • All four idempotency / merge / re-sign flows verified (`completeRegistration`, `completeRegistrationForWalletAddress`, `idempotentRegistrationResult`, the `ALREADY_REGISTERED` path)
  • Client-facing endpoints (`/v1/realunit/wallet/status`, `/v1/realunit/register/status`) return identical JSON shapes — no breaking change for realunit-app

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions