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
106 changes: 106 additions & 0 deletions src/shared/models/fiat/__tests__/fiat.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
6 changes: 5 additions & 1 deletion src/shared/models/fiat/fiat.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ export class FiatController {
@ApiOkResponse({ type: FiatDetailDto, isArray: true })
async getAllFiat(): Promise<FiatDetailDto[]> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TransactionSpecification> {
export class TransactionSpecificationRepository extends CachedRepository<TransactionSpecification> {
constructor(manager: EntityManager) {
super(TransactionSpecification, manager);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});

Expand Down
77 changes: 62 additions & 15 deletions src/subdomains/supporting/realunit/realunit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
Loading