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
@@ -1,5 +1,7 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Wallet } from 'ethers';
import { verifyTypedData } from 'ethers/lib/utils';
import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service';
import { BrokerbotCurrency } from 'src/integration/blockchain/realunit/dto/realunit-broker.dto';
import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service';
Expand Down Expand Up @@ -32,9 +34,16 @@ import { RealUnitDevService } from '../realunit-dev.service';
import { PriceSourceUnavailableException } from '../exceptions/price-source-unavailable.exception';
import { RealUnitService } from '../realunit.service';

let mockEnvironment = 'loc';

jest.mock('src/config/config', () => ({
get Config() {
return { environment: 'loc' };
return {
environment: mockEnvironment,
blockchain: {
realunit: { api: { url: 'https://mock-api.example.com', key: 'mock-key' } },
},
};
},
Environment: {
LOC: 'loc',
Expand Down Expand Up @@ -153,11 +162,12 @@ describe('RealUnitService', () => {
provide: KycService,
useValue: {
createCustomKycStep: jest.fn(),
saveKycStepUpdate: jest.fn(),
},
},
{ provide: CountryService, useValue: {} },
{ provide: LanguageService, useValue: {} },
{ provide: HttpService, useValue: {} },
{ provide: HttpService, useValue: { post: jest.fn() } },
{ provide: FiatService, useValue: {} },
{ provide: BuyService, useValue: {} },
{
Expand Down Expand Up @@ -641,4 +651,156 @@ describe('RealUnitService', () => {
await expect((service as any).withPriceSourceGuard(() => Promise.resolve('ok'))).resolves.toBe('ok');
});
});

describe('forwardRegistration (forwards the signed representation to Aktionariat)', () => {
// Hardhat test accounts — synthetic keys, never real user wallets.
const softwareWallet = new Wallet('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d');
// Stands in for a BitBox the user adds later (hardware can only sign ASCII).
const hardwareWallet = new Wallet('0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a');

const domain = { name: 'RealUnitUser', version: '1' };
const types = {
RealUnitUser: [
{ name: 'email', type: 'string' },
{ name: 'name', type: 'string' },
{ name: 'type', type: 'string' },
{ name: 'phoneNumber', type: 'string' },
{ name: 'birthday', type: 'string' },
{ name: 'nationality', type: 'string' },
{ name: 'addressStreet', type: 'string' },
{ name: 'addressPostalCode', type: 'string' },
{ name: 'addressCity', type: 'string' },
{ name: 'addressCountry', type: 'string' },
{ name: 'swissTaxResidence', type: 'bool' },
{ name: 'registrationDate', type: 'string' },
{ name: 'walletAddress', type: 'address' },
],
};

// UTF-8 originals as persisted on the KYC step / user_data.
const utf8Fields = (walletAddress: string) => ({
email: 'erika.example@example.com',
name: 'Erika Müller',
type: 'HUMAN',
phoneNumber: '+41790000000',
birthday: '1990-01-01',
nationality: 'CH',
addressStreet: 'Bahnhofstrasse 1',
addressPostalCode: '8001',
addressCity: 'Zürich',
addressCountry: 'CH',
swissTaxResidence: true,
registrationDate: '2026-06-08',
walletAddress,
});

// BitBox-safe ASCII transliteration of the same fields — what the wallet signs.
const asciiFields = (walletAddress: string) => ({
...utf8Fields(walletAddress),
name: 'Erika Mueller',
addressCity: 'Zuerich',
});

const buildDto = (fields: Record<string, unknown>, signature: string): any => ({
...fields,
signature,
lang: 'DE',
kycData: {},
});

const fakeKycStep = (): any => ({
id: 1,
userData: { kycLevel: 999 },
complete: jest.fn().mockReturnValue([1, {}]),
manualReview: jest.fn().mockReturnValue([1, {}]),
});

const forwardedPayload = (): any => ((service as any).http.post as jest.Mock).mock.calls[0][1];

// What Aktionariat does: recover the signer from the forwarded payload and compare to walletAddress.
const recoverFromForwarded = (p: any): string =>
verifyTypedData(
domain,
types,
{
email: p.email,
name: p.name,
type: p.type,
phoneNumber: p.phoneNumber,
birthday: p.birthday,
nationality: p.nationality,
addressStreet: p.addressStreet,
addressPostalCode: p.addressPostalCode,
addressCity: p.addressCity,
addressCountry: p.addressCountry,
swissTaxResidence: p.swissTaxResidence,
registrationDate: p.registrationDate,
walletAddress: p.walletAddress,
},
p.signature,
);

beforeEach(() => {
mockEnvironment = 'prd';
});

afterEach(() => {
mockEnvironment = 'loc';
});

// REGRESSION GUARD: a legacy software wallet that signed the raw UTF-8 fields
// (still accepted by verifyRealUnitRegistrationSignature) must keep working —
// the forward must stay UTF-8, not be transliterated, or Aktionariat rejects it.
it('forwards the raw UTF-8 fields unchanged when the wallet signed UTF-8 (legacy app)', async () => {
const wallet = softwareWallet.address;
const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet));
const dto = buildDto(utf8Fields(wallet), signature);

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

expect(ok).toBe(true);
const payload = forwardedPayload();
expect(payload.name).toBe('Erika Müller');
expect(payload.addressCity).toBe('Zürich');
// Aktionariat re-verifies the signature against the payload it receives.
expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase());
});

it('forwards the BitBox-safe ASCII fields when the wallet signed ASCII (current app), even though the dto stores UTF-8', async () => {
const wallet = softwareWallet.address;
const signature = await softwareWallet._signTypedData(domain, types, asciiFields(wallet));
// dto carries the UTF-8 originals as stored; only the signature is over ASCII.
const dto = buildDto(utf8Fields(wallet), signature);

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

expect(ok).toBe(true);
const payload = forwardedPayload();
expect(payload.name).toBe('Erika Mueller');
expect(payload.addressCity).toBe('Zuerich');
expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase());
});

it('supports the software→hardware switch: a BitBox-signed (ASCII-only) wallet verifies against the forwarded payload', async () => {
const wallet = hardwareWallet.address;
const signature = await hardwareWallet._signTypedData(domain, types, asciiFields(wallet));
const dto = buildDto(utf8Fields(wallet), signature);

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

expect(ok).toBe(true);
const [url, payload] = ((service as any).http.post as jest.Mock).mock.calls[0];
expect(url).toContain('/registerUser');
expect(payload.name).toBe('Erika Mueller');
expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase());
});

it('resolveSignedRegistrationMessage 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();
});
});
});
142 changes: 79 additions & 63 deletions src/subdomains/supporting/realunit/realunit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,45 @@ function matchesSignedField(kycValue: string | undefined, signedValue: string |
return toBitboxAscii(kycValue) === signedValue;
}

const REGISTRATION_EIP712_DOMAIN = { name: 'RealUnitUser', version: '1' };

const REGISTRATION_EIP712_TYPES = {
RealUnitUser: [
{ name: 'email', type: 'string' },
{ name: 'name', type: 'string' },
{ name: 'type', type: 'string' },
{ name: 'phoneNumber', type: 'string' },
{ name: 'birthday', type: 'string' },
{ name: 'nationality', type: 'string' },
{ name: 'addressStreet', type: 'string' },
{ name: 'addressPostalCode', type: 'string' },
{ name: 'addressCity', type: 'string' },
{ name: 'addressCountry', type: 'string' },
{ name: 'swissTaxResidence', type: 'bool' },
{ name: 'registrationDate', type: 'string' },
{ name: 'walletAddress', type: 'address' },
],
};

// The EIP-712 fields a registration signature is computed over, in the exact
// representation that was signed (raw UTF-8 or BitBox-safe ASCII).
type SignedRegistrationMessage = Pick<
AktionariatRegistrationDto,
| 'email'
| 'name'
| 'type'
| 'phoneNumber'
| 'birthday'
| 'nationality'
| 'addressStreet'
| 'addressPostalCode'
| 'addressCity'
| 'addressCountry'
| 'swissTaxResidence'
| 'registrationDate'
| 'walletAddress'
>;

@Injectable()
export class RealUnitService {
private readonly logger = new DfxLogger(RealUnitService);
Expand Down Expand Up @@ -828,65 +867,49 @@ export class RealUnitService {
}

private verifyRealUnitRegistrationSignature(data: RealUnitRegistrationDto): boolean {
const domain = {
name: 'RealUnitUser',
version: '1',
};
return this.resolveSignedRegistrationMessage(data) != null;
}

const types = {
RealUnitUser: [
{ name: 'email', type: 'string' },
{ name: 'name', type: 'string' },
{ name: 'type', type: 'string' },
{ name: 'phoneNumber', type: 'string' },
{ name: 'birthday', type: 'string' },
{ name: 'nationality', type: 'string' },
{ name: 'addressStreet', type: 'string' },
{ name: 'addressPostalCode', type: 'string' },
{ name: 'addressCity', type: 'string' },
{ name: 'addressCountry', type: 'string' },
{ name: 'swissTaxResidence', type: 'bool' },
{ name: 'registrationDate', type: 'string' },
{ name: 'walletAddress', type: 'address' },
],
};
// Builds the EIP-712 message in either the raw or the BitBox-safe ASCII
// representation. Only the free-text fields carry diacritics, so only those
// are transliterated — mirrors realunit-app's signing path (Krüger → Krueger).
private buildRegistrationMessage(data: RealUnitRegistrationDto, transliterate: boolean): SignedRegistrationMessage {
const ascii = (value: string): string => (transliterate ? toBitboxAscii(value) : value);

const message = {
email: data.email,
name: data.name,
return {
email: ascii(data.email),
name: ascii(data.name),
type: data.type,
phoneNumber: data.phoneNumber,
birthday: data.birthday,
phoneNumber: ascii(data.phoneNumber),
birthday: ascii(data.birthday),
nationality: data.nationality,
addressStreet: data.addressStreet,
addressPostalCode: data.addressPostalCode,
addressCity: data.addressCity,
addressStreet: ascii(data.addressStreet),
addressPostalCode: ascii(data.addressPostalCode),
addressCity: ascii(data.addressCity),
addressCountry: data.addressCountry,
swissTaxResidence: data.swissTaxResidence,
registrationDate: data.registrationDate,
walletAddress: data.walletAddress,
};
}

const signatureToUse = data.signature.startsWith('0x') ? data.signature : `0x${data.signature}`;
const recoveredAddress = verifyTypedData(domain, types, message, signatureToUse);
if (Util.equalsIgnoreCase(recoveredAddress, data.walletAddress)) return true;

// Backwards-compat: app v0.0.3+ signs BitBox-safe ASCII. If the stored
// accountData still holds UTF-8 from a pre-transliteration registration,
// retry verify with the same fields transliterated so re-login (add new
// wallet) keeps working for those users.
const asciiMessage = {
...message,
email: toBitboxAscii(message.email),
name: toBitboxAscii(message.name),
phoneNumber: toBitboxAscii(message.phoneNumber),
birthday: toBitboxAscii(message.birthday),
addressStreet: toBitboxAscii(message.addressStreet),
addressPostalCode: toBitboxAscii(message.addressPostalCode),
addressCity: toBitboxAscii(message.addressCity),
};
const asciiRecovered = verifyTypedData(domain, types, asciiMessage, signatureToUse);
return Util.equalsIgnoreCase(asciiRecovered, data.walletAddress);
// 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 {
const signature = data.signature.startsWith('0x') ? data.signature : `0x${data.signature}`;

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

return undefined;
}

async forwardRegistrationToAktionariat(kycStepId: number): Promise<void> {
Expand Down Expand Up @@ -1081,21 +1104,14 @@ export class RealUnitService {
const { api } = Config.blockchain.realunit;

try {
// forward only Aktionariat fields (exclude kycData to avoid signature verification issues)
// forward only Aktionariat fields (exclude kycData to avoid signature verification issues).
// Aktionariat re-verifies the EIP-712 signature against this payload, so send back the exact
// 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);
const payload: AktionariatRegistrationDto = {
email: dto.email,
name: dto.name,
type: dto.type,
phoneNumber: dto.phoneNumber,
birthday: dto.birthday,
nationality: dto.nationality,
addressStreet: dto.addressStreet,
addressPostalCode: dto.addressPostalCode,
addressCity: dto.addressCity,
addressCountry: dto.addressCountry,
swissTaxResidence: dto.swissTaxResidence,
registrationDate: dto.registrationDate,
walletAddress: dto.walletAddress,
...signedMessage,
signature: dto.signature,
lang: dto.lang,
countryAndTINs: dto.countryAndTINs,
Expand Down