@windoge98/pnp-coinbase
Version:
Coinbase wallet adapter for Plug-n-Play
444 lines (443 loc) • 16.3 kB
JavaScript
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
};