From 99cbad54d43002eb9cc518e9c35a9840dcfe52ef Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:46:23 +0200 Subject: [PATCH 1/2] baa7748e - Serve the fiat list from a cache instead of rebuilding it per call (#4527) * Serve the fiat list from a cache instead of rebuilding it per call GET /v1/fiat returns a parameterless, unauthenticated master-data list - the same 7,632 bytes for every caller - and production serves it 3,891 times in six hours (~10.8/min, peaking at 30). Median 19.3 ms, but p95 2,487 ms and 14.3% of calls over a second. Two of the three inputs were already cached: fiat and country both go through findCached. transactionSpecification does not - it extends BaseRepository, so every single call hit the database - and the whole list was mapped again on top. Cache the assembled DTO list rather than its parts, so neither the query nor the mapping repeats. Five minutes is not a free choice: it is CachedRepository's default list TTL, which the underlying fiat and country lists already use. Holding the response longer than its own building blocks stay valid would be wrong. The ETag does not cover this. A conditional request still has to build the full response before the tag can be compared, so a 304 saves bandwidth and no server time. * Move the fiat cache down to the repository instead of the response The previous commit cached the assembled response in the controller. Two problems with that, both found in review: The controller was the only one of 24 AsyncCache sites in the repo not sitting in a service, against CONTRIBUTING's thin-controller rule. Worse, it sat above an invalidation it could not see. FiatService.updatePrice() calls fiatRepo.invalidateCache() after every price update, hourly per fiat, and minVolume/maxVolume are derived from approxPriceChf. A response cache with its own clock would keep serving the old limits for up to five minutes after the price job explicitly asked for them to be dropped. Cache transactionSpecification instead - it was the only uncached input left, so it was the actual per-call database query. fiat and country already go through findCached, and now all three inputs behave the same way, so invalidateCache() keeps taking effect immediately. Switching that repository is safe to do: its only other consumer loads it once into a field of its own, and no save/update/insert/delete path for the table exists anywhere in the codebase. The test pins the part that could regress silently - that the controller reads through the cache rather than around it. --- .../fiat/__tests__/fiat.controller.spec.ts | 106 ++++++++++++++++++ src/shared/models/fiat/fiat.controller.ts | 6 +- .../transaction-specification.repository.ts | 6 +- 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/shared/models/fiat/__tests__/fiat.controller.spec.ts diff --git a/src/shared/models/fiat/__tests__/fiat.controller.spec.ts b/src/shared/models/fiat/__tests__/fiat.controller.spec.ts new file mode 100644 index 0000000000..e7a2b71a4c --- /dev/null +++ b/src/shared/models/fiat/__tests__/fiat.controller.spec.ts @@ -0,0 +1,106 @@ +import { ConfigService } from 'src/config/config'; +import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; +import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { TransactionDirection } from 'src/subdomains/supporting/payment/entities/transaction-specification.entity'; +import { Country } from '../../country/country.entity'; +import { CountryService } from '../../country/country.service'; +import { createCustomFiat } from '../__mocks__/fiat.entity.mock'; +import { FiatController } from '../fiat.controller'; +import { FiatService } from '../fiat.service'; + +describe('FiatController', () => { + let controller: FiatController; + let fiatService: { getAllFiat: jest.Mock }; + let countryService: { getAllCountry: jest.Mock }; + let findCachedMock: jest.Mock; + let findMock: jest.Mock; + let getSpecForMock: jest.Mock; + let repos: RepositoryFactory; + + beforeAll(() => { + new ConfigService(); + }); + + beforeEach(() => { + findCachedMock = jest.fn().mockResolvedValue([]); + findMock = jest.fn().mockResolvedValue([]); + getSpecForMock = jest.fn().mockReturnValue({ minVolume: 1, minFee: 0 }); + + // RepositoryFactory is a concrete class whose nested repositories are plain instance properties — + // build only the surface getAllFiat() actually touches. + repos = { + transactionSpecification: { + findCached: findCachedMock, + find: findMock, + getSpecFor: getSpecForMock, + }, + } as unknown as RepositoryFactory; + + fiatService = { + getAllFiat: jest.fn().mockResolvedValue([]), + }; + countryService = { + getAllCountry: jest.fn().mockResolvedValue([]), + }; + + controller = new FiatController( + fiatService as unknown as FiatService, + repos, + countryService as unknown as CountryService, + ); + }); + + describe('getAllFiat', () => { + it('loads transaction specifications via findCached and never via find', async () => { + await controller.getAllFiat(); + + expect(findCachedMock).toHaveBeenCalledWith('all'); + expect(findMock).not.toHaveBeenCalled(); + }); + + it('composes detail DTOs from fiat, specs and countries via FiatDtoMapper', async () => { + // approxPriceChf = 1 keeps convert() arithmetic exact (no rounding edge cases). + const fiat = createCustomFiat({ + id: 42, + name: 'CHF', + buyable: true, + sellable: true, + cardBuyable: false, + cardSellable: false, + instantBuyable: false, + instantSellable: false, + approxPriceChf: 1, + }); + const specs = [{ system: 'Fiat', asset: 'CHF', direction: TransactionDirection.IN, minVolume: 10, minFee: 0 }]; + const spec = { minVolume: 10, minFee: 0 }; + const countries = [ + Object.assign(new Country(), { symbol: 'CH', dfxEnable: true }), + Object.assign(new Country(), { symbol: 'DE', dfxEnable: true }), + Object.assign(new Country(), { symbol: 'US', dfxEnable: false }), + ]; + + findCachedMock.mockResolvedValue(specs); + getSpecForMock.mockReturnValue(spec); + fiatService.getAllFiat.mockResolvedValue([fiat]); + countryService.getAllCountry.mockResolvedValue(countries); + + const result = await controller.getAllFiat(); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(42); + expect(result[0].name).toBe('CHF'); + expect(result[0].buyable).toBe(true); + expect(result[0].sellable).toBe(true); + // minVolume 10 CHF / approxPriceChf 1 → 10; max from Config.tradingLimits.yearlyDefault + expect(result[0].limits[FiatPaymentMethod.BANK].minVolume).toBe(10); + expect(result[0].limits[FiatPaymentMethod.BANK].maxVolume).toBe(1_000_000_000); + expect(result[0].limits[FiatPaymentMethod.INSTANT].minVolume).toBe(0); + expect(result[0].limits[FiatPaymentMethod.INSTANT].maxVolume).toBe(0); + expect(result[0].limits[FiatPaymentMethod.CARD].minVolume).toBe(0); + expect(result[0].limits[FiatPaymentMethod.CARD].maxVolume).toBe(0); + // buyable + dfxEnable countries with default isIbanCountryAllowed → CH, DE (not US) + expect(result[0].allowedIbanCountries).toEqual(['CH', 'DE']); + expect(getSpecForMock).toHaveBeenCalledWith(specs, fiat, TransactionDirection.IN); + }); + }); +}); diff --git a/src/shared/models/fiat/fiat.controller.ts b/src/shared/models/fiat/fiat.controller.ts index 9a03c1caf4..4485aac0b5 100644 --- a/src/shared/models/fiat/fiat.controller.ts +++ b/src/shared/models/fiat/fiat.controller.ts @@ -20,7 +20,11 @@ export class FiatController { @ApiOkResponse({ type: FiatDetailDto, isArray: true }) async getAllFiat(): Promise { const specRepo = this.repoFactory.transactionSpecification; - const specs = await specRepo.find(); + // Endpoint is hit ~10.8×/min (peak 30/min). Fiat and country already use findCached; + // this was the last uncached DB query per call. Cache sits on the repository layer so that + // FiatService.updatePrice() → fiatRepo.invalidateCache() still takes effect immediately + // (same principle as why there is no separate controller-level response cache). + const specs = await specRepo.findCached('all'); const countries = await this.countryService.getAllCountry(); return this.fiatService diff --git a/src/subdomains/supporting/payment/repositories/transaction-specification.repository.ts b/src/subdomains/supporting/payment/repositories/transaction-specification.repository.ts index accdd78f51..d2ad59f615 100644 --- a/src/subdomains/supporting/payment/repositories/transaction-specification.repository.ts +++ b/src/subdomains/supporting/payment/repositories/transaction-specification.repository.ts @@ -1,11 +1,13 @@ import { Injectable } from '@nestjs/common'; import { Active, isAsset } from 'src/shared/models/active'; -import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { CachedRepository } from 'src/shared/repositories/cached.repository'; import { EntityManager } from 'typeorm'; import { TransactionDirection, TransactionSpecification } from '../entities/transaction-specification.entity'; +// Same cache duration as Fiat and Country (CachedRepository default EVERY_5_MINUTES). +// Pure reference/master data: no save/update/insert/delete path exists in the codebase. @Injectable() -export class TransactionSpecificationRepository extends BaseRepository { +export class TransactionSpecificationRepository extends CachedRepository { constructor(manager: EntityManager) { super(TransactionSpecification, manager); } From ec73faf3ffe92a46a0b67cf6a7f1982267365b49 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:18:43 -0300 Subject: [PATCH 2/2] fix(realunit): accept registration signatures over a chainId-extended EIP-712 domain (#4542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(realunit): accept registration signatures over a chainId-extended EIP-712 domain The BitBox02 firmware warns on typed data whose EIP712Domain has no chainId, and over Bluetooth confirming that warning answers with a NACK instead of a signature (BitBoxSwiss/bitbox02-firmware#2019). iOS reaches the device only over Bluetooth, so BitBox users cannot complete a RealUnit registration at all. The app therefore signs hardware-wallet registrations over a chainId-extended domain, which verification has to accept. Express the accepted set as an enumerated list of variants (domain x field encoding) instead of nesting loops: the cross-product was never visible as a set, so widening or narrowing it was a loop side effect rather than a deliberate edit. Verification now walks that list and returns the variant it recovered under, which forwardRegistration names in the log — making the accepted mix observable, and separating a local resolution miss from an Aktionariat-side rejection, which are otherwise indistinguishable downstream. Backward compatible on its own: the legacy chainId-less domain is tried first, so every existing registration takes the same path to the same result. At most one variant can match, since the shapes differ in the signed digest. The forwarded payload is unchanged and carries no domain, so Aktionariat rebuilds it independently and must accept the extended variant as well. * refactor(realunit): write out the four accepted signature shapes instead of iterating The variant list replaced a nested loop with an array assembled by a conditional spread and then walked by a loop, plus four hand-written labels duplicating what domain and encoding already say. The attempts were still not visible as attempts. Write them out as four expressions instead. recoverRegistration performs one attempt and yields the resolved signature or nothing, so resolveRegistrationSignature is the four shapes chained with ??, readable top to bottom. The log name is derived from the variant rather than stored beside it, so the two cannot drift. Tests assert the four shapes one by one — each verifies, forwards the bytes it was signed over, and is named correctly in the log — replacing the assertion on the list of labels. * fix(realunit): build each candidate message once instead of per attempt Moving each attempt into its own function pushed per-request work into the per-attempt path: the signature was re-normalised four times, and the two candidate messages were built twice each, because both domains recover against the same UTF-8 and ASCII messages. The nested loop this replaced built each message once. Normalise the signature and build both messages once in the resolution, and let the attempt do only what is per-attempt: recover under one domain and compare to the claimed wallet. The four attempts stay written out. A test pins the reuse — it drives the variant that matches on the last attempt and asserts two message builds, so a per-attempt rebuild fails it (verified: the regression produces six). * refactor(realunit): resolve the signature with one guard clause per accepted shape The chained-?? version still carried machinery: an attempt closure returning result objects, an enum-keyed message map, and an undefined-domain guard inside the attempt because the extended domain may not exist. Replace it with four guard clauses, read top to bottom — one statement per accepted shape. Per-request work (signature normalisation, the two candidate messages) happens once above them; the only helper left is a one-line boolean predicate. The missing-chainId case becomes a structural early return between the legacy and extended blocks instead of a per-attempt check. No behavioural change; the message-reuse and four-shape tests pass unchanged. * docs(realunit): trim comments to essentials The rationale lives in the PR description; the code keeps one-liners for what is not derivable from the code itself (the BitBox chainId cause, the unreachable chainId guard, the forward-on-miss fallback). * fix(realunit): PascalCase enum values and cover the environment-derived chainId Enum values follow CONTRIBUTING.md (PascalCase strings, never lowercase); the enum is module-private and never serialised, so no contract changes. The extended domain's chainId is the only environment-dependent part of this change and nothing pinned it — a hardcoded 1 passed the whole suite. Add a DEV case asserting Sepolia resolves and Ethereum does not. * docs(realunit): state the exact condition under which both encodings coincide toBitboxAscii returns input unchanged for pure printable ASCII, not merely for data without diacritics — CJK or Cyrillic carries no diacritics yet transliterates to '?', so the two encodings differ there and the label is exact. * docs(realunit): restore 'printable' in the encoding-coincidence condition toBitboxAscii gates passthrough on /^[\x20-\x7E]*$/, so a tab or newline is pure ASCII yet still transliterates to '?'. The commit message and PR body already said printable; only the comment dropped it. --- .../__tests__/realunit.service.spec.ts | 98 +++++++++++++++++-- .../supporting/realunit/realunit.service.ts | 77 ++++++++++++--- 2 files changed, 152 insertions(+), 23 deletions(-) diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 11a5047693..892fa047ce 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -3098,12 +3098,94 @@ describe('RealUnitService', () => { expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase()); }); - it('resolveSignedRegistrationMessage returns undefined when a valid signature does not belong to the claimed wallet', async () => { + // chainId 1 = the PRD REALU chain (Ethereum); this block runs with env 'prd'. + const chainIdDomain = { ...domain, chainId: 1 }; + + // The four accepted shapes: each verifies, forwards the signed bytes, and is named in the log. + it.each([ + ['legacy domain / UTF-8 fields', domain, utf8Fields], + ['legacy domain / BitBox ASCII fields', domain, asciiFields], + ['chainId 1 domain / UTF-8 fields', { ...domain, chainId: 1 }, utf8Fields], + ['chainId 1 domain / BitBox ASCII fields', { ...domain, chainId: 1 }, asciiFields], + ])('accepts and reports %s', async (expected, signingDomain, signedFields) => { + const wallet = hardwareWallet.address; + const fields = (signedFields as any)(wallet); + const signature = await hardwareWallet._signTypedData(signingDomain, types, fields); + + const ok = await (service as any).forwardRegistration(fakeUserData(), buildDto(utf8Fields(wallet), signature)); + + expect(ok).toBe(true); + expect(forwardedPayload()).toEqual(expect.objectContaining(fields)); + expect((service as any).logger.info).toHaveBeenCalledWith(expect.stringContaining(`matched ${expected}`)); + }); + + it('accepts a BitBox signature over the chainId-extended domain and forwards the signed ASCII fields', async () => { + const wallet = hardwareWallet.address; + const signature = await hardwareWallet._signTypedData(chainIdDomain, types, asciiFields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + + const ok = await (service as any).forwardRegistration(fakeUserData(), dto); + + expect(ok).toBe(true); + const payload = forwardedPayload(); + expect(payload.name).toBe('Erika Mueller'); + expect(verifyTypedData(chainIdDomain, types, asciiFields(wallet), payload.signature).toLowerCase()).toBe( + wallet.toLowerCase(), + ); + // Recovery under the legacy domain does NOT match. Aktionariat rebuilds the + // domain itself, so it must attempt the chainId-extended variant too or this + // registration fails on their side as "Invalid signature". + expect(recoverFromForwarded(payload).toLowerCase()).not.toBe(wallet.toLowerCase()); + }); + + it('rejects a signature over a foreign chainId (the domain must match the REALU token chain)', async () => { + const wallet = hardwareWallet.address; + const signature = await hardwareWallet._signTypedData({ ...domain, chainId: 5 }, types, asciiFields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + + expect((service as any).resolveRegistrationSignature(dto)).toBeUndefined(); + }); + + it('builds each candidate message once, whatever the matching variant costs to reach', async () => { + // Matches only on the last attempt — a per-attempt rebuild would show as 4+ calls. + const wallet = hardwareWallet.address; + const signature = await hardwareWallet._signTypedData(chainIdDomain, types, asciiFields(wallet)); + const build = jest.spyOn(service as any, 'buildRegistrationMessage'); + + expect((service as any).resolveRegistrationSignature(buildDto(utf8Fields(wallet), signature))).toBeDefined(); + + expect(build).toHaveBeenCalledTimes(2); + }); + + // The extended domain is the only environment-dependent part: Sepolia on DEV/LOC, Ethereum on PRD. + it('takes the chainId from the token chain of the environment', async () => { + mockEnvironment = 'dev'; + const wallet = hardwareWallet.address; + const sign = (chainId: number) => + hardwareWallet._signTypedData({ ...domain, chainId }, types, asciiFields(wallet)); + const resolve = (signature: string) => + (service as any).resolveRegistrationSignature(buildDto(utf8Fields(wallet), signature)); + + expect(resolve(await sign(11155111))).toBeDefined(); + expect(resolve(await sign(1))).toBeUndefined(); + }); + + it('warns when the signature matches no accepted variant', async () => { + // Well-formed signature from the wrong wallet: recovers cleanly, matches no variant. + const foreign = await softwareWallet._signTypedData(domain, types, asciiFields(softwareWallet.address)); + const dto = buildDto(utf8Fields(hardwareWallet.address), foreign); + + await (service as any).forwardRegistration(fakeUserData(), dto); + + expect((service as any).logger.warn).toHaveBeenCalledWith(expect.stringContaining('matched no accepted variant')); + }); + + it('resolveRegistrationSignature returns undefined when a valid signature does not belong to the claimed wallet', async () => { // Valid signature from the software wallet, but the dto claims a different wallet address. const signature = await softwareWallet._signTypedData(domain, types, asciiFields(softwareWallet.address)); const dto = buildDto(utf8Fields(hardwareWallet.address), signature); - expect((service as any).resolveSignedRegistrationMessage(dto)).toBeUndefined(); + expect((service as any).resolveRegistrationSignature(dto)).toBeUndefined(); }); it('persists the per-wallet registration and writes an INFO audit log on success', async () => { @@ -3541,11 +3623,11 @@ describe('RealUnitService', () => { }); it('falls back to the raw (non-transliterated) message when the signature cannot be resolved', async () => { - // resolveSignedRegistrationMessage returns undefined -> the `?? buildRegistrationMessage(dto, false)` + // resolveRegistrationSignature returns undefined -> the `?? buildRegistrationMessage(dto, false)` // fallback builds the forwarded payload from the raw UTF-8 fields. const wallet = softwareWallet.address; const dto = buildDto(utf8Fields(wallet), '0xdeadbeef'); - jest.spyOn(service as any, 'resolveSignedRegistrationMessage').mockReturnValue(undefined); + jest.spyOn(service as any, 'resolveRegistrationSignature').mockReturnValue(undefined); httpService.post.mockResolvedValue({} as any); const ok = await (service as any).forwardRegistration(fakeUserData(), dto); @@ -5059,13 +5141,13 @@ describe('RealUnitService', () => { } }); - it('resolveSignedRegistrationMessage normalizes a signature that lacks the 0x prefix', async () => { + it('resolveRegistrationSignature normalizes a signature that lacks the 0x prefix', async () => { const fields = humanFields(); const signature = await wallet._signTypedData(domain, types, fields); const dto = { ...fields, signature: signature.slice(2), lang: 'DE', kycData: {} }; - const message = (service as any).resolveSignedRegistrationMessage(dto); - expect(message).toBeDefined(); - expect(message.walletAddress).toBe(wallet.address); + const resolved = (service as any).resolveRegistrationSignature(dto); + expect(resolved).toBeDefined(); + expect(resolved.message.walletAddress).toBe(wallet.address); }); }); diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index eef1f974ed..91ec287551 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -207,6 +207,33 @@ type SignedRegistrationMessage = Pick< | 'walletAddress' >; +type RegistrationEip712Domain = typeof REGISTRATION_EIP712_DOMAIN & { chainId?: number }; + +enum RegistrationFieldEncoding { + UTF8 = 'Utf8', + BITBOX_ASCII = 'BitboxAscii', +} + +interface RegistrationSignatureVariant { + domain: RegistrationEip712Domain; + encoding: RegistrationFieldEncoding; +} + +// The fields exactly as they were signed, plus the variant they recovered under. +interface ResolvedRegistrationSignature { + message: SignedRegistrationMessage; + variant: RegistrationSignatureVariant; +} + +// The encoding half is indicative only: for data that is already pure printable ASCII both encodings +// are byte-identical, so such a registration always reports UTF-8. The domain half is always exact. +function describeVariant({ domain, encoding }: RegistrationSignatureVariant): string { + const domainName = domain.chainId ? `chainId ${domain.chainId} domain` : 'legacy domain'; + const fields = encoding === RegistrationFieldEncoding.BITBOX_ASCII ? 'BitBox ASCII fields' : 'UTF-8 fields'; + + return `${domainName} / ${fields}`; +} + @Injectable() export class RealUnitService { private readonly logger = new DfxLogger(RealUnitService); @@ -1160,7 +1187,7 @@ export class RealUnitService { } private verifyRealUnitRegistrationSignature(data: RealUnitRegistrationDto): boolean { - return this.resolveSignedRegistrationMessage(data) != null; + return this.resolveRegistrationSignature(data) != null; } // Builds the EIP-712 message in either the raw or the BitBox-safe ASCII @@ -1186,21 +1213,31 @@ export class RealUnitService { }; } - // Returns the EIP-712 fields exactly as the wallet signed them — raw UTF-8 - // (legacy software wallets, kept working by #3709) or BitBox-safe ASCII - // (current app / any BitBox, whose firmware rejects non-ASCII bytes). Returns - // undefined if the signature matches neither. Aktionariat re-verifies the - // signature against the payload we POST in forwardRegistration, so the - // forwarded bytes must be exactly these — forwarding any other variant fails - // as "Invalid signature". - private resolveSignedRegistrationMessage(data: RealUnitRegistrationDto): SignedRegistrationMessage | undefined { + // Returns the message exactly as the wallet signed it (Aktionariat re-verifies the + // forwarded bytes), plus the variant it recovered under; undefined if no accepted + // shape matches. The chainId-extended domain exists because the BitBox02 refuses + // chainId-less typed data over Bluetooth (BitBoxSwiss/bitbox02-firmware#2019). + private resolveRegistrationSignature(data: RealUnitRegistrationDto): ResolvedRegistrationSignature | undefined { + const { UTF8, BITBOX_ASCII } = RegistrationFieldEncoding; + const signature = data.signature.startsWith('0x') ? data.signature : `0x${data.signature}`; + const utf8 = this.buildRegistrationMessage(data, false); + const ascii = this.buildRegistrationMessage(data, true); - for (const transliterate of [false, true]) { - const message = this.buildRegistrationMessage(data, transliterate); - const recovered = verifyTypedData(REGISTRATION_EIP712_DOMAIN, REGISTRATION_EIP712_TYPES, message, signature); - if (Util.equalsIgnoreCase(recovered, data.walletAddress)) return message; - } + const isSignedBy = (domain: RegistrationEip712Domain, message: SignedRegistrationMessage): boolean => + Util.equalsIgnoreCase(verifyTypedData(domain, REGISTRATION_EIP712_TYPES, message, signature), data.walletAddress); + + const legacy = REGISTRATION_EIP712_DOMAIN; + if (isSignedBy(legacy, utf8)) return { message: utf8, variant: { domain: legacy, encoding: UTF8 } }; + if (isSignedBy(legacy, ascii)) return { message: ascii, variant: { domain: legacy, encoding: BITBOX_ASCII } }; + + // Always set for Ethereum/Sepolia; the guard satisfies the chain map's type. + const chainId = EvmUtil.getChainId(this.tokenBlockchain); + if (!chainId) return undefined; + + const extended = { ...REGISTRATION_EIP712_DOMAIN, chainId }; + if (isSignedBy(extended, utf8)) return { message: utf8, variant: { domain: extended, encoding: UTF8 } }; + if (isSignedBy(extended, ascii)) return { message: ascii, variant: { domain: extended, encoding: BITBOX_ASCII } }; return undefined; } @@ -1504,7 +1541,17 @@ export class RealUnitService { // representation that was signed — raw UTF-8 (legacy software wallets) or BitBox-safe ASCII // (current app / BitBox). Forwarding the wrong variant fails as "Invalid signature". The // UTF-8 originals stay on user_data for PDF/mail. - const signedMessage = this.resolveSignedRegistrationMessage(dto) ?? this.buildRegistrationMessage(dto, false); + // A miss still forwards (fallback below) and fails at Aktionariat as "Invalid signature" — the warn attributes it. + const resolved = this.resolveRegistrationSignature(dto); + if (resolved) { + this.logger.info( + `RealUnit registration signature matched ${describeVariant(resolved.variant)} (${dto.walletAddress})`, + ); + } else { + this.logger.warn(`RealUnit registration signature matched no accepted variant (${dto.walletAddress})`); + } + + const signedMessage = resolved?.message ?? this.buildRegistrationMessage(dto, false); const payload: AktionariatRegistrationDto = { ...signedMessage, signature: dto.signature,