import type ThresholdKey from '@tkey/core' import type TorusServiceProvider from '@tkey/service-provider-torus' import type { ReconstructedKeyResult, ShareStore, TorusServiceProviderArgs } from '@tkey/common-types' import type SeedPhraseModule from '@tkey/seed-phrase' import type SecurityQuestionsModule from '@tkey/security-questions' import type ShareTransferModule from '@tkey/share-transfer' import type WebStorageModule from '@tkey/web-storage' import type ShareSerialization from '@tkey/share-serialization' import type { Parser } from 'bowser' import Bowser from 'bowser' import type { WalletClient } from 'viem' import { createWalletClient, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from '@wagmi/chains' import type AuthcClient from './AuthcClient' import { singlePromise } from './promise-utils' import { retryOnFailure } from './utils' const TKEY_CDN_LIST = ['https://www.unpkg.com/@tkey/default@7.3.0/dist/default.umd.min.js', 'https://www.unpkg.com/@tkey/seed-phrase@7.3.0/dist/seedPhrase.umd.min.js', 'https://www.unpkg.com/@tkey/web-storage@7.3.0/dist/webStorage.umd.min.js', 'https://www.unpkg.com/@tkey/security-questions@7.3.0/dist/securityQuestions.umd.min.js'] interface ErrorResponse { status: number message: string error: Error } interface SuccessResponse { status: 0 message: 'OK' data: any } type Response = SuccessResponse | ErrorResponse const createSuccessResponse = (data: any): SuccessResponse => { return { status: 0, message: 'OK', data, } } const createErrorResponse = (error: any, status: number): ErrorResponse => { return { status, message: error?.message || 'Error', error, } } export interface TKeyOptions { verifier: string directParams: TorusServiceProviderArgs['customAuthArgs'] } export interface InputSharesOptions { /** * @default true */ device?: boolean password?: string backupPhrase?: string } export default class TKey { directParams: TorusServiceProviderArgs['customAuthArgs'] options: TKeyOptions thresholdKey: ThresholdKey serviceProvider: TorusServiceProvider seedPhrase: SeedPhraseModule securityQuestions: SecurityQuestionsModule shareTransfer: ShareTransferModule shareSerialization: ShareSerialization webStorage: WebStorageModule isReconstruct: boolean private eventTarget = new EventTarget() initializePromise: Promise isLogin = false question: 'WALLET_QUESTION_YES' initialize = false walletCache = new Map() constructor(public authc: AuthcClient, options: TKeyOptions) { this.directParams = options.directParams || { baseUrl: window.location.origin, enableLogging: true, networkUrl: 'https://rpc.ankr.com/eth_ropsten', network: 'testnet' as any, redirectPathName: '', } this.options = options this.initializePromise = this.injectScript().then(async () => { return this.init() }).catch((err) => { console.error('Load tkey cdn failed, Please check your network and refresh the page.', err) throw err }) } async injectScript( cdnList = TKEY_CDN_LIST, ) { return Promise.all(cdnList.map(async (url) => { const loadsScript = async () => { const script = document.body.appendChild(document.createElement('script')) script.defer = true return script } return retryOnFailure(() => new Promise(async (resolve, reject) => { const script = await loadsScript() script.onload = () => { resolve(true) } script.onerror = (failed) => { reject(failed) console.error(failed, url) } script.src = url })) })) } private async init() { const { SeedPhraseModule, MetamaskSeedPhraseFormat } = (window as any).SeedPhrase const { default: SecurityQuestionModule } = (window as any).SecurityQuestions const { default: WebStorageModule } = (window as any).WebStorage const seedPhraseModule = new SeedPhraseModule([new MetamaskSeedPhraseFormat(this.directParams.networkUrl)]) const webStorageModule = new WebStorageModule() const securityQuestionsModule = new SecurityQuestionModule() this.thresholdKey = new (window as any).Default.default({ customAuthArgs: this.directParams, modules: { seedPhrase: seedPhraseModule, securityQuestions: securityQuestionsModule, webStorage: webStorageModule }, }) this.serviceProvider = this.thresholdKey.serviceProvider as any this.seedPhrase = this.thresholdKey.modules.seedPhrase as any this.securityQuestions = this.thresholdKey.modules.securityQuestions as any this.shareTransfer = this.thresholdKey.modules.shareTransfer as any this.webStorage = this.thresholdKey.modules.webStorage as any this.shareSerialization = this.thresholdKey.modules.shareSerialization as any await this.serviceProvider.init({ skipInit: true, }) } private dispatchEvent(name: string, data?: any) { const event = new CustomEvent(`tkey:${name}`, { detail: data, }) this.eventTarget.dispatchEvent(event) } listen(name: 'reconstruct', callback?: (data: ReconstructedKeyResult) => void, options?: AddEventListenerOptions): any listen(name: string, callback?: (...args: any) => void, options?: AddEventListenerOptions) { const eventName = `tkey:${name}` const eventCallback = (event: any) => { callback?.(event) } this.eventTarget.addEventListener(eventName, eventCallback, options) return () => { this.eventTarget.removeEventListener(eventName, eventCallback) } } private async getUserShareKey() { const openid = (await this.authc.getUser() as any)?.openid if (!openid) return null return `${openid}_` + 'AUTHC_TKEY_USER_SHARE' } private async getUserShare() { const share = sessionStorage.getItem(await this.getUserShareKey()) return share ? JSON.parse(share) : null } private async setUserShare() { // console.log(this.securityQuestions.getSecurityQuestions()) // this.securityQuestions.saveAnswers = !!this.securityQuestions.getSecurityQuestions() const userShare = await this.thresholdKey.outputShareStore('1') as ShareStore sessionStorage.setItem(await this.getUserShareKey(), JSON.stringify(userShare)) } async setSaveAnswer() { const list = await this.getShareDescription() if (list.some(item => item.module === 'securityQuestions')) this.securityQuestions.saveAnswers = true } async getShareDescription(): Promise<{ shareIndex: string uaInfo: Parser.ParsedResult module: string userAgent: string description: string }[]> { await this.initializePromise const keyDetails = await this.thresholdKey.getKeyDetails() const descriptions = keyDetails.shareDescriptions || {} let deviceShareIndex: any try { const deviceShare = await this.webStorage.getDeviceShare() deviceShareIndex = deviceShare.share.shareIndex.toString('hex') } catch { } return Object.entries(descriptions).map(([shareIndex, description]) => { const info = JSON.parse(description[0]) return { shareIndex, description, current: deviceShareIndex === shareIndex, uaInfo: info.userAgent ? Bowser.parse(info.userAgent) : null, ...info, } }) } async generateDeviceShare() { if (!this.isLogin) return try { await this.webStorage.getDeviceShare() } catch (err) { if (err.message.includes('No share exists in localstorage')) { let shareStore: ShareStore const descriptionList = await this.getShareDescription() const userAgent = navigator.userAgent const description = descriptionList.find((item) => { if (item.module === 'webStorage' && item.userAgent === userAgent) return item return false }) if (description) { shareStore = this.thresholdKey.outputShareStore(description.shareIndex) } else { const { newShareStores, newShareIndex } = await this.thresholdKey.generateNewShare() shareStore = newShareStores[newShareIndex.toString('hex')] } await this.thresholdKey.storeDeviceShare(shareStore) } } } async getSeedPhrases() { return singlePromise(async () => { if (!this.isLogin || !this.isReconstruct) return let seedPhrase = await this.seedPhrase.getSeedPhrasesWithAccounts() if (!seedPhrase.length) { await this.seedPhrase.setSeedPhrase('HD Key Tree') seedPhrase = await this.seedPhrase.getSeedPhrasesWithAccounts() } return seedPhrase.map((item) => { return { ...item, keys: item.keys.map(k => `0x${k.toString(16)}`), } }) }, 'getSeedPhrases') } async getAccounts(): Promise> { const accounts = await this.seedPhrase.getAccounts() return accounts.map((privateKey) => { const pk = `0x${privateKey.toString(16)}` const wallet = this.walletCache.get(pk) || this.walletCache.set(pk, createWalletClient({ account: privateKeyToAccount(pk as '0x'), transport: http(mainnet.rpcUrls.public.http[0]), }), ).get(pk) return { wallet, address: (wallet as any).account.address, privateKey: pk, } }) } async reconstruct(needInitialize = true, options?: InputSharesOptions) { await this.initializePromise if (needInitialize && !this.initialize) { await this.thresholdKey.initialize({ }).catch((err) => { this.clean() throw err }) } if (this.isReconstruct) { if (!this.securityQuestions.saveAnswers) await this.setSaveAnswer() return createSuccessResponse(this.thresholdKey.getKeyDetails()) } const requiredSharesGreaterThan0 = () => this.thresholdKey.getKeyDetails().requiredShares > 0 if (options && requiredSharesGreaterThan0()) { const tasks = await this.inputShares(options) if (requiredSharesGreaterThan0()) { const rejectedTask = tasks.find(task => task.status === 'rejected') as PromiseRejectedResult throw createErrorResponse(rejectedTask.reason, 10201) } } return this.thresholdKey.reconstructKey().then(async (res) => { this.isReconstruct = true await this.generateDeviceShare() await this.setSaveAnswer() this.dispatchEvent('reconstruct', res) return createSuccessResponse(options) }).catch((err) => { if (err.message.includes('Custom key has not been generated yet')) return createErrorResponse(err, 10101) if (this.isLogin) return createErrorResponse(err, 10201) return createErrorResponse(err, 10001) }) } async triggerLogin(): Promise { if (this.isLogin && this.isReconstruct) { return Promise.resolve( createSuccessResponse({}), ) } return this.initializePromise.then(async () => { const tokens = await this.authc.getTokenSilently({ detailedResponse: true, ignoreCache: true }) return this.serviceProvider.triggerLogin({ typeOfLogin: 'jwt', verifier: this.options.verifier, clientId: 'DO_NOT_NEED', jwtParams: { verifierIdField: 'openid', domain: `https://${this.authc.options.domain}`, id_token: tokens.id_token, }, }).then(async (res: any) => { this.isLogin = true try { await this.thresholdKey.initialize({ neverInitializeNewKey: true }) this.initialize = true } catch { return createErrorResponse(res, 10101) } try { return await this.reconstruct(false, { device: true }) } catch (err) { if (err?.status && err?.message) return err return createErrorResponse(err, 10201) } }) }) } async getBackupPhraseDescription() { const list = await this.getShareDescription() return list.find(item => item.module === 'comsand:email') } async generateBackupPhrase() { if (!this.isLogin) return await this.deleteBackupPhrase() const { newShareIndex } = await this.thresholdKey.generateNewShare() await this.thresholdKey.addShareDescription( newShareIndex.toString('hex'), JSON.stringify({ module: 'comsand:email', dateAdded: Date.now() }), ) return this.thresholdKey.outputShare(newShareIndex, 'mnemonic') as Promise } async deleteBackupPhrase() { const shareDescription = await this.getBackupPhraseDescription() if (shareDescription) await this.thresholdKey.deleteShare(shareDescription.shareIndex) } async inputShares(options: InputSharesOptions = {}) { const tasks: Promise[] = [] if (options.device !== false) tasks.push(this.webStorage.inputShareFromWebStorage()) if (options.backupPhrase) tasks.push(this.thresholdKey.inputShare(options.backupPhrase, 'mnemonic')) if (options.password) tasks.push(this.securityQuestions.inputShareFromSecurityQuestions(options.password)) const userShare = await this.getUserShare() if (userShare) this.thresholdKey.inputShareStore(userShare) return Promise.allSettled(tasks) } getShareIndex = (shareStore: ShareStore) => { return shareStore.share.shareIndex.toString('hex') } async configureSecurityQuestions(password: string) { if (!this.securityQuestions.saveAnswers) { try { const res = await this.securityQuestions.generateNewShareWithSecurityQuestions(password, this.question) this.securityQuestions.saveAnswers = true return res } catch (err) { console.log('generate error, may be have security questions, fallback to changeSecurityQuestionAndAnswer', err) } } return this.securityQuestions.changeSecurityQuestionAndAnswer(password, this.question) } async requestNewShare() { return singlePromise(async () => { return new Promise(async (resolve, reject) => { const encPubKey = await this.shareTransfer.requestNewShare( navigator.userAgent, this.thresholdKey.getCurrentShareIndexes(), async (err, shareStore) => { if (err) return reject(err) try { await this.thresholdKey.inputShareStore(shareStore) await this.reconstruct(false) await this.shareTransfer.deleteShareTransferStore(encPubKey) resolve(shareStore) } catch (err) { reject(err) } }, ) return encPubKey }) }, 'requestNewShare') } async clean() { sessionStorage.removeItem(await this.getUserShareKey()) this.isReconstruct = false } }