sui-svelte-wallet-kit
Version:
Svelte 5 wallet kit for Sui: connect wallets, manage accounts, SuiNS, balance, sign transactions/messages
429 lines (428 loc) • 19.7 kB
JavaScript
/**
* PasskeyWalletAdapter
* Implements SuiWalletAdapter interface for passkey-based accounts
* Based on Mysten Labs passkey-example: https://github.com/MystenLabs/passkey-example
*/
import { PasskeyService, PasskeyError, PasskeyErrorCode, LocalStorageCredentialStorage, createEmptyStorage, addCredential, findCredentialsByRpId } from '../PasskeyService/index.js';
import { toBase64 } from '@mysten/sui/utils';
import { PasskeyPublicKey } from '@mysten/sui/keypairs/passkey';
import { PasskeyKeypair, BrowserPasskeyProvider } from '@mysten/sui/keypairs/passkey';
// Passkey wallet icon (key emoji as data URL)
const PASSKEY_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Im0xNS41IDcuNSAyLjMgMi4zYTEgMSAwIDAgMCAxLjQgMGwyLjEtMi4xYTEgMSAwIDAgMCAwLTEuNEwxOSA0Ii8+PHBhdGggZD0ibTIxIDJsLTkuNiA5LjYiLz48Y2lyY2xlIGN4PSI3LjUiIGN5PSIxNS41IiByPSI1LjUiLz48L3N2Zz4=';
/**
* PasskeyWalletAdapter class
*/
export class PasskeyWalletAdapter {
name = 'Passkey';
version = '1.0.0';
icon;
chains = ['sui:mainnet', 'sui:testnet', 'sui:devnet'];
config;
storage;
_accounts = [];
_connected = false;
currentCredential = null;
passkeyKeypair = null;
passkeyProvider;
constructor(config) {
this.config = config;
this.storage = config.storage || new LocalStorageCredentialStorage();
this.icon = config.icon || PASSKEY_ICON;
// Create BrowserPasskeyProvider (same as Mysten Labs example)
this.passkeyProvider = new BrowserPasskeyProvider(config.rpName, {
rpName: config.rpName,
rpId: config.rpId,
authenticatorSelection: {
authenticatorAttachment: config.authenticatorAttachment || 'platform'
}
});
}
get accounts() {
return this._accounts;
}
get features() {
return {
'standard:connect': {
connect: () => this.connect()
},
'standard:disconnect': {
disconnect: () => this.disconnect()
},
'sui:signAndExecuteTransaction': {
signAndExecuteTransaction: (params) => this.signAndExecuteTransaction(params)
},
'sui:signTransaction': {
signTransaction: (params) => this.signTransaction(params)
},
'sui:signPersonalMessage': {
signPersonalMessage: (params) => this.signPersonalMessage(params)
},
'passkey:getCredential': {
getCredential: () => this.getCredential()
}
};
}
/**
* Connect to passkey wallet
* Always tries to authenticate with existing passkey first (allows recovery after localStorage clear)
* Falls back to creating new passkey if authentication fails
*/
async connect() {
if (!PasskeyService.isSupported()) {
throw new PasskeyError(PasskeyErrorCode.WEBAUTHN_NOT_SUPPORTED, 'WebAuthn is not supported in this browser');
}
// Always try to authenticate first - this allows user to select existing passkey
// even if localStorage was cleared
// console.log('[PasskeyWalletAdapter] Attempting to authenticate with existing passkey...');
try {
await this.authenticateExistingWallet();
return;
}
catch (error) {
// If user cancelled or no passkey exists, fall back to creating new
const isUserCancelled = error instanceof Error &&
(error.message.includes('cancelled') ||
error.message.includes('NotAllowedError') ||
error.name === 'NotAllowedError');
if (isUserCancelled) {
// console.log('[PasskeyWalletAdapter] User cancelled, trying to create new passkey...');
}
else {
// console.warn('[PasskeyWalletAdapter] Authentication failed, creating new passkey...', error);
}
}
// Create a fresh passkey instance
// console.log('[PasskeyWalletAdapter] Creating passkey wallet...');
await this.createNewWallet();
}
/**
* Silent connect - restore from storage without prompting
* Used for auto-connect on page refresh
* Returns true if successfully restored, false if no stored credentials
*/
async silentConnect() {
if (!PasskeyService.isSupported()) {
return false;
}
try {
const storedCredentials = await this.storage.get();
const existingCredentials = storedCredentials
? findCredentialsByRpId(storedCredentials, this.config.rpId)
: [];
if (existingCredentials.length === 0) {
// console.log('[PasskeyWalletAdapter] No stored credentials for silent connect');
return false;
}
// Use the most recently used credential
const mostRecent = existingCredentials.sort((a, b) => (b.lastUsedAt || b.createdAt) - (a.lastUsedAt || a.createdAt))[0];
// console.log('[PasskeyWalletAdapter] Silent connect - restoring from storage:', { suiAddress: mostRecent.suiAddress });
// Restore PasskeyKeypair from stored public key
// Note: This won't prompt user, but signing will prompt later
const { fromBase64 } = await import('@mysten/sui/utils');
const publicKeyBytes = fromBase64(mostRecent.publicKey);
this.passkeyKeypair = new PasskeyKeypair(publicKeyBytes, this.passkeyProvider);
// Some older stored entries may have an address derived differently.
// Always trust the address derived from the public key used for signing.
const derivedSuiAddress = this.passkeyKeypair.getPublicKey().toSuiAddress();
if (mostRecent.suiAddress !== derivedSuiAddress) {
try {
const currentStorage = storedCredentials || createEmptyStorage();
const updatedCredential = {
...mostRecent,
suiAddress: derivedSuiAddress,
lastUsedAt: Date.now()
};
const updatedStorage = addCredential(currentStorage, updatedCredential);
await this.storage.set(updatedStorage);
}
catch { }
}
this.currentCredential = {
credentialId: mostRecent.credentialId,
publicKey: publicKeyBytes,
suiAddress: derivedSuiAddress,
createdAt: mostRecent.createdAt
};
this.setupAccount(this.currentCredential);
this._connected = true;
// console.log('[PasskeyWalletAdapter] Silent connect successful!');
return true;
}
catch (error) {
// console.warn('[PasskeyWalletAdapter] Silent connect failed:', error);
return false;
}
}
/**
* Authenticate with existing passkey using signAndRecover
* This prompts user once to select an existing passkey, then verifies against stored public keys
*/
async authenticateExistingWallet() {
// console.log('[PasskeyWalletAdapter] Authenticating with existing passkey...');
// Sign once to get possible public keys
const testMessage = new TextEncoder().encode('Sui Passkey Authentication');
const possiblePks = await PasskeyKeypair.signAndRecover(this.passkeyProvider, testMessage);
// Get stored credentials to match against
const currentStorage = (await this.storage.get()) || createEmptyStorage();
const existingCredentials = findCredentialsByRpId(currentStorage, this.config.rpId);
// Try to find a matching public key from stored credentials
let matchedPk = null;
let matchedCredential = null;
for (const pk of possiblePks) {
const pkBase64 = toBase64(pk.toRawBytes());
const credential = existingCredentials.find((c) => c.publicKey === pkBase64);
if (credential) {
matchedPk = pk;
matchedCredential = credential;
break;
}
}
if (matchedPk && matchedCredential) {
// Found matching credential - use it
const publicKeyBytes = matchedPk.toRawBytes();
const suiAddress = matchedPk.toSuiAddress();
// console.log('[PasskeyWalletAdapter] Matched existing credential:', { suiAddress, publicKeyBase64: toBase64(publicKeyBytes) });
this.passkeyKeypair = new PasskeyKeypair(publicKeyBytes, this.passkeyProvider);
// Update last used timestamp
const updatedCredential = {
...matchedCredential,
suiAddress,
lastUsedAt: Date.now()
};
const updatedStorage = addCredential(currentStorage, updatedCredential);
await this.storage.set(updatedStorage);
this.currentCredential = {
credentialId: matchedCredential.credentialId,
publicKey: publicKeyBytes,
suiAddress,
createdAt: matchedCredential.createdAt
};
}
else {
// No match found - this is a new passkey, need second sign to confirm
// console.log('[PasskeyWalletAdapter] No match found, confirming with second sign...');
const { findCommonPublicKey } = await import('@mysten/sui/keypairs/passkey');
const testMessage2 = new TextEncoder().encode('Sui Passkey Confirm');
const possiblePks2 = await PasskeyKeypair.signAndRecover(this.passkeyProvider, testMessage2);
const recoveredPk = findCommonPublicKey(possiblePks, possiblePks2);
const publicKeyBytes = recoveredPk.toRawBytes();
const suiAddress = recoveredPk.toSuiAddress();
// console.log('[PasskeyWalletAdapter] New passkey confirmed:', { suiAddress, publicKeyBase64: toBase64(publicKeyBytes) });
this.passkeyKeypair = new PasskeyKeypair(publicKeyBytes, this.passkeyProvider);
// Save new credential
const credentialIdBytes = new Uint8Array(32);
crypto.getRandomValues(credentialIdBytes);
const credentialId = toBase64(credentialIdBytes);
const newStoredCredential = {
credentialId,
publicKey: toBase64(publicKeyBytes),
suiAddress,
createdAt: Date.now(),
rpId: this.config.rpId
};
const updatedStorage = addCredential(currentStorage, newStoredCredential);
await this.storage.set(updatedStorage);
this.currentCredential = {
credentialId,
publicKey: publicKeyBytes,
suiAddress,
createdAt: newStoredCredential.createdAt
};
}
this.setupAccount(this.currentCredential);
this._connected = true;
// console.log('[PasskeyWalletAdapter] Authentication successful!');
}
/**
* Create/connect passkey wallet using PasskeyKeypair.getPasskeyInstance
* This always prompts user to select/create a passkey, ensuring public key matches
*/
async createNewWallet() {
// console.log('[PasskeyWalletAdapter] Getting passkey instance...');
// Use PasskeyKeypair.getPasskeyInstance (same as Mysten Labs example)
// This prompts user to select or create a passkey
this.passkeyKeypair = await PasskeyKeypair.getPasskeyInstance(this.passkeyProvider);
const publicKey = this.passkeyKeypair.getPublicKey();
const suiAddress = publicKey.toSuiAddress();
const publicKeyBytes = publicKey.toRawBytes();
// console.log('[PasskeyWalletAdapter] Passkey connected:', { suiAddress, publicKeyLength: publicKeyBytes.length, publicKeyBase64: toBase64(publicKeyBytes) });
// Check if this address already exists in storage
const currentStorage = (await this.storage.get()) || createEmptyStorage();
const existingCredentials = findCredentialsByRpId(currentStorage, this.config.rpId);
const existingCredential = existingCredentials.find((c) => c.suiAddress === suiAddress);
if (existingCredential) {
// Update existing credential with new public key (in case it changed)
// console.log('[PasskeyWalletAdapter] Found existing credential, updating...');
const updatedCredential = {
...existingCredential,
publicKey: toBase64(publicKeyBytes),
lastUsedAt: Date.now()
};
const updatedStorage = addCredential(currentStorage, updatedCredential);
await this.storage.set(updatedStorage);
this.currentCredential = {
credentialId: existingCredential.credentialId,
publicKey: publicKeyBytes,
suiAddress,
createdAt: existingCredential.createdAt
};
}
else {
// Create new credential entry
// console.log('[PasskeyWalletAdapter] Creating new credential entry...');
const credentialIdBytes = new Uint8Array(32);
crypto.getRandomValues(credentialIdBytes);
const credentialId = toBase64(credentialIdBytes);
const newStoredCredential = {
credentialId,
publicKey: toBase64(publicKeyBytes),
suiAddress,
createdAt: Date.now(),
rpId: this.config.rpId
};
const updatedStorage = addCredential(currentStorage, newStoredCredential);
await this.storage.set(updatedStorage);
this.currentCredential = {
credentialId,
publicKey: publicKeyBytes,
suiAddress,
createdAt: newStoredCredential.createdAt
};
}
this.setupAccount(this.currentCredential);
this._connected = true;
// console.log('[PasskeyWalletAdapter] Connected successfully!');
}
/**
* Disconnect from passkey wallet
*/
async disconnect() {
this._accounts = [];
this._connected = false;
this.currentCredential = null;
this.passkeyKeypair = null;
}
/**
* Sign and execute a transaction
* Following Mysten Labs passkey-example approach
*/
async signAndExecuteTransaction(params) {
if (!this._connected || !this.currentCredential || !this.passkeyKeypair) {
throw new PasskeyError(PasskeyErrorCode.INVALID_CREDENTIAL, 'Not connected to passkey wallet');
}
const { SuiClient, getFullnodeUrl } = await import('@mysten/sui/client');
// Get network from chain
const network = params.chain.replace('sui:', '');
const client = new SuiClient({ url: getFullnodeUrl(network) });
const transaction = params.transaction;
// Use the verified address
const senderAddress = this.passkeyKeypair.getPublicKey().toSuiAddress();
// Ensure sender is set
if (typeof transaction.setSender === 'function') {
transaction.setSender(senderAddress);
}
// console.log('[PasskeyWalletAdapter] Signing transaction for address:', senderAddress);
// console.log('[PasskeyWalletAdapter] Public key:', toBase64(this.passkeyKeypair.getPublicKey().toRawBytes()));
// Build the transaction first
const txBytes = await transaction.build({ client });
// console.log('[PasskeyWalletAdapter] Transaction bytes:', toBase64(txBytes));
// Sign the transaction bytes
const { signature } = await this.passkeyKeypair.signTransaction(txBytes);
// console.log('[PasskeyWalletAdapter] Transaction signed, signature:', signature);
// console.log('[PasskeyWalletAdapter] Executing...');
// Execute the signed transaction
const result = await client.executeTransactionBlock({
transactionBlock: txBytes,
signature,
options: {
showEffects: true,
showEvents: true
}
});
return result;
}
/**
* Sign (but do NOT execute) a transaction.
* This is required for multisig "collect signatures" flows.
*
* Important: we intentionally do NOT override sender here. The caller may set
* sender to a multisig address to ensure all signers sign identical bytes.
*/
async signTransaction(params) {
if (!this._connected || !this.currentCredential || !this.passkeyKeypair) {
throw new PasskeyError(PasskeyErrorCode.INVALID_CREDENTIAL, 'Not connected to passkey wallet');
}
const { SuiClient, getFullnodeUrl } = await import('@mysten/sui/client');
const network = params.chain.replace('sui:', '');
const client = new SuiClient({ url: getFullnodeUrl(network) });
const transaction = params.transaction;
const txBytes = await transaction.build({ client });
const { signature } = await this.passkeyKeypair.signTransaction(txBytes);
return { signature, bytes: txBytes };
}
/**
* Sign a personal message
*/
async signPersonalMessage(params) {
if (!this._connected || !this.passkeyKeypair) {
throw new PasskeyError(PasskeyErrorCode.INVALID_CREDENTIAL, 'Not connected to passkey wallet');
}
// Sign using PasskeyKeypair
const { signature } = await this.passkeyKeypair.signPersonalMessage(params.message);
return {
signature,
messageBytes: toBase64(params.message)
};
}
/**
* Get current credential
*/
async getCredential() {
return this.currentCredential;
}
/**
* Check if connected
*/
isConnected() {
return this._connected;
}
/**
* Set up account from credential
*/
setupAccount(credential) {
const network = this.config.network || 'mainnet';
let address = credential.suiAddress;
try {
// Ensure the displayed address matches the Passkey scheme address.
// Passkey addresses are derived with the Passkey signature flag (not Secp256r1).
address = new PasskeyPublicKey(credential.publicKey).toSuiAddress();
}
catch { }
const account = {
address,
publicKey: credential.publicKey,
chains: [`sui:${network}`],
features: [
'sui:signAndExecuteTransaction',
'sui:signTransaction',
'sui:signPersonalMessage',
'passkey:getCredential'
],
credentialId: credential.credentialId
};
this._accounts = [account];
}
/**
* Get RPC URL for network using SDK's getFullnodeUrl
*/
async getRpcUrl(network) {
const { getFullnodeUrl } = await import('@mysten/sui/client');
return getFullnodeUrl(network);
}
}
/**
* Create a PasskeyWalletAdapter instance
*/
export function createPasskeyWalletAdapter(config) {
return new PasskeyWalletAdapter(config);
}