@fernolab/wallet-adapter-svelte 0.1.0 → 0.1.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.
@@ -11,6 +11,9 @@
11
11
  let { show = $bindable(false), onModalClosed }: Props = $props();
12
12
 
13
13
  let connectError = $state<string | null>(null);
14
+ let hotWalletNotice = $state<string | null>(null);
15
+ let topUpAmount = $state(wallet.hotWalletDefaultTopUpSol);
16
+ let withdrawAmount = $state(wallet.hotWalletDefaultWithdrawSol);
14
17
 
15
18
  const standardWallets = $derived(
16
19
  wallet.availableWallets.filter(
@@ -21,6 +24,9 @@
21
24
  Boolean(wallet.mobileWalletAdapterWallet || wallet.detectedWalletBrowserName)
22
25
  );
23
26
  const hasWallets = $derived(standardWallets.length > 0);
27
+ const showMobileWalletLinks = $derived(
28
+ wallet.mobileRuntime.isMobile && !hasWallets && wallet.mobileWalletLinks.length > 0
29
+ );
24
30
 
25
31
  function getWalletDisplayName(name: string): string {
26
32
  return name === wallet.mobileWalletAdapterName ? wallet.mobileWalletAdapterDisplayName : name;
@@ -80,12 +86,99 @@
80
86
  }
81
87
  }
82
88
 
89
+ async function handleCreateHotWallet(): Promise<void> {
90
+ hotWalletNotice = null;
91
+ try {
92
+ const address = await wallet.createHotWalletVault();
93
+ hotWalletNotice = `Hot wallet ${shortAddress(address)} ready.`;
94
+ } catch (error) {
95
+ hotWalletNotice =
96
+ error instanceof Error ? error.message : "Could not create hot wallet.";
97
+ }
98
+ }
99
+
100
+ async function handleUnlockHotWallet(): Promise<void> {
101
+ hotWalletNotice = null;
102
+ try {
103
+ const address = await wallet.unlockHotWalletVault();
104
+ hotWalletNotice = `Hot wallet ${shortAddress(address)} unlocked.`;
105
+ } catch (error) {
106
+ hotWalletNotice =
107
+ error instanceof Error ? error.message : "Could not unlock hot wallet.";
108
+ }
109
+ }
110
+
111
+ async function handleRefreshHotWalletBalance(): Promise<void> {
112
+ hotWalletNotice = null;
113
+ try {
114
+ await wallet.refreshHotWalletBalance();
115
+ } catch (error) {
116
+ hotWalletNotice =
117
+ error instanceof Error ? error.message : "Could not refresh hot wallet.";
118
+ }
119
+ }
120
+
121
+ async function handleTopUpHotWallet(): Promise<void> {
122
+ hotWalletNotice = null;
123
+ try {
124
+ const result = await wallet.topUpHotWallet(topUpAmount);
125
+ hotWalletNotice = `Top up sent: ${shortAddress(result.signature)}.`;
126
+ } catch (error) {
127
+ hotWalletNotice =
128
+ error instanceof Error ? error.message : "Could not top up hot wallet.";
129
+ }
130
+ }
131
+
132
+ async function handleWithdrawHotWallet(): Promise<void> {
133
+ hotWalletNotice = null;
134
+ try {
135
+ const result = await wallet.withdrawHotWallet(withdrawAmount);
136
+ hotWalletNotice = `Withdraw sent: ${shortAddress(result.signature)}.`;
137
+ } catch (error) {
138
+ hotWalletNotice =
139
+ error instanceof Error ? error.message : "Could not withdraw from hot wallet.";
140
+ }
141
+ }
142
+
143
+ async function handleCopyHotWalletAddress(): Promise<void> {
144
+ hotWalletNotice = null;
145
+
146
+ if (!wallet.hotWalletAddress) {
147
+ hotWalletNotice = "Create a hot wallet first.";
148
+ return;
149
+ }
150
+
151
+ try {
152
+ await navigator.clipboard.writeText(wallet.hotWalletAddress);
153
+ hotWalletNotice = "Hot wallet address copied.";
154
+ } catch {
155
+ hotWalletNotice = wallet.hotWalletAddress;
156
+ }
157
+ }
158
+
159
+ async function handleFundingOption(optionId: string): Promise<void> {
160
+ hotWalletNotice = null;
161
+
162
+ try {
163
+ const url = await wallet.createHotWalletFundingUrl(optionId);
164
+ window.open(url, "_blank", "noopener,noreferrer");
165
+ } catch (error) {
166
+ hotWalletNotice =
167
+ error instanceof Error ? error.message : "Could not open funding option.";
168
+ }
169
+ }
170
+
83
171
  function handleClose(): void {
84
172
  show = false;
85
173
  connectError = null;
174
+ hotWalletNotice = null;
86
175
  onModalClosed && onModalClosed();
87
176
  }
88
177
 
178
+ function shortAddress(address: string): string {
179
+ return address.length > 18 ? `${address.slice(0, 8)}...${address.slice(-8)}` : address;
180
+ }
181
+
89
182
  function handleOverlayKeydown(event: KeyboardEvent): void {
90
183
  if (event.key === "Escape" || event.key === "Enter" || event.key === " ") {
91
184
  event.preventDefault();
@@ -149,14 +242,6 @@
149
242
  </div>
150
243
  {/if}
151
244
 
152
- {#if wallet.mobileRuntime.isMobile && wallet.mobileWalletLinks.length > 0}
153
- <div class="mobile-links" aria-label="Open in wallet browser">
154
- {#each wallet.mobileWalletLinks as link}
155
- <a href={link.url} rel="noreferrer">{link.name}</a>
156
- {/each}
157
- </div>
158
- {/if}
159
-
160
245
  <div class="wallet-list">
161
246
  {#if standardWallets.length > 0}
162
247
  <div class="section-title">Wallets</div>
@@ -175,6 +260,126 @@
175
260
  </div>
176
261
  {/if}
177
262
 
263
+ {#if showMobileWalletLinks}
264
+ <div class="section-title">Mobile wallets</div>
265
+ <div class="mobile-links" aria-label="Open in mobile wallet">
266
+ {#each wallet.mobileWalletLinks as link (link.name)}
267
+ <a href={link.url} rel="noreferrer">{link.name}</a>
268
+ {/each}
269
+ </div>
270
+ {/if}
271
+
272
+ {#if wallet.connected}
273
+ <div class="hot-wallet-panel">
274
+ <div class="section-title">Hot wallet</div>
275
+ <div class="hot-wallet-state">
276
+ <div>
277
+ <span>Owner</span>
278
+ <strong>{wallet.shortAddress}</strong>
279
+ </div>
280
+ <div>
281
+ <span>Address</span>
282
+ <strong>
283
+ {wallet.hotWalletAddress
284
+ ? shortAddress(wallet.hotWalletAddress)
285
+ : "Not created"}
286
+ </strong>
287
+ </div>
288
+ <div>
289
+ <span>Balance</span>
290
+ <strong>{wallet.hotWalletBalance}</strong>
291
+ </div>
292
+ <div>
293
+ <span>Key</span>
294
+ <strong>{wallet.hotWalletUnlocked ? "Unlocked" : "Locked"}</strong>
295
+ </div>
296
+ </div>
297
+
298
+ <div class="hot-wallet-actions">
299
+ <button
300
+ class="action-button"
301
+ type="button"
302
+ disabled={wallet.hotWalletBusy || wallet.signing}
303
+ onclick={handleCreateHotWallet}
304
+ >
305
+ Create
306
+ </button>
307
+ <button
308
+ class="action-button"
309
+ type="button"
310
+ disabled={wallet.hotWalletBusy || wallet.signing || !wallet.hotWalletAddress}
311
+ onclick={handleUnlockHotWallet}
312
+ >
313
+ Unlock
314
+ </button>
315
+ <button
316
+ class="action-button"
317
+ type="button"
318
+ disabled={wallet.hotWalletBusy || !wallet.hotWalletAddress}
319
+ onclick={handleRefreshHotWalletBalance}
320
+ >
321
+ Balance
322
+ </button>
323
+ <button
324
+ class="action-button"
325
+ type="button"
326
+ disabled={!wallet.hotWalletAddress}
327
+ onclick={handleCopyHotWalletAddress}
328
+ >
329
+ Copy
330
+ </button>
331
+ </div>
332
+
333
+ <div class="amount-row">
334
+ <label for="hot-wallet-top-up">Top up</label>
335
+ <input id="hot-wallet-top-up" bind:value={topUpAmount} inputmode="decimal" />
336
+ <button
337
+ class="action-button primary"
338
+ type="button"
339
+ disabled={wallet.hotWalletBusy || wallet.signing}
340
+ onclick={handleTopUpHotWallet}
341
+ >
342
+ Send
343
+ </button>
344
+ </div>
345
+
346
+ <div class="amount-row">
347
+ <label for="hot-wallet-withdraw">Withdraw</label>
348
+ <input id="hot-wallet-withdraw" bind:value={withdrawAmount} inputmode="decimal" />
349
+ <button
350
+ class="action-button"
351
+ type="button"
352
+ disabled={wallet.hotWalletBusy || wallet.signing || !wallet.hotWalletAddress}
353
+ onclick={handleWithdrawHotWallet}
354
+ >
355
+ Send
356
+ </button>
357
+ </div>
358
+
359
+ {#if wallet.hotWalletFundingOptions.length > 0}
360
+ <div class="funding-grid" aria-label="External funding options">
361
+ {#each wallet.hotWalletFundingOptions as option (option.id)}
362
+ <button
363
+ class="funding-button"
364
+ type="button"
365
+ disabled={wallet.hotWalletBusy}
366
+ onclick={() => handleFundingOption(option.id)}
367
+ >
368
+ <span>{option.label}</span>
369
+ <small>{option.description}</small>
370
+ </button>
371
+ {/each}
372
+ </div>
373
+ {/if}
374
+
375
+ {#if hotWalletNotice || wallet.hotWalletError}
376
+ <p class="hot-wallet-note" role="status">
377
+ {hotWalletNotice ?? wallet.hotWalletError?.message}
378
+ </p>
379
+ {/if}
380
+ </div>
381
+ {/if}
382
+
178
383
  <button class="refresh-button" type="button" onclick={handleRefresh}>
179
384
  Refresh wallets
180
385
  </button>
@@ -345,6 +550,116 @@
345
550
  margin: 0;
346
551
  }
347
552
 
553
+ .hot-wallet-panel {
554
+ display: grid;
555
+ gap: 10px;
556
+ margin-top: 6px;
557
+ padding-top: 4px;
558
+ }
559
+
560
+ .hot-wallet-state {
561
+ display: grid;
562
+ grid-template-columns: repeat(2, minmax(0, 1fr));
563
+ gap: 8px;
564
+ }
565
+
566
+ .hot-wallet-state div {
567
+ border: 1px solid #d8d8d8;
568
+ border-radius: 8px;
569
+ padding: 9px 10px;
570
+ min-width: 0;
571
+ }
572
+
573
+ .hot-wallet-state span,
574
+ .amount-row label {
575
+ display: block;
576
+ color: #666666;
577
+ font-size: 0.74rem;
578
+ font-weight: 700;
579
+ }
580
+
581
+ .hot-wallet-state strong {
582
+ display: block;
583
+ color: #111111;
584
+ font-size: 0.86rem;
585
+ margin-top: 2px;
586
+ overflow-wrap: anywhere;
587
+ }
588
+
589
+ .hot-wallet-actions {
590
+ display: grid;
591
+ grid-template-columns: repeat(4, minmax(0, 1fr));
592
+ gap: 8px;
593
+ }
594
+
595
+ .amount-row {
596
+ display: grid;
597
+ grid-template-columns: 72px minmax(0, 1fr) 78px;
598
+ align-items: center;
599
+ gap: 8px;
600
+ }
601
+
602
+ .amount-row input {
603
+ width: 100%;
604
+ min-width: 0;
605
+ border: 1px solid #d8d8d8;
606
+ border-radius: 8px;
607
+ color: #111111;
608
+ font: inherit;
609
+ padding: 9px 10px;
610
+ }
611
+
612
+ .hot-wallet-note {
613
+ margin: 0;
614
+ color: #111111;
615
+ font-size: 0.85rem;
616
+ font-weight: 600;
617
+ overflow-wrap: anywhere;
618
+ }
619
+
620
+ .funding-grid {
621
+ display: grid;
622
+ gap: 8px;
623
+ }
624
+
625
+ .funding-button {
626
+ width: 100%;
627
+ border: 1px solid #d8d8d8;
628
+ border-radius: 8px;
629
+ background: #ffffff;
630
+ color: #111111;
631
+ cursor: pointer;
632
+ padding: 9px 10px;
633
+ text-align: left;
634
+ }
635
+
636
+ .funding-button:hover:not(:disabled) {
637
+ background: #f7f7f7;
638
+ border-color: #b8b8b8;
639
+ }
640
+
641
+ .funding-button:disabled {
642
+ cursor: not-allowed;
643
+ opacity: 0.5;
644
+ }
645
+
646
+ .funding-button span,
647
+ .funding-button small {
648
+ display: block;
649
+ }
650
+
651
+ .funding-button span {
652
+ font-size: 0.88rem;
653
+ font-weight: 750;
654
+ }
655
+
656
+ .funding-button small {
657
+ color: #666666;
658
+ font-size: 0.76rem;
659
+ line-height: 1.35;
660
+ margin-top: 2px;
661
+ }
662
+
348
663
  .refresh-button {
349
664
  margin-top: 4px;
350
665
  padding: 9px 12px;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from './components/index.js';
2
- export { createWalletStore, wallet, type WalletStore } from './wallet/wallet.svelte.js';
2
+ export { createWalletStore, wallet, type WalletStore, type WalletStoreOptions } from './wallet/wallet.svelte.js';
3
3
  export * from './wallet/mobile-detection.js';
4
4
  export * from '@fernolab/wallet-adapter-core';
5
5
  export type { WalletWithStandardCapabilities } from './wallet/types.js';
@@ -1,5 +1,5 @@
1
1
  import type { StandardWallet } from './types.js';
2
- import { adaptWallet, dedupeStandardWallets, isSolanaWallet, type IdentifierString, type InjectedWallet, type WalletAccount } from '@fernolab/wallet-adapter-core';
2
+ import { adaptWallet, dedupeStandardWallets, isSolanaWallet, type IdentifierString, type InjectedWallet, type WalletAccount, type WalletStorage } from '@fernolab/wallet-adapter-core';
3
3
  export { adaptWallet, dedupeStandardWallets, isSolanaWallet };
4
4
  export declare const RUNTIME_DEBUG_MODES: readonly ["auto", "desktop", "ios", "android", "phantom", "solflare", "jupiter", "backpack"];
5
5
  export type RuntimeDebugMode = (typeof RUNTIME_DEBUG_MODES)[number];
@@ -10,11 +10,43 @@ interface SignMessageResult {
10
10
  }
11
11
  type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
12
12
  type WalletEventCallback = (wallet: StandardWallet | null) => void;
13
+ export interface WalletStoreOptions {
14
+ fundingProviders?: readonly HotWalletFundingProvider[];
15
+ storage?: WalletStorage | null;
16
+ }
17
+ export interface HotWalletTransferResult {
18
+ signature: string;
19
+ amountLamports: bigint;
20
+ address: string;
21
+ }
22
+ export type HotWalletFundingKind = 'onramp' | 'bridge' | 'exchange' | 'copy';
23
+ export interface HotWalletFundingOption {
24
+ description: string;
25
+ id: string;
26
+ kind: HotWalletFundingKind;
27
+ label: string;
28
+ requiresSignedUrl?: boolean;
29
+ url?: string;
30
+ }
31
+ export interface HotWalletFundingUrlInput {
32
+ chain: IdentifierString;
33
+ hotWalletAddress: string;
34
+ ownerAddress: string;
35
+ }
36
+ export type HotWalletFundingProvider = {
37
+ enabled?: boolean;
38
+ type: 'jupiter-hosted';
39
+ } | {
40
+ createSignedUrl(input: HotWalletFundingUrlInput): Promise<string>;
41
+ id?: string;
42
+ label?: string;
43
+ type: 'onramper';
44
+ };
13
45
  /**
14
46
  * Determine Solana chain identifier from RPC endpoint
15
47
  */
16
48
  export declare function resolveChainFromEndpoint(endpoint: string): IdentifierString;
17
- export declare function createWalletStore(): {
49
+ export declare function createWalletStore(options?: WalletStoreOptions): {
18
50
  readonly rpcEndpoint: string;
19
51
  readonly availableWallets: StandardWallet[];
20
52
  readonly standardWallet: StandardWallet | null;
@@ -41,6 +73,17 @@ export declare function createWalletStore(): {
41
73
  readonly mobileWalletAdapterWallet: StandardWallet | null;
42
74
  readonly mobileWalletAdapterName: string;
43
75
  readonly mobileWalletAdapterDisplayName: string;
76
+ readonly lastUsedWalletName: string | null;
77
+ readonly hotWalletAddress: string | null;
78
+ readonly hotWalletBalanceLamports: bigint | null;
79
+ readonly hotWalletBalance: string;
80
+ readonly hotWalletBusy: boolean;
81
+ readonly hotWalletError: Error | null;
82
+ readonly hotWalletUnlocked: boolean;
83
+ readonly hotWalletDefaultTopUpSol: string;
84
+ readonly hotWalletDefaultWithdrawSol: string;
85
+ readonly walletAccounts: readonly unknown[];
86
+ readonly hotWalletFundingOptions: HotWalletFundingOption[];
44
87
  initialize: (endpoint?: string) => Promise<void>;
45
88
  refreshAvailableWallets: () => Promise<void>;
46
89
  setRuntimeDebugMode: (mode: RuntimeDebugMode | string) => Promise<void>;
@@ -49,8 +92,15 @@ export declare function createWalletStore(): {
49
92
  connectWalletBrowser: () => Promise<void>;
50
93
  disconnect: () => Promise<void>;
51
94
  signMessage: (message: string | Uint8Array) => Promise<SignMessageResult>;
95
+ createHotWalletVault: () => Promise<string>;
96
+ unlockHotWalletVault: () => Promise<string>;
97
+ refreshHotWalletBalance: () => Promise<bigint>;
98
+ topUpHotWallet: (amountSol?: string) => Promise<HotWalletTransferResult>;
99
+ withdrawHotWallet: (amountSol?: string) => Promise<HotWalletTransferResult>;
100
+ createHotWalletFundingUrl: (optionId: string) => Promise<string>;
52
101
  onWalletChange: (callback: WalletEventCallback) => () => void;
53
102
  clearError: () => void;
103
+ clearHotWalletError: () => void;
54
104
  };
55
105
  export type WalletStore = ReturnType<typeof createWalletStore>;
56
106
  export declare const wallet: WalletStore;
@@ -1,12 +1,11 @@
1
1
  import { getWallets } from '@wallet-standard/app';
2
2
  import { PUBLIC_SOLANA_RPC_ENDPOINT } from '../config.js';
3
3
  import { browser } from '../environment.js';
4
- import { MOBILE_DEEPLINK_WALLETS, MOBILE_WALLETS, SolanaSignMessage, StandardConnect, StandardDisconnect, StandardEvents, adaptStandardWallets, adaptWallet, createWalletController, createMobileWalletBrowseUrl, dedupeStandardWallets, detectInjectedWallets, detectMobileRuntime, isSolanaWallet, resolveSolanaChainFromEndpoint } from '@fernolab/wallet-adapter-core';
4
+ import { MOBILE_DEEPLINK_WALLETS, MOBILE_WALLETS, SolanaSignMessage, SolanaSignAndSendTransaction, SolanaSignTransaction, StandardConnect, StandardDisconnect, StandardEvents, adaptStandardWallets, adaptWallet, applySolanaTransactionSignature, base58Encode, createWalletController, createMobileWalletBrowseUrl, createHotWalletUnlockMessage, createSystemTransferTransaction, createWrappedHotWalletFromSignature, dedupeStandardWallets, getHotWalletAddress, getLatestSolanaBlockhash, getSolanaBalance, detectInjectedWallets, detectMobileRuntime, isSolanaWallet, lamportsToSol, normalizeSignatureBytes, readWrappedHotWallet, resolveSolanaChainFromEndpoint, saveWrappedHotWallet, sendSerializedSolanaTransaction, signWithHotWallet, solToLamports, unlockHotWalletFromSignature } from '@fernolab/wallet-adapter-core';
5
5
  export { adaptWallet, dedupeStandardWallets, isSolanaWallet };
6
6
  // ============================================================================
7
7
  // Constants
8
8
  // ============================================================================
9
- const LAST_WALLET_KEY = 'lastConnectedWallet';
10
9
  const SolanaMobileWalletAdapterWalletName = 'Mobile Wallet Adapter';
11
10
  const MOBILE_WALLET_ADAPTER_DISPLAY_NAME = 'Use Installed Wallet';
12
11
  const INJECTED_WALLET_ICON = 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2248%22%20height%3D%2248%22%20viewBox%3D%220%200%2048%2048%22%20fill%3D%22none%22%3E%3Crect%20width%3D%2248%22%20height%3D%2248%22%20rx%3D%2210%22%20fill%3D%22%23111827%22%2F%3E%3Cpath%20d%3D%22M14%2018h20M14%2024h20M14%2030h20%22%20stroke%3D%22white%22%20stroke-width%3D%222.5%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E';
@@ -38,6 +37,9 @@ const DESKTOP_RUNTIME = {
38
37
  supportsMobileWalletAdapter: false,
39
38
  walletBrowser: null
40
39
  };
40
+ const HOT_WALLET_STORAGE_PREFIX = 'ferno:svelte-adapter:hot-wallet:';
41
+ const DEFAULT_HOT_WALLET_TOP_UP_SOL = '0.001';
42
+ const DEFAULT_HOT_WALLET_WITHDRAW_SOL = '0.0005';
41
43
  // ============================================================================
42
44
  // Wallet Detection & Adaptation
43
45
  // ============================================================================
@@ -355,10 +357,56 @@ function getWalletFeature(wallet, featureName) {
355
357
  const features = wallet.features;
356
358
  return features[featureName] ?? null;
357
359
  }
360
+ function getDefaultWalletStorage() {
361
+ if (!browser || typeof localStorage === 'undefined') {
362
+ return null;
363
+ }
364
+ return localStorage;
365
+ }
366
+ function getWalletStorage(options) {
367
+ return 'storage' in options ? options.storage ?? null : getDefaultWalletStorage();
368
+ }
369
+ function getFundingProviders(options) {
370
+ return options.fundingProviders ?? [{ type: 'jupiter-hosted' }];
371
+ }
372
+ function createJupiterFundingOptions() {
373
+ return [
374
+ {
375
+ description: 'Buy SOL or USDC through Jupiter hosted onboarding.',
376
+ id: 'jupiter-onramp',
377
+ kind: 'onramp',
378
+ label: 'Buy with Jupiter',
379
+ url: 'https://jup.ag/onboard/onramp'
380
+ },
381
+ {
382
+ description: 'Move funds from an exchange into your Solana wallet.',
383
+ id: 'jupiter-exchange',
384
+ kind: 'exchange',
385
+ label: 'Transfer from exchange',
386
+ url: 'https://jup.ag/onboard'
387
+ },
388
+ {
389
+ description: 'Bridge assets from other chains via deBridge.',
390
+ id: 'jupiter-debridge',
391
+ kind: 'bridge',
392
+ label: 'Bridge with deBridge',
393
+ url: 'https://jup.ag/onboard/debridge'
394
+ },
395
+ {
396
+ description: 'Bridge native USDC to Solana through CCTP.',
397
+ id: 'jupiter-cctp',
398
+ kind: 'bridge',
399
+ label: 'Bridge USDC',
400
+ url: 'https://jup.ag/onboard/cctp'
401
+ }
402
+ ];
403
+ }
358
404
  // ============================================================================
359
405
  // Wallet Store
360
406
  // ============================================================================
361
- export function createWalletStore() {
407
+ export function createWalletStore(options = {}) {
408
+ const walletStorage = getWalletStorage(options);
409
+ const fundingProviders = getFundingProviders(options);
362
410
  // State
363
411
  let rpcEndpoint = $state(PUBLIC_SOLANA_RPC_ENDPOINT);
364
412
  let availableWallets = $state([]);
@@ -375,15 +423,44 @@ export function createWalletStore() {
375
423
  let runtimeDebugMode = $state(readRuntimeDebugMode());
376
424
  let injectedWallets = $state([]);
377
425
  let mobileWalletLinks = $state([]);
426
+ let lastUsedWalletName = $state(null);
427
+ let hotWalletAddress = $state(null);
428
+ let hotWalletBalanceLamports = $state(null);
429
+ let hotWalletBusy = $state(false);
430
+ let hotWalletError = $state(null);
431
+ let hotWalletUnlocked = $state(false);
378
432
  let walletChangeCallbacks = new Set();
379
433
  let walletRegistrationOff = null;
380
- const controller = createWalletController();
434
+ let unlockedHotWallet = null;
435
+ const controller = createWalletController({ storage: walletStorage });
381
436
  // Derived state
382
437
  const shortAddress = $derived(publicKey ? `${publicKey.slice(0, 4)}...${publicKey.slice(-4)}` : '');
383
438
  const connectionStatus = $derived(connecting ? 'connecting' : connected ? 'connected' : 'disconnected');
384
439
  const detectedWalletBrowserName = $derived(getDetectedWalletBrowserName(mobileRuntime, injectedWallets));
385
440
  const runtimeLabel = $derived(getRuntimeLabel(mobileRuntime, injectedWallets));
386
441
  const mobileWalletAdapterWallet = $derived(availableWallets.find((wallet) => wallet.name === SolanaMobileWalletAdapterWalletName) ?? null);
442
+ const hotWalletFundingOptions = $derived.by(() => {
443
+ if (!hotWalletAddress) {
444
+ return [];
445
+ }
446
+ return fundingProviders.flatMap((provider) => {
447
+ if (provider.type === 'jupiter-hosted') {
448
+ return provider.enabled === false ? [] : createJupiterFundingOptions();
449
+ }
450
+ if (provider.type === 'onramper') {
451
+ return [
452
+ {
453
+ description: 'Open an embedded-ready Onramper flow with your hot wallet prefilled.',
454
+ id: provider.id ?? 'onramper',
455
+ kind: 'onramp',
456
+ label: provider.label ?? 'Buy with card',
457
+ requiresSignedUrl: true
458
+ }
459
+ ];
460
+ }
461
+ return [];
462
+ });
463
+ });
387
464
  // ============================================================================
388
465
  // Event System
389
466
  // ============================================================================
@@ -404,6 +481,7 @@ export function createWalletStore() {
404
481
  }
405
482
  controller.subscribe((state) => {
406
483
  const previousWallet = standardWallet;
484
+ const previousPublicKey = publicKey;
407
485
  availableWallets = [...state.availableWallets];
408
486
  standardWallet = state.standardWallet;
409
487
  standardAccount = state.standardAccount;
@@ -412,17 +490,15 @@ export function createWalletStore() {
412
490
  connecting = state.connecting;
413
491
  signing = state.signing;
414
492
  error = state.error;
493
+ lastUsedWalletName = state.lastUsedWalletName;
415
494
  if (previousWallet !== state.standardWallet) {
416
- if (browser) {
417
- if (state.standardWallet) {
418
- localStorage.setItem(LAST_WALLET_KEY, state.standardWallet.name);
419
- }
420
- else {
421
- localStorage.removeItem(LAST_WALLET_KEY);
422
- }
423
- }
424
495
  notifyWalletChange(state.standardWallet);
425
496
  }
497
+ if (previousPublicKey !== state.publicKey) {
498
+ unlockedHotWallet = null;
499
+ hotWalletUnlocked = false;
500
+ loadStoredHotWalletAddress(state.publicKey);
501
+ }
426
502
  });
427
503
  // ============================================================================
428
504
  // Internal Helper Functions
@@ -430,6 +506,102 @@ export function createWalletStore() {
430
506
  function setError(err) {
431
507
  error = err;
432
508
  }
509
+ function setHotWalletError(err) {
510
+ hotWalletError = err;
511
+ }
512
+ function getCurrentChain() {
513
+ return resolveChainFromEndpoint(rpcEndpoint);
514
+ }
515
+ function getHotWalletDomain() {
516
+ if (!browser || typeof location === 'undefined') {
517
+ return { domain: 'localhost', uri: 'http://localhost' };
518
+ }
519
+ return { domain: location.host, uri: location.origin };
520
+ }
521
+ function getStoredHotWallet(ownerAddress) {
522
+ return readWrappedHotWallet({
523
+ chainId: getCurrentChain(),
524
+ ownerAddress,
525
+ storage: walletStorage,
526
+ storagePrefix: HOT_WALLET_STORAGE_PREFIX
527
+ });
528
+ }
529
+ function loadStoredHotWalletAddress(ownerAddress = publicKey) {
530
+ const wrappedWallet = ownerAddress ? getStoredHotWallet(ownerAddress) : null;
531
+ hotWalletAddress = wrappedWallet ? getHotWalletAddress(wrappedWallet) : null;
532
+ hotWalletBalanceLamports = null;
533
+ hotWalletUnlocked = Boolean(unlockedHotWallet && hotWalletAddress);
534
+ }
535
+ function resetHotWalletSession() {
536
+ unlockedHotWallet = null;
537
+ hotWalletAddress = null;
538
+ hotWalletBalanceLamports = null;
539
+ hotWalletBusy = false;
540
+ hotWalletError = null;
541
+ hotWalletUnlocked = false;
542
+ }
543
+ function getHotWalletUnlockPayload(ownerAddress) {
544
+ const { domain, uri } = getHotWalletDomain();
545
+ return createHotWalletUnlockMessage({
546
+ chainId: getCurrentChain(),
547
+ domain,
548
+ uri,
549
+ walletAddress: ownerAddress
550
+ });
551
+ }
552
+ async function signHotWalletUnlockPayload(ownerAddress) {
553
+ const result = await signMessage(getHotWalletUnlockPayload(ownerAddress));
554
+ return normalizeSignatureBytes(result.signature);
555
+ }
556
+ function getConnectedOwnerAddress() {
557
+ if (!publicKey) {
558
+ throw new Error('Connect a wallet first');
559
+ }
560
+ return publicKey;
561
+ }
562
+ function getConnectedWalletForTransactions() {
563
+ if (!standardWallet || !standardAccount) {
564
+ throw new Error('Connect a wallet first');
565
+ }
566
+ return { account: standardAccount, wallet: standardWallet };
567
+ }
568
+ async function signOwnerTransfer(transaction) {
569
+ const { account, wallet } = getConnectedWalletForTransactions();
570
+ const signTransactionFeature = getWalletFeature(wallet.wallet, SolanaSignTransaction);
571
+ if (signTransactionFeature) {
572
+ const [result] = await signTransactionFeature.signTransaction({
573
+ account,
574
+ chain: getCurrentChain(),
575
+ transaction
576
+ });
577
+ if (!result) {
578
+ throw new Error(`${wallet.name} returned no signed transaction`);
579
+ }
580
+ return result.signedTransaction;
581
+ }
582
+ const signAndSendFeature = getWalletFeature(wallet.wallet, SolanaSignAndSendTransaction);
583
+ if (signAndSendFeature) {
584
+ const result = await signAndSendFeature.signAndSendTransaction({
585
+ account,
586
+ chain: getCurrentChain(),
587
+ transaction
588
+ });
589
+ const normalizedResult = Array.isArray(result) ? result[0] : result;
590
+ if (!normalizedResult) {
591
+ throw new Error(`${wallet.name} returned no transaction signature`);
592
+ }
593
+ return typeof normalizedResult.signature === 'string'
594
+ ? normalizedResult.signature
595
+ : base58Encode(normalizedResult.signature);
596
+ }
597
+ throw new Error(`${wallet.name} does not support transaction signing`);
598
+ }
599
+ function findFundingProvider(optionId) {
600
+ if (optionId.startsWith('jupiter-')) {
601
+ return fundingProviders.find((provider) => provider.type === 'jupiter-hosted') ?? null;
602
+ }
603
+ return (fundingProviders.find((provider) => provider.type === 'onramper' && (provider.id ?? 'onramper') === optionId) ?? null);
604
+ }
433
605
  function getDetectedInjectedWallets() {
434
606
  const debugWalletId = getRuntimeDebugWalletId(runtimeDebugMode);
435
607
  if (debugWalletId) {
@@ -489,7 +661,7 @@ export function createWalletStore() {
489
661
  await refreshAvailableWallets();
490
662
  const wallets = availableWallets;
491
663
  // Attempt to restore last connected wallet
492
- const lastWallet = localStorage.getItem(LAST_WALLET_KEY);
664
+ const lastWallet = controller.state.lastUsedWalletName;
493
665
  if (lastWallet) {
494
666
  const existing = wallets.find((wallet) => wallet.name === lastWallet);
495
667
  if (existing) {
@@ -497,7 +669,6 @@ export function createWalletStore() {
497
669
  await connectStandard(existing);
498
670
  }
499
671
  catch (err) {
500
- localStorage.removeItem(LAST_WALLET_KEY);
501
672
  setError(err instanceof Error ? err : new Error(String(err)));
502
673
  }
503
674
  }
@@ -559,6 +730,7 @@ export function createWalletStore() {
559
730
  async function disconnect() {
560
731
  try {
561
732
  await controller.disconnect();
733
+ resetHotWalletSession();
562
734
  setError(null);
563
735
  }
564
736
  catch (err) {
@@ -582,6 +754,194 @@ export function createWalletStore() {
582
754
  throw error;
583
755
  }
584
756
  }
757
+ async function createHotWalletVault() {
758
+ const ownerAddress = getConnectedOwnerAddress();
759
+ const signature = await signHotWalletUnlockPayload(ownerAddress);
760
+ hotWalletBusy = true;
761
+ setHotWalletError(null);
762
+ try {
763
+ const { domain } = getHotWalletDomain();
764
+ const wrappedWallet = await createWrappedHotWalletFromSignature({
765
+ chainId: getCurrentChain(),
766
+ domain,
767
+ walletAddress: ownerAddress,
768
+ walletSignature: signature
769
+ });
770
+ unlockedHotWallet = await unlockHotWalletFromSignature({
771
+ chainId: getCurrentChain(),
772
+ domain,
773
+ walletAddress: ownerAddress,
774
+ walletSignature: signature,
775
+ wrappedWallet
776
+ });
777
+ saveWrappedHotWallet({
778
+ chainId: getCurrentChain(),
779
+ ownerAddress,
780
+ storage: walletStorage,
781
+ storagePrefix: HOT_WALLET_STORAGE_PREFIX,
782
+ wallet: wrappedWallet
783
+ });
784
+ hotWalletAddress = getHotWalletAddress(wrappedWallet);
785
+ hotWalletUnlocked = true;
786
+ hotWalletBalanceLamports = null;
787
+ return hotWalletAddress;
788
+ }
789
+ catch (err) {
790
+ const nextError = err instanceof Error ? err : new Error('Failed to create hot wallet');
791
+ setHotWalletError(nextError);
792
+ throw nextError;
793
+ }
794
+ finally {
795
+ signature.fill(0);
796
+ hotWalletBusy = false;
797
+ }
798
+ }
799
+ async function unlockHotWalletVault() {
800
+ const ownerAddress = getConnectedOwnerAddress();
801
+ const signature = await signHotWalletUnlockPayload(ownerAddress);
802
+ hotWalletBusy = true;
803
+ setHotWalletError(null);
804
+ try {
805
+ const { domain } = getHotWalletDomain();
806
+ const wrappedWallet = getStoredHotWallet(ownerAddress);
807
+ if (!wrappedWallet) {
808
+ throw new Error('No hot wallet vault stored for this wallet');
809
+ }
810
+ unlockedHotWallet = await unlockHotWalletFromSignature({
811
+ chainId: getCurrentChain(),
812
+ domain,
813
+ walletAddress: ownerAddress,
814
+ walletSignature: signature,
815
+ wrappedWallet
816
+ });
817
+ hotWalletAddress = getHotWalletAddress(wrappedWallet);
818
+ hotWalletUnlocked = true;
819
+ return hotWalletAddress;
820
+ }
821
+ catch (err) {
822
+ const nextError = err instanceof Error ? err : new Error('Failed to unlock hot wallet');
823
+ setHotWalletError(nextError);
824
+ throw nextError;
825
+ }
826
+ finally {
827
+ signature.fill(0);
828
+ hotWalletBusy = false;
829
+ }
830
+ }
831
+ async function refreshHotWalletBalance() {
832
+ if (!hotWalletAddress) {
833
+ throw new Error('Create or unlock a hot wallet first');
834
+ }
835
+ hotWalletBusy = true;
836
+ setHotWalletError(null);
837
+ try {
838
+ hotWalletBalanceLamports = await getSolanaBalance(rpcEndpoint, hotWalletAddress);
839
+ return hotWalletBalanceLamports;
840
+ }
841
+ catch (err) {
842
+ const nextError = err instanceof Error ? err : new Error('Failed to refresh hot wallet balance');
843
+ setHotWalletError(nextError);
844
+ throw nextError;
845
+ }
846
+ finally {
847
+ hotWalletBusy = false;
848
+ }
849
+ }
850
+ async function topUpHotWallet(amountSol = DEFAULT_HOT_WALLET_TOP_UP_SOL) {
851
+ const ownerAddress = getConnectedOwnerAddress();
852
+ if (!hotWalletAddress) {
853
+ await createHotWalletVault();
854
+ }
855
+ if (!hotWalletAddress) {
856
+ throw new Error('Create a hot wallet first');
857
+ }
858
+ hotWalletBusy = true;
859
+ setHotWalletError(null);
860
+ try {
861
+ const amountLamports = solToLamports(amountSol);
862
+ const { transaction } = createSystemTransferTransaction({
863
+ blockhash: await getLatestSolanaBlockhash(rpcEndpoint),
864
+ fromAddress: ownerAddress,
865
+ lamports: amountLamports,
866
+ toAddress: hotWalletAddress
867
+ });
868
+ const signedOrSignature = await signOwnerTransfer(transaction);
869
+ const signature = typeof signedOrSignature === 'string'
870
+ ? signedOrSignature
871
+ : await sendSerializedSolanaTransaction(rpcEndpoint, signedOrSignature);
872
+ await refreshHotWalletBalance();
873
+ return { address: hotWalletAddress, amountLamports, signature };
874
+ }
875
+ catch (err) {
876
+ const nextError = err instanceof Error ? err : new Error('Failed to top up hot wallet');
877
+ setHotWalletError(nextError);
878
+ throw nextError;
879
+ }
880
+ finally {
881
+ hotWalletBusy = false;
882
+ }
883
+ }
884
+ async function withdrawHotWallet(amountSol = DEFAULT_HOT_WALLET_WITHDRAW_SOL) {
885
+ const ownerAddress = getConnectedOwnerAddress();
886
+ if (!unlockedHotWallet) {
887
+ await unlockHotWalletVault();
888
+ }
889
+ if (!unlockedHotWallet) {
890
+ throw new Error('Unlock the hot wallet first');
891
+ }
892
+ hotWalletBusy = true;
893
+ setHotWalletError(null);
894
+ try {
895
+ const amountLamports = solToLamports(amountSol);
896
+ const address = getHotWalletAddress(unlockedHotWallet);
897
+ const { messageBytes, transaction } = createSystemTransferTransaction({
898
+ blockhash: await getLatestSolanaBlockhash(rpcEndpoint),
899
+ fromAddress: address,
900
+ lamports: amountLamports,
901
+ toAddress: ownerAddress
902
+ });
903
+ const signature = await signWithHotWallet({
904
+ message: messageBytes,
905
+ privateKey: unlockedHotWallet.privateKey
906
+ });
907
+ const tx = await sendSerializedSolanaTransaction(rpcEndpoint, applySolanaTransactionSignature(transaction, signature));
908
+ await refreshHotWalletBalance();
909
+ return { address, amountLamports, signature: tx };
910
+ }
911
+ catch (err) {
912
+ const nextError = err instanceof Error ? err : new Error('Failed to withdraw hot wallet funds');
913
+ setHotWalletError(nextError);
914
+ throw nextError;
915
+ }
916
+ finally {
917
+ hotWalletBusy = false;
918
+ }
919
+ }
920
+ async function createHotWalletFundingUrl(optionId) {
921
+ const ownerAddress = getConnectedOwnerAddress();
922
+ if (!hotWalletAddress) {
923
+ await createHotWalletVault();
924
+ }
925
+ if (!hotWalletAddress) {
926
+ throw new Error('Create a hot wallet first');
927
+ }
928
+ const option = hotWalletFundingOptions.find((candidate) => candidate.id === optionId);
929
+ if (!option) {
930
+ throw new Error('Funding option is not available');
931
+ }
932
+ if (option.url) {
933
+ return option.url;
934
+ }
935
+ const provider = findFundingProvider(optionId);
936
+ if (provider?.type !== 'onramper') {
937
+ throw new Error('Funding option has no URL');
938
+ }
939
+ return await provider.createSignedUrl({
940
+ chain: getCurrentChain(),
941
+ hotWalletAddress,
942
+ ownerAddress
943
+ });
944
+ }
585
945
  // ============================================================================
586
946
  // Return Public Interface
587
947
  // ============================================================================
@@ -656,6 +1016,39 @@ export function createWalletStore() {
656
1016
  get mobileWalletAdapterDisplayName() {
657
1017
  return MOBILE_WALLET_ADAPTER_DISPLAY_NAME;
658
1018
  },
1019
+ get lastUsedWalletName() {
1020
+ return lastUsedWalletName;
1021
+ },
1022
+ get hotWalletAddress() {
1023
+ return hotWalletAddress;
1024
+ },
1025
+ get hotWalletBalanceLamports() {
1026
+ return hotWalletBalanceLamports;
1027
+ },
1028
+ get hotWalletBalance() {
1029
+ return lamportsToSol(hotWalletBalanceLamports);
1030
+ },
1031
+ get hotWalletBusy() {
1032
+ return hotWalletBusy;
1033
+ },
1034
+ get hotWalletError() {
1035
+ return hotWalletError;
1036
+ },
1037
+ get hotWalletUnlocked() {
1038
+ return hotWalletUnlocked;
1039
+ },
1040
+ get hotWalletDefaultTopUpSol() {
1041
+ return DEFAULT_HOT_WALLET_TOP_UP_SOL;
1042
+ },
1043
+ get hotWalletDefaultWithdrawSol() {
1044
+ return DEFAULT_HOT_WALLET_WITHDRAW_SOL;
1045
+ },
1046
+ get walletAccounts() {
1047
+ return standardWallet?.wallet.accounts ?? [];
1048
+ },
1049
+ get hotWalletFundingOptions() {
1050
+ return hotWalletFundingOptions;
1051
+ },
659
1052
  // Methods
660
1053
  initialize,
661
1054
  refreshAvailableWallets,
@@ -665,8 +1058,15 @@ export function createWalletStore() {
665
1058
  connectWalletBrowser,
666
1059
  disconnect,
667
1060
  signMessage,
1061
+ createHotWalletVault,
1062
+ unlockHotWalletVault,
1063
+ refreshHotWalletBalance,
1064
+ topUpHotWallet,
1065
+ withdrawHotWallet,
1066
+ createHotWalletFundingUrl,
668
1067
  onWalletChange,
669
- clearError: () => setError(null)
1068
+ clearError: () => setError(null),
1069
+ clearHotWalletError: () => setHotWalletError(null)
670
1070
  };
671
1071
  }
672
1072
  export const wallet = createWalletStore();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fernolab/wallet-adapter-svelte",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Lean Svelte wallet adapter UI for Solana Wallet Standard providers.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -51,19 +51,19 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@sveltejs/adapter-auto": "^7.0.1",
54
- "@sveltejs/kit": "^2.61.0",
55
- "@sveltejs/package": "^2.5.4",
54
+ "@sveltejs/kit": "^2.66.0",
55
+ "@sveltejs/package": "^2.5.8",
56
56
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
57
- "@types/node": "^25.9.1",
58
- "svelte": "^5.55.9",
59
- "svelte-check": "^4.4.8",
57
+ "@types/node": "^26.0.0",
58
+ "svelte": "^5.56.3",
59
+ "svelte-check": "^4.6.0",
60
60
  "typescript": "^6.0.3",
61
- "vite": "^8.0.14",
62
- "vitest": "^4.1.7"
61
+ "vite": "^8.0.16",
62
+ "vitest": "^4.1.9"
63
63
  },
64
64
  "dependencies": {
65
- "@fernolab/wallet-adapter-core": "^0.1.0",
66
- "@wallet-standard/app": "^1.1.0"
65
+ "@fernolab/wallet-adapter-core": "^0.1.1",
66
+ "@wallet-standard/app": "^1.1.1"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "svelte": "^5.0.0"