Skip to content
Draft
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,136 @@
import { createMock } from '@golevelup/ts-jest';
import { Test, TestingModule } from '@nestjs/testing';
import { ethers } from 'ethers';
import { RailgunService } from 'src/integration/railgun/railgun.service';
import { TestUtil } from 'src/shared/utils/test.util';
import { ArkadeService } from '../../arkade/arkade.service';
import { ArweaveService } from '../../arweave/services/arweave.service';
import { BitcoinService } from '../../bitcoin/services/bitcoin.service';
import { CardanoService } from '../../cardano/services/cardano.service';
import { FiroService } from '../../firo/services/firo.service';
import { InternetComputerService } from '../../icp/services/icp.service';
import { LightningService } from '../../../lightning/services/lightning.service';
import { MoneroService } from '../../monero/services/monero.service';
import { SolanaService } from '../../solana/services/solana.service';
import { SparkService } from '../../spark/spark.service';
import { TronService } from '../../tron/services/tron.service';
import { ZanoService } from '../../zano/services/zano.service';
import { BlockchainRegistryService } from '../services/blockchain-registry.service';
import { CryptoService } from '../services/crypto.service';

describe('CryptoService.verifyEthereumBased', () => {
const ERC6492_SUFFIX = '6492649264926492649264926492649264926492649264926492649264926492';
const MESSAGE = 'sign-in challenge';

let service: CryptoService;
let evmClient: { isContract: jest.Mock; verifyErc6492Signature: jest.Mock };
let blockchainRegistry: BlockchainRegistryService;

beforeEach(async () => {
evmClient = {
isContract: jest.fn(),
verifyErc6492Signature: jest.fn(),
};

blockchainRegistry = createMock<BlockchainRegistryService>();
(blockchainRegistry.getEvmClient as jest.Mock).mockReturnValue(evmClient);

const module: TestingModule = await Test.createTestingModule({
providers: [
CryptoService,
TestUtil.provideConfig({}),
{ provide: BitcoinService, useValue: createMock<BitcoinService>() },
{ provide: LightningService, useValue: createMock<LightningService>() },
{ provide: SparkService, useValue: createMock<SparkService>() },
{ provide: ArkadeService, useValue: createMock<ArkadeService>() },
{ provide: FiroService, useValue: createMock<FiroService>() },
{ provide: MoneroService, useValue: createMock<MoneroService>() },
{ provide: ZanoService, useValue: createMock<ZanoService>() },
{ provide: SolanaService, useValue: createMock<SolanaService>() },
{ provide: TronService, useValue: createMock<TronService>() },
{ provide: CardanoService, useValue: createMock<CardanoService>() },
{ provide: InternetComputerService, useValue: createMock<InternetComputerService>() },
{ provide: ArweaveService, useValue: createMock<ArweaveService>() },
{ provide: RailgunService, useValue: createMock<RailgunService>() },
{ provide: BlockchainRegistryService, useValue: blockchainRegistry },
],
}).compile();

service = module.get(CryptoService);
});

it('returns true for a valid EOA ECDSA signature without hitting the smart-account path', async () => {
const wallet = ethers.Wallet.createRandom();
const signature = await wallet.signMessage(MESSAGE);

await expect(service.verifySignature(MESSAGE, wallet.address, signature)).resolves.toBe(true);

expect(evmClient.isContract).not.toHaveBeenCalled();
expect(evmClient.verifyErc6492Signature).not.toHaveBeenCalled();
});

it('routes 6492-suffixed signatures straight to the universal validator (no isContract gate)', async () => {
const address = '0x1111111111111111111111111111111111111111';
const sigWith6492 = '0x' + 'aa'.repeat(200) + ERC6492_SUFFIX;
evmClient.verifyErc6492Signature.mockResolvedValue(true);

await expect(service.verifySignature(MESSAGE, address, sigWith6492)).resolves.toBe(true);

expect(evmClient.isContract).not.toHaveBeenCalled();
expect(evmClient.verifyErc6492Signature).toHaveBeenCalledTimes(1);
expect(evmClient.verifyErc6492Signature).toHaveBeenCalledWith(MESSAGE, address, sigWith6492);
});

it('routes signatures on deployed contract addresses to the universal validator', async () => {
const address = '0x2222222222222222222222222222222222222222';
const sig = '0x' + 'bb'.repeat(65);
evmClient.isContract.mockResolvedValue(true);
evmClient.verifyErc6492Signature.mockResolvedValue(true);

await expect(service.verifySignature(MESSAGE, address, sig)).resolves.toBe(true);

expect(evmClient.isContract).toHaveBeenCalledWith(address);
expect(evmClient.verifyErc6492Signature).toHaveBeenCalledWith(MESSAGE, address, sig);
});

it('returns false for garbage signatures on EOA addresses without invoking the validator', async () => {
const address = '0x3333333333333333333333333333333333333333';
const garbage = '0x' + 'cc'.repeat(65);
evmClient.isContract.mockResolvedValue(false);

await expect(service.verifySignature(MESSAGE, address, garbage)).resolves.toBe(false);

expect(evmClient.isContract).toHaveBeenCalledTimes(1);
expect(evmClient.verifyErc6492Signature).not.toHaveBeenCalled();
});

it('returns false when the universal validator rejects a 6492-wrapped signature', async () => {
const address = '0x4444444444444444444444444444444444444444';
const sig = '0x' + 'dd'.repeat(200) + ERC6492_SUFFIX;
evmClient.verifyErc6492Signature.mockResolvedValue(false);

await expect(service.verifySignature(MESSAGE, address, sig)).resolves.toBe(false);

expect(evmClient.verifyErc6492Signature).toHaveBeenCalledTimes(1);
});

it('returns false (does not throw) when the registry or RPC throws', async () => {
const address = '0x5555555555555555555555555555555555555555';
const sig = '0x' + 'ee'.repeat(65);
(blockchainRegistry.getEvmClient as jest.Mock).mockImplementation(() => {
throw new Error('chain not configured');
});

await expect(service.verifySignature(MESSAGE, address, sig)).resolves.toBe(false);
});

it('normalises a missing 0x prefix before forwarding to the universal validator', async () => {
const address = '0x6666666666666666666666666666666666666666';
const noPrefixSig = 'aa'.repeat(200) + ERC6492_SUFFIX;
evmClient.verifyErc6492Signature.mockResolvedValue(true);

await expect(service.verifySignature(MESSAGE, address, noPrefixSig)).resolves.toBe(true);

expect(evmClient.verifyErc6492Signature).toHaveBeenCalledWith(MESSAGE, address, '0x' + noPrefixSig);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,36 @@ describe('EvmUtil', () => {
expect(result).toBe(100);
});
});

describe('hasErc6492MagicSuffix', () => {
const magicSuffix = '6492649264926492649264926492649264926492649264926492649264926492';

it('detects 6492-wrapped signature', () => {
const sig = '0x' + 'ab'.repeat(200) + magicSuffix;
expect(EvmUtil.hasErc6492MagicSuffix(sig)).toBe(true);
});

it('is case-insensitive', () => {
const sig = '0x' + 'AB'.repeat(200) + magicSuffix.toUpperCase();
expect(EvmUtil.hasErc6492MagicSuffix(sig)).toBe(true);
});

it('returns false for plain ECDSA signature (65 bytes)', () => {
const sig = '0x' + 'ab'.repeat(65);
expect(EvmUtil.hasErc6492MagicSuffix(sig)).toBe(false);
});

it('returns false for empty string', () => {
expect(EvmUtil.hasErc6492MagicSuffix('')).toBe(false);
});

it('returns false for signature shorter than the suffix', () => {
expect(EvmUtil.hasErc6492MagicSuffix('0x6492')).toBe(false);
});

it('returns false when suffix is in the middle, not at the end', () => {
const sig = '0x' + magicSuffix + 'ab'.repeat(40);
expect(EvmUtil.hasErc6492MagicSuffix(sig)).toBe(false);
});
});
});
21 changes: 21 additions & 0 deletions src/integration/blockchain/shared/evm/evm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import ERC1271_ABI from 'src/integration/blockchain/shared/evm/abi/erc1271.abi.j
import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json';
import SIGNATURE_TRANSFER_ABI from 'src/integration/blockchain/shared/evm/abi/signature-transfer.abi.json';
import UNISWAP_V3_NFT_MANAGER_ABI from 'src/integration/blockchain/shared/evm/abi/uniswap-v3-nft-manager.abi.json';
import { Address, createPublicClient, Hex, http } from 'viem';
import { Asset, AssetType } from 'src/shared/models/asset/asset.entity';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { HttpService } from 'src/shared/services/http.service';
Expand All @@ -24,6 +25,7 @@ import { BlockchainTokenBalance } from '../dto/blockchain-token-balance.dto';
import { EvmSignedTransactionResponse } from '../dto/signed-transaction-reponse.dto';
import { BlockchainClient } from '../util/blockchain-client';
import { WalletAccount } from './domain/wallet-account';
import { getEvmChainConfig } from './evm-chain.config';
import { EvmUtil } from './evm.util';
import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from './interfaces';

Expand Down Expand Up @@ -429,6 +431,25 @@ export abstract class EvmClient extends BlockchainClient {
return result === ERC1271_MAGIC_VALUE;
}

async verifyErc6492Signature(message: string, address: string, signature: string): Promise<boolean> {
const blockchain = EvmUtil.getBlockchain(this.chainId);
if (!blockchain) return false;

const chainConfig = getEvmChainConfig(blockchain);
if (!chainConfig) return false;

const publicClient = createPublicClient({
chain: chainConfig.chain,
transport: http(chainConfig.rpcUrl),
});

return publicClient.verifyMessage({
address: address as Address,
message,
signature: signature as Hex,
});
}

// got from https://gist.github.com/gluk64/fdea559472d957f1138ed93bcbc6f78a
async getTxError(txHash: string): Promise<string> {
const tx = await this.getTx(txHash);
Expand Down
6 changes: 6 additions & 0 deletions src/integration/blockchain/shared/evm/evm.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { Blockchain } from '../enums/blockchain.enum';
import ERC20_ABI from './abi/erc20.abi.json';
import { WalletAccount } from './domain/wallet-account';

const ERC6492_DETECTION_SUFFIX = '6492649264926492649264926492649264926492649264926492649264926492';

// Viem chain configuration mapping
const VIEM_CHAIN_CONFIG: Partial<Record<Blockchain, { chain: Chain; configKey: string; prefix: string }>> = {
[Blockchain.ETHEREUM]: { chain: mainnet, configKey: 'ethereum', prefix: 'eth' },
Expand Down Expand Up @@ -92,6 +94,10 @@ export class EvmUtil {
return amount / 1000000;
}

static hasErc6492MagicSuffix(signature: string): boolean {
return Boolean(signature) && signature.toLowerCase().endsWith(ERC6492_DETECTION_SUFFIX);
}

static getPaymentRequest(address: string, asset: Asset, amount: number): string | undefined {
const chainId = this.getChainId(asset.blockchain);
if (!chainId || asset.decimals == null) return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,11 +333,13 @@ export class CryptoService {

if (this.verifyEoaSignature(message, address, signatureToUse)) return true;

// Fallback to ERC-1271 for smart contract wallets
// Fallback to ERC-6492 universal validator (covers ERC-1271 deployed + ERC-6492 counterfactual smart accounts)
try {
const client = this.blockchainRegistry.getEvmClient(blockchain);
if (await client.isContract(address)) {
return await client.verifyErc1271Signature(message, address, signatureToUse);
const isSmartAccountSig = EvmUtil.hasErc6492MagicSuffix(signatureToUse) || (await client.isContract(address));

if (isSmartAccountSig) {
return await client.verifyErc6492Signature(message, address, signatureToUse);
}
} catch {
// ignore
Expand Down
Loading