import { PortalMobileMpcMetadata } from '@portal-hq/provider/types' import { CHAIN_NAMESPACES, type ChainNamespace, DEFAULT_HOSTS, Events, KeychainAdapter, MpcStatuses, NAMESPACE_TO_CURVE, PortalError, type ProgressCallback, Storage, generateTraceId, sdkLogger, } from '@portal-hq/utils' import { PortalMpcError, getClientPlatformVersion } from '@portal-hq/utils' import { AddressesByNamespace, type BackupConfigs, GenerateResponse, Presignature, Shares, } from '@portal-hq/utils/types' import { type BackupOptions, type DecryptResult, type EjectResult, type EjectedKeys, type EncryptResult, type EncryptResultWithPassword, type EncryptedData, type EncryptedDataWithPassword, type GenerateResult, type PortalMpcOptions, type RotateResult, orgShares, } from '../../types' import type { Spec as PortalMobileMpcSpec } from '../NativePortalMobileMpc' import Portal from '../index' import PortalMobileMpcModule from './NativeModule' import MpcError, { MpcErrorCodes } from './errors' export { MpcError, MpcErrorCodes } from './errors' export enum BackupMethods { Custom = 'custom', Firebase = 'firebase', GoogleDrive = 'gdrive', Password = 'password', Passkey = 'passkey', Unknown = 'unknown', iCloud = 'icloud', } export enum BackupMethodsUpperCase { Custom = 'CUSTOM', Firebase = 'FIREBASE', GoogleDrive = 'GDRIVE', Password = 'PASSWORD', Passkey = 'PASSKEY', Unknown = 'UNKNOWN', iCloud = 'ICLOUD', } export enum PortalCurve { ED25519 = 'ED25519', SECP256K1 = 'SECP256K1', } export enum PortalSharePairStatus { COMPLETED = 'completed', INCOMPLETE = 'incomplete', } export enum PortalNamespace { eip155 = 'eip155', solana = 'solana', } // --------------------------------------------------------------------------- // Presignature configuration (per-curve for future multi-curve support) // --------------------------------------------------------------------------- /** Default max presignatures per curve. Only SECP256K1 uses presignatures today; ED25519 can be added when supported. */ const DEFAULT_MAX_PRESIGNATURES_PER_CURVE: Partial< Record > = { [PortalCurve.SECP256K1]: 3, } // Expiration is left blank here; the MPC binary defaults to 1 year // --------------------------------------------------------------------------- // Presignature retry policy (bounded exponential backoff) // --------------------------------------------------------------------------- // Policy: delay = min(base * multiplier^attempt, maxDelay); max 3 attempts; stop if total window > 5 min. // Example with PRESIGNATURE_RETRY_MAX_ATTEMPTS = 3 (1 initial + 2 retries): 2s → 4s (capped at 30s per attempt). /** Base delay in ms before first retry (attempt 0 = 2s). */ const PRESIGNATURE_RETRY_BASE_DELAY_MS = 2000 /** Multiplier for exponential backoff: delay = base * (multiplier ^ attempt), capped at max. */ const PRESIGNATURE_RETRY_MULTIPLIER = 2 /** Cap on delay between retries (30 seconds per attempt). */ const PRESIGNATURE_RETRY_MAX_DELAY_MS = 30 * 1000 /** Maximum total time for all retries (5 minutes); stop retrying if exceeded. */ const PRESIGNATURE_RETRY_MAX_WINDOW_MS = 5 * 60 * 1000 /** Maximum number of attempts (3 = 1 initial + 2 retries), then give up. */ const PRESIGNATURE_RETRY_MAX_ATTEMPTS = 3 class PortalMpc { public apiHost: string public apiKey: string public backupOptions: BackupOptions public host: string public isSimulator: boolean public isWalletModificationInProgress = false public keychain: KeychainAdapter public mpc: PortalMobileMpcSpec public portal: Portal public ready = false public version: string /** Prevents concurrent fill for the same curve (skip if already filling). */ private presignatureBufferLocks: Map = new Map() /** * Incremented on recover() and reset() so in-flight presigns (started before clear) do not * insert stale presignatures. Before insert we check generation; if it changed, we discard. */ private presignatureGeneration = 0 /** * Per-curve mutex for all presignature keychain read-modify-write. With JSI (new arch), * keychain calls are no longer serialized by the bridge, so insertPresignature (fill) and * popOldestPresignature (consume) could race on the same key — this lock serializes them. */ private presignatureKeychainMutexTails: Map> = new Map() constructor({ // Required apiKey, keychain, portal, backup, // Optional isSimulator = false, host = DEFAULT_HOSTS.MPC, version = 'v6', apiHost = DEFAULT_HOSTS.API, }: PortalMpcOptions) { this.apiKey = apiKey this.backupOptions = backup this.host = host this.isSimulator = isSimulator this.keychain = keychain this.portal = portal this.version = version this.apiHost = apiHost.startsWith('localhost:') ? `http://${apiHost}` : `https://${apiHost}` this.mpc = PortalMobileMpcModule as PortalMobileMpcSpec if (!this.mpc) { throw new MpcError(MpcErrorCodes.MPC_MODULE_NOT_FOUND) } } public async backup( backupMethod: BackupMethods, progress: ProgressCallback = () => { // Noop }, backupConfig: BackupConfigs = {}, ): Promise { if (this.version !== 'v6') { throw new MpcError(MpcErrorCodes.UNSUPPORTED_MPC_VERSION, 'must be v6') } if (this.isWalletModificationInProgress) { throw new MpcError(MpcErrorCodes.WALLET_MODIFICATION_ALREADY_IN_PROGRESS) } const client = await this.portal.api.getClient() if (!client?.wallets?.length) { throw new MpcError(MpcErrorCodes.CLIENT_NOT_VERIFIED) } this.isWalletModificationInProgress = true try { const storage = this.backupOptions[backupMethod] as Storage if (!storage) { throw new MpcError( MpcErrorCodes.UNSUPPORTED_STORAGE_METHOD, backupMethod, ) } await this.validate({ storage }) await progress({ status: MpcStatuses.ReadingShare, done: false, }) const signingShares = await this.keychain.getShares(progress) if (!signingShares) { throw new MpcError(MpcErrorCodes.UNABLE_TO_READ_SIGNING_STORAGE) } try { // Handle Google Auth before running backup to avoid lost backup shares if (backupMethod === BackupMethods.GoogleDrive) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call await (storage as any).assignAccessToken() if (!(storage as any).drive.accessToken) { throw new MpcError(MpcErrorCodes.GOOGLE_UNAUTHENTICATED) } } else if ( backupMethod === BackupMethods.Password && (!backupConfig.passwordStorage?.password || backupConfig.passwordStorage.password.trim() === '') ) { throw new MpcError(MpcErrorCodes.PASSWORD_REQUIRED) } // Send progress update await progress({ status: MpcStatuses.GeneratingShare, done: false, }) const traceId = generateTraceId() sdkLogger.info( `[Portal MPC] backup started | backupMethod=${backupMethod} | traceId=${traceId}`, ) const metadata: PortalMobileMpcMetadata = { backupMethod: this.deriveBackupMethod(backupMethod), clientPlatform: 'REACT_NATIVE', clientPlatformVersion: getClientPlatformVersion(), isMultiBackupEnabled: this.portal.featureFlags.isMultiBackupEnabled, mpcServerVersion: this.version, optimized: true, reqId: traceId, } const stringifiedMetadata = JSON.stringify(metadata) let resSecp256k1: string let resEd25519: string | undefined // Check for the presence of ed25519 and execute the corresponding mpc backups if (signingShares.ed25519) { // eslint-disable-next-line no-extra-semi ;[resSecp256k1, resEd25519] = await Promise.all([ this.mpc.backupSecp256k1( this.apiKey, this.host, JSON.stringify(signingShares.secp256k1.share), this.apiHost, stringifiedMetadata, ), this.mpc.backupEd25519( this.apiKey, this.host, JSON.stringify(signingShares.ed25519.share), this.apiHost, stringifiedMetadata, ), ]) } else { resSecp256k1 = await this.mpc.backupSecp256k1( this.apiKey, this.host, JSON.stringify(signingShares.secp256k1.share), this.apiHost, stringifiedMetadata, ) } await progress({ status: MpcStatuses.ParsingShare, done: false, }) const backupResSecp256k1 = this.parseMpcResult( resSecp256k1, ) as RotateResult let backupResEd25519: RotateResult | undefined if (resEd25519) { backupResEd25519 = this.parseMpcResult(resEd25519) as RotateResult } const shares: Shares = { secp256k1: { id: backupResSecp256k1.data.share.backupSharePairId, share: backupResSecp256k1.data.share, } as GenerateResponse, } if (backupResEd25519) { shares.ed25519 = { id: backupResEd25519.data.share.backupSharePairId, share: backupResEd25519.data.share, } as GenerateResponse } let encryptedShares: EncryptedData | EncryptedDataWithPassword if (backupMethod === BackupMethods.Password) { const password = backupConfig.passwordStorage!.password encryptedShares = await this.encryptSharesWithPassword( JSON.stringify(shares), password, progress, ) await this.portal.api.storedClientBackupSharesKey( [shares.secp256k1.id, shares.ed25519?.id].filter( Boolean, ) as string[], ) await progress({ status: MpcStatuses.Done, done: true, }) this.isWalletModificationInProgress = false const shareIds = [shares.secp256k1.id] if (shares.ed25519) { shareIds.push(shares.ed25519.id) } if (client.environment?.backupWithPortalEnabled) { for (const id of shareIds) { const stored = await this.portal.api.storeClientCipherText( id, encryptedShares.cipherText, ) if (!stored) { throw new MpcError(MpcErrorCodes.UNEXPECTED_ERROR) } } } return encryptedShares.cipherText } else { encryptedShares = await this.encryptShares( JSON.stringify(shares), progress, ) await progress({ status: MpcStatuses.StoringShare, done: false, }) await storage.write((encryptedShares as EncryptedData).privateKey) await this.portal.api.storedClientBackupSharesKey( [shares.secp256k1.id, shares.ed25519?.id].filter( Boolean, ) as string[], ) await progress({ status: MpcStatuses.Done, done: true, }) this.isWalletModificationInProgress = false const shareIds = [shares.secp256k1.id] if (shares.ed25519) { shareIds.push(shares.ed25519.id) } if (client.environment?.backupWithPortalEnabled) { for (const id of shareIds) { const stored = await this.portal.api.storeClientCipherText( id, encryptedShares.cipherText, ) if (!stored) { throw new MpcError(MpcErrorCodes.UNEXPECTED_ERROR) } } } return encryptedShares.cipherText } } catch (error) { if (error instanceof PortalMpcError) { throw error } throw new MpcError(MpcErrorCodes.UNEXPECTED_ERROR, `backup: ${error}`) } } catch (error) { this.isWalletModificationInProgress = false throw error } } public async generate( progress: ProgressCallback = () => { // Noop }, ): Promise { await this.validate({ keychain: this.keychain }) if (this.isWalletModificationInProgress) { throw new MpcError(MpcErrorCodes.WALLET_MODIFICATION_ALREADY_IN_PROGRESS) } this.isWalletModificationInProgress = true try { // Send progress update await progress({ status: MpcStatuses.GeneratingShare, done: false, }) const traceId = generateTraceId() sdkLogger.info(`[Portal MPC] generate started | traceId=${traceId}`) const metadata = JSON.stringify({ clientPlatform: 'REACT_NATIVE', clientPlatformVersion: getClientPlatformVersion(), isMultiBackupEnabled: this.portal.featureFlags.isMultiBackupEnabled, mpcServerVersion: this.version, optimized: true, reqId: traceId, }) const [resSecp256k1, resEd25519] = await Promise.all([ this.mpc.generateSecp256k1( this.apiKey, this.host, this.apiHost, metadata, ), this.mpc.generateEd25519( this.apiKey, this.host, this.apiHost, metadata, ), ]) // Send progress update await progress({ status: MpcStatuses.ParsingShare, done: false, }) const genResSecp256k1 = this.parseMpcResult(resSecp256k1) const genResEd25519 = this.parseMpcResult(resEd25519) // Send progress update await progress({ status: MpcStatuses.StoringShare, done: false, }) // Confirm that the signingSharePairId is present. const signingSharePairIdSecp256k1 = genResSecp256k1.data.share .signingSharePairId as string if (!signingSharePairIdSecp256k1) { throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, `Generate: Unable to read signingSharePairId from share`, ) } // Confirm that the signingSharePairId is present. const signingSharePairIdEd25519 = genResEd25519.data.share .signingSharePairId as string if (!signingSharePairIdEd25519) { throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, `Generate: Unable to read signingSharePairId from share`, ) } const shares = { secp256k1: { id: genResSecp256k1.data.share.signingSharePairId, share: genResSecp256k1.data.share, } as GenerateResponse, ed25519: { id: genResEd25519.data.share.signingSharePairId, share: genResEd25519.data.share, } as GenerateResponse, } as Shares await this.keychain.storeShares(shares) await this.keychain.loadMetadata() // Send stored client signing share await this.portal.api.storedClientSigningShares([ signingSharePairIdSecp256k1, signingSharePairIdEd25519, ]) const addresses = await this.keychain.getAddresses() try { await this.portal.api.track(Events.WalletCreated, { address: addresses.eip155, }) } catch (error) { // Do nothing because we don't want metrics gathering to block // MPC operations } // Send progress update await progress({ status: MpcStatuses.Done, done: true, }) this.isWalletModificationInProgress = false await this.keychain.storeAddress(addresses.eip155, this.isSimulator) // Mark wallet ready so initializePresignatureBuffers() (called next) passes isReady() check. // Otherwise isReady() can return cached false from before the wallet existed. this.ready = true this.initializePresignatureBuffers().catch((error) => { sdkLogger.warn( '[PortalMpc] Failed to initialize presignature buffers after wallet creation:', error, ) }) // get the addresses from the keychain return addresses } catch (error) { this.isWalletModificationInProgress = false if (error instanceof PortalMpcError) { throw error } throw new MpcError(MpcErrorCodes.UNEXPECTED_ERROR, `generate: ${error}`) } } /** Internal-only: do not call directly. Pre-signatures are created/replenished by the SDK; use portal.request() or portal.sendAsset() for signing. */ public async preSign(params: { chainId?: string metadata?: PortalMobileMpcMetadata }): Promise<{ id: string expiresAt: string data: string }> { const { share } = await this.getShareForChain(params.chainId) const metadata: PortalMobileMpcMetadata = { reqId: params?.metadata?.traceId ?? generateTraceId(), clientPlatform: 'REACT_NATIVE', clientPlatformVersion: getClientPlatformVersion(), mpcServerVersion: this.version, optimized: true, ...params.metadata, } try { const res = await this.mpc.presign( this.apiKey, this.host, JSON.stringify(share.share), JSON.stringify(metadata), ) interface PreSignResponse { id: string expiresAt: string data: string error?: PortalError } const parsed: PreSignResponse = JSON.parse(res) if (parsed.error && (parsed.error.id || parsed.error.code > 0)) { throw new PortalMpcError(parsed.error) } return { id: parsed.id, expiresAt: parsed.expiresAt, data: parsed.data, } } catch (error) { if (error instanceof PortalMpcError) throw error throw new MpcError(MpcErrorCodes.UNEXPECTED_ERROR, `preSign: ${error}`) } } /** Internal-only: do not call directly. Used by the signer when a presignature is available during portal.request() or portal.sendAsset() signing. */ public async signWithPreSignature(params: { presignatureData: string method: string params: string rpcURL: string chainId: string metadata?: PortalMobileMpcMetadata }): Promise<{ hash: string }> { const { share } = await this.getShareForChain(params.chainId) const metadata: PortalMobileMpcMetadata = { reqId: params?.metadata?.traceId ?? generateTraceId(), clientPlatform: 'REACT_NATIVE', clientPlatformVersion: getClientPlatformVersion(), mpcServerVersion: this.version, optimized: true, ...params.metadata, } try { const res = await this.mpc.signWithPresignature( this.apiKey, this.host, JSON.stringify(share.share), params.presignatureData, params.method, params.params, params.rpcURL, params.chainId, JSON.stringify(metadata), ) interface SignWithPreSignatureResponse { /** Hash/signature from native (SigningResponse shape) */ data?: string /** Legacy field name some binaries may return */ hash?: string error?: PortalError } const parsed: SignWithPreSignatureResponse = JSON.parse(res) if (parsed.error && (parsed.error.id || parsed.error.code > 0)) { throw new PortalMpcError(parsed.error) } // Native/binary returns SigningResponse with hash in "data"; support both "data" and "hash" const hash = parsed.data ?? parsed.hash if (hash == null || hash === '') { sdkLogger.warn( '[PortalMpc] signWithPreSignature: native response missing hash/data', { parsedKeys: Object.keys(parsed) }, ) throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, 'signWithPreSignature: native response missing hash (expected "data" or "hash" field)', ) } return { hash, } } catch (error) { if (error instanceof PortalMpcError) throw error throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, `signWithPreSignature: ${error}`, ) } } /** * Helper method to validate and retrieve the share for a given chainId */ private async getShareForChain(chainId?: string): Promise<{ share: { id: string; share: any } curve: string namespace: ChainNamespace }> { const signingShares = await this.keychain.getShares() if (!signingShares) { throw new MpcError(MpcErrorCodes.UNABLE_TO_READ_SIGNING_STORAGE) } const namespace = (chainId?.split(':')[0].toLowerCase() ?? CHAIN_NAMESPACES.EIP155) as ChainNamespace const curve = NAMESPACE_TO_CURVE[namespace] if (!curve) { throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, `Unsupported chain namespace: ${namespace}`, ) } const isEd25519 = curve === 'ED25519' const share = isEd25519 ? signingShares.ed25519 : signingShares.secp256k1 if (!share) { throw new MpcError( MpcErrorCodes.UNABLE_TO_READ_SIGNING_STORAGE, `No ${curve.toLowerCase()} share found for namespace: ${namespace}`, ) } return { share, curve, namespace, } } private parseMpcResult(result: string): GenerateResult { const parsedResult = JSON.parse(result) as GenerateResult if ( parsedResult.error && (parsedResult.error.id || parsedResult.error.code > 0) ) { throw new PortalMpcError(parsedResult.error) } return parsedResult } public async isReady() { if (typeof this.ready !== 'undefined') { return this.ready } try { const addresses = await this.keychain.getAddresses() this.ready = !!addresses?.eip155 } catch (err) { this.ready = false } return this.ready } public async recover( cipherText: string, backupMethod: BackupMethods, progress: ProgressCallback = () => { // Noop }, backupConfig: BackupConfigs = {}, ): Promise { if (this.version !== 'v6') { throw new MpcError(MpcErrorCodes.UNSUPPORTED_MPC_VERSION, 'must be v6') } if (this.isWalletModificationInProgress) { throw new MpcError(MpcErrorCodes.WALLET_MODIFICATION_ALREADY_IN_PROGRESS) } const clientCipherText = await this.getClientCiphertextFromClient( backupMethod, cipherText, ) this.isWalletModificationInProgress = true try { const storage = this.backupOptions[backupMethod] as Storage if (!storage) { throw new MpcError( MpcErrorCodes.UNSUPPORTED_STORAGE_METHOD, backupMethod, ) } if ( backupMethod === BackupMethods.Password && (!backupConfig.passwordStorage?.password || backupConfig.passwordStorage.password.trim() === '') ) { throw new MpcError(MpcErrorCodes.PASSWORD_REQUIRED) } // Parse and format the backup shares const backupShares = await this.formatShares( clientCipherText, backupMethod, backupConfig, ) // Progress update await progress({ status: MpcStatuses.RecoveringSigningShare, done: false, }) // Get the client data const client = await this.portal.api.getClient() if (!client || !client.wallets || client.wallets.length === 0) { throw new MpcError(MpcErrorCodes.CLIENT_NOT_VERIFIED) } // Progress update await progress({ status: MpcStatuses.GeneratingShare, done: false, }) const traceId = generateTraceId() sdkLogger.info( `[Portal MPC] recover started | backupMethod=${backupMethod} | traceId=${traceId}`, ) try { const metadata = JSON.stringify({ backupMethod: this.deriveBackupMethod(backupMethod), clientPlatform: 'REACT_NATIVE', clientPlatformVersion: getClientPlatformVersion(), isMultiBackupEnabled: this.portal.featureFlags.isMultiBackupEnabled, mpcServerVersion: this.version, optimized: true, reqId: traceId, }) let resSecp256k1: string let resEd25519: string | undefined // Check for the presence of ed25519 and execute the corresponding recover methods if (backupShares.ED25519) { // eslint-disable-next-line no-extra-semi ;[resSecp256k1, resEd25519] = await Promise.all([ this.mpc.recoverSigningSecp256k1( this.apiKey, this.host, backupShares.SECP256K1.share, this.apiHost, metadata, ), this.mpc.recoverSigningEd25519( this.apiKey, this.host, backupShares.ED25519.share, this.apiHost, metadata, ), ]) } else { resSecp256k1 = await this.mpc.recoverSigningSecp256k1( this.apiKey, this.host, backupShares.SECP256K1.share, this.apiHost, metadata, ) } await progress({ status: MpcStatuses.ParsingShare, done: false, }) const genResSecp256k1 = JSON.parse(resSecp256k1) as RotateResult if ( genResSecp256k1.error && (genResSecp256k1.error.id || genResSecp256k1.error.code > 0) ) { throw new PortalMpcError(genResSecp256k1.error) } let genResEd25519: RotateResult | undefined if (resEd25519) { genResEd25519 = JSON.parse(resEd25519) as RotateResult if ( genResEd25519.error && (genResEd25519.error.id || genResEd25519.error.code > 0) ) { throw new PortalMpcError(genResEd25519.error) } } await progress({ status: MpcStatuses.StoringShare, done: false, }) const shares: Shares = { secp256k1: { id: genResSecp256k1.data.share.signingSharePairId, share: genResSecp256k1.data.share, } as GenerateResponse, ed25519: genResEd25519 ? ({ id: genResEd25519.data.share.signingSharePairId, share: genResEd25519.data.share, } as GenerateResponse) : undefined, } // Confirm that the signingSharePairId is present if ( !shares.secp256k1.share.signingSharePairId || (shares.ed25519 && !shares.ed25519.share.signingSharePairId) ) { throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, 'recoverSigning: Unable to read signingSharePairId from share', ) } await this.keychain.storeShares(shares) await this.keychain.loadMetadata() // Notify Portal that the signing share has been stored on the client await this.portal.api.storedClientSigningShares( [shares.secp256k1.id, shares.ed25519?.id].filter(Boolean) as string[], ) } catch (error) { if (error instanceof PortalMpcError) throw error throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, `recoverSigning: ${error}`, ) } // Attempt to send the analytics event. try { await this.portal.api.track(Events.WalletRecovered, { backupMethod, }) } catch (error) { // Do nothing because we don't want metrics gathering to block MPC operations. } // Send progress update await progress({ status: MpcStatuses.Done, done: true, }) // Get the address const addresses = await this.keychain.getAddresses() // Mark wallet ready so initializePresignatureBuffers() passes isReady() check. this.ready = true // Presignatures are bound to the signing share pair ID. After recover we have NEW shares, // so any presignatures stored in keychain (from before recover) are invalid and must be // cleared. Increment generation so any in-flight presign (from before recover) will skip // insert when it completes, avoiding "signing share pair ID doesn't match" on next sign. this.presignatureGeneration++ await this.deleteAllPresignatures() this.initializePresignatureBuffers().catch((error) => { sdkLogger.warn( '[PortalMpc] Failed to initialize presignature buffers after recovery:', error, ) }) // End the wallet modification process this.isWalletModificationInProgress = false return addresses } catch (error) { this.isWalletModificationInProgress = false throw error } } private async getClientCiphertextFromClient( backupMethod: BackupMethods, ciphertext: string, ): Promise { const client = await this.portal.api.getClient() if (!client?.wallets?.length) { throw new MpcError(MpcErrorCodes.CLIENT_NOT_VERIFIED) } if (client.environment?.backupWithPortalEnabled) { let backupSharePairId: string = '' for (const wallet of client.wallets) { for (const share of wallet.backupSharePairs) { if ( share.backupMethod === backupMethod.toUpperCase() && share.status === PortalSharePairStatus.COMPLETED ) { backupSharePairId = share.id break } } } if (backupSharePairId.length === 0) { throw new Error('No valid backup found') } const clientCipherText = await this.portal.api.getClientCipherText(backupSharePairId) if (!clientCipherText) { throw new Error('Failed to retrieve client cipher text') } return clientCipherText } else { return ciphertext } } private async decryptShare( cipherText: string, privateKey: string, backupMethod: BackupMethods, progress: ProgressCallback = () => { // Noop }, ): Promise { // Send progress update await progress({ status: MpcStatuses.DecryptingShare, done: false, }) if (!privateKey) { throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, 'decrypting share, privateKey is empty', ) } if (!cipherText) { throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, 'decrypting share, cipherText is empty', ) } const result = backupMethod === BackupMethods.Password ? await this.mpc.decryptWithPassword(privateKey, cipherText) : await this.mpc.decrypt(privateKey, cipherText) // Send progress update await progress({ status: MpcStatuses.ParsingShare, done: false, }) const { data, error } = JSON.parse(result) as DecryptResult if (error && (error.id || error.code > 0)) { throw new PortalMpcError(error) } return data.plaintext } private async encryptShares( shares: string, progress: ProgressCallback = () => { // Noop }, ): Promise { // Send progress update await progress({ status: MpcStatuses.EncryptingShare, done: false, }) const encryptResult = await this.mpc.encrypt(shares) const { data, error } = JSON.parse(encryptResult) as EncryptResult if (error && (error.id || error.code > 0)) { throw new PortalMpcError(error) } return { cipherText: data.cipherText, privateKey: data.key } } private async encryptSharesWithPassword( shares: string, password: string, progress: ProgressCallback = () => { // Noop }, ): Promise { // Send progress update await progress({ status: MpcStatuses.EncryptingShare, done: false, }) const encryptResult = await this.mpc.encryptWithPassword(shares, password) const { data, error } = JSON.parse( encryptResult, ) as EncryptResultWithPassword if (error && (error.id || error.code > 0)) { throw new PortalMpcError(error) } return { cipherText: data.cipherText } } public async ejectPrivateKey( backupShareCipherText: string = '', backupMethod: BackupMethods, backupConfig: BackupConfigs = {}, orgShare: string = '', ): Promise { this.isWalletModificationInProgress = true try { const storage = this.backupOptions[backupMethod] as Storage if (!storage) { throw new MpcError( MpcErrorCodes.UNSUPPORTED_STORAGE_METHOD, backupMethod, ) } if ( backupMethod === BackupMethods.Password && (!backupConfig.passwordStorage?.password || backupConfig.passwordStorage.password.trim() === '') ) { throw new MpcError(MpcErrorCodes.PASSWORD_REQUIRED) } await this.validate({ keychain: this.keychain }) let clientCipherText = backupShareCipherText let custodianBackupShare = orgShare const client = await this.portal.api.getClient() if (!client?.wallets?.length) { throw new MpcError(MpcErrorCodes.CLIENT_NOT_VERIFIED) } if (client.environment?.backupWithPortalEnabled) { let backupSharePairId: string = '' let SECP256K1WalletId: string = '' for (const wallet of client.wallets) { if (wallet.curve === PortalCurve.SECP256K1) { for (const share of wallet.backupSharePairs) { if ( share.backupMethod === backupMethod.toUpperCase() && share.status === PortalSharePairStatus.COMPLETED ) { backupSharePairId = share.id SECP256K1WalletId = wallet.id break } } } } if (!backupSharePairId || !SECP256K1WalletId) { throw new Error('No valid backup found') } clientCipherText = await this.portal.api.getClientCipherText(backupSharePairId) if (!clientCipherText) { throw new MpcError( MpcErrorCodes.UNSUPPORTED_STORAGE_METHOD, 'Backup with Portal is not enabled', ) } custodianBackupShare = await this.portal.api.prepareClientEject( SECP256K1WalletId, backupMethod.toUpperCase() as BackupMethodsUpperCase, ) } const backupShares = await this.formatShares( clientCipherText, backupMethod, backupConfig, ) if (!backupShares.SECP256K1) { throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, 'SECP256K1 share not found in decrypted data', ) } const res = await this.mpc.ejectSecp256k1( backupShares.SECP256K1.share, custodianBackupShare, ) const { privateKey, error } = JSON.parse(res) as EjectResult if (error && (error.id || error.code > 0)) { throw new PortalMpcError(error as PortalError) } await this.portal.api.ejectClient() this.isWalletModificationInProgress = false return privateKey } catch (error: unknown) { this.isWalletModificationInProgress = false if (error instanceof PortalMpcError) throw error throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, `ejectPrivateKey: ${error}`, ) } } public async ejectPrivateKeys( backupShareCipherText: string = '', backupMethod: BackupMethods, backupConfig: BackupConfigs = {}, orgShare: orgShares = { secp256k1: '', ed25519: '' }, ): Promise { this.isWalletModificationInProgress = true try { const storage = this.backupOptions[backupMethod] as Storage if (!storage) { throw new MpcError( MpcErrorCodes.UNSUPPORTED_STORAGE_METHOD, backupMethod, ) } if ( backupMethod === BackupMethods.Password && (!backupConfig.passwordStorage?.password || backupConfig.passwordStorage.password.trim() === '') ) { throw new MpcError(MpcErrorCodes.PASSWORD_REQUIRED) } await this.validate({ keychain: this.keychain }) let clientCipherText = backupShareCipherText let custodianBackupShare = orgShare const client = await this.portal.api.getClient() if (!client?.wallets?.length) { throw new MpcError(MpcErrorCodes.CLIENT_NOT_VERIFIED) } if (client.environment?.backupWithPortalEnabled) { let backupSharePairId: string = '' let SECP256K1WalletId: string = '' let Ed25519WalletId: string = '' for (const wallet of client.wallets) { if (wallet.curve === PortalCurve.SECP256K1) { for (const share of wallet.backupSharePairs) { if ( share.backupMethod === backupMethod.toUpperCase() && share.status === PortalSharePairStatus.COMPLETED ) { backupSharePairId = share.id SECP256K1WalletId = wallet.id break } } } else if (wallet.curve === PortalCurve.ED25519) { for (const share of wallet.backupSharePairs) { if ( share.backupMethod === backupMethod.toUpperCase() && share.status === PortalSharePairStatus.COMPLETED ) { backupSharePairId = share.id Ed25519WalletId = wallet.id break } } } } if (!backupSharePairId || !SECP256K1WalletId || !Ed25519WalletId) { throw new Error('No valid backup found') } clientCipherText = await this.portal.api.getClientCipherText(backupSharePairId) if (!clientCipherText) { throw new MpcError( MpcErrorCodes.UNSUPPORTED_STORAGE_METHOD, 'Backup with Portal is not enabled', ) } const secp256k1 = await this.portal.api.prepareClientEject( SECP256K1WalletId, backupMethod.toUpperCase() as BackupMethodsUpperCase, ) const ed25519 = await this.portal.api.prepareClientEject( Ed25519WalletId, backupMethod.toUpperCase() as BackupMethodsUpperCase, ) custodianBackupShare = { secp256k1, ed25519 } } const backupShares = await this.formatShares( clientCipherText, backupMethod, backupConfig, ) let privateKeySecp256k1: string | undefined let privateKeyEd25519: string | undefined if (backupShares.SECP256K1) { const resSecp256k1 = await this.mpc.ejectSecp256k1( backupShares.SECP256K1.share, custodianBackupShare.secp256k1, ) const { privateKey, error } = JSON.parse(resSecp256k1) as EjectResult if (error && (error.id || error.code > 0)) { throw new PortalMpcError(error as PortalError) } privateKeySecp256k1 = privateKey } if (backupShares.ED25519) { if (!custodianBackupShare.ed25519) { throw new Error("Ed25519 share doesn't exist in `OrgShare`.") } const resEd25519 = await this.mpc.ejectEd25519( backupShares.ED25519.share, custodianBackupShare.ed25519, ) const { privateKey, error } = JSON.parse(resEd25519) as EjectResult if (error && (error.id || error.code > 0)) { throw new PortalMpcError(error as PortalError) } privateKeyEd25519 = privateKey } this.isWalletModificationInProgress = false const ejectedKeys: EjectedKeys = {} if (privateKeySecp256k1) ejectedKeys.secp256k1Key = privateKeySecp256k1 if (privateKeyEd25519) ejectedKeys.ed25519Key = privateKeyEd25519 await this.portal.api.ejectClient() return ejectedKeys } catch (error) { this.isWalletModificationInProgress = false if (error instanceof PortalMpcError) throw error throw new MpcError( MpcErrorCodes.UNEXPECTED_ERROR, `ejectPrivateKeys: ${error}`, ) } } private async formatShares( cipherText: string, backupMethod: BackupMethods, backupConfig: BackupConfigs = {}, ): Promise<{ SECP256K1: { id: string; share: string } ED25519: { id: string; share: string } }> { const storage = this.backupOptions[backupMethod] as Storage if (!storage) { throw new MpcError(MpcErrorCodes.UNSUPPORTED_STORAGE_METHOD, backupMethod) } if ( backupMethod === BackupMethods.Password && (!backupConfig.passwordStorage?.password || backupConfig.passwordStorage.password.trim() === '') ) { throw new MpcError(MpcErrorCodes.PASSWORD_REQUIRED) } const privateKey = backupMethod === BackupMethods.Password ? backupConfig.passwordStorage!.password : await storage.read() const decryptedData = await this.decryptShare( cipherText, privateKey, backupMethod, ) const formattedSharesString: string = await this.mpc.formatShares(decryptedData) const formattedShares = JSON.parse(formattedSharesString) as { data?: { SECP256K1: { id: string; share: string } ED25519: { id: string; share: string } } error?: { id: string message: string } } if (formattedShares.error) { sdkLogger.error(formattedShares.error.message) throw new MpcError( MpcErrorCodes.FORMAT_SHARES_ERROR, `formatShares: ${formattedShares.error.message}`, ) } if (!formattedShares.data) { throw new MpcError( MpcErrorCodes.FORMAT_SHARES_ERROR, 'formatShares: formattedShares.data is empty', ) } return formattedShares.data } private async validate(options: { keychain?: KeychainAdapter storage?: Storage }) { if (options?.keychain) { try { // Validate Keychain access. await options.keychain.validateOperations() } catch (error) { sdkLogger.error('[PortalMpc] Keychain validation failed:', error) throw new MpcError( MpcErrorCodes.KEYCHAIN_UNAVAILABLE, (error as { message: string })?.message, ) } } if (options?.storage) { try { // Validate Storage access. await options.storage.validateOperations() } catch (error) { sdkLogger.error('[PortalMpc] Storage validation failed:', error) throw new MpcError( MpcErrorCodes.STORAGE_UNAVAILABLE, (error as { message: string })?.message, ) } } } private deriveBackupMethod( backupMethod: BackupMethods, ): | 'GDRIVE' | 'ICLOUD' | 'PASSKEY' | 'PASSWORD' | 'CUSTOM' | 'FIREBASE' | 'UNKNOWN' { switch (backupMethod) { case BackupMethods.GoogleDrive: return 'GDRIVE' case BackupMethods.iCloud: return 'ICLOUD' case BackupMethods.Password: return 'PASSWORD' case BackupMethods.Passkey: return 'PASSKEY' case BackupMethods.Custom: return 'CUSTOM' case BackupMethods.Firebase: return 'FIREBASE' default: return 'UNKNOWN' } } /** * ======================================================================== * PRESIGNATURE MANAGEMENT * ======================================================================== * Handles generation, consumption, and buffer management of presignatures * for accelerated signing operations. */ /** * Resolve max presignatures for a curve: per-curve override (featureFlags.maxPresignaturesPerCurve) * then default per curve. Prepares for multi-curve support (e.g. ED25519) even though only SECP256K1 uses presignatures today. */ private getMaxPresignaturesForCurve(curve: PortalCurve): number { const flags = this.portal.featureFlags const perCurve = flags?.maxPresignaturesPerCurve const curveVal = perCurve?.[curve as string] if (curveVal != null && curveVal >= 0) { return curveVal } const defaultForCurve = DEFAULT_MAX_PRESIGNATURES_PER_CURVE[curve] return defaultForCurve != null && defaultForCurve >= 0 ? defaultForCurve : 3 } /** * Generate a single presignature for the specified curve * Includes retry logic with exponential backoff (max 3 attempts, 5-minute window, 30s cap per delay) * * @param curve - The cryptographic curve (SECP256K1 or ED25519) * @param retryAttempt - Current retry attempt number * @param windowStartTime - Start time for the retry window (used to enforce 5-minute max) * @returns Generated presignature or null if failed after retries or window exceeded */ private async generatePresignature( curve: PortalCurve, retryAttempt = 0, windowStartTime?: number, ): Promise { const startTime = windowStartTime ?? Date.now() try { // Get the signing share for the specified curve const shares = await this.keychain.getShares() const share = curve === PortalCurve.ED25519 ? shares.ed25519 : shares.secp256k1 if (!share) { sdkLogger.warn(`[PortalMpc] No share found for curve ${curve}`) return null } // Prepare metadata for MPC binary const metadata = JSON.stringify({ reqId: generateTraceId(), clientPlatform: 'REACT_NATIVE', clientPlatformVersion: getClientPlatformVersion(), mpcServerVersion: this.version, optimized: true, }) // Call native MPC module to generate presignature const presignResult = await this.mpc.presign( this.apiKey, this.host, JSON.stringify(share.share), metadata, ) // Parse the result interface PreSignResponse { id: string expiresAt: string data: string error?: PortalError } const parsed: PreSignResponse = JSON.parse(presignResult) // Check for errors in MPC response if (parsed.error && (parsed.error.id || parsed.error.code > 0)) { throw new Error( `Presign error: ${parsed.error.message || 'Unknown error'}`, ) } // Use values returned by the binary (not generated locally) const presignature: Presignature = { id: parsed.id, expiresAt: parsed.expiresAt, data: parsed.data, } return presignature } catch (error) { sdkLogger.warn( `[PortalMpc] Presignature generation failed for ${curve} (attempt ${retryAttempt + 1}/${PRESIGNATURE_RETRY_MAX_ATTEMPTS}):`, error, ) // No more attempts allowed if (retryAttempt >= PRESIGNATURE_RETRY_MAX_ATTEMPTS - 1) { return null } // Stop if 5-minute retry window exceeded const elapsed = Date.now() - startTime if (elapsed >= PRESIGNATURE_RETRY_MAX_WINDOW_MS) { sdkLogger.warn( `[PortalMpc] Presignature retry window exceeded for ${curve} (${elapsed}ms >= ${PRESIGNATURE_RETRY_MAX_WINDOW_MS}ms), giving up`, ) return null } // Bounded exponential backoff: delay = min(base * multiplier^attempt, 30s), // additionally capped by the remaining time in the retry window so we never exceed 5 min. const rawDelay = PRESIGNATURE_RETRY_BASE_DELAY_MS * Math.pow(PRESIGNATURE_RETRY_MULTIPLIER, retryAttempt) const remainingWindow = PRESIGNATURE_RETRY_MAX_WINDOW_MS - elapsed if (remainingWindow <= 0) { sdkLogger.warn( `[PortalMpc] Presignature retry window exhausted for ${curve} (remainingWindow=${remainingWindow}ms), giving up`, ) return null } const delay = Math.min( rawDelay, PRESIGNATURE_RETRY_MAX_DELAY_MS, remainingWindow, ) await new Promise((resolve) => setTimeout(resolve, delay)) // After waiting, check again in case window was exceeded during delay if (Date.now() - startTime >= PRESIGNATURE_RETRY_MAX_WINDOW_MS) { return null } return this.generatePresignature(curve, retryAttempt + 1, startTime) } } /** * Get the current count of valid (non-expired) presignatures for a curve * * @param curve - The cryptographic curve * @returns Number of valid presignatures in buffer */ private async getPresignatureCount(curve: PortalCurve): Promise { try { const presignatures = await this.keychain.getPresignatures( curve.toString(), ) const now = new Date() // Count only non-expired presignatures return presignatures.filter((p) => new Date(p.expiresAt) > now).length } catch (error) { sdkLogger.warn( `[PortalMpc] Failed to get presignature count for ${curve}:`, error, ) return 0 } } /** * Run fn with exclusive access to the presignature keychain for the given curve. * Serializes all read-modify-write (getPresignatureCount, insertPresignature, popOldestPresignature, * deletePresignatures) so fill and consume never run concurrently on the same key — avoids races under JSI. */ private async withPresignatureKeychainLock( curve: PortalCurve, fn: () => Promise, ): Promise { const key = curve.toString() let release!: () => void const myTurn = new Promise((r) => { release = r }) const prev = this.presignatureKeychainMutexTails.get(key) ?? Promise.resolve() this.presignatureKeychainMutexTails.set( key, prev.then(() => myTurn), ) await prev try { return await fn() } finally { release() } } /** * Fill presignature buffer up to maxPresignatures limit for the given curve * Generates presignatures sequentially (one at a time) to avoid overwhelming servers * when many users fill buffers at once. Includes mutex to prevent concurrent * operations for the same curve. * * @param curve - The cryptographic curve * @param maxPresignatures - Maximum buffer size; when omitted uses getMaxPresignaturesForCurve(curve) */ private async fillPresignatureBuffer( curve: PortalCurve, maxPresignatures?: number, ): Promise { const limit = maxPresignatures ?? this.getMaxPresignaturesForCurve(curve) const lockKey = curve.toString() if (this.presignatureBufferLocks.get(lockKey)) { sdkLogger.debug( `[PortalMpc] Buffer filling already in progress for ${curve}, skipping`, ) return } try { this.presignatureBufferLocks.set(lockKey, true) // Get current buffer count under keychain lock so we don't race with consume (JSI). const currentCount = await this.withPresignatureKeychainLock(curve, () => this.getPresignatureCount(curve), ) const needed = limit - currentCount if (needed <= 0) { return // Buffer is already full } sdkLogger.debug( `[PortalMpc] Filling presignature buffer for ${curve}: generating ${needed} presignatures (max ${limit})`, ) // Generate presignatures sequentially (one at a time) to avoid server load spikes let inserted = 0 for (let i = 0; i < needed; i++) { const generationAtStart = this.presignatureGeneration const presignature = await this.generatePresignature(curve) // If recover/reset ran while we were generating, do not insert — would be stale (wrong share pair ID). if (this.presignatureGeneration !== generationAtStart) { sdkLogger.debug( `[PortalMpc] Presignature generation changed (recover/reset?), skipping insert for ${curve}`, ) break } if (presignature) { try { await this.withPresignatureKeychainLock(curve, () => this.keychain.insertPresignature(curve.toString(), presignature), ) inserted++ } catch (error) { sdkLogger.warn( `[PortalMpc] Failed to insert presignature for ${curve}:`, error, ) } } } if (inserted > 0) { sdkLogger.debug( `[PortalMpc] Successfully stored ${inserted}/${needed} presignatures for ${curve}`, ) } } catch (error) { sdkLogger.error( `[PortalMpc] Failed to fill presignature buffer for ${curve}:`, error, ) } finally { this.presignatureBufferLocks.delete(lockKey) } } /** * Initialize presignature buffers for all available curves * Called after wallet creation, recovery, or on Portal init * Runs asynchronously without blocking main flow * * NOTE: Currently only SECP256K1 is supported for presignatures */ public async initializePresignatureBuffers(): Promise { try { if (!this.portal.featureFlags?.usePresignatures) { return } if (!(await this.isReady())) { return } // Determine which curves to generate presignatures for; limit per curve from getMaxPresignaturesForCurve const shares = await this.keychain.getShares() const curves: PortalCurve[] = [] // NOTE: Only SECP256K1 is currently supported for presignatures if (shares.secp256k1) { curves.push(PortalCurve.SECP256K1) } // ED25519 presignatures are not yet supported by the MPC binary // if (shares.ed25519) { // curves.push(PortalCurve.ED25519) // } if (curves.length === 0) { sdkLogger.warn( '[PortalMpc] No SECP256K1 share found, cannot initialize presignature buffers', ) return } // Fill buffers for all curves in parallel (each curve uses its own max from getMaxPresignaturesForCurve) await Promise.allSettled( curves.map((curve) => this.fillPresignatureBuffer(curve)), ) } catch (error) { sdkLogger.error( '[PortalMpc] Failed to initialize presignature buffers:', error, ) } } /** * Consume oldest presignature for signing operation * Automatically triggers async buffer replenishment * * @param curve - The cryptographic curve * @returns Presignature to use, or null if none available */ public async consumePresignature( curve: PortalCurve, ): Promise { try { // Pop under keychain lock so we don't race with fill's insert (read-modify-write under JSI). const presignature = await this.withPresignatureKeychainLock(curve, () => this.keychain.popOldestPresignature(curve.toString()), ) if (presignature) { sdkLogger.debug( `[PortalMpc] Consumed presignature ${presignature.id} for ${curve}`, ) // Asynchronously replenish buffer (fire-and-forget); limit from getMaxPresignaturesForCurve this.fillPresignatureBuffer(curve).catch((error) => { sdkLogger.warn( `[PortalMpc] Failed to replenish presignature buffer for ${curve}:`, error, ) }) } else { sdkLogger.debug(`[PortalMpc] No presignatures available for ${curve}`) } return presignature } catch (error) { sdkLogger.error( `[PortalMpc] Failed to consume presignature for ${curve}:`, error, ) return null } } /** * Delete all presignatures for all curves * Called after recovery/when shares change to clear potentially invalid presignatures * * NOTE: Currently only SECP256K1 is supported for presignatures */ private async deleteAllPresignatures(): Promise { try { await this.withPresignatureKeychainLock(PortalCurve.SECP256K1, () => this.keychain.deletePresignatures(PortalCurve.SECP256K1.toString()), ) sdkLogger.debug('[PortalMpc] Deleted all presignatures') } catch (error) { sdkLogger.warn('[PortalMpc] Failed to delete presignatures:', error) } } } export default PortalMpc