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); } 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,