import * as admin from 'firebase-admin'; import IORedis from 'ioredis'; import { randomBytes } from 'node:crypto'; import { Not, Repository } from 'typeorm'; import { createPublicClient, http } from 'viem'; import { mainnet } from 'viem/chains'; import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User, Wallet } from './database/entities'; import { InjectRedis } from './libs/redis'; import { EvmAuthResponse, EvmValidateAuthDto } from './funki-id.dto'; export const publicClient = createPublicClient({ chain: mainnet, transport: http(), }); const NONCE_REDIS_PREFIX = 'auth_nonce'; @Injectable() export class FunkiIdEvmService { constructor( @Inject('FIREBASE_AUTH') private readonly auth: admin.auth.Auth, @InjectRedis() private readonly redis: IORedis, @InjectRepository(Wallet, 'funki-id') private readonly walletRepository: Repository, ) {} async generateNonce(publicKey: string) { const nonce = randomBytes(32).toString('base64url'); await this.redis.set(`${NONCE_REDIS_PREFIX}:${nonce}`, publicKey, 'EX', 3600); return nonce; } private prepareAuthMessage(nonce: string) { return [ `Sign this message to sign in to Funki.`, '', 'This request will not trigger a blockchain transaction or cost any gas fees.', '', `Nonce: ${nonce}`, ].join('\n'); } private prepareUnlinkMessage(nonce: string) { return [ `Sign this message to unlink your wallet from your Funki ID.`, '', 'This request will not trigger a blockchain transaction or cost any gas fees.', '', `Nonce: ${nonce}`, ].join('\n'); } async generateMessage(publicKey: string, type: 'auth' | 'unlink'): Promise { const nonce = await this.generateNonce(publicKey); const message = type === 'auth' ? this.prepareAuthMessage(nonce) : this.prepareUnlinkMessage(nonce); return { message, nonce }; } async validateSignature(msg: EvmValidateAuthDto, type: 'auth' | 'unlink') { const { nonce, signature, publicKey } = msg; await this.validateNonce(nonce, publicKey); const message = type === 'auth' ? this.prepareAuthMessage(nonce) : this.prepareUnlinkMessage(nonce); const valid = await publicClient.verifyMessage({ address: publicKey as `0x${string}`, message: message, signature: signature as `0x${string}`, }); return valid; } async validateNonce(nonce: string, publicKey: string) { const storePublicKey = await this.redis.get(`${NONCE_REDIS_PREFIX}:${nonce}`); if (!publicKey || publicKey !== storePublicKey) { throw new UnauthorizedException('Invalid nonce'); } await this.redis.del(`${NONCE_REDIS_PREFIX}:${nonce}`); return publicKey; } async authenticateWithSignature(authUser: User | null, msg: EvmValidateAuthDto) { const match = await this.validateSignature(msg, 'auth'); if (!match) { throw new UnauthorizedException('Invalid signature'); } const { publicKey, provider } = msg; let userUid: string | null = authUser?.uid ?? null; if (!userUid) { const wallet = await this.walletRepository.findOne({ where: { address: publicKey, }, relations: ['user'], }); if (!wallet) { throw new UnauthorizedException('Wallet not found'); } userUid = wallet.user.uid; } else { // Check if user already has a linked wallet const existingWallet = await this.walletRepository.findOne({ where: { userUid }, }); // One user can only have one wallet for NOW if (existingWallet) { throw new UnauthorizedException('User already has a linked wallet'); } // Check if wallet is already linked to another user const wallet = await this.walletRepository.findOne({ where: { address: publicKey, userUid: Not(userUid) }, }); if (wallet) { throw new UnauthorizedException('Wallet already linked to another user'); } // Save new wallet for this user await this.walletRepository.save({ address: publicKey, userUid, provider, }); } const customToken = await this.auth.createCustomToken(userUid, { wallet_pub: publicKey, }); return { customToken }; } async unlinkWallet(authUser: User, msg: EvmValidateAuthDto) { // Validate the signature const valid = await this.validateSignature(msg, 'unlink'); if (!valid) { throw new UnauthorizedException('Invalid signature'); } const { publicKey } = msg; // Find the wallet associated with the public key const wallet = await this.walletRepository.findOne({ where: { address: publicKey, userUid: authUser.uid }, }); if (!wallet) { throw new UnauthorizedException('Wallet not found'); } // Remove the wallet await this.walletRepository.remove(wallet); return true; } }