@usebutr/core 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +76 -33
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1461 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,2 +1,1462 @@
|
|
|
1
|
-
import{createStore as e}from"zustand/vanilla";const t=e=>({bitcoin:e.bitcoin??[],evm:e.evm??[],polkadot:e.polkadot??[],sui:e.sui??[],svm:e.svm??[]}),n=e=>{if(e instanceof Error){let t=e.message,n=t.toLowerCase();if(t===`Connection timeout`)return{kind:`Timeout`,message:t};if(t===`Failed to get account`||n.includes(`not connected`))return{kind:`NotConnected`,message:t};let r=e.code;return r===4001?{kind:`UserRejected`,message:t}:r===-32002?{kind:`RequestPending`,message:t}:r===4100||r===4900||r===4901?{kind:`NotConnected`,message:t}:n.includes(`user rejected`)||n.includes(`user denied`)?{kind:`UserRejected`,message:t}:n.includes(`locked`)?{kind:`WalletLocked`,message:t}:n.includes(`chain`)&&(n.includes(`mismatch`)||n.includes(`unsupported`))?{kind:`ChainMismatch`,message:t}:{cause:e,kind:`Unknown`,message:t}}return typeof e==`string`?{kind:`Unknown`,message:e}:{cause:e,kind:`Unknown`,message:`Connection failed`}},r=e=>({subscribe:e}),i=(...e)=>{console.warn(...e)},a=(...e)=>{console.error(...e)},o=e=>{try{return typeof globalThis<`u`&&globalThis[e]!==void 0}catch{return!1}},s=()=>{let e=new Map;return{getItem(t){return e.get(t)??null},removeItem(t){e.delete(t)},setItem(t,n){e.set(t,n)}}},c=e=>{if(!o(e))return s();let t=globalThis[e];return{getItem(e){try{return t.getItem(e)}catch{return null}},removeItem(e){try{t.removeItem(e)}catch{}},setItem(e,n){try{t.setItem(e,n)}catch{}}}},l=()=>({persistent:c(`localStorage`),session:c(`sessionStorage`)}),u=e=>encodeURIComponent(e),d=e=>decodeURIComponent(e),f=()=>{let e=new Map;if(typeof document>`u`)return e;let t=document.cookie;if(!t)return e;for(let n of t.split(`;`)){let t=n.trim();if(!t)continue;let r=t.indexOf(`=`);if(r===-1)continue;let i=t.slice(0,r),a=t.slice(r+1);try{e.set(i,d(a))}catch{}}return e},p=(e,t,n,r=!1)=>{let i=[`${e}=${u(t)}`,`Path=${n.path??`/`}`];if(n.domain&&i.push(`Domain=${n.domain}`),r)i.push(`Max-Age=0`);else{let e=n.maxAgeSeconds??2592e3;i.push(`Max-Age=${e}`)}return i.push(`SameSite=${n.sameSite??`lax`}`),(n.secure??!0)&&i.push(`Secure`),i.join(`; `)},m=e=>e?typeof e[Symbol.iterator]==`function`?new Map(e):new Map(Object.entries(e)):null,h=(e={})=>{let t=m(e.initialCookies);return typeof document>`u`?t?{getItem(e){return t.get(e)??null},removeItem(){},setItem(){}}:{getItem(){return null},removeItem(){},setItem(){}}:{getItem(e){return f().get(e)??null},removeItem(t){document.cookie=p(t,``,e,!0)},setItem(t,n){document.cookie=p(t,n,e)}}},g=new Set([`evm`,`svm`,`sui`,`bitcoin`]),_=Object.freeze({activeConnectorId:null,pool:Object.freeze({}),selection:Object.freeze({})}),v=e=>{if(typeof e[Symbol.iterator]!=`function`)return new Map(Object.entries(e));let t=new Map;for(let n of e){if(Array.isArray(n)){let[e,r]=n;typeof e==`string`&&typeof r==`string`&&t.set(e,r);continue}n&&typeof n==`object`&&`name`in n&&`value`in n&&typeof n.name==`string`&&typeof n.value==`string`&&t.set(n.name,n.value)}return t},y=e=>{if(!e||typeof e!=`object`)return!1;let t=e;return!(typeof t.walletAddress!=`string`||typeof t.id!=`string`||!t.chain||typeof t.chain!=`object`)},b=(e,t)=>{if(!t||typeof t!=`object`)return!1;let n=t;return!(typeof n.connectorId!=`string`||n.connectorId!==e||typeof n.chainPlatform!=`string`||!g.has(n.chainPlatform)||typeof n.name!=`string`||n.name.length===0||n.icon!==void 0&&typeof n.icon!=`string`||!y(n.account)||!Array.isArray(n.accounts)||!n.accounts.every(y))},x=e=>{if(!e)return{};try{let t=JSON.parse(e);if(!t||typeof t!=`object`)return{};let n={};for(let[e,r]of Object.entries(t))b(e,r)?n[e]=r:i(`[butr] readWalletSnapshot: dropping invalid pool entry for ${e}`);return n}catch(e){return i(`[butr] readWalletSnapshot: failed to parse pool cookie:`,e),{}}},S=e=>{if(!e)return{};try{let t=JSON.parse(e);if(!t||typeof t!=`object`)return{};let n={};for(let[e,r]of Object.entries(t))g.has(e)&&typeof r==`string`&&r.length>0&&(n[e]=r);return n}catch(e){return i(`[butr] readWalletSnapshot: failed to parse selection cookie:`,e),{}}},C=(e,t={})=>{let n=t.keyPrefix||`butr`,r=v(e),i=x(r.get(`${n}-pool`)),a=S(r.get(`${n}-selection`)),o=r.get(`${n}-active`),s=null;if(o&&o.length>0&&i[o])s=o;else{let e=Object.keys(i)[0];e&&(s=e)}return{activeConnectorId:s,pool:i,selection:a}},w=new Set([`evm`,`svm`,`sui`,`bitcoin`]),T=e=>{if(!e||typeof e!=`object`)return!1;let t=e;return!(typeof t.walletAddress!=`string`||typeof t.id!=`string`||!t.chain||typeof t.chain!=`object`)},E=(e,t)=>{if(!t||typeof t!=`object`)return!1;let n=t;return!(typeof n.connectorId!=`string`||n.connectorId!==e||typeof n.chainPlatform!=`string`||!w.has(n.chainPlatform)||typeof n.name!=`string`||n.name.length===0||n.icon!==void 0&&typeof n.icon!=`string`||!T(n.account)||!Array.isArray(n.accounts)||!n.accounts.every(T))};var D=class{poolKey;selectionKey;activeKey;userDisconnectedKey;persistent;session;poolMutationQueue=Promise.resolve();constructor(e){if(this.poolKey=`${e.keyPrefix}-pool`,this.selectionKey=`${e.keyPrefix}-selection`,this.activeKey=`${e.keyPrefix}-active`,this.userDisconnectedKey=`${e.keyPrefix}-user-disconnected`,e.persistent&&e.session)this.persistent=e.persistent,this.session=e.session;else{let t=l();this.persistent=e.persistent??t.persistent,this.session=e.session??t.session}}async serializePoolMutation(e){let t=this.poolMutationQueue,n=()=>{};this.poolMutationQueue=new Promise(e=>{n=e});try{await t}catch{}try{return await e()}finally{n()}}async getPool(){try{let e=await this.persistent.getItem(this.poolKey);if(!e)return{};let t=JSON.parse(e);if(!t||typeof t!=`object`)return await this.clearPool(),{};let n={};for(let[e,r]of Object.entries(t))E(e,r)?n[e]=r:i(`[butr] dropping invalid pool entry for ${e}`);return n}catch(e){return i(`[butr] failed to parse pool from storage:`,e),await this.clearPool(),{}}}setPool(e){return this.serializePoolMutation(async()=>{try{let t={...await this.getPool()};for(let[n,r]of e){let e={account:r.account,accounts:r.accounts,chainPlatform:r.connector.chainPlatform,connectorId:n,icon:r.connector.icon,name:r.connector.name};if(!E(n,e))throw Error(`[butr] refusing to persist invalid pool entry for ${n}`);t[n]=e}await this.persistent.setItem(this.poolKey,JSON.stringify(t))}catch(e){i(`[butr] failed to persist pool:`,e)}})}removePoolEntry(e){return this.serializePoolMutation(async()=>{try{let t=await this.getPool();if(t[e]){let{[e]:n,...r}=t;await this.persistent.setItem(this.poolKey,JSON.stringify(r))}}catch(t){i(`[butr] failed to remove pool entry ${e}:`,t)}})}clearPool(){return this.serializePoolMutation(async()=>{await this.persistent.removeItem(this.poolKey)})}async getSelection(){try{let e=await this.persistent.getItem(this.selectionKey);if(!e)return{};let t=JSON.parse(e);if(!t||typeof t!=`object`)return{};let n={};for(let[e,r]of Object.entries(t))w.has(e)&&typeof r==`string`&&r.length>0&&(n[e]=r);return n}catch(e){return i(`[butr] failed to parse selection from storage:`,e),{}}}async setSelection(e){try{let t={};for(let[n,r]of e)t[n]=r;await this.persistent.setItem(this.selectionKey,JSON.stringify(t))}catch(e){i(`[butr] failed to persist selection:`,e)}}async getActiveConnectorId(){try{let e=await this.persistent.getItem(this.activeKey);return e&&e.length>0?e:null}catch{return null}}async setActiveConnectorId(e){try{await(e===null?this.persistent.removeItem(this.activeKey):this.persistent.setItem(this.activeKey,e))}catch(e){i(`[butr] failed to persist active connector id:`,e)}}async clearAll(){await Promise.all([this.clearPool(),this.persistent.removeItem(this.selectionKey),this.persistent.removeItem(this.activeKey)])}async isUserDisconnected(){try{return await this.session.getItem(this.userDisconnectedKey)===`true`}catch{return!1}}async markUserDisconnected(e){try{await(e?this.session.setItem(this.userDisconnectedKey,`true`):this.session.removeItem(this.userDisconnectedKey))}catch{}}};const O=e=>{let t=new Map,n=e=>{let n=t.get(e);if(n){try{n()}catch(e){i(`[butr] unsubscribe threw:`,e)}t.delete(e)}};return{attach:(r,a)=>{if(a.subscribe){n(r);try{let i=a.subscribe(t=>{switch(t.type){case`accountChanged`:e.onAccountChanged(r,t.accounts,t.account);break;case`disconnected`:n(r),e.onDisconnected(r,a.chainPlatform);break;default:}});t.set(r,i)}catch(e){i(`[butr] subscribe failed for ${r}:`,e)}}},detach:n,detachAll:()=>{let e=[];for(let n of t.keys())e.push(n);for(let t of e)n(t)}}},k=async(e,t,n)=>{if(e.getAccounts){let t=await e.getAccounts().catch(()=>null);if(t&&t.length>0)return t}return t.length>0?[...t]:[n]},A=async(e,t,n)=>{try{await n.connect({silent:!0});let r=await n.getAccount()||t.account;return{connectorId:e,entry:{account:r,accounts:await k(n,t.accounts,r),connector:n},kind:`ok`}}catch(t){return{connectorId:e,error:t,kind:`fail`}}},j=(e,t)=>{let n=new Map;return{drainPending:async e=>{let r=n.get(e);if(!r)return null;let i=t(e);if(!i)return null;let a=await A(e,r,i);return n.delete(e),a},hydrate:async()=>{let[r,i,a,o]=await Promise.all([e.getPool(),e.getSelection(),e.getActiveConnectorId(),e.isUserDisconnected()]),s=new Map,c=[];n.clear();let l=[];for(let[e,i]of Object.entries(r)){if(!i)continue;let r=t(e);if(!r){n.set(e,i);continue}l.push(A(e,i,r))}let u=await Promise.all(l);for(let e of u){if(e.kind===`ok`){s.set(e.connectorId,e.entry);continue}c.push({connectorId:e.connectorId,reason:e.error})}let d=new Map;for(let[e,t]of Object.entries(i))t&&s.has(t)&&d.set(e,t);for(let[e,t]of s){let n=t.connector.chainPlatform;d.has(n)||d.set(n,e)}let f=null;return a&&s.has(a)?f=a:s.size>0&&(f=s.keys().next().value??null),{activeConnectorId:f,dropped:c,isUserDisconnected:o,pendingIds:[...n.keys()],pool:s,selection:d}},pendingIds:()=>[...n.keys()]}},M={activeConnectorId:null,connectingConnectorId:null,connectionError:null,connectionStatus:`idle`,isHydrated:!1,isUserDisconnected:!1,pool:new Map,reconnectingIds:new Set,selection:new Map},N=(e,t)=>{for(let[n,r]of e)if(r.connector.chainPlatform===t)return n},P=(e,t)=>{switch(t.type){case`HYDRATED`:{let n=new Map(e.pool);for(let[e,r]of t.pool)n.set(e,r);let r=new Map(e.selection);for(let[e,n]of t.selection)r.set(e,n);let i=null;t.activeConnectorId&&n.has(t.activeConnectorId)?i=t.activeConnectorId:e.activeConnectorId&&n.has(e.activeConnectorId)?i=e.activeConnectorId:n.size>0&&(i=n.keys().next().value??null);let a=e.reconnectingIds;if(e.reconnectingIds.size>0&&t.pool.size>0){let n=new Set(e.reconnectingIds);for(let e of t.pool.keys())n.delete(e);n.size!==e.reconnectingIds.size&&(a=n)}return{...e,activeConnectorId:i,isHydrated:!0,isUserDisconnected:t.isUserDisconnected,pool:n,reconnectingIds:a,selection:r}}case`USER_DISCONNECTED_SET`:return{...e,isUserDisconnected:t.value};case`CONNECT_STARTED`:return{...e,connectingConnectorId:t.connectorId,connectionError:null,connectionStatus:`connecting`};case`CONNECT_SUCCEEDED`:{let{connectorId:n,entry:r}=t,i=e.pool.get(n),a=e.reconnectingIds.has(n)?new Set([...e.reconnectingIds].filter(e=>e!==n)):e.reconnectingIds;if(i&&i.account.walletAddress.toLowerCase()===r.account.walletAddress.toLowerCase())return{...e,activeConnectorId:n,connectingConnectorId:null,connectionStatus:`success`,pool:i.connector===r.connector?e.pool:new Map([...e.pool,[n,r]]),reconnectingIds:a};let o=new Map([...e.pool,[n,r]]),s=new Map([...e.selection,[r.connector.chainPlatform,n]]);return{...e,activeConnectorId:n,connectingConnectorId:null,connectionStatus:`success`,pool:o,reconnectingIds:a,selection:s}}case`CONNECT_FAILED`:return{...e,connectingConnectorId:null,connectionError:t.error,connectionStatus:`error`};case`DISCONNECTED`:{let n=e.pool.get(t.connectorId);if(!n)return e;let r=new Map(e.pool);r.delete(t.connectorId);let i=new Map(e.selection),a=n.connector.chainPlatform;if(i.get(a)===t.connectorId){i.delete(a);let e=N(r,a);e&&i.set(a,e)}let o=r.keys().next(),s=e.activeConnectorId===t.connectorId?o.value??null:e.activeConnectorId,c=e.reconnectingIds.has(t.connectorId)?new Set([...e.reconnectingIds].filter(e=>e!==t.connectorId)):e.reconnectingIds;return{...e,activeConnectorId:s,pool:r,reconnectingIds:c,selection:i}}case`ACCOUNTS_REFRESHED`:{let n=e.pool.get(t.connectorId);if(!n)return e;let r;r=t.active?t.active:t.accounts.some(e=>e.walletAddress===n.account.walletAddress&&e.chain.id===n.account.chain.id)?n.account:t.accounts[0]??n.account;let i=r.walletAddress===n.account.walletAddress&&r.chain.id===n.account.chain.id,a=t.accounts.length===n.accounts.length&&t.accounts.every((e,t)=>e.walletAddress===n.accounts[t]?.walletAddress);if(i&&a)return e;let o={...n,account:r,accounts:[...t.accounts]},s=new Map([...e.pool,[t.connectorId,o]]);return{...e,pool:s}}case`WALLET_REFRESHED`:{let n=e.pool.get(t.connectorId);if(!n)return e;let r=new Map([...e.pool,[t.connectorId,{...n}]]);return{...e,pool:r}}case`ACTIVE_CHANGED`:return{...e,activeConnectorId:t.connectorId};case`SELECTION_CHANGED`:{let n=new Map(e.selection);return t.connectorId===null?n.delete(t.chainPlatform):n.set(t.chainPlatform,t.connectorId),{...e,selection:n}}case`STATUS_RESET`:return{...e,connectingConnectorId:null,connectionError:null,connectionStatus:`idle`};case`ERROR_SET`:return{...e,connectionError:t.error,connectionStatus:t.error?`error`:`idle`};case`RESET`:return{...M,isHydrated:e.isHydrated,isUserDisconnected:!0,reconnectingIds:new Set};default:return e}};var F=class extends Error{code=`BUTR_RECONNECTING`;connectorId;method;constructor(e,t){super(`[butr] ${e} called on shadow connector "${t}" — wait for silent reconnect to complete (check reconnectingIds.has(id) before calling).`),this.name=`ShadowConnectorError`,this.connectorId=t,this.method=e}};const I=Object.freeze({getBalance:!1,getTransactionReceipt:!1,requestAccounts:!1,sendTransaction:!1,signIn:!1,signMessage:!1,signTransaction:!1,subscribe:!1,switchAccount:!1,switchChain:!1}),L=e=>{let t=e.connectorId,n=e.name,r=e.icon,i=e=>Promise.reject(new F(e,t)),a={capabilities:I,connect:()=>i(`connect`),disconnect:()=>i(`disconnect`),getAccount:()=>i(`getAccount`),getBalance:()=>i(`getBalance`),getSigner:()=>i(`getSigner`),getTransactionReceipt:()=>i(`getTransactionReceipt`),icon:r,id:t,name:n,sendTx:()=>i(`sendTx`),sendTxToChain:()=>i(`sendTxToChain`),signMessage:()=>i(`signMessage`),switchChain:()=>i(`switchChain`)};switch(e.chainPlatform){case`bitcoin`:return{...a,chainPlatform:`bitcoin`};case`evm`:return{...a,chainPlatform:`evm`};case`sui`:return{...a,chainPlatform:`sui`};case`svm`:return{...a,chainPlatform:`svm`};case`polkadot`:return{...a,chainPlatform:`polkadot`};default:throw e.chainPlatform,Error(`[butr] unknown chainPlatform: ${e.chainPlatform}`)}},R=async(e,t)=>{try{await e()}catch(e){t(e)}},z=e=>{let t=new Map,n=new Set;for(let[r,i]of Object.entries(e.pool)){if(!i)continue;let e=L(i);t.set(r,{account:i.account,accounts:[...i.accounts],connector:e}),n.add(r)}let r=new Map;for(let[n,i]of Object.entries(e.selection))i&&t.has(i)&&r.set(n,i);let i=e.activeConnectorId&&t.has(e.activeConnectorId)?e.activeConnectorId:t.keys().next().value??null;return{...M,activeConnectorId:i,isHydrated:!0,pool:t,reconnectingIds:n,selection:r}},B=e=>e.toLowerCase(),V=t=>{let r=t.storageKeyPrefix||`butr`,o=t.storage||new D({keyPrefix:r}),s=e=>n=>{if(t.onStorageError){try{t.onStorageError(n,e)}catch(e){i(`[butr] onStorageError threw:`,e)}return}i(`[butr] ${e}:`,n)},c=j(o,t.createConnector);return e()((e,r)=>{let l=t=>{e(e=>P(e,t),!1)},u=()=>R(()=>o.setPool(r().pool),s(`failed to persist pool`)),d=()=>R(()=>o.setSelection(r().selection),s(`failed to persist selection`)),f=()=>R(()=>o.setActiveConnectorId(r().activeConnectorId),s(`failed to persist active connector`)),p=(e,t,n)=>{l({accounts:t,active:n,connectorId:e,type:`ACCOUNTS_REFRESHED`}),u()},m=O({onAccountChanged:(e,t,n)=>{p(e,[...t],n)},onDisconnected:(e,n)=>{l({connectorId:e,type:`DISCONNECTED`}),t.onDisconnect?.(n)}});return{...t.initialState?z(t.initialState):M,_config:t,_storage:o,connectWallet:async(e,a,o)=>{r().setUserDisconnected(!1);let s=t.createConnector(e);if(!s)throw Error(`Failed to create connector for ${e}`);l({connectorId:e,type:`CONNECT_STARTED`});let c=t.slowConnectThresholdMs??5e3,p=t.onSlowConnect?setTimeout(()=>{try{t.onSlowConnect?.(e)}catch(e){i(`[butr] onSlowConnect threw:`,e)}},c):null;try{let n=s.connect();n.catch(()=>{}),await Promise.race([n,new Promise((e,t)=>{setTimeout(()=>{t(Error(`Connection timeout`))},9e4)})]);let r=await s.getAccount();if(!r)throw Error(`Failed to get account`);let i=s.getAccounts?await s.getAccounts().catch(()=>null):null,o={account:r,accounts:i&&i.length>0?i:[r],connector:s};l({connectorId:e,entry:o,type:`CONNECT_SUCCEEDED`}),m.attach(e,s),await Promise.all([u(),d(),f()]),t.onConnect?.(o),a?.(o)}catch(r){let a=n(r);l({error:a,type:`CONNECT_FAILED`});try{await s.disconnect?.()}catch(e){i(`[butr] disconnect during error recovery failed:`,e)}try{t.onConnectError?.(a,e)}catch(e){i(`[butr] onConnectError threw:`,e)}throw o?.(r),r}finally{p&&clearTimeout(p)}},disconnectWallet:e=>{r().setUserDisconnected(!0);let n=r();if(!n.isHydrated)return;let i=n.pool.get(e);if(!i)return;m.detach(e),R(()=>i.connector.disconnect?.()??Promise.resolve(),a);let c=i.connector.chainPlatform;l({connectorId:e,type:`DISCONNECTED`}),r().pool.size===0?R(()=>o.clearAll(),s(`failed to clear storage`)):(R(()=>o.removePoolEntry(e),s(`failed to remove pool entry`)),d(),f()),t.onDisconnect?.(c)},getConnectorInstance:e=>t.createConnector(e),hydrateWallets:async()=>{let e=await c.hydrate();l({activeConnectorId:e.activeConnectorId,isUserDisconnected:e.isUserDisconnected,pool:e.pool,selection:e.selection,type:`HYDRATED`});for(let[t,n]of e.pool)m.attach(t,n.connector);if(await Promise.all([d(),f()]),Promise.all(c.pendingIds().map(e=>r().tryRestoreFromPending(e))),t.onHydrated)try{t.onHydrated({dropped:e.dropped,pendingIds:[...e.pendingIds],restoredIds:[...e.pool.keys()]})}catch(e){i(`[butr] onHydrated threw:`,e)}else if(e.dropped.length>0)for(let{connectorId:t,reason:n}of e.dropped)i(`[butr] failed to restore connector ${t}:`,n)},refreshWallet:e=>{l({connectorId:e,type:`WALLET_REFRESHED`})},requestAccounts:async e=>{let t=r().pool.get(e);if(!t)return;await t.connector.requestAccounts?.();let n=t.connector.getAccounts?await t.connector.getAccounts().catch(()=>null):null;!n||n.length===0||p(e,n)},reset:()=>{r().setUserDisconnected(!0);let e=r();if(e.isHydrated){m.detachAll();for(let t of e.pool.values())R(()=>t.connector.disconnect?.()??Promise.resolve(),a);if(R(()=>o.clearAll(),s(`failed to clear storage`)),t.onReset){let e=t.onReset;R(()=>Promise.resolve(e()),a)}l({type:`RESET`})}},resetConnectionStatus:()=>{l({type:`STATUS_RESET`})},setActiveConnector:e=>{l({connectorId:e,type:`ACTIVE_CHANGED`}),f()},setConnectionError:e=>{l({error:e,type:`ERROR_SET`})},setSelection:(e,t)=>{l({chainPlatform:e,connectorId:t,type:`SELECTION_CHANGED`}),d()},setUserDisconnected:e=>{l({type:`USER_DISCONNECTED_SET`,value:e}),R(()=>o.markUserDisconnected(e),s(`failed to persist disconnect intent`))},tryRestoreFromPending:async e=>{let n=await c.drainPending(e);if(n){if(n.kind===`fail`){i(`[butr] late restore failed for ${e}:`,n.error);return}l({connectorId:e,entry:n.entry,type:`CONNECT_SUCCEEDED`}),m.attach(e,n.entry.connector),await Promise.all([u(),d(),f()]),t.onConnect?.(n.entry)}},updateWalletAccount:(e,t)=>{let n=r().pool.get(e);if(!n)return;let i=t.chain,a=n.accounts.map(e=>e.chain.id===i.id?e:{chain:i,id:`${i.id}:${B(e.walletAddress)}`,walletAddress:e.walletAddress});p(e,a.some(e=>B(e.walletAddress)===B(t.walletAddress))?a:[...a,t],t)}}})},H=(e,t)=>e===t?!0:!e||!t?!1:e.connector.id===t.connector.id&&e.account.walletAddress===t.account.walletAddress&&e.account.chain.id===t.account.chain.id,U=e=>{if(e===void 0)return;let t=e.trim();return t.length>0?t:void 0};export{_ as EMPTY_SNAPSHOT,D as WalletStorage,t as buildChainsByPlatform,l as createBrowserStorageDriver,h as createCookieStorageDriver,s as createMemoryStorageDriver,r as createWalletSource,V as createWalletStore,a as logError,i as logWarn,n as mapConnectionError,C as readWalletSnapshot,U as sanitizeIcon,H as walletEqual};
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
|
|
3
|
+
//#region src/types/chains-by-platform.ts
|
|
4
|
+
/**
|
|
5
|
+
* Build a fully-populated `ChainsByPlatform` from a partial. Platforms
|
|
6
|
+
* the consumer doesn't specify default to an empty list.
|
|
7
|
+
*
|
|
8
|
+
* Use this in apps that target one or two chain platforms — importing
|
|
9
|
+
* only those packages keeps unused chain registries out of the bundle.
|
|
10
|
+
* Apps that want every chain reach for `CHAINS_BY_PLATFORM` from
|
|
11
|
+
* `@usebutr/wallets` instead.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // EVM-only app — Solana/Sui/Bitcoin tables never enter the bundle
|
|
15
|
+
* import { EVM_CHAINS_LIST } from "@usebutr/evm";
|
|
16
|
+
* import { buildChainsByPlatform } from "@usebutr/core";
|
|
17
|
+
*
|
|
18
|
+
* const chains = buildChainsByPlatform({ evm: EVM_CHAINS_LIST });
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* // Multi-chain app — pull from each package the app actually uses
|
|
22
|
+
* import { EVM_CHAINS_LIST } from "@usebutr/evm";
|
|
23
|
+
* import { SVM_CHAINS_LIST } from "@usebutr/svm";
|
|
24
|
+
*
|
|
25
|
+
* const chains = buildChainsByPlatform({
|
|
26
|
+
* evm: EVM_CHAINS_LIST,
|
|
27
|
+
* svm: SVM_CHAINS_LIST,
|
|
28
|
+
* });
|
|
29
|
+
*/
|
|
30
|
+
const buildChainsByPlatform = (partial) => ({
|
|
31
|
+
bitcoin: partial.bitcoin ?? [],
|
|
32
|
+
evm: partial.evm ?? [],
|
|
33
|
+
polkadot: partial.polkadot ?? [],
|
|
34
|
+
sui: partial.sui ?? [],
|
|
35
|
+
svm: partial.svm ?? []
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/types/errors.ts
|
|
40
|
+
/**
|
|
41
|
+
* Normalise a thrown value into a `ConnectionError`.
|
|
42
|
+
*
|
|
43
|
+
* Recognises:
|
|
44
|
+
* - butr's own `Error("Connection timeout")` (from the 90s connect timeout)
|
|
45
|
+
* - butr's own `Error("Failed to get account")` (from the connect flow)
|
|
46
|
+
* - EIP-1193 numeric `code` properties (`4001` → UserRejected,
|
|
47
|
+
* `-32002` → RequestPending, `4100`/`4900`/`4901` → NotConnected —
|
|
48
|
+
* unauthorized / disconnected from all-or-one chains)
|
|
49
|
+
* - common message substrings: "user rejected" / "user denied",
|
|
50
|
+
* "locked", "chain", etc.
|
|
51
|
+
* - anything else → `Unknown` with `cause` set to the original value.
|
|
52
|
+
*/
|
|
53
|
+
const mapConnectionError = (raw) => {
|
|
54
|
+
if (raw instanceof Error) {
|
|
55
|
+
const message = raw.message;
|
|
56
|
+
const lower = message.toLowerCase();
|
|
57
|
+
if (message === "Connection timeout") return {
|
|
58
|
+
kind: "Timeout",
|
|
59
|
+
message
|
|
60
|
+
};
|
|
61
|
+
if (message === "Failed to get account" || lower.includes("not connected")) return {
|
|
62
|
+
kind: "NotConnected",
|
|
63
|
+
message
|
|
64
|
+
};
|
|
65
|
+
const code = raw.code;
|
|
66
|
+
if (code === 4001) return {
|
|
67
|
+
kind: "UserRejected",
|
|
68
|
+
message
|
|
69
|
+
};
|
|
70
|
+
if (code === -32002) return {
|
|
71
|
+
kind: "RequestPending",
|
|
72
|
+
message
|
|
73
|
+
};
|
|
74
|
+
if (code === 4100 || code === 4900 || code === 4901) return {
|
|
75
|
+
kind: "NotConnected",
|
|
76
|
+
message
|
|
77
|
+
};
|
|
78
|
+
if (lower.includes("user rejected") || lower.includes("user denied")) return {
|
|
79
|
+
kind: "UserRejected",
|
|
80
|
+
message
|
|
81
|
+
};
|
|
82
|
+
if (lower.includes("locked")) return {
|
|
83
|
+
kind: "WalletLocked",
|
|
84
|
+
message
|
|
85
|
+
};
|
|
86
|
+
if (lower.includes("chain") && (lower.includes("mismatch") || lower.includes("unsupported"))) return {
|
|
87
|
+
kind: "ChainMismatch",
|
|
88
|
+
message
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
cause: raw,
|
|
92
|
+
kind: "Unknown",
|
|
93
|
+
message
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (typeof raw === "string") return {
|
|
97
|
+
kind: "Unknown",
|
|
98
|
+
message: raw
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
cause: raw,
|
|
102
|
+
kind: "Unknown",
|
|
103
|
+
message: "Connection failed"
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/types/wallet.ts
|
|
109
|
+
/**
|
|
110
|
+
* Canonical list of supported chain platforms. The single runtime source
|
|
111
|
+
* of truth: `ChainPlatform` is derived from it, and storage validators
|
|
112
|
+
* build their allowlists from it, so the type and the runtime checks can
|
|
113
|
+
* never drift (a missing platform here was why Polkadot connections failed
|
|
114
|
+
* to persist).
|
|
115
|
+
*/
|
|
116
|
+
const CHAIN_PLATFORMS = [
|
|
117
|
+
"evm",
|
|
118
|
+
"svm",
|
|
119
|
+
"sui",
|
|
120
|
+
"bitcoin",
|
|
121
|
+
"polkadot"
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/wallet-source.ts
|
|
126
|
+
/**
|
|
127
|
+
* Wrap a bare `subscribe` function (the exact shape of
|
|
128
|
+
* `discoverEvmAdapters` / `discoverSvmAdapters`) into a `WalletSource`,
|
|
129
|
+
* so an EVM-only app can do
|
|
130
|
+
* `createWalletSource(discoverEvmAdapters)` without importing anything
|
|
131
|
+
* protocol-bearing beyond `@usebutr/evm`.
|
|
132
|
+
*/
|
|
133
|
+
const createWalletSource = (subscribe) => ({ subscribe });
|
|
134
|
+
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/logger.ts
|
|
137
|
+
const logWarn = (...args) => {
|
|
138
|
+
console.warn(...args);
|
|
139
|
+
};
|
|
140
|
+
const logError = (...args) => {
|
|
141
|
+
console.error(...args);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/storage/browser-storage-driver.ts
|
|
146
|
+
const hasWebStorage = (kind) => {
|
|
147
|
+
try {
|
|
148
|
+
return typeof globalThis !== "undefined" && globalThis[kind] !== void 0;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const createMemoryStorageDriver = () => {
|
|
154
|
+
const memory = /* @__PURE__ */ new Map();
|
|
155
|
+
return {
|
|
156
|
+
getItem(key) {
|
|
157
|
+
return memory.get(key) ?? null;
|
|
158
|
+
},
|
|
159
|
+
removeItem(key) {
|
|
160
|
+
memory.delete(key);
|
|
161
|
+
},
|
|
162
|
+
setItem(key, value) {
|
|
163
|
+
memory.set(key, value);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
const createWebStorageDriver = (kind) => {
|
|
168
|
+
if (!hasWebStorage(kind)) return createMemoryStorageDriver();
|
|
169
|
+
const storage = globalThis[kind];
|
|
170
|
+
return {
|
|
171
|
+
getItem(key) {
|
|
172
|
+
try {
|
|
173
|
+
return storage.getItem(key);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
removeItem(key) {
|
|
179
|
+
try {
|
|
180
|
+
storage.removeItem(key);
|
|
181
|
+
} catch {}
|
|
182
|
+
},
|
|
183
|
+
setItem(key, value) {
|
|
184
|
+
try {
|
|
185
|
+
storage.setItem(key, value);
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Default storage drivers for web. `persistent` wraps `localStorage`,
|
|
192
|
+
* `session` wraps `sessionStorage`. Both fall back to in-memory maps
|
|
193
|
+
* when the global isn't available (SSR, non-browser environments).
|
|
194
|
+
*/
|
|
195
|
+
const createBrowserStorageDriver = () => ({
|
|
196
|
+
persistent: createWebStorageDriver("localStorage"),
|
|
197
|
+
session: createWebStorageDriver("sessionStorage")
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/storage/wallet-storage.ts
|
|
202
|
+
const VALID_CHAIN_PLATFORMS$1 = new Set(CHAIN_PLATFORMS);
|
|
203
|
+
const isValidAccount$1 = (value) => {
|
|
204
|
+
if (!value || typeof value !== "object") return false;
|
|
205
|
+
const account = value;
|
|
206
|
+
if (typeof account.walletAddress !== "string" || typeof account.id !== "string") return false;
|
|
207
|
+
if (!account.chain || typeof account.chain !== "object") return false;
|
|
208
|
+
return true;
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Structural validator for a serialized pool entry.
|
|
212
|
+
*
|
|
213
|
+
* Used in two directions:
|
|
214
|
+
* - On read (`getPool`): malformed entries from storage are warned
|
|
215
|
+
* and dropped — legacy / cross-tab corruption shouldn't crash the
|
|
216
|
+
* consumer.
|
|
217
|
+
* - On write (`setPool`): the runtime's reducer state is the source
|
|
218
|
+
* of truth, and a malformed write would indicate a programming
|
|
219
|
+
* error inside butr. Throw rather than silently corrupt storage.
|
|
220
|
+
*
|
|
221
|
+
* Same validator either way — keeping the two paths symmetric means
|
|
222
|
+
* "what's storable" is a single fact.
|
|
223
|
+
*/
|
|
224
|
+
const isValidPoolEntry$1 = (key, value) => {
|
|
225
|
+
if (!value || typeof value !== "object") return false;
|
|
226
|
+
const entry = value;
|
|
227
|
+
if (typeof entry.connectorId !== "string" || entry.connectorId !== key) return false;
|
|
228
|
+
if (typeof entry.chainPlatform !== "string") return false;
|
|
229
|
+
if (!VALID_CHAIN_PLATFORMS$1.has(entry.chainPlatform)) return false;
|
|
230
|
+
if (typeof entry.name !== "string" || entry.name.length === 0) return false;
|
|
231
|
+
if (entry.icon !== void 0 && typeof entry.icon !== "string") return false;
|
|
232
|
+
if (!isValidAccount$1(entry.account)) return false;
|
|
233
|
+
if (!Array.isArray(entry.accounts) || !entry.accounts.every(isValidAccount$1)) return false;
|
|
234
|
+
return true;
|
|
235
|
+
};
|
|
236
|
+
var WalletStorage = class {
|
|
237
|
+
poolKey;
|
|
238
|
+
selectionKey;
|
|
239
|
+
activeKey;
|
|
240
|
+
userDisconnectedKey;
|
|
241
|
+
persistent;
|
|
242
|
+
session;
|
|
243
|
+
/**
|
|
244
|
+
* Serializes pool-key mutations so concurrent fire-and-forget
|
|
245
|
+
* writes can't interleave their read-modify-write phases. Without
|
|
246
|
+
* this, two simultaneous `setPool` calls both read the pre-write
|
|
247
|
+
* state, each merge their own entries, and whichever finishes last
|
|
248
|
+
* overwrites the other's additions. Reads (`getPool`) don't enter
|
|
249
|
+
* the queue — they observe whatever's currently in the driver.
|
|
250
|
+
*/
|
|
251
|
+
poolMutationQueue = Promise.resolve();
|
|
252
|
+
constructor(config) {
|
|
253
|
+
this.poolKey = `${config.keyPrefix}-pool`;
|
|
254
|
+
this.selectionKey = `${config.keyPrefix}-selection`;
|
|
255
|
+
this.activeKey = `${config.keyPrefix}-active`;
|
|
256
|
+
this.userDisconnectedKey = `${config.keyPrefix}-user-disconnected`;
|
|
257
|
+
if (config.persistent && config.session) {
|
|
258
|
+
this.persistent = config.persistent;
|
|
259
|
+
this.session = config.session;
|
|
260
|
+
} else {
|
|
261
|
+
const defaults = createBrowserStorageDriver();
|
|
262
|
+
this.persistent = config.persistent ?? defaults.persistent;
|
|
263
|
+
this.session = config.session ?? defaults.session;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Chain `fn` after the in-flight pool mutation. */
|
|
267
|
+
async serializePoolMutation(fn) {
|
|
268
|
+
const previous = this.poolMutationQueue;
|
|
269
|
+
let resolve = () => {};
|
|
270
|
+
this.poolMutationQueue = new Promise((r) => {
|
|
271
|
+
resolve = r;
|
|
272
|
+
});
|
|
273
|
+
try {
|
|
274
|
+
await previous;
|
|
275
|
+
} catch {}
|
|
276
|
+
try {
|
|
277
|
+
return await fn();
|
|
278
|
+
} finally {
|
|
279
|
+
resolve();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async getPool() {
|
|
283
|
+
try {
|
|
284
|
+
const stored = await this.persistent.getItem(this.poolKey);
|
|
285
|
+
if (!stored) return {};
|
|
286
|
+
const parsed = JSON.parse(stored);
|
|
287
|
+
if (!parsed || typeof parsed !== "object") {
|
|
288
|
+
await this.clearPool();
|
|
289
|
+
return {};
|
|
290
|
+
}
|
|
291
|
+
const result = {};
|
|
292
|
+
for (const [key, value] of Object.entries(parsed)) if (isValidPoolEntry$1(key, value)) result[key] = value;
|
|
293
|
+
else logWarn(`[butr] dropping invalid pool entry for ${key}`);
|
|
294
|
+
return result;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
logWarn("[butr] failed to parse pool from storage:", error);
|
|
297
|
+
await this.clearPool();
|
|
298
|
+
return {};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Upsert the in-memory pool into storage. Additive: entries in
|
|
303
|
+
* `pool` are written; entries already in storage that aren't in
|
|
304
|
+
* `pool` are kept. The in-memory pool reflects "what's live right
|
|
305
|
+
* now", not "the complete list of remembered connections" — a
|
|
306
|
+
* silent reconnect that fails on reload leaves the entry out of
|
|
307
|
+
* the pool but the saved entry stays so the next load can retry.
|
|
308
|
+
* Use `removePoolEntry` for explicit eviction (the user clicked
|
|
309
|
+
* Disconnect) and `clearAll` for a full wipe (reset).
|
|
310
|
+
*/
|
|
311
|
+
setPool(pool) {
|
|
312
|
+
return this.serializePoolMutation(async () => {
|
|
313
|
+
try {
|
|
314
|
+
const serializable = { ...await this.getPool() };
|
|
315
|
+
for (const [connectorId, wallet] of pool) {
|
|
316
|
+
const entry = {
|
|
317
|
+
account: wallet.account,
|
|
318
|
+
accounts: wallet.accounts,
|
|
319
|
+
chainPlatform: wallet.connector.chainPlatform,
|
|
320
|
+
connectorId,
|
|
321
|
+
icon: wallet.connector.icon,
|
|
322
|
+
name: wallet.connector.name
|
|
323
|
+
};
|
|
324
|
+
if (!isValidPoolEntry$1(connectorId, entry)) throw new Error(`[butr] refusing to persist invalid pool entry for ${connectorId}`);
|
|
325
|
+
serializable[connectorId] = entry;
|
|
326
|
+
}
|
|
327
|
+
await this.persistent.setItem(this.poolKey, JSON.stringify(serializable));
|
|
328
|
+
} catch (error) {
|
|
329
|
+
logWarn("[butr] failed to persist pool:", error);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
removePoolEntry(connectorId) {
|
|
334
|
+
return this.serializePoolMutation(async () => {
|
|
335
|
+
try {
|
|
336
|
+
const stored = await this.getPool();
|
|
337
|
+
if (stored[connectorId]) {
|
|
338
|
+
const { [connectorId]: _, ...remaining } = stored;
|
|
339
|
+
await this.persistent.setItem(this.poolKey, JSON.stringify(remaining));
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
logWarn(`[butr] failed to remove pool entry ${connectorId}:`, error);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
clearPool() {
|
|
347
|
+
return this.serializePoolMutation(async () => {
|
|
348
|
+
await this.persistent.removeItem(this.poolKey);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async getSelection() {
|
|
352
|
+
try {
|
|
353
|
+
const stored = await this.persistent.getItem(this.selectionKey);
|
|
354
|
+
if (!stored) return {};
|
|
355
|
+
const parsed = JSON.parse(stored);
|
|
356
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
357
|
+
const result = {};
|
|
358
|
+
for (const [key, value] of Object.entries(parsed)) if (VALID_CHAIN_PLATFORMS$1.has(key) && typeof value === "string" && value.length > 0) result[key] = value;
|
|
359
|
+
return result;
|
|
360
|
+
} catch (error) {
|
|
361
|
+
logWarn("[butr] failed to parse selection from storage:", error);
|
|
362
|
+
return {};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async setSelection(selection) {
|
|
366
|
+
try {
|
|
367
|
+
const serializable = {};
|
|
368
|
+
for (const [platform, connectorId] of selection) serializable[platform] = connectorId;
|
|
369
|
+
await this.persistent.setItem(this.selectionKey, JSON.stringify(serializable));
|
|
370
|
+
} catch (error) {
|
|
371
|
+
logWarn("[butr] failed to persist selection:", error);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async getActiveConnectorId() {
|
|
375
|
+
try {
|
|
376
|
+
const value = await this.persistent.getItem(this.activeKey);
|
|
377
|
+
return value && value.length > 0 ? value : null;
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async setActiveConnectorId(connectorId) {
|
|
383
|
+
try {
|
|
384
|
+
await (connectorId === null ? this.persistent.removeItem(this.activeKey) : this.persistent.setItem(this.activeKey, connectorId));
|
|
385
|
+
} catch (error) {
|
|
386
|
+
logWarn("[butr] failed to persist active connector id:", error);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async clearAll() {
|
|
390
|
+
await Promise.all([
|
|
391
|
+
this.clearPool(),
|
|
392
|
+
this.persistent.removeItem(this.selectionKey),
|
|
393
|
+
this.persistent.removeItem(this.activeKey)
|
|
394
|
+
]);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Disconnect-intent tracking.
|
|
398
|
+
*
|
|
399
|
+
* Lives in the session driver: survives component remounts (unlike refs)
|
|
400
|
+
* but clears when the session ends (unlike the persistent driver).
|
|
401
|
+
* Prevents auto-connect from firing immediately after a manual disconnect,
|
|
402
|
+
* while still allowing auto-connect on fresh sessions.
|
|
403
|
+
*/
|
|
404
|
+
async isUserDisconnected() {
|
|
405
|
+
try {
|
|
406
|
+
return await this.session.getItem(this.userDisconnectedKey) === "true";
|
|
407
|
+
} catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async markUserDisconnected(value) {
|
|
412
|
+
try {
|
|
413
|
+
await (value ? this.session.setItem(this.userDisconnectedKey, "true") : this.session.removeItem(this.userDisconnectedKey));
|
|
414
|
+
} catch {}
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/store/connector-lifecycle.ts
|
|
420
|
+
const createConnectorLifecycle = (handlers) => {
|
|
421
|
+
const unsubscribers = /* @__PURE__ */ new Map();
|
|
422
|
+
const detach = (connectorId) => {
|
|
423
|
+
const unsub = unsubscribers.get(connectorId);
|
|
424
|
+
if (!unsub) return;
|
|
425
|
+
try {
|
|
426
|
+
unsub();
|
|
427
|
+
} catch (error) {
|
|
428
|
+
logWarn("[butr] unsubscribe threw:", error);
|
|
429
|
+
}
|
|
430
|
+
unsubscribers.delete(connectorId);
|
|
431
|
+
};
|
|
432
|
+
return {
|
|
433
|
+
attach: (connectorId, connector) => {
|
|
434
|
+
if (!connector.subscribe) return;
|
|
435
|
+
detach(connectorId);
|
|
436
|
+
try {
|
|
437
|
+
const unsub = connector.subscribe((event) => {
|
|
438
|
+
switch (event.type) {
|
|
439
|
+
case "accountChanged":
|
|
440
|
+
handlers.onAccountChanged(connectorId, event.accounts, event.account);
|
|
441
|
+
break;
|
|
442
|
+
case "disconnected":
|
|
443
|
+
detach(connectorId);
|
|
444
|
+
handlers.onDisconnected(connectorId, connector.chainPlatform);
|
|
445
|
+
break;
|
|
446
|
+
default:
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
unsubscribers.set(connectorId, unsub);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
logWarn(`[butr] subscribe failed for ${connectorId}:`, error);
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
detach,
|
|
455
|
+
detachAll: () => {
|
|
456
|
+
const ids = [];
|
|
457
|
+
for (const id of unsubscribers.keys()) ids.push(id);
|
|
458
|
+
for (const id of ids) detach(id);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/store/hydration.ts
|
|
465
|
+
/** Pick the right `accounts` list: fresh from the connector if it
|
|
466
|
+
* exposes `getAccounts`, otherwise the persisted list, otherwise just
|
|
467
|
+
* the active account. */
|
|
468
|
+
const resolveHydratedAccounts = async (connector, storedAccounts, fallbackAccount) => {
|
|
469
|
+
if (connector.getAccounts) {
|
|
470
|
+
const fresh = await connector.getAccounts().catch(() => null);
|
|
471
|
+
if (fresh && fresh.length > 0) return fresh;
|
|
472
|
+
}
|
|
473
|
+
if (storedAccounts.length > 0) return [...storedAccounts];
|
|
474
|
+
return [fallbackAccount];
|
|
475
|
+
};
|
|
476
|
+
const restoreOneEntry = async (connectorId, entry, connector) => {
|
|
477
|
+
try {
|
|
478
|
+
await connector.connect({ silent: true });
|
|
479
|
+
const accountToUse = await connector.getAccount() || entry.account;
|
|
480
|
+
return {
|
|
481
|
+
connectorId,
|
|
482
|
+
entry: {
|
|
483
|
+
account: accountToUse,
|
|
484
|
+
accounts: await resolveHydratedAccounts(connector, entry.accounts, accountToUse),
|
|
485
|
+
connector
|
|
486
|
+
},
|
|
487
|
+
kind: "ok"
|
|
488
|
+
};
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return {
|
|
491
|
+
connectorId,
|
|
492
|
+
error,
|
|
493
|
+
kind: "fail"
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
const createHydrationCoordinator = (storage, createConnector) => {
|
|
498
|
+
const pending = /* @__PURE__ */ new Map();
|
|
499
|
+
return {
|
|
500
|
+
drainPending: async (connectorId) => {
|
|
501
|
+
const entry = pending.get(connectorId);
|
|
502
|
+
if (!entry) return null;
|
|
503
|
+
const connector = createConnector(connectorId);
|
|
504
|
+
if (!connector) return null;
|
|
505
|
+
const outcome = await restoreOneEntry(connectorId, entry, connector);
|
|
506
|
+
pending.delete(connectorId);
|
|
507
|
+
return outcome;
|
|
508
|
+
},
|
|
509
|
+
hydrate: async () => {
|
|
510
|
+
const [storedPool, storedSelection, storedActive, userDisconnected] = await Promise.all([
|
|
511
|
+
storage.getPool(),
|
|
512
|
+
storage.getSelection(),
|
|
513
|
+
storage.getActiveConnectorId(),
|
|
514
|
+
storage.isUserDisconnected()
|
|
515
|
+
]);
|
|
516
|
+
const pool = /* @__PURE__ */ new Map();
|
|
517
|
+
const dropped = [];
|
|
518
|
+
pending.clear();
|
|
519
|
+
const tasks = [];
|
|
520
|
+
for (const [connectorId, entry] of Object.entries(storedPool)) {
|
|
521
|
+
if (!entry) continue;
|
|
522
|
+
const connector = createConnector(connectorId);
|
|
523
|
+
if (!connector) {
|
|
524
|
+
pending.set(connectorId, entry);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
tasks.push(restoreOneEntry(connectorId, entry, connector));
|
|
528
|
+
}
|
|
529
|
+
const outcomes = await Promise.all(tasks);
|
|
530
|
+
for (const outcome of outcomes) {
|
|
531
|
+
if (outcome.kind === "ok") {
|
|
532
|
+
pool.set(outcome.connectorId, outcome.entry);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
dropped.push({
|
|
536
|
+
connectorId: outcome.connectorId,
|
|
537
|
+
reason: outcome.error
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
const selection = /* @__PURE__ */ new Map();
|
|
541
|
+
for (const [platform, connectorId] of Object.entries(storedSelection)) if (connectorId && pool.has(connectorId)) selection.set(platform, connectorId);
|
|
542
|
+
for (const [connectorId, wallet] of pool) {
|
|
543
|
+
const platform = wallet.connector.chainPlatform;
|
|
544
|
+
if (!selection.has(platform)) selection.set(platform, connectorId);
|
|
545
|
+
}
|
|
546
|
+
let activeConnectorId = null;
|
|
547
|
+
if (storedActive && pool.has(storedActive)) activeConnectorId = storedActive;
|
|
548
|
+
else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
|
|
549
|
+
return {
|
|
550
|
+
activeConnectorId,
|
|
551
|
+
dropped,
|
|
552
|
+
isUserDisconnected: userDisconnected,
|
|
553
|
+
pendingIds: [...pending.keys()],
|
|
554
|
+
pool,
|
|
555
|
+
selection
|
|
556
|
+
};
|
|
557
|
+
},
|
|
558
|
+
pendingIds: () => [...pending.keys()]
|
|
559
|
+
};
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region src/store/reducer.ts
|
|
564
|
+
const initialState = {
|
|
565
|
+
activeConnectorId: null,
|
|
566
|
+
connectingConnectorId: null,
|
|
567
|
+
connectionError: null,
|
|
568
|
+
connectionStatus: "idle",
|
|
569
|
+
isHydrated: false,
|
|
570
|
+
isUserDisconnected: false,
|
|
571
|
+
pool: /* @__PURE__ */ new Map(),
|
|
572
|
+
reconnectingIds: /* @__PURE__ */ new Set(),
|
|
573
|
+
selection: /* @__PURE__ */ new Map()
|
|
574
|
+
};
|
|
575
|
+
/** Find any connector in the pool serving a given platform. */
|
|
576
|
+
const findConnectorForPlatform = (pool, chainPlatform) => {
|
|
577
|
+
for (const [id, wallet] of pool) if (wallet.connector.chainPlatform === chainPlatform) return id;
|
|
578
|
+
};
|
|
579
|
+
/** Pure reducer: every state transition is expressed as `(state, event) => state`.
|
|
580
|
+
* No I/O, no async, no side effects. Side effects live in the runtime. */
|
|
581
|
+
const reducer = (state, event) => {
|
|
582
|
+
switch (event.type) {
|
|
583
|
+
case "HYDRATED": {
|
|
584
|
+
const pool = new Map(state.pool);
|
|
585
|
+
for (const [id, entry] of event.pool) pool.set(id, entry);
|
|
586
|
+
const selection = new Map(state.selection);
|
|
587
|
+
for (const [platform, id] of event.selection) selection.set(platform, id);
|
|
588
|
+
let activeConnectorId = null;
|
|
589
|
+
if (event.activeConnectorId && pool.has(event.activeConnectorId)) activeConnectorId = event.activeConnectorId;
|
|
590
|
+
else if (state.activeConnectorId && pool.has(state.activeConnectorId)) activeConnectorId = state.activeConnectorId;
|
|
591
|
+
else if (pool.size > 0) activeConnectorId = pool.keys().next().value ?? null;
|
|
592
|
+
let nextReconnecting = state.reconnectingIds;
|
|
593
|
+
if (state.reconnectingIds.size > 0 && event.pool.size > 0) {
|
|
594
|
+
const next = new Set(state.reconnectingIds);
|
|
595
|
+
for (const id of event.pool.keys()) next.delete(id);
|
|
596
|
+
if (next.size !== state.reconnectingIds.size) nextReconnecting = next;
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
...state,
|
|
600
|
+
activeConnectorId,
|
|
601
|
+
isHydrated: true,
|
|
602
|
+
isUserDisconnected: event.isUserDisconnected,
|
|
603
|
+
pool,
|
|
604
|
+
reconnectingIds: nextReconnecting,
|
|
605
|
+
selection
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
case "USER_DISCONNECTED_SET": return {
|
|
609
|
+
...state,
|
|
610
|
+
isUserDisconnected: event.value
|
|
611
|
+
};
|
|
612
|
+
case "CONNECT_STARTED": return {
|
|
613
|
+
...state,
|
|
614
|
+
connectingConnectorId: event.connectorId,
|
|
615
|
+
connectionError: null,
|
|
616
|
+
connectionStatus: "connecting"
|
|
617
|
+
};
|
|
618
|
+
case "CONNECT_SUCCEEDED": {
|
|
619
|
+
const { connectorId, entry } = event;
|
|
620
|
+
const existing = state.pool.get(connectorId);
|
|
621
|
+
const nextReconnecting = state.reconnectingIds.has(connectorId) ? new Set([...state.reconnectingIds].filter((id) => id !== connectorId)) : state.reconnectingIds;
|
|
622
|
+
if (existing && existing.account.walletAddress.toLowerCase() === entry.account.walletAddress.toLowerCase()) return {
|
|
623
|
+
...state,
|
|
624
|
+
activeConnectorId: connectorId,
|
|
625
|
+
connectingConnectorId: null,
|
|
626
|
+
connectionStatus: "success",
|
|
627
|
+
pool: existing.connector === entry.connector ? state.pool : new Map([...state.pool, [connectorId, entry]]),
|
|
628
|
+
reconnectingIds: nextReconnecting
|
|
629
|
+
};
|
|
630
|
+
const newPool = new Map([...state.pool, [connectorId, entry]]);
|
|
631
|
+
const newSelection = new Map([...state.selection, [entry.connector.chainPlatform, connectorId]]);
|
|
632
|
+
return {
|
|
633
|
+
...state,
|
|
634
|
+
activeConnectorId: connectorId,
|
|
635
|
+
connectingConnectorId: null,
|
|
636
|
+
connectionStatus: "success",
|
|
637
|
+
pool: newPool,
|
|
638
|
+
reconnectingIds: nextReconnecting,
|
|
639
|
+
selection: newSelection
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
case "CONNECT_FAILED": return {
|
|
643
|
+
...state,
|
|
644
|
+
connectingConnectorId: null,
|
|
645
|
+
connectionError: event.error,
|
|
646
|
+
connectionStatus: "error"
|
|
647
|
+
};
|
|
648
|
+
case "DISCONNECTED": {
|
|
649
|
+
const wallet = state.pool.get(event.connectorId);
|
|
650
|
+
if (!wallet) return state;
|
|
651
|
+
const newPool = new Map(state.pool);
|
|
652
|
+
newPool.delete(event.connectorId);
|
|
653
|
+
const newSelection = new Map(state.selection);
|
|
654
|
+
const platform = wallet.connector.chainPlatform;
|
|
655
|
+
if (newSelection.get(platform) === event.connectorId) {
|
|
656
|
+
newSelection.delete(platform);
|
|
657
|
+
const fallback = findConnectorForPlatform(newPool, platform);
|
|
658
|
+
if (fallback) newSelection.set(platform, fallback);
|
|
659
|
+
}
|
|
660
|
+
const firstId = newPool.keys().next();
|
|
661
|
+
const activeConnectorId = state.activeConnectorId === event.connectorId ? firstId.value ?? null : state.activeConnectorId;
|
|
662
|
+
const nextReconnecting = state.reconnectingIds.has(event.connectorId) ? new Set([...state.reconnectingIds].filter((id) => id !== event.connectorId)) : state.reconnectingIds;
|
|
663
|
+
return {
|
|
664
|
+
...state,
|
|
665
|
+
activeConnectorId,
|
|
666
|
+
pool: newPool,
|
|
667
|
+
reconnectingIds: nextReconnecting,
|
|
668
|
+
selection: newSelection
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
case "ACCOUNTS_REFRESHED": {
|
|
672
|
+
const wallet = state.pool.get(event.connectorId);
|
|
673
|
+
if (!wallet) return state;
|
|
674
|
+
let nextAccount;
|
|
675
|
+
if (event.active) nextAccount = event.active;
|
|
676
|
+
else nextAccount = event.accounts.some((a) => a.walletAddress === wallet.account.walletAddress && a.chain.id === wallet.account.chain.id) ? wallet.account : event.accounts[0] ?? wallet.account;
|
|
677
|
+
const sameAccount = nextAccount.walletAddress === wallet.account.walletAddress && nextAccount.chain.id === wallet.account.chain.id;
|
|
678
|
+
const sameAccountsShape = event.accounts.length === wallet.accounts.length && event.accounts.every((a, i) => a.walletAddress === wallet.accounts[i]?.walletAddress);
|
|
679
|
+
if (sameAccount && sameAccountsShape) return state;
|
|
680
|
+
const updated = {
|
|
681
|
+
...wallet,
|
|
682
|
+
account: nextAccount,
|
|
683
|
+
accounts: [...event.accounts]
|
|
684
|
+
};
|
|
685
|
+
const newPool = new Map([...state.pool, [event.connectorId, updated]]);
|
|
686
|
+
return {
|
|
687
|
+
...state,
|
|
688
|
+
pool: newPool
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
case "WALLET_REFRESHED": {
|
|
692
|
+
const wallet = state.pool.get(event.connectorId);
|
|
693
|
+
if (!wallet) return state;
|
|
694
|
+
const newPool = new Map([...state.pool, [event.connectorId, { ...wallet }]]);
|
|
695
|
+
return {
|
|
696
|
+
...state,
|
|
697
|
+
pool: newPool
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
case "ACTIVE_CHANGED": return {
|
|
701
|
+
...state,
|
|
702
|
+
activeConnectorId: event.connectorId
|
|
703
|
+
};
|
|
704
|
+
case "SELECTION_CHANGED": {
|
|
705
|
+
const newSelection = new Map(state.selection);
|
|
706
|
+
if (event.connectorId === null) newSelection.delete(event.chainPlatform);
|
|
707
|
+
else newSelection.set(event.chainPlatform, event.connectorId);
|
|
708
|
+
return {
|
|
709
|
+
...state,
|
|
710
|
+
selection: newSelection
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
case "STATUS_RESET": return {
|
|
714
|
+
...state,
|
|
715
|
+
connectingConnectorId: null,
|
|
716
|
+
connectionError: null,
|
|
717
|
+
connectionStatus: "idle"
|
|
718
|
+
};
|
|
719
|
+
case "ERROR_SET": return {
|
|
720
|
+
...state,
|
|
721
|
+
connectionError: event.error,
|
|
722
|
+
connectionStatus: event.error ? "error" : "idle"
|
|
723
|
+
};
|
|
724
|
+
case "RESET": return {
|
|
725
|
+
...initialState,
|
|
726
|
+
isHydrated: state.isHydrated,
|
|
727
|
+
isUserDisconnected: true,
|
|
728
|
+
reconnectingIds: /* @__PURE__ */ new Set()
|
|
729
|
+
};
|
|
730
|
+
default: return state;
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/store/shadow-adapter.ts
|
|
736
|
+
/**
|
|
737
|
+
* Error thrown when a method is called on a shadow adapter — the
|
|
738
|
+
* placeholder `WalletAdapter` that the store seeds into the pool when
|
|
739
|
+
* an `initialState` is provided (e.g. from a server-rendered cookie
|
|
740
|
+
* snapshot). Shadow adapters carry the identity and account data of a
|
|
741
|
+
* previously-connected wallet, but the live wallet extension hasn't
|
|
742
|
+
* been verified yet — the silent reconnect happens asynchronously
|
|
743
|
+
* after mount.
|
|
744
|
+
*
|
|
745
|
+
* UI code that gates affordances on `wallet.connector.capabilities.*`
|
|
746
|
+
* never reaches a shadow method (capabilities are all `false`). Code
|
|
747
|
+
* that calls through anyway hits this typed error, which is the
|
|
748
|
+
* correct loud failure: the consumer ignored the capability gate.
|
|
749
|
+
*
|
|
750
|
+
* Consumers wanting to wait out the reconnecting window should branch
|
|
751
|
+
* on whether `connectorId` is in `state.reconnectingIds`.
|
|
752
|
+
*/
|
|
753
|
+
var ShadowConnectorError = class extends Error {
|
|
754
|
+
code = "BUTR_RECONNECTING";
|
|
755
|
+
connectorId;
|
|
756
|
+
method;
|
|
757
|
+
constructor(method, connectorId) {
|
|
758
|
+
super(`[butr] ${method} called on shadow connector "${connectorId}" — wait for silent reconnect to complete (check reconnectingIds.has(id) before calling).`);
|
|
759
|
+
this.name = "ShadowConnectorError";
|
|
760
|
+
this.connectorId = connectorId;
|
|
761
|
+
this.method = method;
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
const ALL_FALSE_CAPABILITIES = Object.freeze({
|
|
765
|
+
getBalance: false,
|
|
766
|
+
getTransactionReceipt: false,
|
|
767
|
+
requestAccounts: false,
|
|
768
|
+
sendTransaction: false,
|
|
769
|
+
signIn: false,
|
|
770
|
+
signMessage: false,
|
|
771
|
+
signTransaction: false,
|
|
772
|
+
subscribe: false,
|
|
773
|
+
switchAccount: false,
|
|
774
|
+
switchChain: false
|
|
775
|
+
});
|
|
776
|
+
/**
|
|
777
|
+
* Builds a placeholder `WalletAdapter` from a persisted pool entry.
|
|
778
|
+
*
|
|
779
|
+
* Used by `createWalletStore` when `WalletManagerConfig.initialState`
|
|
780
|
+
* is provided: each stored entry becomes a `ConnectedWallet` whose
|
|
781
|
+
* `connector` is one of these. The store flips `isHydrated` true
|
|
782
|
+
* synchronously and exposes the data through the usual hooks
|
|
783
|
+
* (`useActiveWallet`, `useConnectedWallets`, …) — but the connector
|
|
784
|
+
* can't actually talk to a wallet yet, so its capabilities are all
|
|
785
|
+
* `false` and its methods throw `ShadowConnectorError` if called.
|
|
786
|
+
*
|
|
787
|
+
* The hydration coordinator's silent-reconnect pass upgrades each
|
|
788
|
+
* shadow to a live `WalletAdapter` by calling `createConnector(id)`
|
|
789
|
+
* (which only succeeds once the live adapter has been announced via
|
|
790
|
+
* discovery or registered explicitly) and replacing the pool entry.
|
|
791
|
+
* On success the connector id is removed from `reconnectingIds`; on
|
|
792
|
+
* failure the entry is dropped from the pool and storage.
|
|
793
|
+
*
|
|
794
|
+
* The entry's `name` is required (the storage validator rejects
|
|
795
|
+
* entries without it); `icon` is optional only because some live
|
|
796
|
+
* adapters genuinely have no icon to begin with.
|
|
797
|
+
*/
|
|
798
|
+
const createShadowAdapter = (entry) => {
|
|
799
|
+
const id = entry.connectorId;
|
|
800
|
+
const name = entry.name;
|
|
801
|
+
const icon = entry.icon;
|
|
802
|
+
const reject = (method) => Promise.reject(new ShadowConnectorError(method, id));
|
|
803
|
+
const base = {
|
|
804
|
+
capabilities: ALL_FALSE_CAPABILITIES,
|
|
805
|
+
connect: () => reject("connect"),
|
|
806
|
+
disconnect: () => reject("disconnect"),
|
|
807
|
+
getAccount: () => reject("getAccount"),
|
|
808
|
+
getBalance: () => reject("getBalance"),
|
|
809
|
+
getSigner: () => reject("getSigner"),
|
|
810
|
+
getTransactionReceipt: () => reject("getTransactionReceipt"),
|
|
811
|
+
icon,
|
|
812
|
+
id,
|
|
813
|
+
name,
|
|
814
|
+
sendTx: () => reject("sendTx"),
|
|
815
|
+
sendTxToChain: () => reject("sendTxToChain"),
|
|
816
|
+
signMessage: () => reject("signMessage"),
|
|
817
|
+
switchChain: () => reject("switchChain")
|
|
818
|
+
};
|
|
819
|
+
switch (entry.chainPlatform) {
|
|
820
|
+
case "bitcoin": return {
|
|
821
|
+
...base,
|
|
822
|
+
chainPlatform: "bitcoin"
|
|
823
|
+
};
|
|
824
|
+
case "evm": return {
|
|
825
|
+
...base,
|
|
826
|
+
chainPlatform: "evm"
|
|
827
|
+
};
|
|
828
|
+
case "sui": return {
|
|
829
|
+
...base,
|
|
830
|
+
chainPlatform: "sui"
|
|
831
|
+
};
|
|
832
|
+
case "svm": return {
|
|
833
|
+
...base,
|
|
834
|
+
chainPlatform: "svm"
|
|
835
|
+
};
|
|
836
|
+
case "polkadot": return {
|
|
837
|
+
...base,
|
|
838
|
+
chainPlatform: "polkadot"
|
|
839
|
+
};
|
|
840
|
+
default:
|
|
841
|
+
entry.chainPlatform;
|
|
842
|
+
throw new Error(`[butr] unknown chainPlatform: ${entry.chainPlatform}`);
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
//#endregion
|
|
847
|
+
//#region src/store/wallet-store-helpers.ts
|
|
848
|
+
/** Run an async operation fire-and-forget, calling onError if it throws. */
|
|
849
|
+
const run = async (fn, onError) => {
|
|
850
|
+
try {
|
|
851
|
+
await fn();
|
|
852
|
+
} catch (error) {
|
|
853
|
+
onError(error);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
//#endregion
|
|
858
|
+
//#region src/store/wallet-store.ts
|
|
859
|
+
/**
|
|
860
|
+
* Build a synchronously-populated `State` from `config.initialState`.
|
|
861
|
+
* Each pool entry becomes a `ConnectedWallet` whose `connector` is a
|
|
862
|
+
* shadow adapter; every connector id enters `reconnectingIds` so
|
|
863
|
+
* consumers can branch on "is this connection verified" without
|
|
864
|
+
* waiting for the async silent reconnect. `isHydrated` flips true
|
|
865
|
+
* synchronously — the consumer's first render sees the persisted
|
|
866
|
+
* state in the live store, not undefined.
|
|
867
|
+
*/
|
|
868
|
+
const seedStateFromSnapshot = (snapshot) => {
|
|
869
|
+
const pool = /* @__PURE__ */ new Map();
|
|
870
|
+
const reconnectingIds = /* @__PURE__ */ new Set();
|
|
871
|
+
for (const [connectorId, entry] of Object.entries(snapshot.pool)) {
|
|
872
|
+
if (!entry) continue;
|
|
873
|
+
const connector = createShadowAdapter(entry);
|
|
874
|
+
pool.set(connectorId, {
|
|
875
|
+
account: entry.account,
|
|
876
|
+
accounts: [...entry.accounts],
|
|
877
|
+
connector
|
|
878
|
+
});
|
|
879
|
+
reconnectingIds.add(connectorId);
|
|
880
|
+
}
|
|
881
|
+
const selection = /* @__PURE__ */ new Map();
|
|
882
|
+
for (const [platform, connectorId] of Object.entries(snapshot.selection)) if (connectorId && pool.has(connectorId)) selection.set(platform, connectorId);
|
|
883
|
+
const activeConnectorId = snapshot.activeConnectorId && pool.has(snapshot.activeConnectorId) ? snapshot.activeConnectorId : pool.keys().next().value ?? null;
|
|
884
|
+
return {
|
|
885
|
+
...initialState,
|
|
886
|
+
activeConnectorId,
|
|
887
|
+
isHydrated: true,
|
|
888
|
+
pool,
|
|
889
|
+
reconnectingIds,
|
|
890
|
+
selection
|
|
891
|
+
};
|
|
892
|
+
};
|
|
893
|
+
const CONNECT_TIMEOUT_MS = 9e4;
|
|
894
|
+
const DEFAULT_SLOW_CONNECT_THRESHOLD_MS = 5e3;
|
|
895
|
+
const normaliseAddress = (addr) => addr.toLowerCase();
|
|
896
|
+
const createWalletStore = (config) => {
|
|
897
|
+
const storageKeyPrefix = config.storageKeyPrefix || "butr";
|
|
898
|
+
const storage = config.storage || new WalletStorage({ keyPrefix: storageKeyPrefix });
|
|
899
|
+
const reportStorageError = (context) => (error) => {
|
|
900
|
+
if (config.onStorageError) {
|
|
901
|
+
try {
|
|
902
|
+
config.onStorageError(error, context);
|
|
903
|
+
} catch (cbError) {
|
|
904
|
+
logWarn("[butr] onStorageError threw:", cbError);
|
|
905
|
+
}
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
logWarn(`[butr] ${context}:`, error);
|
|
909
|
+
};
|
|
910
|
+
const hydration = createHydrationCoordinator(storage, config.createConnector);
|
|
911
|
+
return createStore()((set, get) => {
|
|
912
|
+
const dispatch = (event) => {
|
|
913
|
+
set((prev) => reducer(prev, event), false);
|
|
914
|
+
};
|
|
915
|
+
const persistPool = () => run(() => storage.setPool(get().pool), reportStorageError("failed to persist pool"));
|
|
916
|
+
const persistSelection = () => run(() => storage.setSelection(get().selection), reportStorageError("failed to persist selection"));
|
|
917
|
+
const persistActive = () => run(() => storage.setActiveConnectorId(get().activeConnectorId), reportStorageError("failed to persist active connector"));
|
|
918
|
+
const refreshPoolEntry = (connectorId, accounts, active) => {
|
|
919
|
+
dispatch({
|
|
920
|
+
accounts,
|
|
921
|
+
active,
|
|
922
|
+
connectorId,
|
|
923
|
+
type: "ACCOUNTS_REFRESHED"
|
|
924
|
+
});
|
|
925
|
+
persistPool();
|
|
926
|
+
};
|
|
927
|
+
const lifecycle = createConnectorLifecycle({
|
|
928
|
+
onAccountChanged: (connectorId, accounts, active) => {
|
|
929
|
+
refreshPoolEntry(connectorId, [...accounts], active);
|
|
930
|
+
},
|
|
931
|
+
onDisconnected: (connectorId, chainPlatform) => {
|
|
932
|
+
dispatch({
|
|
933
|
+
connectorId,
|
|
934
|
+
type: "DISCONNECTED"
|
|
935
|
+
});
|
|
936
|
+
config.onDisconnect?.(chainPlatform);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
return {
|
|
940
|
+
...config.initialState ? seedStateFromSnapshot(config.initialState) : initialState,
|
|
941
|
+
_config: config,
|
|
942
|
+
_storage: storage,
|
|
943
|
+
connectWallet: async (connectorId, onSuccess, onError) => {
|
|
944
|
+
get().setUserDisconnected(false);
|
|
945
|
+
const connector = config.createConnector(connectorId);
|
|
946
|
+
if (!connector) throw new Error(`Failed to create connector for ${connectorId}`);
|
|
947
|
+
dispatch({
|
|
948
|
+
connectorId,
|
|
949
|
+
type: "CONNECT_STARTED"
|
|
950
|
+
});
|
|
951
|
+
const slowThreshold = config.slowConnectThresholdMs ?? DEFAULT_SLOW_CONNECT_THRESHOLD_MS;
|
|
952
|
+
const slowTimer = config.onSlowConnect ? setTimeout(() => {
|
|
953
|
+
try {
|
|
954
|
+
config.onSlowConnect?.(connectorId);
|
|
955
|
+
} catch (cbError) {
|
|
956
|
+
logWarn("[butr] onSlowConnect threw:", cbError);
|
|
957
|
+
}
|
|
958
|
+
}, slowThreshold) : null;
|
|
959
|
+
let connectTimeoutId = null;
|
|
960
|
+
try {
|
|
961
|
+
const connectPromise = connector.connect();
|
|
962
|
+
connectPromise.catch(() => {});
|
|
963
|
+
await Promise.race([connectPromise, new Promise((_, reject) => {
|
|
964
|
+
connectTimeoutId = setTimeout(() => {
|
|
965
|
+
reject(/* @__PURE__ */ new Error("Connection timeout"));
|
|
966
|
+
}, CONNECT_TIMEOUT_MS);
|
|
967
|
+
})]);
|
|
968
|
+
const account = await connector.getAccount();
|
|
969
|
+
if (!account) throw new Error("Failed to get account");
|
|
970
|
+
const fetchedAccounts = connector.getAccounts ? await connector.getAccounts().catch(() => null) : null;
|
|
971
|
+
const entry = {
|
|
972
|
+
account,
|
|
973
|
+
accounts: fetchedAccounts && fetchedAccounts.length > 0 ? fetchedAccounts : [account],
|
|
974
|
+
connector
|
|
975
|
+
};
|
|
976
|
+
dispatch({
|
|
977
|
+
connectorId,
|
|
978
|
+
entry,
|
|
979
|
+
type: "CONNECT_SUCCEEDED"
|
|
980
|
+
});
|
|
981
|
+
lifecycle.attach(connectorId, connector);
|
|
982
|
+
await Promise.all([
|
|
983
|
+
persistPool(),
|
|
984
|
+
persistSelection(),
|
|
985
|
+
persistActive()
|
|
986
|
+
]);
|
|
987
|
+
config.onConnect?.(entry);
|
|
988
|
+
onSuccess?.(entry);
|
|
989
|
+
} catch (error) {
|
|
990
|
+
const normalised = mapConnectionError(error);
|
|
991
|
+
dispatch({
|
|
992
|
+
error: normalised,
|
|
993
|
+
type: "CONNECT_FAILED"
|
|
994
|
+
});
|
|
995
|
+
try {
|
|
996
|
+
await connector.disconnect?.();
|
|
997
|
+
} catch (disconnectError) {
|
|
998
|
+
logWarn("[butr] disconnect during error recovery failed:", disconnectError);
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
config.onConnectError?.(normalised, connectorId);
|
|
1002
|
+
} catch (cbError) {
|
|
1003
|
+
logWarn("[butr] onConnectError threw:", cbError);
|
|
1004
|
+
}
|
|
1005
|
+
onError?.(error);
|
|
1006
|
+
throw error;
|
|
1007
|
+
} finally {
|
|
1008
|
+
if (connectTimeoutId) clearTimeout(connectTimeoutId);
|
|
1009
|
+
if (slowTimer) clearTimeout(slowTimer);
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
disconnectWallet: (connectorId) => {
|
|
1013
|
+
get().setUserDisconnected(true);
|
|
1014
|
+
const state = get();
|
|
1015
|
+
if (!state.isHydrated) return;
|
|
1016
|
+
const wallet = state.pool.get(connectorId);
|
|
1017
|
+
if (!wallet) return;
|
|
1018
|
+
lifecycle.detach(connectorId);
|
|
1019
|
+
run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
|
|
1020
|
+
const platform = wallet.connector.chainPlatform;
|
|
1021
|
+
dispatch({
|
|
1022
|
+
connectorId,
|
|
1023
|
+
type: "DISCONNECTED"
|
|
1024
|
+
});
|
|
1025
|
+
if (get().pool.size === 0) run(() => storage.clearAll(), reportStorageError("failed to clear storage"));
|
|
1026
|
+
else {
|
|
1027
|
+
run(() => storage.removePoolEntry(connectorId), reportStorageError("failed to remove pool entry"));
|
|
1028
|
+
persistSelection();
|
|
1029
|
+
persistActive();
|
|
1030
|
+
}
|
|
1031
|
+
config.onDisconnect?.(platform);
|
|
1032
|
+
},
|
|
1033
|
+
getConnectorInstance: (id) => config.createConnector(id),
|
|
1034
|
+
hydrateWallets: async () => {
|
|
1035
|
+
const result = await hydration.hydrate();
|
|
1036
|
+
dispatch({
|
|
1037
|
+
activeConnectorId: result.activeConnectorId,
|
|
1038
|
+
isUserDisconnected: result.isUserDisconnected,
|
|
1039
|
+
pool: result.pool,
|
|
1040
|
+
selection: result.selection,
|
|
1041
|
+
type: "HYDRATED"
|
|
1042
|
+
});
|
|
1043
|
+
for (const [connectorId, wallet] of result.pool) lifecycle.attach(connectorId, wallet.connector);
|
|
1044
|
+
await Promise.all([persistSelection(), persistActive()]);
|
|
1045
|
+
for (const id of hydration.pendingIds()) run(() => get().tryRestoreFromPending(id), (e) => logWarn(`[butr] late restore rejected for ${id}:`, e));
|
|
1046
|
+
if (config.onHydrated) try {
|
|
1047
|
+
config.onHydrated({
|
|
1048
|
+
dropped: result.dropped,
|
|
1049
|
+
pendingIds: [...result.pendingIds],
|
|
1050
|
+
restoredIds: [...result.pool.keys()]
|
|
1051
|
+
});
|
|
1052
|
+
} catch (cbError) {
|
|
1053
|
+
logWarn("[butr] onHydrated threw:", cbError);
|
|
1054
|
+
}
|
|
1055
|
+
else if (result.dropped.length > 0) for (const { connectorId, reason } of result.dropped) logWarn(`[butr] failed to restore connector ${connectorId}:`, reason);
|
|
1056
|
+
},
|
|
1057
|
+
refreshWallet: (connectorId) => {
|
|
1058
|
+
dispatch({
|
|
1059
|
+
connectorId,
|
|
1060
|
+
type: "WALLET_REFRESHED"
|
|
1061
|
+
});
|
|
1062
|
+
},
|
|
1063
|
+
requestAccounts: async (connectorId) => {
|
|
1064
|
+
const entry = get().pool.get(connectorId);
|
|
1065
|
+
if (!entry) return;
|
|
1066
|
+
await entry.connector.requestAccounts?.();
|
|
1067
|
+
const fresh = entry.connector.getAccounts ? await entry.connector.getAccounts().catch(() => null) : null;
|
|
1068
|
+
if (!fresh || fresh.length === 0) return;
|
|
1069
|
+
refreshPoolEntry(connectorId, fresh);
|
|
1070
|
+
},
|
|
1071
|
+
reset: () => {
|
|
1072
|
+
get().setUserDisconnected(true);
|
|
1073
|
+
const state = get();
|
|
1074
|
+
if (!state.isHydrated) return;
|
|
1075
|
+
lifecycle.detachAll();
|
|
1076
|
+
for (const wallet of state.pool.values()) run(() => wallet.connector.disconnect?.() ?? Promise.resolve(), logError);
|
|
1077
|
+
run(() => storage.clearAll(), reportStorageError("failed to clear storage"));
|
|
1078
|
+
if (config.onReset) {
|
|
1079
|
+
const onReset = config.onReset;
|
|
1080
|
+
run(() => Promise.resolve(onReset()), logError);
|
|
1081
|
+
}
|
|
1082
|
+
dispatch({ type: "RESET" });
|
|
1083
|
+
},
|
|
1084
|
+
resetConnectionStatus: () => {
|
|
1085
|
+
dispatch({ type: "STATUS_RESET" });
|
|
1086
|
+
},
|
|
1087
|
+
setActiveConnector: (connectorId) => {
|
|
1088
|
+
dispatch({
|
|
1089
|
+
connectorId,
|
|
1090
|
+
type: "ACTIVE_CHANGED"
|
|
1091
|
+
});
|
|
1092
|
+
persistActive();
|
|
1093
|
+
},
|
|
1094
|
+
setConnectionError: (error) => {
|
|
1095
|
+
dispatch({
|
|
1096
|
+
error,
|
|
1097
|
+
type: "ERROR_SET"
|
|
1098
|
+
});
|
|
1099
|
+
},
|
|
1100
|
+
setSelection: (chainPlatform, connectorId) => {
|
|
1101
|
+
dispatch({
|
|
1102
|
+
chainPlatform,
|
|
1103
|
+
connectorId,
|
|
1104
|
+
type: "SELECTION_CHANGED"
|
|
1105
|
+
});
|
|
1106
|
+
persistSelection();
|
|
1107
|
+
},
|
|
1108
|
+
setUserDisconnected: (value) => {
|
|
1109
|
+
dispatch({
|
|
1110
|
+
type: "USER_DISCONNECTED_SET",
|
|
1111
|
+
value
|
|
1112
|
+
});
|
|
1113
|
+
run(() => storage.markUserDisconnected(value), reportStorageError("failed to persist disconnect intent"));
|
|
1114
|
+
},
|
|
1115
|
+
tryRestoreFromPending: async (connectorId) => {
|
|
1116
|
+
const outcome = await hydration.drainPending(connectorId);
|
|
1117
|
+
if (!outcome) return;
|
|
1118
|
+
if (outcome.kind === "fail") {
|
|
1119
|
+
logWarn(`[butr] late restore failed for ${connectorId}:`, outcome.error);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
dispatch({
|
|
1123
|
+
connectorId,
|
|
1124
|
+
entry: outcome.entry,
|
|
1125
|
+
type: "CONNECT_SUCCEEDED"
|
|
1126
|
+
});
|
|
1127
|
+
lifecycle.attach(connectorId, outcome.entry.connector);
|
|
1128
|
+
await Promise.all([
|
|
1129
|
+
persistPool(),
|
|
1130
|
+
persistSelection(),
|
|
1131
|
+
persistActive()
|
|
1132
|
+
]);
|
|
1133
|
+
config.onConnect?.(outcome.entry);
|
|
1134
|
+
},
|
|
1135
|
+
updateWalletAccount: (connectorId, account) => {
|
|
1136
|
+
const entry = get().pool.get(connectorId);
|
|
1137
|
+
if (!entry) return;
|
|
1138
|
+
const newChain = account.chain;
|
|
1139
|
+
const remapped = entry.accounts.map((a) => a.chain.id === newChain.id ? a : {
|
|
1140
|
+
chain: newChain,
|
|
1141
|
+
id: `${newChain.id}:${normaliseAddress(a.walletAddress)}`,
|
|
1142
|
+
walletAddress: a.walletAddress
|
|
1143
|
+
});
|
|
1144
|
+
const accounts = remapped.some((a) => normaliseAddress(a.walletAddress) === normaliseAddress(account.walletAddress)) ? remapped : [...remapped, account];
|
|
1145
|
+
refreshPoolEntry(connectorId, accounts, account);
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
});
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
//#endregion
|
|
1152
|
+
//#region src/storage/cookie-storage-driver.ts
|
|
1153
|
+
const DEFAULT_MAX_AGE_SECONDS = 3600 * 24 * 30;
|
|
1154
|
+
const encode = (value) => encodeURIComponent(value);
|
|
1155
|
+
const decode = (value) => decodeURIComponent(value);
|
|
1156
|
+
const readCookies = () => {
|
|
1157
|
+
const out = /* @__PURE__ */ new Map();
|
|
1158
|
+
if (typeof document === "undefined") return out;
|
|
1159
|
+
const cookieString = document.cookie;
|
|
1160
|
+
if (!cookieString) return out;
|
|
1161
|
+
for (const segment of cookieString.split(";")) {
|
|
1162
|
+
const trimmed = segment.trim();
|
|
1163
|
+
if (!trimmed) continue;
|
|
1164
|
+
const eq = trimmed.indexOf("=");
|
|
1165
|
+
if (eq === -1) continue;
|
|
1166
|
+
const name = trimmed.slice(0, eq);
|
|
1167
|
+
const raw = trimmed.slice(eq + 1);
|
|
1168
|
+
try {
|
|
1169
|
+
out.set(name, decode(raw));
|
|
1170
|
+
} catch {}
|
|
1171
|
+
}
|
|
1172
|
+
return out;
|
|
1173
|
+
};
|
|
1174
|
+
const buildCookieString = (name, value, options, expired = false) => {
|
|
1175
|
+
const parts = [`${name}=${encode(value)}`, `Path=${options.path ?? "/"}`];
|
|
1176
|
+
if (options.domain) parts.push(`Domain=${options.domain}`);
|
|
1177
|
+
if (expired) parts.push("Max-Age=0");
|
|
1178
|
+
else {
|
|
1179
|
+
const maxAge = options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
|
|
1180
|
+
parts.push(`Max-Age=${maxAge}`);
|
|
1181
|
+
}
|
|
1182
|
+
parts.push(`SameSite=${options.sameSite ?? "lax"}`);
|
|
1183
|
+
if (options.secure ?? true) parts.push("Secure");
|
|
1184
|
+
return parts.join("; ");
|
|
1185
|
+
};
|
|
1186
|
+
const toCookieMap$1 = (input) => {
|
|
1187
|
+
if (!input) return null;
|
|
1188
|
+
if (typeof input[Symbol.iterator] === "function") return new Map(input);
|
|
1189
|
+
return new Map(Object.entries(input));
|
|
1190
|
+
};
|
|
1191
|
+
/**
|
|
1192
|
+
* Cookie-backed storage driver. Reads/writes `document.cookie` —
|
|
1193
|
+
* server-readable, survives reloads, scoped per `domain`/`path`.
|
|
1194
|
+
*
|
|
1195
|
+
* **When to use this:** SSR apps that need to know who's connected
|
|
1196
|
+
* during the server render (so they can stream the connected-wallet
|
|
1197
|
+
* UI without a client-side hydration flicker). Pass `initialCookies`
|
|
1198
|
+
* from a server-side cookie source (e.g. `cookies()` in Next.js'
|
|
1199
|
+
* `next/headers`, or a parsed `req.headers.cookie`) and the same
|
|
1200
|
+
* driver will serve those values during the server render and switch
|
|
1201
|
+
* to `document.cookie` once it mounts in the browser.
|
|
1202
|
+
*
|
|
1203
|
+
* **Trade-offs vs `localStorage`:** cookies travel with every
|
|
1204
|
+
* request, so they cost bytes on the wire. Keep the storage key
|
|
1205
|
+
* prefix short, and prefer this driver for the `persistent` slot
|
|
1206
|
+
* only — the `session` slot can stay in `sessionStorage` (which
|
|
1207
|
+
* cookies can't natively model anyway).
|
|
1208
|
+
*
|
|
1209
|
+
* **Server-side writes are no-ops.** Emitting `Set-Cookie` requires
|
|
1210
|
+
* access to the framework's response object, which a storage driver
|
|
1211
|
+
* shouldn't reach into. The store doesn't mutate persisted state
|
|
1212
|
+
* during the SSR pass anyway — writes only fire after client mount,
|
|
1213
|
+
* once `document.cookie` is reachable.
|
|
1214
|
+
*/
|
|
1215
|
+
const createCookieStorageDriver = (options = {}) => {
|
|
1216
|
+
const seeded = toCookieMap$1(options.initialCookies);
|
|
1217
|
+
if (typeof document === "undefined") {
|
|
1218
|
+
if (!seeded) return {
|
|
1219
|
+
getItem() {
|
|
1220
|
+
return null;
|
|
1221
|
+
},
|
|
1222
|
+
removeItem() {},
|
|
1223
|
+
setItem() {}
|
|
1224
|
+
};
|
|
1225
|
+
return {
|
|
1226
|
+
getItem(key) {
|
|
1227
|
+
return seeded.get(key) ?? null;
|
|
1228
|
+
},
|
|
1229
|
+
removeItem() {},
|
|
1230
|
+
setItem() {}
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
getItem(key) {
|
|
1235
|
+
return readCookies().get(key) ?? null;
|
|
1236
|
+
},
|
|
1237
|
+
removeItem(key) {
|
|
1238
|
+
document.cookie = buildCookieString(key, "", options, true);
|
|
1239
|
+
},
|
|
1240
|
+
setItem(key, value) {
|
|
1241
|
+
document.cookie = buildCookieString(key, value, options);
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
//#endregion
|
|
1247
|
+
//#region src/storage/snapshot.ts
|
|
1248
|
+
const VALID_CHAIN_PLATFORMS = new Set(CHAIN_PLATFORMS);
|
|
1249
|
+
const EMPTY_SNAPSHOT = Object.freeze({
|
|
1250
|
+
activeConnectorId: null,
|
|
1251
|
+
pool: Object.freeze({}),
|
|
1252
|
+
selection: Object.freeze({})
|
|
1253
|
+
});
|
|
1254
|
+
const toCookieMap = (input) => {
|
|
1255
|
+
if (typeof input[Symbol.iterator] !== "function") return new Map(Object.entries(input));
|
|
1256
|
+
const out = /* @__PURE__ */ new Map();
|
|
1257
|
+
for (const entry of input) {
|
|
1258
|
+
if (Array.isArray(entry)) {
|
|
1259
|
+
const [name, value] = entry;
|
|
1260
|
+
if (typeof name === "string" && typeof value === "string") out.set(name, value);
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
if (entry && typeof entry === "object" && "name" in entry && "value" in entry && typeof entry.name === "string" && typeof entry.value === "string") out.set(entry.name, entry.value);
|
|
1264
|
+
}
|
|
1265
|
+
return out;
|
|
1266
|
+
};
|
|
1267
|
+
const isValidAccount = (value) => {
|
|
1268
|
+
if (!value || typeof value !== "object") return false;
|
|
1269
|
+
const account = value;
|
|
1270
|
+
if (typeof account.walletAddress !== "string" || typeof account.id !== "string") return false;
|
|
1271
|
+
if (!account.chain || typeof account.chain !== "object") return false;
|
|
1272
|
+
return true;
|
|
1273
|
+
};
|
|
1274
|
+
const isValidPoolEntry = (key, value) => {
|
|
1275
|
+
if (!value || typeof value !== "object") return false;
|
|
1276
|
+
const entry = value;
|
|
1277
|
+
if (typeof entry.connectorId !== "string" || entry.connectorId !== key) return false;
|
|
1278
|
+
if (typeof entry.chainPlatform !== "string") return false;
|
|
1279
|
+
if (!VALID_CHAIN_PLATFORMS.has(entry.chainPlatform)) return false;
|
|
1280
|
+
if (typeof entry.name !== "string" || entry.name.length === 0) return false;
|
|
1281
|
+
if (entry.icon !== void 0 && typeof entry.icon !== "string") return false;
|
|
1282
|
+
if (!isValidAccount(entry.account)) return false;
|
|
1283
|
+
if (!Array.isArray(entry.accounts) || !entry.accounts.every(isValidAccount)) return false;
|
|
1284
|
+
return true;
|
|
1285
|
+
};
|
|
1286
|
+
const parsePool = (raw) => {
|
|
1287
|
+
if (!raw) return {};
|
|
1288
|
+
try {
|
|
1289
|
+
const parsed = JSON.parse(raw);
|
|
1290
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
1291
|
+
const result = {};
|
|
1292
|
+
for (const [key, value] of Object.entries(parsed)) if (isValidPoolEntry(key, value)) result[key] = value;
|
|
1293
|
+
else logWarn(`[butr] readWalletSnapshot: dropping invalid pool entry for ${key}`);
|
|
1294
|
+
return result;
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
logWarn("[butr] readWalletSnapshot: failed to parse pool cookie:", error);
|
|
1297
|
+
return {};
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
const parseSelection = (raw) => {
|
|
1301
|
+
if (!raw) return {};
|
|
1302
|
+
try {
|
|
1303
|
+
const parsed = JSON.parse(raw);
|
|
1304
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
1305
|
+
const result = {};
|
|
1306
|
+
for (const [key, value] of Object.entries(parsed)) if (VALID_CHAIN_PLATFORMS.has(key) && typeof value === "string" && value.length > 0) result[key] = value;
|
|
1307
|
+
return result;
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
logWarn("[butr] readWalletSnapshot: failed to parse selection cookie:", error);
|
|
1310
|
+
return {};
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
/**
|
|
1314
|
+
* Parse a cookie source into a server-safe `WalletSnapshot`.
|
|
1315
|
+
*
|
|
1316
|
+
* Pure, sync, no `document`, no React — runnable in any environment
|
|
1317
|
+
* (Server Component, route handler, edge middleware, even client
|
|
1318
|
+
* code). Pair with `createCookieStorageDriver({ initialCookies })`
|
|
1319
|
+
* and `<WalletManagerProvider initialSnapshot={…} />` to render a
|
|
1320
|
+
* connected shell server-side without a hydration flash.
|
|
1321
|
+
*
|
|
1322
|
+
* **Stale-snapshot semantics.** The snapshot reflects whatever the
|
|
1323
|
+
* browser most recently persisted. If the user has since uninstalled
|
|
1324
|
+
* the wallet, switched accounts, or disconnected in another tab, the
|
|
1325
|
+
* client-side hydration will reconcile reality and the live store
|
|
1326
|
+
* will diverge from the snapshot. Treat the snapshot as an
|
|
1327
|
+
* *optimistic* shell — accurate enough to avoid a paint flicker,
|
|
1328
|
+
* authoritative only after `useIsHydrated()` is true.
|
|
1329
|
+
*
|
|
1330
|
+
* **Inputs.** Accepts the three shapes Next.js / Express / Hono /
|
|
1331
|
+
* generic-Node cookie code naturally produces:
|
|
1332
|
+
* - A plain object: `{ "butr-pool": "{...}", … }`
|
|
1333
|
+
* - An array of `{ name, value }` (Next.js' `cookies().getAll()`)
|
|
1334
|
+
* - An iterable of `[name, value]` tuples
|
|
1335
|
+
*
|
|
1336
|
+
* Malformed entries are dropped with a `logWarn` (same policy as
|
|
1337
|
+
* `WalletStorage.getPool`) — a cross-tab corruption shouldn't crash
|
|
1338
|
+
* the server render.
|
|
1339
|
+
*/
|
|
1340
|
+
const readWalletSnapshot = (source, options = {}) => {
|
|
1341
|
+
const keyPrefix = options.keyPrefix || "butr";
|
|
1342
|
+
const cookies = toCookieMap(source);
|
|
1343
|
+
const pool = parsePool(cookies.get(`${keyPrefix}-pool`));
|
|
1344
|
+
const selection = parseSelection(cookies.get(`${keyPrefix}-selection`));
|
|
1345
|
+
const rawActive = cookies.get(`${keyPrefix}-active`);
|
|
1346
|
+
let activeConnectorId = null;
|
|
1347
|
+
if (rawActive && rawActive.length > 0 && pool[rawActive]) activeConnectorId = rawActive;
|
|
1348
|
+
else {
|
|
1349
|
+
const firstKey = Object.keys(pool)[0];
|
|
1350
|
+
if (firstKey) activeConnectorId = firstKey;
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
activeConnectorId,
|
|
1354
|
+
pool,
|
|
1355
|
+
selection
|
|
1356
|
+
};
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
//#endregion
|
|
1360
|
+
//#region src/wallet-equal.ts
|
|
1361
|
+
/**
|
|
1362
|
+
* Two wallet snapshots are equivalent for selector purposes iff they
|
|
1363
|
+
* share connectorId, active account address, and active account chain
|
|
1364
|
+
* id. Used by `useStoreWithEqualityFn` consumers (active-wallet,
|
|
1365
|
+
* selected-wallet, useWalletEntry) to suppress spurious re-renders
|
|
1366
|
+
* when the underlying Map churns but the resolved entry hasn't changed.
|
|
1367
|
+
*
|
|
1368
|
+
* Hoisted to its own module so the equivalence rule lives in one place
|
|
1369
|
+
* — if we ever extend the snapshot (e.g. to consider `accounts.length`),
|
|
1370
|
+
* every selector hook picks up the new rule for free.
|
|
1371
|
+
*/
|
|
1372
|
+
const walletEqual = (a, b) => {
|
|
1373
|
+
if (a === b) return true;
|
|
1374
|
+
if (!a || !b) return false;
|
|
1375
|
+
return a.connector.id === b.connector.id && a.account.walletAddress === b.account.walletAddress && a.account.chain.id === b.account.chain.id;
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
//#endregion
|
|
1379
|
+
//#region src/sanitize-icon.ts
|
|
1380
|
+
/**
|
|
1381
|
+
* Normalize a wallet-announced icon string.
|
|
1382
|
+
*
|
|
1383
|
+
* Wallets announce their icon through external metadata — EIP-6963
|
|
1384
|
+
* `providerInfo.icon`, Wallet Standard `wallet.icon`. That value is
|
|
1385
|
+
* not under butr's control, and some wallets ship data-URI icons with
|
|
1386
|
+
* surrounding whitespace (a newline left over from a pretty-printed
|
|
1387
|
+
* manifest). Strict consumers reject it: Next.js's `<Image>` throws
|
|
1388
|
+
* because `src` must not start with a control character.
|
|
1389
|
+
*
|
|
1390
|
+
* Trims surrounding whitespace and treats an all-whitespace (or empty)
|
|
1391
|
+
* icon as absent, so consumers get either a usable string or
|
|
1392
|
+
* `undefined` — never a blank or malformed one. `undefined` passes
|
|
1393
|
+
* through untouched.
|
|
1394
|
+
*/
|
|
1395
|
+
const sanitizeIcon = (icon) => {
|
|
1396
|
+
if (icon === void 0) return;
|
|
1397
|
+
const trimmed = icon.trim();
|
|
1398
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
//#endregion
|
|
1402
|
+
//#region src/encoding/bytes.ts
|
|
1403
|
+
/**
|
|
1404
|
+
* Shared byte-encoding helpers for the connector packages.
|
|
1405
|
+
*
|
|
1406
|
+
* These functions sit directly on the signing/address path of every
|
|
1407
|
+
* chain. They used to be hand-reimplemented in ~10 connector files; a
|
|
1408
|
+
* single tested module removes the drift surface (a `padStart` omission
|
|
1409
|
+
* or base64 variant mismatch corrupts signatures/addresses for one chain
|
|
1410
|
+
* only, and the divergence is invisible because the copies look "the
|
|
1411
|
+
* same").
|
|
1412
|
+
*
|
|
1413
|
+
* Hex prefixing diverges across chains, so the module exposes **explicit
|
|
1414
|
+
* variants** rather than one function:
|
|
1415
|
+
* - {@link bytesToHex} returns bare hex (Bitcoin, Ledger).
|
|
1416
|
+
* - {@link bytesToHexPrefixed} returns `0x`-prefixed hex (EVM, Polkadot).
|
|
1417
|
+
* - {@link hexToBytes} tolerantly strips an optional `0x` (all callers).
|
|
1418
|
+
*/
|
|
1419
|
+
/** Bare lowercase hex — no `0x` prefix (Bitcoin, Ledger). */
|
|
1420
|
+
const bytesToHex = (bytes) => {
|
|
1421
|
+
let hex = "";
|
|
1422
|
+
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
1423
|
+
return hex;
|
|
1424
|
+
};
|
|
1425
|
+
/** `0x`-prefixed lowercase hex (EVM, Polkadot). */
|
|
1426
|
+
const bytesToHexPrefixed = (bytes) => `0x${bytesToHex(bytes)}`;
|
|
1427
|
+
/**
|
|
1428
|
+
* Decode hex (with or without a leading `0x`) into raw bytes. Throws on
|
|
1429
|
+
* odd-length input or non-hex characters rather than silently producing
|
|
1430
|
+
* `NaN`-filled bytes.
|
|
1431
|
+
*/
|
|
1432
|
+
const hexToBytes = (hex) => {
|
|
1433
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
1434
|
+
if (clean.length % 2 !== 0) throw new TypeError("Invalid hex: odd length");
|
|
1435
|
+
const out = new Uint8Array(clean.length / 2);
|
|
1436
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
1437
|
+
const byte = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
1438
|
+
if (Number.isNaN(byte)) throw new TypeError(`Invalid hex character at offset ${i * 2}`);
|
|
1439
|
+
out[i] = byte;
|
|
1440
|
+
}
|
|
1441
|
+
return out;
|
|
1442
|
+
};
|
|
1443
|
+
/**
|
|
1444
|
+
* Cross-platform base64 → `Uint8Array`. `atob` is available everywhere
|
|
1445
|
+
* butr runs (browsers, RN since Hermes, Node 16+, Bun, Deno).
|
|
1446
|
+
*/
|
|
1447
|
+
const base64ToBytes = (b64) => {
|
|
1448
|
+
const binary = atob(b64);
|
|
1449
|
+
const out = new Uint8Array(binary.length);
|
|
1450
|
+
for (let i = 0; i < binary.length; i += 1) out[i] = binary.codePointAt(i) ?? 0;
|
|
1451
|
+
return out;
|
|
1452
|
+
};
|
|
1453
|
+
/** Cross-platform `Uint8Array` → base64. */
|
|
1454
|
+
const bytesToBase64 = (bytes) => {
|
|
1455
|
+
let binary = "";
|
|
1456
|
+
for (const byte of bytes) binary += String.fromCodePoint(byte);
|
|
1457
|
+
return btoa(binary);
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
//#endregion
|
|
1461
|
+
export { CHAIN_PLATFORMS, EMPTY_SNAPSHOT, WalletStorage, base64ToBytes, buildChainsByPlatform, bytesToBase64, bytesToHex, bytesToHexPrefixed, createBrowserStorageDriver, createCookieStorageDriver, createMemoryStorageDriver, createWalletSource, createWalletStore, hexToBytes, logError, logWarn, mapConnectionError, readWalletSnapshot, sanitizeIcon, walletEqual };
|
|
2
1462
|
//# sourceMappingURL=index.js.map
|