Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Backfill: un-gate the RealUnit registrations that Aktionariat answered with "Existing user found, updated
* your address." (existing share-register shareholders).
*
* WHY: PR #4215 gates buy quotes on a confirmed registration email. When Aktionariat matches an EXISTING
* shareholder it updates the wallet in place and sends NO confirmation email (only newly registered emails
* get "Confirmation email sent to ..."). Those registrations were persisted COMPLETED with
* requiresEmailConfirmation = true and therefore hang forever on a confirmation link that never arrives.
* The service fix persists new existing-shareholder registrations with requiresEmailConfirmation = false;
* this migration heals the rows already stuck before the fix shipped.
*
* The existing-shareholder evidence is the durable Aktionariat/Registration audit row in the `log` table
* (the designated PII audit store), whose `response.message` is "Existing user found ...". We only touch rows
* that are (a) the active COMPLETED registration, (b) still gated (requiresEmailConfirmation = true), (c) not
* yet confirmed (confirmedDate IS NULL — a genuinely email-confirmed row is left untouched), and (d) proven
* existing-shareholder by such a log row for the same wallet. LOWER() both sides: the log keeps the mixed-case
* (EIP-55) wallet, the registration column is canonically lowercased.
*
* Idempotent: re-running is a no-op (the requiresEmailConfirmation = true predicate no longer matches). The
* reconciliation count is emitted via RAISE NOTICE — nothing changes silently.
*
* down(): intentional no-op. This is a forward-only data correction; re-gating these customers would lock
* them back out of buy/sell, and after the service fix ships the same predicate can no longer distinguish a
* backfilled row from a natively un-gated new existing-shareholder registration. The prior state stays
* reconstructible from the append-only `log` rows this migration reads (never mutates).
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class BackfillExistingShareholderConfirmation1784560000000 {
name = 'BackfillExistingShareholderConfirmation1784560000000';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`
DO $$
DECLARE
backfilled_count integer;
BEGIN
UPDATE "aktionariat_registration" r
SET "requiresEmailConfirmation" = false
WHERE r."active" = true
AND r."status" = 'Completed'
AND r."requiresEmailConfirmation" = true
AND r."confirmedDate" IS NULL
AND EXISTS (
SELECT 1 FROM "log" l
WHERE l."system" = 'Aktionariat'
AND l."subsystem" = 'Registration'
AND l."message" LIKE '%Existing user found%'
AND LOWER(l."message") LIKE '%' || LOWER(r."walletAddress") || '%'
);
GET DIAGNOSTICS backfilled_count = ROW_COUNT;

RAISE NOTICE 'BackfillExistingShareholderConfirmation: un-gated existing-shareholder registrations=%', backfilled_count;
END $$;
`);
}

/**
* @param {QueryRunner} _queryRunner
*/
async down(_queryRunner) {
// No-op: forward-only data correction (see class doc).
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,56 @@ describe('RealUnitService', () => {
expect(postConfig.timeout).toBe(30000);
});

it('does not gate the completed registration on a confirmation mail when Aktionariat matched an existing shareholder', async () => {
const wallet = softwareWallet.address;
const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet));
const dto = buildDto(utf8Fields(wallet), signature);
// Aktionariat updates an existing share-register shareholder in place and sends NO confirmation mail.
httpService.post.mockResolvedValue({ message: 'Existing user found, updated your address.' } as any);

const ok = await (service as any).forwardRegistration(fakeUserData(), dto);

expect(ok).toBe(true);
const created = (aktionariatRegistrationRepo.create as jest.Mock).mock.calls[0][0];
expect(created.status).toBe(ReviewStatus.COMPLETED);
// no confirmation mail will ever arrive for an existing shareholder → must not be gated (else buy dead-ends)
expect(created.requiresEmailConfirmation).toBe(false);
});

it('keeps the completed registration gated when Aktionariat sent a confirmation mail to a newly registered email', async () => {
const wallet = softwareWallet.address;
const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet));
const dto = buildDto(utf8Fields(wallet), signature);
httpService.post.mockResolvedValue({ message: 'Confirmation email sent to erika.example@example.com' } as any);

const ok = await (service as any).forwardRegistration(fakeUserData(), dto);

expect(ok).toBe(true);
const created = (aktionariatRegistrationRepo.create as jest.Mock).mock.calls[0][0];
expect(created.status).toBe(ReviewStatus.COMPLETED);
// a confirmation mail was sent → the row stays gated until the customer confirms
expect(created.requiresEmailConfirmation).toBe(true);
});

it('keeps the registration gated when a confirmation mail echoes an address that merely embeds the marker words', async () => {
const wallet = softwareWallet.address;
const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet));
const dto = buildDto(utf8Fields(wallet), signature);
// Aktionariat echoes the raw registrant email into the message; a quoted local part can embed the
// marker words. The marker is matched start-anchored, so this stays a "Confirmation email sent" reply
// and the row must stay gated — an embedded substring must not spoof an existing-shareholder match.
httpService.post.mockResolvedValue({
message: 'Confirmation email sent to "existing user found"@example.com',
} as any);

const ok = await (service as any).forwardRegistration(fakeUserData(), dto);

expect(ok).toBe(true);
const created = (aktionariatRegistrationRepo.create as jest.Mock).mock.calls[0][0];
expect(created.status).toBe(ReviewStatus.COMPLETED);
expect(created.requiresEmailConfirmation).toBe(true);
});

it('persists the registration in DEV/LOC without calling Aktionariat and logs the response as skipped', async () => {
mockEnvironment = 'loc';
const wallet = softwareWallet.address;
Expand Down
42 changes: 36 additions & 6 deletions src/subdomains/supporting/realunit/realunit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ export class RealUnitService {
private readonly ponderUrl: string;
private readonly genesisDate = new Date('2022-04-12 07:46:41.000');
private readonly tokenName = 'REALU';
// Lower-cased prefix of Aktionariat's registerUser response that marks a matched existing shareholder
// (full message: "Existing user found, updated your address."). Matched as a start-anchored prefix, not a
// free substring: the alternative "Confirmation email sent to <email>" reply echoes the raw registrant
// email, so a substring test would let an address embedding these words (e.g. a quoted local part) spoof
// the marker and wrongly un-gate a registration whose confirmation email WAS sent and never confirmed.
private static readonly existingShareholderMarker = 'existing user found';
// Getter, not a field: Config is undefined until ConfigService is constructed, so reading it
// in a field initializer can crash bootstrap depending on provider-instantiation order.
private get tokenBlockchain(): Blockchain {
Expand Down Expand Up @@ -1492,6 +1498,11 @@ export class RealUnitService {
}
}

// An existing share-register shareholder gets no confirmation email from Aktionariat, so the completed
// registration must not be gated on one (see isExistingShareholderResponse). Skip-forward (DEV/LOC) and
// failed forwards have no such response and stay gated by the normal rules.
const existingShareholder = this.isExistingShareholderResponse(registerResponse);

// 2) Persist the outcome in a short advisory-locked transaction (no external I/O inside it).
let outcome: 'completed' | 'forward-failed' | 'idempotent';
try {
Expand Down Expand Up @@ -1524,7 +1535,15 @@ export class RealUnitService {
return 'forward-failed';
}

await this.persistAktionariatRegistration(manager, user, dto, payload, ReviewStatus.COMPLETED, new Date());
await this.persistAktionariatRegistration(
manager,
user,
dto,
payload,
ReviewStatus.COMPLETED,
new Date(),
existingShareholder,
);
return 'completed';
});
} catch (error) {
Expand Down Expand Up @@ -1600,6 +1619,16 @@ export class RealUnitService {
}
}

// Aktionariat's registerUser answers "Existing user found, updated your address." when the signed email
// already belongs to a share-register shareholder: it updates that shareholder's wallet in place and sends
// NO confirmation email (a newly registered email instead gets "Confirmation email sent to ..."). Such a
// registration must not be gated on a confirmation email that never arrives — Aktionariat has
// authoritatively identified an existing shareholder, which is itself the confirmation.
private isExistingShareholderResponse(response: Record<string, unknown> | undefined): boolean {
const message = typeof response?.message === 'string' ? response.message : undefined;
return message?.toLowerCase().startsWith(RealUnitService.existingShareholderMarker) ?? false;
}

// Persist the queryable, per-wallet Aktionariat registration record for the resolved wallet-user within
// the caller's transaction (the caller holds the per-wallet-user advisory lock). Deactivate any prior
// active registration for this wallet-user, then insert the new one — so the partial unique index
Expand All @@ -1614,6 +1643,7 @@ export class RealUnitService {
payload: AktionariatRegistrationDto,
status: ReviewStatus,
forwardedToAktionariatDate: Date | null,
existingShareholder = false,
): Promise<void> {
await manager.update(AktionariatRegistration, { user: { id: user.id }, active: true }, { active: false });

Expand All @@ -1626,11 +1656,11 @@ export class RealUnitService {
status,
forwardedToAktionariatDate: forwardedToAktionariatDate ?? undefined,
active: true,
// Only a COMPLETED registration is gated on the Aktionariat confirmation email (sent on a successful
// forward). A MANUAL_REVIEW row's forward failed, so no confirmation mail was ever sent — gating it
// would dead-end the flow on a mail that never arrives. (The completion migration clears this for rows
// that predate the gate.)
requiresEmailConfirmation: status === ReviewStatus.COMPLETED,
// A COMPLETED registration is gated on the Aktionariat confirmation email (sent on a successful forward)
// UNLESS Aktionariat matched an existing shareholder — it then sends no confirmation email, so gating
// would dead-end the flow on a mail that never arrives. A MANUAL_REVIEW row's forward failed, so no mail
// was sent either. (The completion migration clears this for rows that predate the gate.)
requiresEmailConfirmation: status === ReviewStatus.COMPLETED && !existingShareholder,
});
registration.signedPayloadData = payload;
registration.kycDataObj = dto.kycData;
Expand Down
Loading