UNPKG

@windoge98/pnp-coinbase

Version:

Coinbase wallet adapter for Plug-n-Play

483 lines (409 loc) 15.9 kB
// Coinbase Wallet Adapter for SIWS (Sign-In with Solana) import { BaseSiwxAdapter, Adapter, Wallet, deriveAccountId, formatSiwsMessage } from '@windoge98/plug-n-play'; import { ActorSubclass, AnonymousIdentity, type Identity } from "@dfinity/agent"; import { CoinbaseWalletAdapter } from "@solana/wallet-adapter-coinbase"; import { WalletAdapterNetwork, WalletReadyState } from "@solana/wallet-adapter-base"; import { Connection, PublicKey, Transaction, LAMPORTS_PER_SOL } from "@solana/web3.js"; import { Ed25519KeyIdentity, DelegationIdentity, DelegationChain } from "@dfinity/identity"; import bs58 from "bs58"; // @ts-ignore - DID file import import { idlFactory as siwsProviderIdlFactory } from './did/ic_siws_provider.did.js'; // @ts-ignore - DID type import import type { _SERVICE as SiwsProviderService } from './did/ic_siws_provider'; // Configuration specific to Coinbase export interface CoinbaseAdapterConfig { enabled: boolean; solanaNetwork?: WalletAdapterNetwork; siwsProviderCanisterId?: string; providerCanisterId?: string; rpcEndpoint?: string; } /** * Coinbase-specific SIWS adapter implementation * Provides Sign-In with Solana functionality for Coinbase wallet */ export class CoinbaseAdapter extends BaseSiwxAdapter<CoinbaseAdapterConfig> { public walletName: string = 'Coinbase Wallet'; public logo: string; public readonly id: string = 'coinbase'; static supportedChains: Adapter.Chain[] = [ Adapter.Chain.ICP, Adapter.Chain.SOL, ]; private coinbaseAdapter: CoinbaseWalletAdapter | null = null; private solanaConnection!: Connection; // Initialized in constructor via initializeConnection() private solanaAddress: string | null = null; private connectingPromise: Promise<Wallet.Account> | null = null; protected resolveProviderCanisterId(): string { const cfg: any = this.config as any; const canisterId = cfg.providerCanisterId || cfg.siwsProviderCanisterId; if (!canisterId) { throw new Error("SIWS provider canister ID not configured."); } return String(canisterId); } constructor(args: Adapter.ConstructorArgs & { config: CoinbaseAdapterConfig }) { super(args); this.id = args.adapter.id; this.walletName = args.adapter.walletName; this.logo = args.adapter.logo; this.config = args.config; this.initializeConnection(); // Initialize Coinbase adapter if in browser if (this.isBrowser()) { this.initializeCoinbaseAdapter(); } this.setState(Adapter.Status.READY); } private isBrowser(): boolean { return typeof window !== "undefined" && typeof window.document !== "undefined"; } private initializeConnection(): void { const network = this.config.solanaNetwork || WalletAdapterNetwork.Mainnet; const endpoint = this.config.rpcEndpoint || ( network === WalletAdapterNetwork.Mainnet ? "https://api.mainnet-beta.solana.com" : "https://api.devnet.solana.com" ); this.solanaConnection = new Connection(endpoint); } private initializeCoinbaseAdapter(): void { this.coinbaseAdapter = new CoinbaseWalletAdapter(); this.setupWalletListeners(); } private setupWalletListeners(): void { if (!this.coinbaseAdapter) return; this.coinbaseAdapter.on("connect", this.handleCoinbaseConnect); this.coinbaseAdapter.on("disconnect", this.handleCoinbaseDisconnect); this.coinbaseAdapter.on("error", this.handleCoinbaseError); } private removeWalletListeners(): void { if (!this.coinbaseAdapter) return; this.coinbaseAdapter.off("connect", this.handleCoinbaseConnect); this.coinbaseAdapter.off("disconnect", this.handleCoinbaseDisconnect); this.coinbaseAdapter.off("error", this.handleCoinbaseError); } private handleCoinbaseConnect = (publicKey: PublicKey): void => { this.solanaAddress = publicKey.toBase58(); this.logger.debug(`Coinbase wallet connected`, { address: this.solanaAddress }); }; private handleCoinbaseDisconnect = (): void => { if ( this.state !== Adapter.Status.DISCONNECTING && this.state !== Adapter.Status.DISCONNECTED ) { this.disconnect(); } }; private handleCoinbaseError = (error: any): void => { this.logger.error(`Coinbase wallet error`, error, { wallet: this.walletName, }); this.setState(Adapter.Status.ERROR); this.disconnect(); }; destroy(): void { this.removeWalletListeners(); } async isConnected(): Promise<boolean> { if (!this.coinbaseAdapter) return false; return ( this.coinbaseAdapter.connected && this.identity !== null && !this.identity.getPrincipal().isAnonymous() ); } async connect(): Promise<Wallet.Account> { if (this.connectingPromise) return this.connectingPromise; this.connectingPromise = (async () => { if (!this.isBrowser()) { throw new Error("Cannot connect to Coinbase wallet in non-browser environment"); } // Validate provider canister this.resolveProviderCanisterId(); // If already connected, return the current account if (this.identity && this.state === Adapter.Status.CONNECTED) { const principal = this.identity.getPrincipal(); return { owner: principal.toText(), subaccount: deriveAccountId(principal), }; } this.setState(Adapter.Status.CONNECTING); try { // Ensure Coinbase adapter is ready if (!this.coinbaseAdapter) { this.initializeCoinbaseAdapter(); } if (!this.coinbaseAdapter) { throw new Error("Failed to initialize Coinbase adapter"); } // Connect to Coinbase wallet if not already connected if (!this.coinbaseAdapter.connected) { this.logger.debug(`Connecting to Coinbase wallet...`); // Check if Coinbase is installed if (this.coinbaseAdapter.readyState === WalletReadyState.NotDetected) { throw new Error("Coinbase wallet is not installed. Please install the Coinbase Wallet browser extension."); } try { await this.coinbaseAdapter.connect(); } catch (error: any) { this.logger.error(`Coinbase connection error`, error, { wallet: this.walletName, }); if ( error.name === "WalletWindowClosedError" || error.message?.includes("User rejected the request") || error.message?.includes("Wallet closed") ) { this.setState(Adapter.Status.DISCONNECTED); throw new Error("Connection cancelled by user"); } this.setState(Adapter.Status.ERROR); throw error; } } // Verify connection was successful if (!this.coinbaseAdapter.publicKey) { throw new Error("Coinbase wallet connected but no public key available"); } // Verify adapter supports message signing (required for SIWS) if (!("signMessage" in this.coinbaseAdapter)) { throw new Error(`Coinbase wallet does not support message signing required for SIWS`); } // Perform SIWS authentication const address = this.coinbaseAdapter.publicKey.toBase58(); this.solanaAddress = address; await this.storeExternalAddress(`${this.id}-solana-address`, address); const { identity, sessionKey } = await this.performSiwsLogin(address, this.coinbaseAdapter); this.identity = identity; this.sessionKey = sessionKey; const principal = identity.getPrincipal(); this.setState(Adapter.Status.CONNECTED); return { owner: principal.toText(), subaccount: deriveAccountId(principal), }; } catch (error) { this.logger.error(`Connect process failed`, error as Error, { wallet: this.walletName, }); this.setState(Adapter.Status.ERROR); throw error; } finally { this.connectingPromise = null; } })(); return this.connectingPromise; } private async createSiwsProviderActor(identity?: Identity): Promise<ActorSubclass<SiwsProviderService>> { const id = identity ?? new AnonymousIdentity(); return this.createProviderActor<SiwsProviderService>(siwsProviderIdlFactory, id); } private async signSiwsMessage(siwsMessage: any, adapter: CoinbaseWalletAdapter): Promise<string> { const messageText = formatSiwsMessage(siwsMessage); const messageBytes = new TextEncoder().encode(messageText); if (!("signMessage" in adapter)) { throw new Error(`Coinbase wallet does not support signMessage.`); } const signatureBytes: any = await adapter.signMessage(messageBytes); // Normalize to base58 if (signatureBytes instanceof Uint8Array) { return bs58.encode(signatureBytes); } if (signatureBytes instanceof ArrayBuffer) { return bs58.encode(new Uint8Array(signatureBytes)); } // Handle any other format try { const arr = Array.isArray(signatureBytes) ? signatureBytes : Object.values(signatureBytes as any); return bs58.encode(new Uint8Array(arr as any)); } catch (e) { this.logger.error(`Error encoding signature`, e as Error, { wallet: this.walletName }); throw new Error(`Failed to encode signature from Coinbase: ${(e as Error).message}`); } } private async performSiwsLogin( address: string, adapter: CoinbaseWalletAdapter, ): Promise<{ identity: DelegationIdentity; sessionKey: Ed25519KeyIdentity }> { const actor = await this.createSiwsProviderActor(); // 1) Prepare login const prepareResult = await actor.siws_prepare_login(address); if ("Err" in prepareResult) { throw new Error(`SIWS Prepare Login failed: ${prepareResult.Err}`); } // 2) Sign message const signature = await this.signSiwsMessage(prepareResult.Ok, adapter); // 3) Generate session identity and do login const sessionIdentity = Ed25519KeyIdentity.generate(); const sessionPublicKeyDer = sessionIdentity.getPublicKey().toDer(); const loginResult = await actor.siws_login( signature, address, new Uint8Array(sessionPublicKeyDer), prepareResult.Ok.nonce, ); if ("Err" in loginResult) { throw new Error(`SIWS Login failed: ${loginResult.Err}`); } // 4) Fetch signed delegation const delegationResult = await actor.siws_get_delegation( address, new Uint8Array(sessionPublicKeyDer), loginResult.Ok.expiration, ); if ("Err" in delegationResult) { throw new Error(`SIWS Get Delegation failed: ${delegationResult.Err}`); } // 5) Build identity const identity = this.createDelegationIdentity( delegationResult.Ok, sessionIdentity, new Uint8Array(loginResult.Ok.user_canister_pubkey).buffer as ArrayBuffer, ); return { identity, sessionKey: sessionIdentity }; } protected async disconnectInternal(): Promise<void> { // Clear stored delegation session and identity await super.disconnectInternal(); // Disconnect Coinbase adapter if (this.coinbaseAdapter) { try { if (this.coinbaseAdapter.connected) { this.removeWalletListeners(); await this.coinbaseAdapter.disconnect(); this.setupWalletListeners(); } } catch (error) { this.logger.warn(`Error during Coinbase disconnect`, { error, wallet: this.walletName, }); } } // Clear local state this.identity = null; this.solanaAddress = null; } async getPrincipal(): Promise<string> { if (!this.identity) { throw new Error("Not connected or SIWS flow not completed."); } return this.identity.getPrincipal().toText(); } async getAccountId(): Promise<string> { const principal = await this.getPrincipal(); if (!principal) throw new Error("Principal not available to derive account ID"); return deriveAccountId(principal); } async getSolanaAddress(): Promise<string> { if (!this.solanaAddress) { throw new Error("Not connected or Solana address not available."); } return this.solanaAddress; } async getAddresses(): Promise<Adapter.Addresses> { const principal = this.identity?.getPrincipal(); return { sol: { address: this.solanaAddress, network: this.config.solanaNetwork, }, icp: { address: principal?.toText(), subaccount: principal ? deriveAccountId(principal) : undefined, }, }; } protected createActorInternal<T>( canisterId: string, idl: any, options?: { requiresSigning?: boolean }, ): ActorSubclass<T> { const requiresSigning = options?.requiresSigning ?? true; if (requiresSigning && !this.identity) { throw new Error( "Cannot create signed actor: Not connected or SIWS flow not completed.", ); } const agent = this.buildHttpAgentSync({ identity: this.identity ?? undefined }); return this.createActorWithAgent<T>(agent as any, canisterId, idl); } async getSolBalance(): Promise<{ amount: number; usdValue?: number }> { if (!this.coinbaseAdapter?.publicKey) { throw new Error("Wallet not connected"); } try { const balance = await this.solanaConnection.getBalance(this.coinbaseAdapter.publicKey); const solAmount = balance / LAMPORTS_PER_SOL; // USD value can be fetched from a price API if needed return { amount: solAmount }; } catch (error) { this.logger.error(`Failed to get SOL balance`, error as Error, { wallet: this.walletName, }); throw error; } } async estimateTransactionFee(transaction: Transaction): Promise<number> { if (!this.coinbaseAdapter?.publicKey) { throw new Error("Wallet not connected"); } try { const { blockhash } = await this.solanaConnection.getLatestBlockhash(); transaction.recentBlockhash = blockhash; transaction.feePayer = this.coinbaseAdapter.publicKey; const message = transaction.compileMessage(); const fee = await this.solanaConnection.getFeeForMessage(message, 'confirmed'); if (fee.value === null) { throw new Error("Unable to estimate fee"); } return fee.value / LAMPORTS_PER_SOL; } catch (error) { this.logger.error(`Failed to estimate transaction fee`, error as Error, { wallet: this.walletName, }); throw error; } } async getTransactionStatus(signature: string): Promise<{ confirmed: boolean; slot?: number; err?: any; }> { try { const status = await this.solanaConnection.getSignatureStatus(signature); if (!status.value) { return { confirmed: false }; } return { confirmed: status.value.confirmationStatus === 'confirmed' || status.value.confirmationStatus === 'finalized', slot: status.value.slot, err: status.value.err, }; } catch (error) { this.logger.error(`Failed to get transaction status`, error as Error, { wallet: this.walletName, signature, }); throw error; } } protected async onStorageRestored(_sessionKey: Ed25519KeyIdentity, _delegationChain: DelegationChain): Promise<void> { const storedSolanaAddress = await this.readExternalAddress(`${this.id}-solana-address`); if (storedSolanaAddress) this.solanaAddress = storedSolanaAddress; } protected async onClearStoredSession(): Promise<void> { this.solanaAddress = null; await this.storage.remove(`${this.id}-solana-address`); } }