UNPKG

sui-svelte-wallet-kit

Version:

Svelte 5 wallet kit for Sui: connect wallets, manage accounts, SuiNS, balance, sign transactions/messages

699 lines (698 loc) 29.1 kB
/** * Multisig Store using Svelte 5 Runes * Class-based reactive state management */ import { Transaction } from '@mysten/sui/transactions'; import { SuiClient, getFullnodeUrl } from '@mysten/sui/client'; import { MultiSigPublicKey } from '@mysten/sui/multisig'; import { toZkLoginPublicIdentifier } from '@mysten/sui/zklogin'; import { toBase64 } from '@mysten/sui/utils'; import { parseSerializedSignature } from '@mysten/sui/cryptography'; import { MultisigError, MultisigErrorCode } from './MultisigTypes.js'; import { SignerResolver, parsePublicKey } from './SignerResolver.js'; import { isPreconfiguredMode, isDynamicMode } from './MultisigValidation.js'; /** * Derive multisig address from resolved signers */ function deriveMultisigAddress(signers, threshold) { const resolvedSigners = signers.filter((s) => s.resolved && s.publicKey); if (resolvedSigners.length === 0) return null; // Check if threshold is reachable const totalWeight = resolvedSigners.reduce((sum, s) => sum + s.weight, 0); if (threshold > totalWeight) { // Don't log error - this is expected when user is still adding signers return null; } try { const publicKeys = resolvedSigners.map((signer) => { let pk; if (signer.type === 'zklogin' && signer.addressSeed && signer.issuer) { pk = toZkLoginPublicIdentifier(BigInt(signer.addressSeed), signer.issuer); } else if (signer.publicKey) { pk = signer.publicKey; } else { throw new Error(`Signer ${signer.id} has no public key`); } return { publicKey: pk, weight: signer.weight }; }); const multiSigPublicKey = MultiSigPublicKey.fromPublicKeys({ threshold, publicKeys }); return multiSigPublicKey.toSuiAddress(); } catch (error) { console.error('Failed to derive multisig address:', error); return null; } } function bytesEqual(a, b) { if (a === b) return true; if (!a || !b) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } /** * Multisig Store Class * Uses Svelte 5 Runes for reactive state */ export class MultisigStore { // Reactive state using $state #config = $state(null); #threshold = $state(1); #mode = $state(null); #resolvedSigners = $state([]); #rawSigners = $state([]); #error = $state(null); #network = $state('testnet'); // Providers (set by SuiModule) #providers = $state(null); // Resolver instance #resolver = new SignerResolver(); // Derived state using $derived resolvedCount = $derived(this.#resolvedSigners.filter((s) => s.resolved).length); totalWeight = $derived(this.#resolvedSigners.reduce((sum, s) => sum + s.weight, 0)); allResolved = $derived(this.resolvedCount === this.#resolvedSigners.length && this.#resolvedSigners.length > 0); address = $derived.by(() => { if (!this.allResolved) return null; return deriveMultisigAddress(this.#resolvedSigners, this.#threshold); }); addressReady = $derived(!!this.address); isReady = $derived(!!this.address && this.#threshold <= this.totalWeight); // Public getters get config() { return this.#config; } get threshold() { return this.#threshold; } get mode() { return this.#mode; } get signers() { return this.#resolvedSigners; } get error() { return this.#error; } get network() { return this.#network; } get providers() { return this.#providers; } // State object for compatibility get state() { return { config: this.#config, threshold: this.#threshold, mode: this.#mode, signers: this.#resolvedSigners, resolvedCount: this.resolvedCount, totalWeight: this.totalWeight, address: this.address, addressReady: this.addressReady, isReady: this.isReady, error: this.#error }; } /** * Initialize with config */ initialize(config, network) { this.#config = config; this.#network = network || config.network || 'testnet'; this.#error = null; if (isPreconfiguredMode(config)) { this.#mode = 'preconfigured'; this.#threshold = config.threshold; this.#rawSigners = [...config.signers]; } else if (isDynamicMode(config)) { this.#mode = 'dynamic'; this.#threshold = config.defaultThreshold ?? 1; this.#rawSigners = []; // Load from storage if available if (config.storageKey) { this.loadFromStorage(config.storageKey); } } } /** * Set providers (called by SuiModule) */ setProviders(providers) { this.#providers = providers; } /** * Resolve signers with current context */ resolveSigners(context) { const ctx = context || this.#providers?.getResolverContext() || { connectedWallets: new Map() }; if (this.#rawSigners.length === 0) { this.#resolvedSigners = []; return; } this.#resolvedSigners = this.#resolver.resolveAll(this.#rawSigners, ctx); } /** * Set threshold (dynamic mode only) */ setThreshold(threshold) { if (this.#mode !== 'dynamic') { throw new Error('Cannot change threshold in preconfigured mode'); } this.#threshold = threshold; } /** * Add signer from current wallet (dynamic mode only) */ async addSignerFromCurrentWallet(options) { if (this.#mode !== 'dynamic') { throw new Error('Cannot add signers in preconfigured mode'); } if (!this.#providers) { throw new MultisigError(MultisigErrorCode.WALLET_NOT_CONNECTED, 'Multisig providers not set'); } const resolverContext = this.#providers.getResolverContext(); let signer; let signerAddress; // Get connected wallet addresses const connectedAddresses = Array.from(resolverContext.connectedWallets.keys()); // Check for passkey first - use public key if available if (resolverContext.passkeyPublicKey || resolverContext.passkeyAddress) { signerAddress = resolverContext.passkeyAddress; // Check for duplicate if (this.#isDuplicateSigner('passkey', signerAddress)) { throw new MultisigError(MultisigErrorCode.INVALID_SIGNER, 'This passkey is already added as a signer'); } // If we have public key, save as publicKey type for persistence if (resolverContext.passkeyPublicKey) { const pkBytes = resolverContext.passkeyPublicKey.toRawBytes(); signer = { type: 'publicKey', publicKey: toBase64(pkBytes), keyType: 'passkey', weight: options?.weight ?? 1, name: options?.name ?? 'Passkey', address: signerAddress }; } else { signer = { type: 'passkey', weight: options?.weight ?? 1, name: options?.name ?? 'Passkey', address: signerAddress }; } } // Check for zkLogin (Sign in with Google) - get public key via signing else if (resolverContext.zkLoginAddress) { signerAddress = resolverContext.zkLoginAddress; // Check for duplicate if (this.#isDuplicateSigner('zklogin', signerAddress)) { throw new MultisigError(MultisigErrorCode.INVALID_SIGNER, 'This zkLogin account is already added as a signer'); } // Try to get public key (this will sign a message for zkLogin) const walletInfo = await this.#providers.getCurrentWalletPublicKey(); if (walletInfo && walletInfo.type === 'zklogin') { const pkBytes = walletInfo.publicKey.toRawBytes(); signer = { type: 'publicKey', publicKey: toBase64(pkBytes), keyType: 'zklogin', // zkLogin public identifier weight: options?.weight ?? 1, name: options?.name ?? 'zkLogin', address: signerAddress }; } else { // Fallback to zklogin type (will need to resolve later) signer = { type: 'zklogin', weight: options?.weight ?? 1, name: options?.name ?? 'zkLogin', address: signerAddress }; } } // Try to get public key from wallet else { const walletInfo = await this.#providers.getCurrentWalletPublicKey(); signerAddress = connectedAddresses[0]; if (walletInfo) { // Got public key - use publicKey type const pkBytes = walletInfo.publicKey.toRawBytes(); const derivedAddress = walletInfo.publicKey.toSuiAddress(); signerAddress = derivedAddress || signerAddress; // Check for duplicate by address if (this.#isDuplicateSigner('publicKey', signerAddress, toBase64(pkBytes))) { throw new MultisigError(MultisigErrorCode.INVALID_SIGNER, 'This wallet is already added as a signer'); } const keyType = walletInfo.type === 'secp256k1' ? 'secp256k1' : walletInfo.type === 'secp256r1' ? 'secp256r1' : 'ed25519'; signer = { type: 'publicKey', publicKey: toBase64(pkBytes), keyType: keyType, weight: options?.weight ?? 1, name: options?.name ?? 'Wallet', address: signerAddress }; } else { // Cannot get public key from wallet - this is required for multisig if (connectedAddresses.length === 0) { throw new MultisigError(MultisigErrorCode.WALLET_NOT_CONNECTED, 'No wallet connected'); } throw new MultisigError(MultisigErrorCode.WALLET_NOT_CONNECTED, 'Could not get public key from wallet. This wallet may not support multisig. Try using a different wallet or Sign in with Google.'); } } this.#rawSigners = [...this.#rawSigners, signer]; this.resolveSigners(); } /** * Check if a signer is already added (duplicate check) */ #isDuplicateSigner(type, address, publicKeyBase64) { for (const existing of this.#rawSigners) { // Check by type first if (existing.type === type) { // For passkey/zklogin - only one of each type allowed if (type === 'passkey' || type === 'zklogin') { return true; } // For wallet type - check by address if (type === 'wallet' && existing.type === 'wallet' && existing.address === address) { return true; } // For publicKey type - check by public key if (type === 'publicKey' && existing.type === 'publicKey' && existing.publicKey === publicKeyBase64) { return true; } } // Also check publicKey signers by address if (existing.type === 'publicKey' && address && existing.address === address) { return true; } } return false; } /** * Add signer (dynamic mode only) */ addSigner(signer) { if (this.#mode !== 'dynamic') { throw new Error('Cannot add signers in preconfigured mode'); } this.#rawSigners = [...this.#rawSigners, signer]; this.resolveSigners(); } /** * Remove signer by ID (dynamic mode only) */ removeSigner(signerId) { if (this.#mode !== 'dynamic') { throw new Error('Cannot remove signers in preconfigured mode'); } const signerIndex = this.#resolvedSigners.findIndex((s) => s.id === signerId); if (signerIndex >= 0) { this.#rawSigners = this.#rawSigners.filter((_, i) => i !== signerIndex); this.resolveSigners(); } } /** * Update signer weight by ID (dynamic mode only) */ updateSignerWeight(signerId, weight) { if (this.#mode !== 'dynamic') { throw new Error('Cannot update signers in preconfigured mode'); } const signerIndex = this.#resolvedSigners.findIndex((s) => s.id === signerId); if (signerIndex >= 0 && weight >= 1) { // Update the easy signer weight this.#rawSigners = this.#rawSigners.map((s, i) => (i === signerIndex ? { ...s, weight } : s)); this.resolveSigners(); } } /** * Create a proposal for signature collection */ async createProposal(tx) { if (!this.isReady || !this.address) { throw new MultisigError(MultisigErrorCode.PROPOSAL_NOT_READY, 'Multisig is not ready. Ensure all signers are resolved.'); } if (!this.#providers) { throw new MultisigError(MultisigErrorCode.WALLET_NOT_CONNECTED, 'Multisig providers not set'); } const client = new SuiClient({ url: getFullnodeUrl(this.#network) }); tx.setSender(this.address); // Get gas coin const coins = await client.getCoins({ owner: this.address, coinType: '0x2::sui::SUI', limit: 1 }); const gasCoin = coins?.data?.[0]; if (!gasCoin) { throw new MultisigError(MultisigErrorCode.PROPOSAL_NOT_READY, 'No gas coin found for multisig address. Please fund the address first.'); } tx.setGasOwner(this.address); tx.setGasPayment([ { objectId: gasCoin.coinObjectId, version: gasCoin.version, digest: gasCoin.digest } ]); try { const rgp = await client.getReferenceGasPrice(); tx.setGasPrice(Number(rgp)); } catch { } tx.setGasBudget(10_000_000); const txBytes = await tx.build({ client }); return this.#createProposalObject(tx, txBytes); } #createProposalObject(transaction, txBytes) { const signatures = new Map(); let signedWeight = 0; const signers = this.#resolvedSigners; const threshold = this.#threshold; const multisigAddress = this.address; const providers = this.#providers; const network = this.#network; const getSignerStatus = (signerId) => { const signer = signers.find((s) => s.id === signerId); const signature = signatures.get(signerId); return { signerId, signed: !!signature, signature, error: signer?.error }; }; const getSignerStatuses = () => { return signers.map((s) => getSignerStatus(s.id)); }; const signWithSigner = (signerId, signature) => { const signer = signers.find((s) => s.id === signerId); if (!signer) { throw new MultisigError(MultisigErrorCode.SIGNER_MISMATCH, `Signer '${signerId}' not found`); } if (!signatures.has(signerId)) { signedWeight += signer.weight; } signatures.set(signerId, signature); }; const signWithCurrentWallet = async () => { const result = await providers.signTransaction(transaction, { sender: multisigAddress }); if (!bytesEqual(result.bytes, txBytes)) { throw new MultisigError(MultisigErrorCode.SIGNER_MISMATCH, 'Wallet produced different tx bytes. Reset proposal and rebuild before collecting signatures again.'); } const resolverContext = providers.getResolverContext(); let matchedSigner; const connectedAddresses = new Set(Array.from(resolverContext.connectedWallets.keys())); for (const signer of signers) { // Prefer a direct address match (covers passkey saved as 'publicKey', and any signer with a known address) if (signer.address && connectedAddresses.has(signer.address)) { matchedSigner = signer; break; } if (signer.type === 'passkey' && resolverContext.passkeyPublicKey) { matchedSigner = signer; break; } if (signer.type === 'zklogin' && resolverContext.zkLoginAddress) { matchedSigner = signer; break; } if (signer.type !== 'passkey' && signer.type !== 'zklogin') { for (const [address] of resolverContext.connectedWallets) { const signerAddress = signer.publicKey?.toSuiAddress(); if (signerAddress === address) { matchedSigner = signer; break; } } } } if (!matchedSigner) { throw new MultisigError(MultisigErrorCode.SIGNER_MISMATCH, 'Current wallet does not match any signer in the multisig'); } // Extra safety: ensure the produced signature matches the expected signer public key/address. // This prevents "looks signed in UI, but combine/execute fails" situations. try { const parsed = parseSerializedSignature(result.signature); const pk = parsed?.publicKey; const sigAddr = pk && typeof pk.toSuiAddress === 'function' ? pk.toSuiAddress() : undefined; const expectedAddr = matchedSigner.publicKey?.toSuiAddress?.() || matchedSigner.address; if (sigAddr && expectedAddr && sigAddr !== expectedAddr) { throw new MultisigError(MultisigErrorCode.SIGNER_MISMATCH, `Signature pubkey mismatch. Expected ${expectedAddr} but got ${sigAddr}. Clear saved demo data and re-add signers.`); } } catch (e) { // Ignore parse errors (some signature schemes may not be parsable here). if (e instanceof MultisigError) throw e; } // For Passkey signatures, verify locally before accepting it. if (matchedSigner.type === 'passkey' && matchedSigner.publicKey) { try { const ok = await matchedSigner.publicKey.verifyTransaction?.(txBytes, result.signature); if (ok === false) { throw new MultisigError(MultisigErrorCode.SIGNER_MISMATCH, 'Passkey signature failed local verification. Reset proposal and sign again.'); } } catch (e) { if (e instanceof MultisigError) throw e; } } signWithSigner(matchedSigner.id, result.signature); }; const execute = async () => { if (signedWeight < threshold) { throw new MultisigError(MultisigErrorCode.INSUFFICIENT_SIGNATURES, `Cannot execute: signed weight (${signedWeight}) < threshold (${threshold})`); } const pkEntries = signers .filter((s) => s.resolved && s.publicKey) .map((signer) => { let pk; if (signer.type === 'zklogin' && signer.addressSeed && signer.issuer) { pk = toZkLoginPublicIdentifier(BigInt(signer.addressSeed), signer.issuer); } else { pk = signer.publicKey; } return { signerId: signer.id, publicKey: pk, weight: signer.weight }; }); const publicKeys = pkEntries.map(({ publicKey, weight }) => ({ publicKey, weight })); const multiSigPublicKey = MultiSigPublicKey.fromPublicKeys({ threshold, publicKeys }); // Ensure we're combining against the same multisig address used to build the proposal bytes. const derivedMultisigAddress = multiSigPublicKey.toSuiAddress(); if (derivedMultisigAddress !== multisigAddress) { throw new MultisigError(MultisigErrorCode.SIGNER_MISMATCH, `Multisig address mismatch. Proposal sender is ${multisigAddress} but current signer set derives ${derivedMultisigAddress}. Reset proposal and rebuild.`); } const signerIdByAddress = new Map(); for (const e of pkEntries) { try { signerIdByAddress.set(e.publicKey.toSuiAddress(), e.signerId); } catch { } } // MultiSigPublicKey may re-order keys internally; always follow its public key map order. const pkMap = multiSigPublicKey.getPublicKeys(); const includedSignedWeight = pkMap.reduce((sum, entry) => { const addr = entry.publicKey.toSuiAddress(); const signerId = signerIdByAddress.get(addr); return signerId && signatures.has(signerId) ? sum + entry.weight : sum; }, 0); if (includedSignedWeight < threshold) { throw new MultisigError(MultisigErrorCode.INSUFFICIENT_SIGNATURES, `Not enough signatures for the multisig public key map. Collected weight (${includedSignedWeight}) < threshold (${threshold}). Reset proposal and re-collect signatures.`); } // CombinePartialSignatures is order-sensitive. Always collect signatures in the same // order as the pk_map used inside MultiSigPublicKey. const orderedSigs = []; for (const entry of pkMap) { const addr = entry.publicKey.toSuiAddress(); const signerId = signerIdByAddress.get(addr); const sig = signerId ? signatures.get(signerId) : undefined; if (!sig) continue; // Best-effort sanity check to detect swapped/mismatched signatures early. try { const parsed = parseSerializedSignature(sig); const pk = parsed?.publicKey; const sigAddr = pk && typeof pk.toSuiAddress === 'function' ? pk.toSuiAddress() : undefined; const expectedAddr = addr; if (sigAddr && sigAddr !== expectedAddr) { throw new MultisigError(MultisigErrorCode.SIGNER_MISMATCH, `Collected signature does not match signer public key. Expected ${expectedAddr} but got ${sigAddr}. Clear saved demo data and re-add signers.`); } } catch (err) { if (err instanceof MultisigError) throw err; } orderedSigs.push(sig); } if (orderedSigs.length === 0) { throw new MultisigError(MultisigErrorCode.INSUFFICIENT_SIGNATURES, 'No signatures collected'); } const combinedSignature = multiSigPublicKey.combinePartialSignatures(orderedSigs); try { const ok = await multiSigPublicKey.verifyTransaction?.(txBytes, combinedSignature); if (ok === false) { throw new MultisigError(MultisigErrorCode.EXECUTION_FAILED, 'Combined multisig signature failed local verification. Reset proposal and re-collect signatures.'); } } catch (e) { if (e instanceof MultisigError) throw e; } const client = new SuiClient({ url: getFullnodeUrl(network) }); try { const result = await client.executeTransactionBlock({ transactionBlock: txBytes, signature: combinedSignature, options: { showEffects: true, showEvents: true } }); return { digest: result.digest, effects: result.effects, events: result.events ?? undefined }; } catch (error) { throw new MultisigError(MultisigErrorCode.EXECUTION_FAILED, `Transaction execution failed: ${error.message}`, error); } }; return { id: `proposal-${Date.now()}`, transaction, txBytes, multisigAddress, threshold, signatures, get signedWeight() { return signedWeight; }, get canExecute() { return signedWeight >= threshold; }, signWithCurrentWallet, signWithSigner, execute, getSignerStatus, getSignerStatuses }; } /** * Save config to localStorage */ saveConfig() { if (this.#mode !== 'dynamic' || !this.#config) return; const config = this.#config; if (!config.storageKey) return; try { const data = { signers: this.#rawSigners, threshold: this.#threshold }; window.localStorage.setItem(config.storageKey, JSON.stringify(data)); } catch (error) { console.error('Failed to save multisig config:', error); } } /** * Load config from localStorage */ loadFromStorage(storageKey) { if (typeof window === 'undefined') return false; try { const raw = window.localStorage.getItem(storageKey); if (!raw) return false; const data = JSON.parse(raw); if (Array.isArray(data.signers)) { // Normalize persisted signer addresses to match their public keys (prevents drift bugs). this.#rawSigners = data.signers.map((s) => { try { if (s && s.type === 'publicKey' && typeof s.publicKey === 'string' && s.keyType) { // Migration: older passkey entries were persisted as secp256r1. // Passkey signatures require the Passkey scheme/public key (different address flag). const keyType = s.name === 'Passkey' && s.keyType === 'secp256r1' ? 'passkey' : s.keyType; const pk = parsePublicKey(s.publicKey, keyType); const derived = pk.toSuiAddress(); return { ...s, keyType, address: derived }; } } catch { } return s; }); } if (typeof data.threshold === 'number') { this.#threshold = data.threshold; } return true; } catch (error) { console.error('Failed to load multisig config:', error); return false; } } /** * Clear config from localStorage */ clearConfig() { if (this.#mode !== 'dynamic' || !this.#config) return; const config = this.#config; if (!config.storageKey) return; try { window.localStorage.removeItem(config.storageKey); } catch { } this.#rawSigners = []; this.#resolvedSigners = []; this.#threshold = this.#config.defaultThreshold ?? 1; } /** * Reset store */ reset() { this.#config = null; this.#threshold = 1; this.#mode = null; this.#resolvedSigners = []; this.#rawSigners = []; this.#error = null; } } // Global singleton instance export const multisigStore = new MultisigStore();