UNPKG

@phantom/embedded-provider-core

Version:

Platform-agnostic embedded provider core logic for Phantom Wallet SDK

1 lines 67.3 kB
{"version":3,"sources":["../src/embedded-provider.ts","../src/constants.ts","../src/auth/jwt-auth.ts","../src/utils/session.ts","../src/utils/retry.ts"],"sourcesContent":["import { PhantomClient } from \"@phantom/client\";\nimport { base64urlEncode } from \"@phantom/base64url\";\nimport bs58 from \"bs58\";\nimport {\n parseMessage,\n parseTransaction,\n parseSignMessageResponse,\n parseTransactionResponse,\n type ParsedTransactionResult,\n type ParsedSignatureResult,\n} from \"@phantom/parsers\";\nimport { AUTHENTICATOR_EXPIRATION_TIME_MS, AUTHENTICATOR_RENEWAL_WINDOW_MS } from \"./constants\";\n\nimport type {\n PlatformAdapter,\n Session,\n AuthResult,\n DebugLogger,\n EmbeddedStorage,\n AuthProvider,\n URLParamsAccessor,\n StamperInfo,\n} from \"./interfaces\";\nimport type {\n EmbeddedProviderConfig,\n ConnectResult,\n SignMessageParams,\n SignAndSendTransactionParams,\n WalletAddress,\n AuthOptions,\n} from \"./types\";\nimport { JWTAuth } from \"./auth/jwt-auth\";\nimport { generateSessionId } from \"./utils/session\";\nimport { retryWithBackoff } from \"./utils/retry\";\nimport type { StamperWithKeyManagement } from \"@phantom/sdk-types\";\n\nexport type EmbeddedProviderEvent = 'connect' | 'connect_start' | 'connect_error' | 'disconnect' | 'error';\nexport type EventCallback = (data?: any) => void;\n\nexport class EmbeddedProvider {\n private config: EmbeddedProviderConfig;\n private platform: PlatformAdapter;\n private storage: EmbeddedStorage;\n private authProvider: AuthProvider;\n private urlParamsAccessor: URLParamsAccessor;\n private stamper: StamperWithKeyManagement;\n private logger: DebugLogger;\n private client: PhantomClient | null = null;\n private walletId: string | null = null;\n private addresses: WalletAddress[] = [];\n private jwtAuth: JWTAuth;\n private eventListeners: Map<EmbeddedProviderEvent, Set<EventCallback>> = new Map();\n\n constructor(config: EmbeddedProviderConfig, platform: PlatformAdapter, logger: DebugLogger) {\n this.logger = logger;\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Initializing EmbeddedProvider\", { config });\n\n this.config = config;\n this.platform = platform;\n this.storage = platform.storage;\n this.authProvider = platform.authProvider;\n this.urlParamsAccessor = platform.urlParamsAccessor;\n this.stamper = platform.stamper;\n this.jwtAuth = new JWTAuth();\n\n // Store solana provider config (unused for now)\n config.solanaProvider;\n \n this.logger.info(\"EMBEDDED_PROVIDER\", \"EmbeddedProvider initialized\");\n\n // Auto-connect is now handled manually via autoConnect() method to avoid race conditions\n }\n\n /*\n * Event system methods for listening to provider state changes\n */\n on(event: EmbeddedProviderEvent, callback: EventCallback): void {\n if (!this.eventListeners.has(event)) {\n this.eventListeners.set(event, new Set());\n }\n this.eventListeners.get(event)!.add(callback);\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Event listener added\", { event });\n }\n\n off(event: EmbeddedProviderEvent, callback: EventCallback): void {\n const listeners = this.eventListeners.get(event);\n if (listeners) {\n listeners.delete(callback);\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Event listener removed\", { event });\n }\n }\n\n private emit(event: EmbeddedProviderEvent, data?: any): void {\n const listeners = this.eventListeners.get(event);\n if (listeners && listeners.size > 0) {\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Emitting event\", { event, listenerCount: listeners.size, data });\n listeners.forEach(callback => {\n try {\n callback(data);\n } catch (error) {\n this.logger.error(\"EMBEDDED_PROVIDER\", \"Event callback error\", { event, error });\n }\n });\n }\n }\n\n private async getAndFilterWalletAddresses(walletId: string): Promise<WalletAddress[]> {\n // Get session to access derivation index\n const session = await this.storage.getSession();\n const derivationIndex = session?.accountDerivationIndex ?? 0;\n\n // Get wallet addresses with retry and auto-disconnect on failure\n const addresses = await retryWithBackoff(\n () => this.client!.getWalletAddresses(walletId, undefined, derivationIndex),\n \"getWalletAddresses\",\n this.logger,\n ).catch(async error => {\n this.logger.error(\"EMBEDDED_PROVIDER\", \"getWalletAddresses failed after retries, disconnecting\", {\n walletId,\n error: error.message,\n derivationIndex: derivationIndex,\n });\n // Clear the session if getWalletAddresses fails after retries\n await this.storage.clearSession();\n this.client = null;\n this.walletId = null;\n this.addresses = [];\n throw error;\n });\n\n // Filter by enabled address types and return formatted addresses\n return addresses\n .filter(addr => this.config.addressTypes.some(type => type === addr.addressType))\n }\n\n /*\n * We use this method to make sure the session is not invalid, or there's a different session id in the url.\n * If there's a different one, we delete the current session and start from scratch.\n * This prevents issues where users have stale sessions or URL mismatches after redirects.\n */\n private async validateAndCleanSession(session: Session | null): Promise<Session | null> {\n if (!session) return null;\n\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Found existing session, validating\", {\n sessionId: session.sessionId,\n status: session.status,\n walletId: session.walletId,\n });\n\n // If session is not completed, check if we're in the right context\n if (session.status !== \"completed\") {\n const urlSessionId = this.urlParamsAccessor.getParam(\"session_id\");\n\n // If we have a pending session but no sessionId in URL, this is a mismatch\n if (session.status === \"pending\" && !urlSessionId) {\n this.logger.warn(\"EMBEDDED_PROVIDER\", \"Session mismatch detected - pending session without redirect context\", {\n sessionId: session.sessionId,\n status: session.status,\n });\n // Clear the invalid session and start fresh\n await this.storage.clearSession();\n return null;\n }\n // If sessionId in URL doesn't match stored session, clear invalid session\n else if (urlSessionId && urlSessionId !== session.sessionId) {\n this.logger.warn(\"EMBEDDED_PROVIDER\", \"Session ID mismatch detected\", {\n storedSessionId: session.sessionId,\n urlSessionId: urlSessionId,\n });\n await this.storage.clearSession();\n return null;\n }\n }\n\n // For completed sessions, check if session is valid (only checks authenticator expiration)\n if (session.status === \"completed\" && !this.isSessionValid(session)) {\n this.logger.warn(\"EMBEDDED_PROVIDER\", \"Session invalid due to authenticator expiration\", {\n sessionId: session.sessionId,\n authenticatorExpiresAt: session.authenticatorExpiresAt,\n });\n // Clear the invalid session\n await this.storage.clearSession();\n return null;\n }\n\n return session;\n }\n\n /*\n * Shared connection logic for both connect() and autoConnect().\n * Handles redirect resume, existing session validation, and session initialization.\n * Returns ConnectResult if connection succeeds, null if should continue with new auth flow.\n */\n private async tryExistingConnection(): Promise<ConnectResult | null> {\n // Get and validate existing session\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Getting existing session\");\n let session = await this.storage.getSession();\n session = await this.validateAndCleanSession(session);\n\n // First, check if we're resuming from a redirect\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Checking for redirect resume\");\n if (this.authProvider.resumeAuthFromRedirect) {\n const authResult = this.authProvider.resumeAuthFromRedirect();\n if (authResult) {\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Resuming from redirect\", {\n walletId: authResult.walletId,\n provider: authResult.provider,\n });\n return this.completeAuthConnection(authResult);\n }\n }\n\n // If we have a completed session, use it\n if (session && session.status === \"completed\") {\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Using existing completed session\", {\n sessionId: session.sessionId,\n walletId: session.walletId,\n });\n\n await this.initializeClientFromSession(session);\n\n // Update session timestamp\n session.lastUsed = Date.now();\n await this.storage.saveSession(session);\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Connection from existing session successful\", {\n walletId: this.walletId,\n addressCount: this.addresses.length,\n });\n\n // Ensure authenticator is valid after successful connection\n await this.ensureValidAuthenticator();\n\n const result: ConnectResult = {\n walletId: this.walletId!,\n addresses: this.addresses,\n status: \"completed\",\n };\n\n // Emit connect event for existing session success\n this.emit(\"connect\", {\n walletId: this.walletId,\n addresses: this.addresses,\n source: \"existing-session\",\n });\n\n return result;\n }\n\n // No existing connection available\n return null;\n }\n\n /*\n * We use this method to validate authentication options before processing them.\n * This ensures only supported auth providers are used and required tokens are present.\n */\n private validateAuthOptions(authOptions?: AuthOptions): void {\n if (!authOptions) return;\n\n if (authOptions.provider && ![\"google\", \"apple\", \"jwt\"].includes(authOptions.provider)) {\n throw new Error(`Invalid auth provider: ${authOptions.provider}. Must be \"google\", \"apple\", or \"jwt\"`);\n }\n\n if (authOptions.provider === \"jwt\" && !authOptions.jwtToken) {\n throw new Error(\"JWT token is required when using JWT authentication\");\n }\n }\n\n /*\n * We use this method to validate if a session is still valid.\n * This checks session status, required fields, and authenticator expiration.\n * Sessions never expire by age - only authenticators expire.\n */\n private isSessionValid(session: Session | null): boolean {\n if (!session) {\n return false;\n }\n\n // Check required fields\n if (!session.walletId || !session.organizationId || !session.stamperInfo) {\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Session missing required fields\", {\n hasWalletId: !!session.walletId,\n hasOrganizationId: !!session.organizationId,\n hasStamperInfo: !!session.stamperInfo,\n });\n return false;\n }\n\n // Check session status\n if (session.status !== \"completed\") {\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Session not completed\", { status: session.status });\n return false;\n }\n\n // Sessions without authenticator timing are invalid\n if (!session.authenticatorExpiresAt) {\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Session invalid - missing authenticator timing\", {\n sessionId: session.sessionId,\n });\n return false;\n }\n\n // Check authenticator expiration - if expired, session is invalid\n if (Date.now() >= session.authenticatorExpiresAt) {\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Authenticator expired, session invalid\", {\n authenticatorExpiresAt: new Date(session.authenticatorExpiresAt).toISOString(),\n now: new Date().toISOString(),\n });\n return false;\n }\n\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Session is valid\", {\n sessionId: session.sessionId,\n walletId: session.walletId,\n authenticatorExpires: new Date(session.authenticatorExpiresAt).toISOString(),\n });\n return true;\n }\n\n /*\n * Public method to attempt auto-connection using an existing valid session.\n * This should be called after setting up event listeners to avoid race conditions.\n * Silently fails if no valid session exists, enabling seamless reconnection.\n */\n async autoConnect(): Promise<void> {\n try {\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Starting auto-connect attempt\");\n \n // Emit connect_start event for auto-connect\n this.emit(\"connect_start\", { source: \"auto-connect\" });\n\n // Try to use existing connection (redirect resume or completed session)\n const result = await this.tryExistingConnection();\n \n if (result) {\n // Successfully connected using existing session or redirect\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Auto-connect successful\", {\n walletId: result.walletId,\n addressCount: result.addresses.length,\n });\n\n this.emit(\"connect\", {\n walletId: result.walletId,\n addresses: result.addresses,\n source: \"auto-connect\",\n });\n return;\n }\n\n // No existing connection available - auto-connect should fail silently\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Auto-connect failed: no valid session found\");\n \n // Emit connect_error to reset isConnecting state\n this.emit(\"connect_error\", {\n error: \"No valid session found\",\n source: \"auto-connect\",\n });\n \n } catch (error) {\n this.logger.error(\"EMBEDDED_PROVIDER\", \"Auto-connect failed\", {\n error: error instanceof Error ? error.message : String(error),\n });\n \n // Emit connect_error to reset isConnecting state\n this.emit(\"connect_error\", {\n error: error instanceof Error ? error.message : \"Auto-connect failed\",\n source: \"auto-connect\",\n });\n }\n }\n\n /*\n * We use this method to initialize the stamper and create an organization for new sessions.\n * This is the first step when no existing session is found and we need to set up a new wallet.\n */\n private async createOrganizationAndStamper(): Promise<{ organizationId: string; stamperInfo: StamperInfo; expiresAtMs: number; username: string }> {\n // Initialize stamper (generates keypair in IndexedDB)\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Initializing stamper\");\n const stamperInfo = await this.stamper.init();\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Stamper initialized\", {\n publicKey: stamperInfo.publicKey,\n keyId: stamperInfo.keyId,\n algorithm: this.stamper.algorithm,\n });\n\n // Create a temporary client with the stamper\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Creating temporary PhantomClient\");\n const tempClient = new PhantomClient(\n {\n apiBaseUrl: this.config.apiBaseUrl,\n },\n this.stamper,\n );\n\n // Create an organization\n // organization name is a combination of this organizationId and this userId, which will be a unique identifier\n const platformName = this.platform.name || \"unknown\";\n const shortPubKey = stamperInfo.publicKey.slice(0, 8);\n const organizationName = `${this.config.organizationId}-${platformName}-${shortPubKey}`;\n\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Creating organization\", {\n organizationName,\n publicKey: stamperInfo.publicKey,\n platform: platformName,\n });\n\n // Convert base58 public key to base64url format as required by the API\n const base64urlPublicKey = base64urlEncode(bs58.decode(stamperInfo.publicKey));\n const expiresAtMs = Date.now() + AUTHENTICATOR_EXPIRATION_TIME_MS;\n\n const username = `user-${shortPubKey}`;\n const { organizationId } = await tempClient.createOrganization(organizationName, [\n {\n username,\n role: \"ADMIN\",\n authenticators: [\n {\n authenticatorName: `auth-${shortPubKey}`,\n authenticatorKind: \"keypair\",\n publicKey: base64urlPublicKey,\n algorithm: \"Ed25519\",\n // Commented for now until KMS supports fully expirable organizations\n // expiresAtMs: expiresAtMs,\n } as any,\n ],\n },\n ]);\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Organization created\", { organizationId });\n\n return { organizationId, stamperInfo, expiresAtMs, username };\n }\n\n async connect(authOptions?: AuthOptions): Promise<ConnectResult> {\n try {\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Starting embedded provider connect\", {\n authOptions: authOptions\n ? {\n provider: authOptions.provider,\n hasJwtToken: !!authOptions.jwtToken,\n }\n : undefined,\n });\n \n // Emit connect_start event for manual connect\n this.emit(\"connect_start\", { \n source: \"manual-connect\",\n authOptions: authOptions ? { provider: authOptions.provider } : undefined\n });\n\n // Try to use existing connection (redirect resume or completed session)\n const existingResult = await this.tryExistingConnection();\n if (existingResult) {\n // Successfully connected using existing session or redirect\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Manual connect using existing connection\", {\n walletId: existingResult.walletId,\n addressCount: existingResult.addresses.length,\n });\n \n // Emit connect event for manual connect success with existing connection\n this.emit(\"connect\", {\n walletId: existingResult.walletId,\n addresses: existingResult.addresses,\n source: \"manual-existing\",\n });\n \n return existingResult;\n }\n\n // Validate auth options before proceeding with new auth flow\n this.validateAuthOptions(authOptions);\n\n // No existing connection available, create new one\n this.logger.info(\"EMBEDDED_PROVIDER\", \"No existing connection, creating new auth flow\");\n const { organizationId, stamperInfo, expiresAtMs, username } = await this.createOrganizationAndStamper();\n const session = await this.handleAuthFlow(organizationId, stamperInfo, authOptions, expiresAtMs, username);\n\n // If session is null here, it means we're doing a redirect\n if (!session) {\n // This should not return anything as redirect is happening\n return {\n addresses: [],\n status: \"pending\",\n } as ConnectResult;\n }\n\n // Update session last used timestamp (only for non-redirect flows)\n // For redirect flows, timestamp is updated before redirect to prevent race condition\n if (!authOptions || authOptions.provider === \"jwt\" || this.config.embeddedWalletType === \"app-wallet\") {\n session.lastUsed = Date.now();\n await this.storage.saveSession(session);\n }\n\n // Initialize client and get addresses\n await this.initializeClientFromSession(session);\n\n // Ensure authenticator is valid after successful connection\n await this.ensureValidAuthenticator();\n\n const result: ConnectResult = {\n walletId: this.walletId!,\n addresses: this.addresses,\n status: \"completed\",\n };\n\n // Emit connect event for manual connect success\n this.emit(\"connect\", {\n walletId: this.walletId,\n addresses: this.addresses,\n source: \"manual\",\n });\n\n return result;\n } catch (error) {\n // Log the full error details for debugging\n this.logger.error(\"EMBEDDED_PROVIDER\", \"Connect failed with error\", {\n error:\n error instanceof Error\n ? {\n name: error.name,\n message: error.message,\n stack: error.stack,\n }\n : error,\n });\n\n // Emit connect_error event for manual connect failure\n this.emit(\"connect_error\", {\n error: error instanceof Error ? error.message : String(error),\n source: \"manual-connect\",\n });\n\n // Enhanced error handling with specific error types\n if (error instanceof Error) {\n // Check for specific error types and provide better error messages\n if (error.message.includes(\"IndexedDB\") || error.message.includes(\"storage\")) {\n throw new Error(\n \"Storage error: Unable to access browser storage. Please ensure storage is available and try again.\",\n );\n }\n\n if (error.message.includes(\"network\") || error.message.includes(\"fetch\")) {\n throw new Error(\n \"Network error: Unable to connect to authentication server. Please check your internet connection and try again.\",\n );\n }\n\n if (error.message.includes(\"JWT\") || error.message.includes(\"jwt\")) {\n throw new Error(`JWT Authentication error: ${error.message}`);\n }\n\n if (error.message.includes(\"Authentication\") || error.message.includes(\"auth\")) {\n throw new Error(`Authentication error: ${error.message}`);\n }\n\n if (error.message.includes(\"organization\") || error.message.includes(\"wallet\")) {\n throw new Error(`Wallet creation error: ${error.message}`);\n }\n\n // Re-throw the original error if it's already well-formatted\n throw error;\n }\n\n // Handle unknown error types\n throw new Error(`Embedded wallet connection failed: ${String(error)}`);\n }\n }\n\n async disconnect(): Promise<void> {\n const wasConnected = this.client !== null;\n \n await this.storage.clearSession();\n\n this.client = null;\n this.walletId = null;\n this.addresses = [];\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Disconnected from embedded wallet\");\n\n // Emit disconnect event if we were previously connected\n if (wasConnected) {\n this.emit(\"disconnect\", {\n source: \"manual\",\n });\n }\n }\n\n async signMessage(params: SignMessageParams): Promise<ParsedSignatureResult> {\n if (!this.client || !this.walletId) {\n throw new Error(\"Not connected\");\n }\n\n // Check if authenticator needs renewal before performing the operation\n await this.ensureValidAuthenticator();\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Signing message\", {\n walletId: this.walletId,\n message: params.message,\n });\n\n // Parse message to base64url format for client\n const parsedMessage = parseMessage(params.message);\n\n // Get session to access derivation index\n const session = await this.storage.getSession();\n const derivationIndex = session?.accountDerivationIndex ?? 0;\n\n // Get raw response from client\n const rawResponse = await this.client.signMessage({\n walletId: this.walletId,\n message: parsedMessage.base64url,\n networkId: params.networkId,\n derivationIndex: derivationIndex,\n });\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Message signed successfully\", {\n walletId: this.walletId,\n message: params.message,\n });\n\n // Parse the response to get human-readable signature and explorer URL\n return parseSignMessageResponse(rawResponse, params.networkId);\n }\n\n async signAndSendTransaction(params: SignAndSendTransactionParams): Promise<ParsedTransactionResult> {\n if (!this.client || !this.walletId) {\n throw new Error(\"Not connected\");\n }\n\n // Check if authenticator needs renewal before performing the operation\n await this.ensureValidAuthenticator();\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Signing and sending transaction\", {\n walletId: this.walletId,\n networkId: params.networkId,\n });\n\n // Parse transaction to base64url format for client based on network\n const parsedTransaction = await parseTransaction(params.transaction, params.networkId);\n\n // Get session to access derivation index\n const session = await this.storage.getSession();\n const derivationIndex = session?.accountDerivationIndex ?? 0;\n\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Parsed transaction for signing\", {\n walletId: this.walletId,\n transaction: parsedTransaction,\n derivationIndex: derivationIndex,\n });\n\n // Get raw response from client\n const rawResponse = await this.client.signAndSendTransaction({\n walletId: this.walletId,\n transaction: parsedTransaction.base64url,\n networkId: params.networkId,\n derivationIndex: derivationIndex,\n });\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Transaction signed and sent successfully\", {\n walletId: this.walletId,\n networkId: params.networkId,\n hash: rawResponse.hash,\n rawTransaction: rawResponse.rawTransaction,\n });\n\n // Parse the response to get transaction hash and explorer URL\n return await parseTransactionResponse(rawResponse.rawTransaction, params.networkId, rawResponse.hash);\n }\n\n getAddresses(): WalletAddress[] {\n return this.addresses;\n }\n\n isConnected(): boolean {\n return this.client !== null && this.walletId !== null;\n }\n\n /*\n * We use this method to route between different authentication flows based on wallet type and auth options.\n * It handles app-wallet creation directly or routes to JWT/redirect authentication for user-wallets.\n * Returns null for redirect flows since they don't complete synchronously.\n */\n private async handleAuthFlow(\n organizationId: string,\n stamperInfo: StamperInfo,\n authOptions: AuthOptions | undefined,\n expiresAtMs: number,\n username: string,\n ): Promise<Session | null> {\n if (this.config.embeddedWalletType === \"user-wallet\") {\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Creating user-wallet, routing authentication\", {\n authProvider: authOptions?.provider || \"phantom-connect\",\n });\n\n // Route to appropriate authentication flow based on authOptions\n if (authOptions?.provider === \"jwt\") {\n return await this.handleJWTAuth(organizationId, stamperInfo, authOptions, expiresAtMs, username);\n } else {\n // This will redirect in browser, so we don't return a session\n // In react-native this will return an auth result\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Starting redirect-based authentication flow\", {\n organizationId,\n parentOrganizationId: this.config.organizationId,\n provider: authOptions?.provider,\n });\n return await this.handleRedirectAuth(organizationId, stamperInfo, authOptions, username);\n }\n } else {\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Creating app-wallet\", {\n organizationId,\n });\n // Create app-wallet directly\n const tempClient = new PhantomClient(\n {\n apiBaseUrl: this.config.apiBaseUrl,\n organizationId: organizationId,\n },\n this.stamper,\n );\n\n const wallet = await tempClient.createWallet(`Wallet ${Date.now()}`);\n const walletId = wallet.walletId;\n\n // Save session with app-wallet info\n const now = Date.now();\n const session = {\n sessionId: generateSessionId(),\n walletId: walletId,\n organizationId: organizationId,\n stamperInfo,\n authProvider: \"app-wallet\",\n userInfo: { embeddedWalletType: this.config.embeddedWalletType },\n accountDerivationIndex: 0, // App wallets default to index 0\n status: \"completed\" as const,\n createdAt: now,\n lastUsed: now,\n authenticatorCreatedAt: now,\n authenticatorExpiresAt: expiresAtMs,\n lastRenewalAttempt: undefined,\n username,\n };\n\n await this.storage.saveSession(session);\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"App-wallet created successfully\", { walletId, organizationId });\n return session;\n }\n }\n\n /*\n * We use this method to handle JWT-based authentication for user-wallets.\n * It authenticates using the provided JWT token and creates a completed session.\n */\n private async handleJWTAuth(\n organizationId: string,\n stamperInfo: StamperInfo,\n authOptions: AuthOptions,\n expiresAtMs: number,\n username: string,\n ): Promise<Session> {\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Using JWT authentication flow\");\n\n // Use JWT authentication flow\n if (!authOptions.jwtToken) {\n this.logger.error(\"EMBEDDED_PROVIDER\", \"JWT token missing for JWT authentication\");\n throw new Error(\"JWT token is required for JWT authentication\");\n }\n\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Starting JWT authentication\");\n const authResult = await this.jwtAuth.authenticate({\n organizationId: organizationId,\n parentOrganizationId: this.config.organizationId,\n jwtToken: authOptions.jwtToken,\n customAuthData: authOptions.customAuthData,\n });\n const walletId = authResult.walletId;\n this.logger.info(\"EMBEDDED_PROVIDER\", \"JWT authentication completed\", { walletId });\n\n // Save session with auth info\n const now = Date.now();\n const session = {\n sessionId: generateSessionId(),\n walletId: walletId,\n organizationId: organizationId,\n stamperInfo,\n authProvider: authResult.provider,\n userInfo: authResult.userInfo,\n accountDerivationIndex: authResult.accountDerivationIndex,\n status: \"completed\" as const,\n createdAt: now,\n lastUsed: now,\n authenticatorCreatedAt: now,\n authenticatorExpiresAt: expiresAtMs,\n lastRenewalAttempt: undefined,\n username,\n };\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Saving JWT session\");\n await this.storage.saveSession(session);\n return session;\n }\n\n /*\n * We use this method to handle redirect-based authentication (Google/Apple OAuth).\n * It saves a temporary session before redirecting to prevent losing state during the redirect flow.\n * Session timestamp is updated before redirect to prevent race conditions.\n */\n private async handleRedirectAuth(\n organizationId: string,\n stamperInfo: StamperInfo,\n authOptions?: AuthOptions,\n username?: string,\n ): Promise<Session | null> {\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Using Phantom Connect authentication flow (redirect-based)\", {\n provider: authOptions?.provider,\n hasRedirectUrl: !!this.config.authOptions?.redirectUrl,\n authUrl: this.config.authOptions?.authUrl,\n });\n\n // Use Phantom Connect authentication flow (redirect-based)\n // Store session before redirect so we can restore it after redirect\n const now = Date.now();\n const sessionId = generateSessionId();\n const tempSession: Session = {\n sessionId: sessionId,\n walletId: `temp-${now}`, // Temporary ID, will be updated after redirect\n organizationId: organizationId,\n stamperInfo,\n authProvider: \"phantom-connect\",\n userInfo: { provider: authOptions?.provider },\n accountDerivationIndex: undefined, // Will be set when redirect completes\n status: \"pending\" as const,\n createdAt: now,\n lastUsed: now,\n authenticatorCreatedAt: now,\n authenticatorExpiresAt: now + AUTHENTICATOR_EXPIRATION_TIME_MS,\n lastRenewalAttempt: undefined,\n username: username || `user-${stamperInfo.keyId.substring(0, 8)}`,\n };\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Saving temporary session before redirect\", {\n sessionId: tempSession.sessionId,\n tempWalletId: tempSession.walletId,\n });\n\n // Update session timestamp before redirect (prevents race condition)\n tempSession.lastUsed = Date.now();\n await this.storage.saveSession(tempSession);\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Starting Phantom Connect redirect\", {\n organizationId,\n parentOrganizationId: this.config.organizationId,\n provider: authOptions?.provider,\n authUrl: this.config.authOptions?.authUrl,\n });\n\n // Start the authentication flow (this will redirect the user in the browser, or handle it in React Native)\n const authResult = await this.authProvider.authenticate({\n organizationId: organizationId,\n parentOrganizationId: this.config.organizationId,\n provider: authOptions?.provider as \"google\" | \"apple\" | undefined,\n redirectUrl: this.config.authOptions?.redirectUrl,\n customAuthData: authOptions?.customAuthData,\n authUrl: this.config.authOptions?.authUrl,\n sessionId: sessionId,\n appName: this.config.appName,\n appLogo: this.config.appLogo,\n });\n\n if (authResult && \"walletId\" in authResult) {\n // If we got an auth result, we need to update the session with actual wallet ID\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Authentication completed after redirect\", {\n walletId: authResult.walletId,\n provider: authResult.provider,\n });\n\n // Update the temporary session with actual wallet ID and auth info\n tempSession.walletId = authResult.walletId;\n tempSession.authProvider = authResult.provider || tempSession.authProvider;\n tempSession.accountDerivationIndex = authResult.accountDerivationIndex;\n tempSession.status = \"completed\";\n tempSession.lastUsed = Date.now();\n await this.storage.saveSession(tempSession);\n\n return tempSession; // Return the auth result for further processing\n }\n // If we don't have an auth result, it means we're in a redirect flow\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Redirect authentication initiated, waiting for redirect completion\");\n // In this case, we don't return anything as the redirect will handle the rest\n return null;\n }\n\n private async completeAuthConnection(authResult: AuthResult): Promise<ConnectResult> {\n // Check if we have an existing session\n const session = await this.storage.getSession();\n\n if (!session) {\n throw new Error(\"No session found after redirect - session may have expired\");\n }\n\n // Update session with actual wallet ID and auth info from redirect\n session.walletId = authResult.walletId;\n session.authProvider = authResult.provider || session.authProvider;\n session.accountDerivationIndex = authResult.accountDerivationIndex;\n session.status = \"completed\";\n session.lastUsed = Date.now();\n await this.storage.saveSession(session);\n\n await this.initializeClientFromSession(session);\n\n // Ensure authenticator is valid after successful connection\n await this.ensureValidAuthenticator();\n\n return {\n walletId: this.walletId!,\n addresses: this.addresses,\n status: \"completed\",\n };\n }\n\n /*\n * Ensures the authenticator is valid and performs renewal if needed.\n * The renewal of the authenticator can only happen meanwhile the previous authenticator is still valid. \n */\n private async ensureValidAuthenticator(): Promise<void> {\n // Get current session to check authenticator timing\n const session = await this.storage.getSession();\n if (!session) {\n throw new Error(\"No active session found\");\n }\n\n const now = Date.now();\n \n // Sessions without authenticator timing fields are invalid - clear them\n if (!session.authenticatorExpiresAt) {\n this.logger.warn(\"EMBEDDED_PROVIDER\", \"Session missing authenticator timing - treating as invalid session\");\n await this.disconnect();\n throw new Error(\"Invalid session - missing authenticator timing\");\n }\n\n const timeUntilExpiry = session.authenticatorExpiresAt - now;\n \n this.logger.log(\"EMBEDDED_PROVIDER\", \"Checking authenticator expiration\", {\n expiresAt: new Date(session.authenticatorExpiresAt).toISOString(),\n timeUntilExpiry,\n });\n\n // Check if authenticator has expired\n if (timeUntilExpiry <= 0) {\n this.logger.error(\"EMBEDDED_PROVIDER\", \"Authenticator has expired, disconnecting\");\n await this.disconnect();\n throw new Error(\"Authenticator expired\");\n }\n\n // Check if authenticator needs renewal (within renewal window)\n const renewalWindow = AUTHENTICATOR_RENEWAL_WINDOW_MS;\n if (timeUntilExpiry <= renewalWindow) {\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Authenticator needs renewal\", {\n expiresAt: new Date(session.authenticatorExpiresAt).toISOString(),\n timeUntilExpiry,\n renewalWindow,\n });\n\n try {\n await this.renewAuthenticator(session);\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Authenticator renewed successfully\");\n } catch (error) {\n this.logger.error(\"EMBEDDED_PROVIDER\", \"Failed to renew authenticator\", {\n error: error instanceof Error ? error.message : String(error),\n });\n // Don't throw - renewal failure shouldn't break existing functionality\n }\n }\n }\n\n\n /*\n * We use this method to perform silent authenticator renewal.\n * It generates a new keypair, creates a new authenticator, and switches to it.\n */\n private async renewAuthenticator(session: Session): Promise<void> {\n if (!this.client) {\n throw new Error(\"Client not initialized\");\n }\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Starting authenticator renewal\");\n\n try {\n // Step 1: Generate new keypair (but don't make it active yet)\n const newKeyInfo = await this.stamper.rotateKeyPair();\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Generated new keypair for renewal\", {\n newKeyId: newKeyInfo.keyId,\n newPublicKey: newKeyInfo.publicKey,\n });\n\n // Step 2: Convert public key and set expiration\n const base64urlPublicKey = base64urlEncode(bs58.decode(newKeyInfo.publicKey));\n const expiresAtMs = Date.now() + AUTHENTICATOR_EXPIRATION_TIME_MS;\n\n // Step 3: Create new authenticator with replaceExpirable=true\n let authenticatorResult;\n try {\n authenticatorResult = await this.client.createAuthenticator({\n organizationId: session.organizationId,\n username: session.username,\n authenticatorName: `auth-${newKeyInfo.keyId.substring(0, 8)}`,\n authenticator: {\n authenticatorName: `auth-${newKeyInfo.keyId.substring(0, 8)}`,\n authenticatorKind: \"keypair\",\n publicKey: base64urlPublicKey,\n algorithm: \"Ed25519\",\n // Commented for now until KMS supports fully expiring organizations\n // expiresAtMs: expiresAtMs,\n } as any,\n replaceExpirable: true,\n } as any);\n } catch (error) {\n this.logger.error(\"EMBEDDED_PROVIDER\", \"Failed to create new authenticator\", {\n error: error instanceof Error ? error.message : String(error),\n });\n // Rollback the rotation on server error\n await this.stamper.rollbackRotation();\n throw new Error(`Failed to create new authenticator: ${error instanceof Error ? error.message : String(error)}`);\n }\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Created new authenticator\", {\n authenticatorId: (authenticatorResult as any).id,\n });\n\n // Step 4: Commit the rotation (switch stamper to use new keypair)\n await this.stamper.commitRotation((authenticatorResult as any).id || 'unknown');\n\n // Step 5: Update session with new authenticator timing\n const now = Date.now();\n session.stamperInfo = newKeyInfo;\n session.authenticatorCreatedAt = now;\n session.authenticatorExpiresAt = expiresAtMs;\n session.lastRenewalAttempt = now;\n await this.storage.saveSession(session);\n\n this.logger.info(\"EMBEDDED_PROVIDER\", \"Authenticator renewal completed successfully\", {\n newKeyId: newKeyInfo.keyId,\n expiresAt: new Date(expiresAtMs).toISOString(),\n });\n } catch (error) {\n // Rollback rotation on any failure\n await this.stamper.rollbackRotation();\n throw error;\n }\n }\n\n /*\n * We use this method to initialize the PhantomClient and fetch wallet addresses from a completed session.\n * This is the final step that sets up the provider's client state and retrieves available addresses.\n */\n private async initializeClientFromSession(session: Session): Promise<void> {\n // Create client from session\n this.logger.log(\"EMBEDDED_PROVIDER\", \"Initializing PhantomClient from session\", {\n organizationId: session.organizationId,\n walletId: session.walletId,\n });\n\n // Ensure stamper is initialized with existing keys\n if (!this.stamper.getKeyInfo()) {\n await this.stamper.init();\n }\n\n this.client = new PhantomClient(\n {\n apiBaseUrl: this.config.apiBaseUrl,\n organizationId: session.organizationId,\n },\n this.stamper,\n );\n\n this.walletId = session.walletId;\n\n // Get wallet addresses and filter by enabled address types with retry\n this.addresses = await this.getAndFilterWalletAddresses(session.walletId);\n }\n}\n","/**\n * Constants for authenticator lifecycle management\n */\n\n/**\n * How long an authenticator is valid before it expires (in milliseconds)\n * Default: 7 days\n * For testing: Use smaller values like 5 * 60 * 1000 (5 minutes)\n */\nexport const AUTHENTICATOR_EXPIRATION_TIME_MS = 7 * 24 * 60 * 60 * 1000; // 7 days\n/**\n * How long before expiration should we attempt to renew the authenticator (in milliseconds)\n * Default: 2 days before expiration\n * For testing: Use smaller values like 2 * 60 * 1000 (2 minutes)\n */\nexport const AUTHENTICATOR_RENEWAL_WINDOW_MS = 2 * 24 * 60 * 60 * 1000; // 2 days\n\n/**\n * Example configurations for testing:\n * \n * Quick testing (5 minute expiration, 2 minute renewal window):\n * export const AUTHENTICATOR_EXPIRATION_TIME_MS = 5 * 60 * 1000;\n * export const AUTHENTICATOR_RENEWAL_WINDOW_MS = 2 * 60 * 1000;\n * \n * Medium testing (1 hour expiration, 15 minute renewal window):\n * export const AUTHENTICATOR_EXPIRATION_TIME_MS = 60 * 60 * 1000;\n * export const AUTHENTICATOR_RENEWAL_WINDOW_MS = 15 * 60 * 1000;\n * \n * Aggressive testing (30 seconds expiration, 10 second renewal window):\n * export const AUTHENTICATOR_EXPIRATION_TIME_MS = 30 * 1000;\n * export const AUTHENTICATOR_RENEWAL_WINDOW_MS = 10 * 1000;\n */","import type { AuthResult, JWTAuthOptions } from \"../interfaces\";\n\nexport class JWTAuth {\n async authenticate(options: JWTAuthOptions): Promise<AuthResult> {\n // Validate JWT token format\n if (!options.jwtToken || typeof options.jwtToken !== \"string\") {\n throw new Error(\"Invalid JWT token: token must be a non-empty string\");\n }\n\n // Basic JWT format validation (3 parts separated by dots)\n const jwtParts = options.jwtToken.split(\".\");\n if (jwtParts.length !== 3) {\n throw new Error(\"Invalid JWT token format: token must have 3 parts separated by dots\");\n }\n\n // JWT authentication flow - direct API call to create wallet with JWT\n try {\n // This would typically make an API call to your backend\n // which would validate the JWT and create/retrieve the wallet\n const response = await fetch(\"/api/auth/jwt\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${options.jwtToken}`,\n },\n body: JSON.stringify({\n organizationId: options.organizationId,\n parentOrganizationId: options.parentOrganizationId,\n customAuthData: options.customAuthData,\n }),\n });\n\n if (!response.ok) {\n let errorMessage = `HTTP ${response.status}`;\n try {\n const errorData = await response.json();\n errorMessage = errorData.message || errorData.error || errorMessage;\n } catch {\n errorMessage = response.statusText || errorMessage;\n }\n\n switch (response.status) {\n case 400:\n throw new Error(`Invalid JWT authentication request: ${errorMessage}`);\n case 401:\n throw new Error(`JWT token is invalid or expired: ${errorMessage}`);\n case 403:\n throw new Error(`JWT authentication forbidden: ${errorMessage}`);\n case 404:\n throw new Error(`JWT authentication endpoint not found: ${errorMessage}`);\n case 429:\n throw new Error(`Too many JWT authentication requests: ${errorMessage}`);\n case 500:\n case 502:\n case 503:\n case 504:\n throw new Error(`JWT authentication server error: ${errorMessage}`);\n default:\n throw new Error(`JWT authentication failed: ${errorMessage}`);\n }\n }\n\n let result;\n try {\n result = await response.json();\n } catch (parseError) {\n throw new Error(\"Invalid response from JWT authentication server: response is not valid JSON\");\n }\n\n if (!result.walletId) {\n throw new Error(\"Invalid JWT authentication response: missing walletId\");\n }\n\n return {\n walletId: result.walletId,\n provider: \"jwt\",\n userInfo: result.userInfo || {},\n };\n } catch (error) {\n if (error instanceof TypeError && error.message.includes(\"fetch\")) {\n throw new Error(\"JWT authentication failed: network error or invalid endpoint\");\n }\n\n if (error instanceof Error) {\n throw error; // Re-throw known errors\n }\n\n throw new Error(`JWT authentication error: ${String(error)}`);\n }\n }\n}\n","export function generateSessionId(): string {\n return (\n \"session_\" +\n Math.random().toString(36).substring(2, 15) +\n Math.random().toString(36).substring(2, 15) +\n \"_\" +\n Date.now()\n );\n}\n","import type { DebugLogger } from \"../interfaces\";\n\nexport async function retryWithBackoff<T>(\n operation: () => Promise<T>,\n operationName: string,\n logger: DebugLogger,\n maxRetries: number = 3,\n baseDelay: number = 1000,\n): Promise<T> {\n let lastError: Error;\n\n for (let attempt = 1; attempt <= maxRetries; attempt++) {\n try {\n logger.log(\"EMBEDDED_PROVIDER\", `Attempting ${operationName}`, {\n attempt,\n maxRetries,\n });\n return await operation();\n } catch (error) {\n lastError = error as Error;\n logger.warn(\"EMBEDDED_PROVIDER\", `${operationName} failed`, {\n attempt,\n maxRetries,\n error: error instanceof Error ? error.message : String(error),\n });\n\n if (attempt === maxRetries) {\n logger.error(\"EMBEDDED_PROVIDER\", `${operationName} failed after ${maxRetries} attempts`, {\n finalError: error instanceof Error ? error.message : String(error),\n });\n break;\n }\n\n // Exponential backoff: 1s, 2s, 4s\n const delay = baseDelay * Math.pow(2, attempt - 1);\n logger.log(\"EMBEDDED_PROVIDER\", `Retrying ${operationName} in ${delay}ms`, {\n attempt: attempt + 1,\n delay,\n });\n await new Promise(resolve => setTimeout(resolve, delay));\n }\n }\n\n throw lastError!;\n}\n"],"mappings":"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