UNPKG

@windoge98/pnp-coinbase

Version:

Coinbase wallet adapter for Plug-n-Play

444 lines (443 loc) 16.3 kB
import { BaseSiwxAdapter, Adapter, deriveAccountId, formatSiwsMessage, createAdapterExtension } from "@windoge98/plug-n-play"; import { formatSiwsMessage as formatSiwsMessage2 } from "@windoge98/plug-n-play"; import { AnonymousIdentity } from "@dfinity/agent"; import { CoinbaseWalletAdapter } from "@solana/wallet-adapter-coinbase"; import { WalletAdapterNetwork, WalletReadyState } from "@solana/wallet-adapter-base"; import { Connection, LAMPORTS_PER_SOL } from "@solana/web3.js"; import { Ed25519KeyIdentity } from "@dfinity/identity"; import bs58 from "bs58"; const idlFactory = ({ IDL }) => { const RuntimeFeature = IDL.Variant({ "IncludeUriInSeed": IDL.Null, "DisablePrincipalToSolMapping": IDL.Null, "DisableSolToPrincipalMapping": IDL.Null }); IDL.Record({ "uri": IDL.Text, "runtime_features": IDL.Opt(IDL.Vec(RuntimeFeature)), "domain": IDL.Text, "statement": IDL.Opt(IDL.Text), "scheme": IDL.Opt(IDL.Text), "salt": IDL.Text, "session_expires_in": IDL.Opt(IDL.Nat64), "targets": IDL.Opt(IDL.Vec(IDL.Text)), "chain_id": IDL.Opt(IDL.Text), "sign_in_expires_in": IDL.Opt(IDL.Nat64) }); const Principal = IDL.Vec(IDL.Nat8); const Address = IDL.Text; const GetAddressResponse = IDL.Variant({ "Ok": Address, "Err": IDL.Text }); const GetPrincipalResponse = IDL.Variant({ "Ok": Principal, "Err": IDL.Text }); const PublicKey = IDL.Vec(IDL.Nat8); const SessionKey = PublicKey; const Timestamp = IDL.Nat64; const Delegation = IDL.Record({ "pubkey": PublicKey, "targets": IDL.Opt(IDL.Vec(IDL.Principal)), "expiration": Timestamp }); const SignedDelegation = IDL.Record({ "signature": IDL.Vec(IDL.Nat8), "delegation": Delegation }); const GetDelegationResponse = IDL.Variant({ "Ok": SignedDelegation, "Err": IDL.Text }); const SiwsSignature = IDL.Text; const Nonce = IDL.Text; const CanisterPublicKey = PublicKey; const LoginDetails = IDL.Record({ "user_canister_pubkey": CanisterPublicKey, "expiration": Timestamp }); const LoginResponse = IDL.Variant({ "Ok": LoginDetails, "Err": IDL.Text }); const SiwsMessage = IDL.Record({ "uri": IDL.Text, "issued_at": IDL.Nat64, "domain": IDL.Text, "statement": IDL.Text, "version": IDL.Nat32, "chain_id": IDL.Text, "address": Address, "nonce": IDL.Text, "expiration_time": IDL.Nat64 }); const PrepareLoginResponse = IDL.Variant({ "Ok": SiwsMessage, "Err": IDL.Text }); return IDL.Service({ "get_address": IDL.Func([Principal], [GetAddressResponse], ["query"]), "get_caller_address": IDL.Func([], [GetAddressResponse], ["query"]), "get_principal": IDL.Func([Address], [GetPrincipalResponse], ["query"]), "siws_get_delegation": IDL.Func( [Address, SessionKey, Timestamp], [GetDelegationResponse], ["query"] ), "siws_login": IDL.Func( [SiwsSignature, Address, SessionKey, Nonce], [LoginResponse], [] ), "siws_prepare_login": IDL.Func([Address], [PrepareLoginResponse], []) }); }; const _CoinbaseAdapter = class _CoinbaseAdapter extends BaseSiwxAdapter { constructor(args) { super(args); this.walletName = "Coinbase Wallet"; this.id = "coinbase"; this.coinbaseAdapter = null; this.solanaAddress = null; this.connectingPromise = null; this.handleCoinbaseConnect = (publicKey) => { this.solanaAddress = publicKey.toBase58(); this.logger.debug(`Coinbase wallet connected`, { address: this.solanaAddress }); }; this.handleCoinbaseDisconnect = () => { if (this.state !== Adapter.Status.DISCONNECTING && this.state !== Adapter.Status.DISCONNECTED) { this.disconnect(); } }; this.handleCoinbaseError = (error) => { this.logger.error(`Coinbase wallet error`, error, { wallet: this.walletName }); this.setState(Adapter.Status.ERROR); this.disconnect(); }; this.id = args.adapter.id; this.walletName = args.adapter.walletName; this.logo = args.adapter.logo; this.config = args.config; this.initializeConnection(); if (this.isBrowser()) { this.initializeCoinbaseAdapter(); } this.setState(Adapter.Status.READY); } resolveProviderCanisterId() { const cfg = this.config; const canisterId = cfg.providerCanisterId || cfg.siwsProviderCanisterId; if (!canisterId) { throw new Error("SIWS provider canister ID not configured."); } return String(canisterId); } isBrowser() { return typeof window !== "undefined" && typeof window.document !== "undefined"; } initializeConnection() { 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); } initializeCoinbaseAdapter() { this.coinbaseAdapter = new CoinbaseWalletAdapter(); this.setupWalletListeners(); } setupWalletListeners() { if (!this.coinbaseAdapter) return; this.coinbaseAdapter.on("connect", this.handleCoinbaseConnect); this.coinbaseAdapter.on("disconnect", this.handleCoinbaseDisconnect); this.coinbaseAdapter.on("error", this.handleCoinbaseError); } removeWalletListeners() { if (!this.coinbaseAdapter) return; this.coinbaseAdapter.off("connect", this.handleCoinbaseConnect); this.coinbaseAdapter.off("disconnect", this.handleCoinbaseDisconnect); this.coinbaseAdapter.off("error", this.handleCoinbaseError); } destroy() { this.removeWalletListeners(); } async isConnected() { if (!this.coinbaseAdapter) return false; return this.coinbaseAdapter.connected && this.identity !== null && !this.identity.getPrincipal().isAnonymous(); } async connect() { if (this.connectingPromise) return this.connectingPromise; this.connectingPromise = (async () => { if (!this.isBrowser()) { throw new Error("Cannot connect to Coinbase wallet in non-browser environment"); } this.resolveProviderCanisterId(); 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 { if (!this.coinbaseAdapter) { this.initializeCoinbaseAdapter(); } if (!this.coinbaseAdapter) { throw new Error("Failed to initialize Coinbase adapter"); } if (!this.coinbaseAdapter.connected) { this.logger.debug(`Connecting to Coinbase wallet...`); 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) { 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; } } if (!this.coinbaseAdapter.publicKey) { throw new Error("Coinbase wallet connected but no public key available"); } if (!("signMessage" in this.coinbaseAdapter)) { throw new Error(`Coinbase wallet does not support message signing required for SIWS`); } 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, { wallet: this.walletName }); this.setState(Adapter.Status.ERROR); throw error; } finally { this.connectingPromise = null; } })(); return this.connectingPromise; } async createSiwsProviderActor(identity) { const id = identity ?? new AnonymousIdentity(); return this.createProviderActor(idlFactory, id); } async signSiwsMessage(siwsMessage, adapter) { 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 = await adapter.signMessage(messageBytes); if (signatureBytes instanceof Uint8Array) { return bs58.encode(signatureBytes); } if (signatureBytes instanceof ArrayBuffer) { return bs58.encode(new Uint8Array(signatureBytes)); } try { const arr = Array.isArray(signatureBytes) ? signatureBytes : Object.values(signatureBytes); return bs58.encode(new Uint8Array(arr)); } catch (e) { this.logger.error(`Error encoding signature`, e, { wallet: this.walletName }); throw new Error(`Failed to encode signature from Coinbase: ${e.message}`); } } async performSiwsLogin(address, adapter) { const actor = await this.createSiwsProviderActor(); const prepareResult = await actor.siws_prepare_login(address); if ("Err" in prepareResult) { throw new Error(`SIWS Prepare Login failed: ${prepareResult.Err}`); } const signature = await this.signSiwsMessage(prepareResult.Ok, adapter); 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}`); } 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}`); } const identity = this.createDelegationIdentity( delegationResult.Ok, sessionIdentity, new Uint8Array(loginResult.Ok.user_canister_pubkey).buffer ); return { identity, sessionKey: sessionIdentity }; } async disconnectInternal() { await super.disconnectInternal(); 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 }); } } this.identity = null; this.solanaAddress = null; } async getPrincipal() { if (!this.identity) { throw new Error("Not connected or SIWS flow not completed."); } return this.identity.getPrincipal().toText(); } async getAccountId() { const principal = await this.getPrincipal(); if (!principal) throw new Error("Principal not available to derive account ID"); return deriveAccountId(principal); } async getSolanaAddress() { if (!this.solanaAddress) { throw new Error("Not connected or Solana address not available."); } return this.solanaAddress; } async getAddresses() { const principal = this.identity?.getPrincipal(); return { sol: { address: this.solanaAddress, network: this.config.solanaNetwork }, icp: { address: principal?.toText(), subaccount: principal ? deriveAccountId(principal) : void 0 } }; } createActorInternal(canisterId, idl, options) { 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 ?? void 0 }); return this.createActorWithAgent(agent, canisterId, idl); } async getSolBalance() { 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; return { amount: solAmount }; } catch (error) { this.logger.error(`Failed to get SOL balance`, error, { wallet: this.walletName }); throw error; } } async estimateTransactionFee(transaction) { 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, { wallet: this.walletName }); throw error; } } async getTransactionStatus(signature) { 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, { wallet: this.walletName, signature }); throw error; } } async onStorageRestored(_sessionKey, _delegationChain) { const storedSolanaAddress = await this.readExternalAddress(`${this.id}-solana-address`); if (storedSolanaAddress) this.solanaAddress = storedSolanaAddress; } async onClearStoredSession() { this.solanaAddress = null; await this.storage.remove(`${this.id}-solana-address`); } }; _CoinbaseAdapter.supportedChains = [ Adapter.Chain.ICP, Adapter.Chain.SOL ]; let CoinbaseAdapter = _CoinbaseAdapter; const coinbaseLogo = "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%201024%201024'%3e%3crect%20width='1024'%20height='1024'%20rx='512'%20fill='%230052FF'/%3e%3cpath%20fill='%23FFF'%20d='M512%20692c-99.4%200-180-80.6-180-180s80.6-180%20180-180c89.1%200%20163.2%2065%20177.5%20150h-90c-12.6-34.4-45.6-60-85.5-60-49.7%200-90%2040.3-90%2090s40.3%2090%2090%2090c39.9%200%2072.9-25.6%2085.5-60h90C675.2%20627%20602.1%20692%20512%20692z'/%3e%3c/svg%3e"; const CoinbaseExtension = createAdapterExtension({ coinbase: { id: "coinbase", enabled: false, walletName: "Coinbase Wallet", logo: coinbaseLogo, website: "https://www.coinbase.com/wallet", chain: "SOL", adapter: CoinbaseAdapter, config: { enabled: false, solanaNetwork: WalletAdapterNetwork.Mainnet, // Provider canister ID should be set by the user siwsProviderCanisterId: "" } } }); export { CoinbaseAdapter, CoinbaseExtension, formatSiwsMessage2 as formatSiwsMessage };