From 00e9b8bb567144cd2e3ae2224f4b105b04e51939 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 8 May 2026 15:45:08 +0200 Subject: [PATCH] feat(auth): support ERC-6492 signatures for predeployed smart accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables wallets such as Tether's WDK (@tetherto/wdk-wallet-evm-erc-4337) to sign in to DFX before their Safe-style smart account has been deployed on-chain. The existing EVM auth path verifies ECDSA EOA sigs (fast) and ERC-1271 sigs from already-deployed contracts; counterfactual smart accounts fail. - EvmClient.verifyErc6492Signature: delegates to viem's PublicClient.verifyMessage, which transparently dispatches to ERC-6492 (counterfactual) / ERC-1271 (deployed) / ecrecover. Same viem + getEvmChainConfig setup the existing Pimlico services already use for AA concerns. - EvmUtil.hasErc6492MagicSuffix: cheap detector to gate the smart-account path and avoid an unnecessary RPC roundtrip on plain EOA sigs. - CryptoService.verifyEthereumBased: ECDSA local fast-path → on miss, if 6492 magic suffix detected OR address is a deployed contract → universal validator → else false. The same cryptoService.verifySignature is the only sig-check used by authService.signIn / signUp / challenge, so all wallet-registration paths are covered without touching auth.service or the user/wallet domain. Tests: - evm.util.spec.ts: 6 new cases for hasErc6492MagicSuffix - crypto.service.evm.spec.ts: 7 cases covering the full verifyEthereumBased flow with Test.createTestingModule + createMock from @golevelup/ts-jest --- .../__test__/crypto.service.evm.spec.ts | 136 ++++++++++++++++++ .../shared/evm/__tests__/evm.util.spec.ts | 32 +++++ .../blockchain/shared/evm/evm-client.ts | 21 +++ .../blockchain/shared/evm/evm.util.ts | 6 + .../shared/services/crypto.service.ts | 8 +- 5 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 src/integration/blockchain/shared/__test__/crypto.service.evm.spec.ts diff --git a/src/integration/blockchain/shared/__test__/crypto.service.evm.spec.ts b/src/integration/blockchain/shared/__test__/crypto.service.evm.spec.ts new file mode 100644 index 0000000000..eb64d019f5 --- /dev/null +++ b/src/integration/blockchain/shared/__test__/crypto.service.evm.spec.ts @@ -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(); + (blockchainRegistry.getEvmClient as jest.Mock).mockReturnValue(evmClient); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CryptoService, + TestUtil.provideConfig({}), + { provide: BitcoinService, useValue: createMock() }, + { provide: LightningService, useValue: createMock() }, + { provide: SparkService, useValue: createMock() }, + { provide: ArkadeService, useValue: createMock() }, + { provide: FiroService, useValue: createMock() }, + { provide: MoneroService, useValue: createMock() }, + { provide: ZanoService, useValue: createMock() }, + { provide: SolanaService, useValue: createMock() }, + { provide: TronService, useValue: createMock() }, + { provide: CardanoService, useValue: createMock() }, + { provide: InternetComputerService, useValue: createMock() }, + { provide: ArweaveService, useValue: createMock() }, + { provide: RailgunService, useValue: createMock() }, + { 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); + }); +}); diff --git a/src/integration/blockchain/shared/evm/__tests__/evm.util.spec.ts b/src/integration/blockchain/shared/evm/__tests__/evm.util.spec.ts index 1fa433f01f..f708d56b8f 100644 --- a/src/integration/blockchain/shared/evm/__tests__/evm.util.spec.ts +++ b/src/integration/blockchain/shared/evm/__tests__/evm.util.spec.ts @@ -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); + }); + }); }); diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 2d54478062..dce0308b4e 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -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'; @@ -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'; @@ -429,6 +431,25 @@ export abstract class EvmClient extends BlockchainClient { return result === ERC1271_MAGIC_VALUE; } + async verifyErc6492Signature(message: string, address: string, signature: string): Promise { + 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 { const tx = await this.getTx(txHash); diff --git a/src/integration/blockchain/shared/evm/evm.util.ts b/src/integration/blockchain/shared/evm/evm.util.ts index 141c9a7597..74775eb5ac 100644 --- a/src/integration/blockchain/shared/evm/evm.util.ts +++ b/src/integration/blockchain/shared/evm/evm.util.ts @@ -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> = { [Blockchain.ETHEREUM]: { chain: mainnet, configKey: 'ethereum', prefix: 'eth' }, @@ -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; diff --git a/src/integration/blockchain/shared/services/crypto.service.ts b/src/integration/blockchain/shared/services/crypto.service.ts index 8cdcc24424..029b95b9dc 100644 --- a/src/integration/blockchain/shared/services/crypto.service.ts +++ b/src/integration/blockchain/shared/services/crypto.service.ts @@ -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