@xian-tech/wallet-core 0.1.0 → 0.1.4

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.
@@ -1,8 +1,9 @@
1
1
  import { Ed25519Signer, XianClient } from "@xian-tech/client";
2
2
  import { ProviderChainMismatchError, ProviderUnauthorizedError, ProviderUnsupportedMethodError } from "@xian-tech/provider";
3
- import { approvalKindFromMethod, buildApprovalView } from "./approvals";
4
- import { DEFAULT_NETWORK_PRESETS, DEFAULT_DASHBOARD_URL, DEFAULT_RPC_URL, LOCAL_NETWORK_PRESET_NAME, DEFAULT_WALLET_CAPABILITIES, LOCAL_NETWORK_PRESET_ID } from "./constants";
5
- import { createWalletSecret, decryptMnemonic, decryptPrivateKey, encryptMnemonic, encryptPrivateKey, isUnsafeMessageToSign } from "./crypto";
3
+ import { approvalKindFromMethod, buildApprovalView } from "./approvals.js";
4
+ import { DEFAULT_NETWORK_PRESETS, DEFAULT_DASHBOARD_URL, DEFAULT_RPC_URL, LOCAL_NETWORK_PRESET_NAME, DEFAULT_WALLET_CAPABILITIES, LOCAL_NETWORK_PRESET_ID, UNLOCKED_SESSION_TIMEOUT_MS } from "./constants.js";
5
+ import { createWalletSecret, decryptMnemonic, decryptPrivateKey, derivePrivateKeyFromMnemonic, encryptMnemonic, encryptPrivateKey, isUnsafeMessageToSign } from "./crypto.js";
6
+ const SAFE_CHAIN_ID_LOOKUP_TIMEOUT_MS = 2_000;
6
7
  function firstParamObject(params) {
7
8
  if (Array.isArray(params)) {
8
9
  return (params[0] ?? {});
@@ -35,6 +36,15 @@ function trimOptionalString(value) {
35
36
  const trimmed = value?.trim();
36
37
  return trimmed ? trimmed : undefined;
37
38
  }
39
+ function normalizeTrackedAsset(asset) {
40
+ return {
41
+ contract: asset.contract.trim(),
42
+ name: trimOptionalString(asset.name),
43
+ symbol: trimOptionalString(asset.symbol),
44
+ icon: trimOptionalString(asset.icon),
45
+ decimals: asset.decimals
46
+ };
47
+ }
38
48
  function createLocalNetworkPreset() {
39
49
  const preset = DEFAULT_NETWORK_PRESETS[0];
40
50
  if (preset) {
@@ -168,13 +178,55 @@ export class WalletController {
168
178
  message: String(error)
169
179
  };
170
180
  }
171
- getUnlockedSigner() {
181
+ async restoreUnlockedSession() {
182
+ if (this.unlockedPrivateKey) {
183
+ return true;
184
+ }
185
+ const session = await this.store.loadUnlockedSession();
186
+ if (!session) {
187
+ return false;
188
+ }
189
+ if (session.expiresAt <= this.now()) {
190
+ await this.store.clearUnlockedSession();
191
+ return false;
192
+ }
193
+ this.unlockedPrivateKey = session.privateKey;
194
+ this.unlockedSigner = new Ed25519Signer(session.privateKey);
195
+ if (session.mnemonic) {
196
+ this.unlockedMnemonic = session.mnemonic;
197
+ }
198
+ if (session.password) {
199
+ this.unlockedPassword = session.password;
200
+ }
201
+ return true;
202
+ }
203
+ unlockedMnemonic = null;
204
+ unlockedPassword = null;
205
+ async persistUnlockedSession(privateKey, expiresAt = this.now() + UNLOCKED_SESSION_TIMEOUT_MS) {
206
+ const session = {
207
+ privateKey,
208
+ mnemonic: this.unlockedMnemonic ?? undefined,
209
+ password: this.unlockedPassword ?? undefined,
210
+ expiresAt
211
+ };
212
+ await this.store.saveUnlockedSession(session);
213
+ }
214
+ async clearUnlockedSession() {
215
+ this.unlockedPrivateKey = null;
216
+ this.unlockedSigner = null;
217
+ this.unlockedMnemonic = null;
218
+ this.unlockedPassword = null;
219
+ await this.store.clearUnlockedSession();
220
+ }
221
+ async getUnlockedSigner() {
222
+ await this.restoreUnlockedSession();
172
223
  if (!this.unlockedPrivateKey) {
173
224
  throw new ProviderUnauthorizedError("wallet is locked");
174
225
  }
175
226
  if (!this.unlockedSigner) {
176
227
  this.unlockedSigner = new Ed25519Signer(this.unlockedPrivateKey);
177
228
  }
229
+ await this.persistUnlockedSession(this.unlockedPrivateKey);
178
230
  return this.unlockedSigner;
179
231
  }
180
232
  currentClient(state) {
@@ -255,12 +307,30 @@ export class WalletController {
255
307
  return undefined;
256
308
  }
257
309
  try {
258
- return await this.currentClient(state).getChainId();
310
+ return await this.withTimeout(this.currentClient(state).getChainId(), SAFE_CHAIN_ID_LOOKUP_TIMEOUT_MS);
259
311
  }
260
312
  catch {
261
313
  return undefined;
262
314
  }
263
315
  }
316
+ async withTimeout(promise, timeoutMs) {
317
+ let timeoutId;
318
+ try {
319
+ return await Promise.race([
320
+ promise,
321
+ new Promise((_, reject) => {
322
+ timeoutId = globalThis.setTimeout(() => {
323
+ reject(new Error("operation timed out"));
324
+ }, timeoutMs);
325
+ })
326
+ ]);
327
+ }
328
+ finally {
329
+ if (timeoutId !== undefined) {
330
+ globalThis.clearTimeout(timeoutId);
331
+ }
332
+ }
333
+ }
264
334
  async buildWalletInfo(state, origin) {
265
335
  if (!state) {
266
336
  return {
@@ -272,7 +342,7 @@ export class WalletController {
272
342
  };
273
343
  }
274
344
  const connected = state.connectedOrigins.includes(origin);
275
- const unlocked = this.unlockedPrivateKey != null;
345
+ const unlocked = await this.restoreUnlockedSession();
276
346
  const preset = this.activeNetworkPreset(state);
277
347
  const resolvedChainId = await this.safeGetChainId(state);
278
348
  return {
@@ -312,6 +382,50 @@ export class WalletController {
312
382
  watchedAssets: updater(state.watchedAssets)
313
383
  });
314
384
  }
385
+ async fetchDetectedAssets(state) {
386
+ if (!state) {
387
+ return [];
388
+ }
389
+ const client = this.currentClient(state);
390
+ const trackedContracts = new Set(state.watchedAssets.map((asset) => asset.contract));
391
+ const detectedAssets = [];
392
+ const seenContracts = new Set();
393
+ const pageSize = 200;
394
+ let offset = 0;
395
+ while (true) {
396
+ const page = await client.getTokenBalances(state.publicKey, {
397
+ limit: pageSize,
398
+ offset
399
+ });
400
+ for (const item of page.items) {
401
+ const contract = item.contract.trim();
402
+ if (!contract || seenContracts.has(contract)) {
403
+ continue;
404
+ }
405
+ seenContracts.add(contract);
406
+ detectedAssets.push({
407
+ contract,
408
+ name: trimOptionalString(item.name ?? undefined),
409
+ symbol: trimOptionalString(item.symbol ?? undefined),
410
+ icon: trimOptionalString(item.logoUrl ?? undefined),
411
+ balance: item.balance,
412
+ tracked: trackedContracts.has(contract)
413
+ });
414
+ }
415
+ const fetched = page.items.length;
416
+ if (fetched === 0 || offset + fetched >= page.total) {
417
+ break;
418
+ }
419
+ offset += fetched;
420
+ }
421
+ detectedAssets.sort((left, right) => {
422
+ if (left.tracked !== right.tracked) {
423
+ return left.tracked ? 1 : -1;
424
+ }
425
+ return left.contract.localeCompare(right.contract);
426
+ });
427
+ return detectedAssets;
428
+ }
315
429
  sanitizeNetworkPresetInput(input) {
316
430
  const name = input.name.trim();
317
431
  const rpcUrl = input.rpcUrl.trim();
@@ -375,8 +489,45 @@ export class WalletController {
375
489
  await this.broadcastProviderEvent("accountsChanged", [[]], origin);
376
490
  await this.broadcastProviderEvent("disconnect", [{ code: 4100, message: "wallet disconnected" }], origin);
377
491
  }
492
+ async notifyUnlockedOrigins(state) {
493
+ if (state.connectedOrigins.length === 0) {
494
+ return;
495
+ }
496
+ const chainId = this.displayChainId(this.activeNetworkPreset(state), await this.safeGetChainId(state)) ?? "unknown";
497
+ await Promise.allSettled(state.connectedOrigins.map((origin) => this.emitConnectionLifecycle(origin, chainId, state.publicKey)));
498
+ }
499
+ async emitSelectedAccountChangedForConnectedOrigins(state) {
500
+ if (state.connectedOrigins.length === 0) {
501
+ return;
502
+ }
503
+ if (await this.restoreUnlockedSession()) {
504
+ await Promise.allSettled(state.connectedOrigins.map((origin) => this.broadcastProviderEvent("accountsChanged", [[state.publicKey]], origin)));
505
+ return;
506
+ }
507
+ await Promise.allSettled(state.connectedOrigins.map((origin) => this.emitDisconnectLifecycle(origin)));
508
+ }
509
+ async invalidatePendingRequests(reason) {
510
+ const requestStates = await this.store.listRequestStates();
511
+ const settledPendingRequestIds = new Set();
512
+ for (const requestState of requestStates) {
513
+ if (requestState.status !== "pending") {
514
+ continue;
515
+ }
516
+ settledPendingRequestIds.add(requestState.requestId);
517
+ await this.rejectRequest(requestState, reason);
518
+ }
519
+ for (const [requestId, waiter] of this.requestWaiters.entries()) {
520
+ if (!settledPendingRequestIds.has(requestId)) {
521
+ waiter.reject(reason);
522
+ }
523
+ }
524
+ this.requestWaiters.clear();
525
+ for (const approval of await this.store.listApprovalStates()) {
526
+ await this.store.deleteApprovalState(approval.id);
527
+ }
528
+ }
378
529
  async prepareTransaction(state, intent) {
379
- const signer = this.getUnlockedSigner();
530
+ const signer = await this.getUnlockedSigner();
380
531
  const client = this.currentClient(state);
381
532
  const activeChainId = await client.getChainId();
382
533
  if (intent.chainId && intent.chainId !== activeChainId) {
@@ -393,7 +544,7 @@ export class WalletController {
393
544
  });
394
545
  }
395
546
  async signPreparedTransaction(state, tx) {
396
- const signer = this.getUnlockedSigner();
547
+ const signer = await this.getUnlockedSigner();
397
548
  const activeChainId = await this.currentClient(state).getChainId();
398
549
  if (tx.payload.sender !== signer.address) {
399
550
  throw new ProviderUnauthorizedError("transaction sender does not match the active wallet");
@@ -411,7 +562,7 @@ export class WalletController {
411
562
  const state = this.requireStoredWallet(await this.loadWalletState());
412
563
  switch (request.method) {
413
564
  case "xian_requestAccounts": {
414
- this.getUnlockedSigner();
565
+ await this.getUnlockedSigner();
415
566
  const chainId = this.displayChainId(this.activeNetworkPreset(state), await this.safeGetChainId(state));
416
567
  const nextState = await this.updateConnectedOrigin(origin, true);
417
568
  await this.emitConnectionLifecycle(origin, chainId ?? "unknown", nextState.publicKey);
@@ -419,9 +570,9 @@ export class WalletController {
419
570
  }
420
571
  case "xian_watchAsset": {
421
572
  this.requireConnectedOrigin(state, origin);
422
- this.getUnlockedSigner();
573
+ await this.getUnlockedSigner();
423
574
  const assetRequest = firstParamObject(request.params);
424
- const asset = assetRequest.options;
575
+ const asset = normalizeTrackedAsset(assetRequest.options);
425
576
  await this.updateWatchedAssets((assets) => {
426
577
  const next = assets.filter((entry) => entry.contract !== asset.contract);
427
578
  next.push(asset);
@@ -431,7 +582,7 @@ export class WalletController {
431
582
  }
432
583
  case "xian_signMessage": {
433
584
  this.requireConnectedOrigin(state, origin);
434
- const signer = this.getUnlockedSigner();
585
+ const signer = await this.getUnlockedSigner();
435
586
  const { message } = firstParamObject(request.params);
436
587
  if (typeof message !== "string") {
437
588
  throw new TypeError("xian_signMessage requires a message string");
@@ -443,13 +594,13 @@ export class WalletController {
443
594
  }
444
595
  case "xian_signTransaction": {
445
596
  this.requireConnectedOrigin(state, origin);
446
- this.getUnlockedSigner();
597
+ await this.getUnlockedSigner();
447
598
  const { tx } = firstParamObject(request.params);
448
599
  return this.signPreparedTransaction(state, tx);
449
600
  }
450
601
  case "xian_sendTransaction": {
451
602
  this.requireConnectedOrigin(state, origin);
452
- this.getUnlockedSigner();
603
+ await this.getUnlockedSigner();
453
604
  const { tx, mode, waitForTx, timeoutMs, pollIntervalMs } = firstParamObject(request.params);
454
605
  return this.sendPreparedTransaction(state, tx, {
455
606
  mode: mode,
@@ -460,7 +611,7 @@ export class WalletController {
460
611
  }
461
612
  case "xian_sendCall": {
462
613
  this.requireConnectedOrigin(state, origin);
463
- this.getUnlockedSigner();
614
+ await this.getUnlockedSigner();
464
615
  const { intent, mode, waitForTx, timeoutMs, pollIntervalMs } = firstParamObject(request.params);
465
616
  const tx = await this.prepareTransaction(state, intent);
466
617
  return this.sendPreparedTransaction(state, tx, {
@@ -563,7 +714,7 @@ export class WalletController {
563
714
  };
564
715
  case "xian_requestAccounts": {
565
716
  const walletState = this.requireStoredWallet(state);
566
- this.getUnlockedSigner();
717
+ await this.getUnlockedSigner();
567
718
  const approvalChainId = this.displayChainId(this.activeNetworkPreset(walletState), await this.safeGetChainId(walletState));
568
719
  if (walletState.connectedOrigins.includes(origin)) {
569
720
  return {
@@ -592,9 +743,7 @@ export class WalletController {
592
743
  };
593
744
  }
594
745
  case "xian_accounts":
595
- if (!state ||
596
- this.unlockedPrivateKey == null ||
597
- !state.connectedOrigins.includes(origin)) {
746
+ if (!state || !(await this.restoreUnlockedSession()) || !state.connectedOrigins.includes(origin)) {
598
747
  return {
599
748
  kind: "result",
600
749
  value: []
@@ -640,7 +789,7 @@ export class WalletController {
640
789
  case "xian_watchAsset": {
641
790
  const walletState = this.requireStoredWallet(state);
642
791
  this.requireConnectedOrigin(walletState, origin);
643
- this.getUnlockedSigner();
792
+ await this.getUnlockedSigner();
644
793
  return {
645
794
  kind: "approval",
646
795
  account: walletState.publicKey,
@@ -650,7 +799,7 @@ export class WalletController {
650
799
  case "xian_signMessage": {
651
800
  const walletState = this.requireStoredWallet(state);
652
801
  this.requireConnectedOrigin(walletState, origin);
653
- this.getUnlockedSigner();
802
+ await this.getUnlockedSigner();
654
803
  return {
655
804
  kind: "approval",
656
805
  account: walletState.publicKey,
@@ -660,7 +809,7 @@ export class WalletController {
660
809
  case "xian_prepareTransaction": {
661
810
  const walletState = this.requireStoredWallet(state);
662
811
  this.requireConnectedOrigin(walletState, origin);
663
- this.getUnlockedSigner();
812
+ await this.getUnlockedSigner();
664
813
  const { intent } = firstParamObject(request.params);
665
814
  return {
666
815
  kind: "result",
@@ -672,7 +821,7 @@ export class WalletController {
672
821
  case "xian_sendCall": {
673
822
  const walletState = this.requireStoredWallet(state);
674
823
  this.requireConnectedOrigin(walletState, origin);
675
- this.getUnlockedSigner();
824
+ await this.getUnlockedSigner();
676
825
  return {
677
826
  kind: "approval",
678
827
  account: walletState.publicKey,
@@ -683,6 +832,13 @@ export class WalletController {
683
832
  throw new ProviderUnsupportedMethodError(request.method);
684
833
  }
685
834
  }
835
+ getAccountsList(state) {
836
+ if (state.accounts && state.accounts.length > 0) {
837
+ return state.accounts.map((a) => ({ index: a.index, publicKey: a.publicKey, name: a.name }));
838
+ }
839
+ // Backward compat: create virtual account from legacy fields
840
+ return [{ index: 0, publicKey: state.publicKey, name: "Account 1" }];
841
+ }
686
842
  async getPopupState() {
687
843
  const state = await this.loadWalletState();
688
844
  const approvals = await this.store.listApprovalStates();
@@ -691,9 +847,11 @@ export class WalletController {
691
847
  .sort((left, right) => right.createdAt - left.createdAt);
692
848
  const activePreset = state ? this.activeNetworkPreset(state) : undefined;
693
849
  const resolvedChainId = await this.safeGetChainId(state);
850
+ const unlocked = await this.restoreUnlockedSession();
851
+ const watchedAssets = state?.watchedAssets ?? [];
694
852
  return {
695
853
  hasWallet: state != null,
696
- unlocked: this.unlockedPrivateKey != null,
854
+ unlocked,
697
855
  publicKey: state?.publicKey,
698
856
  rpcUrl: state?.rpcUrl ?? DEFAULT_RPC_URL,
699
857
  dashboardUrl: state?.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
@@ -708,16 +866,133 @@ export class WalletController {
708
866
  activeNetworkId: activePreset?.id,
709
867
  activeNetworkName: activePreset?.name,
710
868
  networkPresets: state?.networkPresets ?? DEFAULT_NETWORK_PRESETS,
711
- watchedAssets: state?.watchedAssets ?? [],
869
+ watchedAssets,
870
+ detectedAssets: [],
871
+ assetBalances: {},
872
+ assetFiatValues: {},
712
873
  connectedOrigins: state?.connectedOrigins ?? [],
713
874
  pendingApprovalCount: pendingApprovals.length,
714
875
  pendingApprovals,
715
876
  hasRecoveryPhrase: Boolean(state?.encryptedMnemonic),
716
877
  seedSource: state?.seedSource,
717
878
  mnemonicWordCount: state?.mnemonicWordCount,
879
+ accounts: state ? this.getAccountsList(state) : [],
880
+ activeAccountIndex: state?.activeAccountIndex ?? 0,
718
881
  version: this.options.version
719
882
  };
720
883
  }
884
+ async getAssetBalances() {
885
+ const state = await this.loadWalletState();
886
+ if (!state) {
887
+ return {};
888
+ }
889
+ return this.fetchAssetBalances(state, state.watchedAssets);
890
+ }
891
+ async getTokenMetadata(contract) {
892
+ const state = this.requireStoredWallet(await this.loadWalletState());
893
+ return this.currentClient(state).getTokenMetadata(contract);
894
+ }
895
+ async getDetectedAssets() {
896
+ const state = await this.loadWalletState();
897
+ if (!state) {
898
+ return [];
899
+ }
900
+ try {
901
+ return await this.fetchDetectedAssets(state);
902
+ }
903
+ catch {
904
+ return [];
905
+ }
906
+ }
907
+ async trackAsset(asset) {
908
+ const normalized = normalizeTrackedAsset(asset);
909
+ if (!normalized.contract) {
910
+ throw new TypeError("asset contract is required");
911
+ }
912
+ await this.updateWatchedAssets((assets) => {
913
+ const next = assets.filter((entry) => entry.contract !== normalized.contract);
914
+ next.push(normalized);
915
+ return next;
916
+ });
917
+ return this.getPopupState();
918
+ }
919
+ async updateAssetSettings(assets) {
920
+ const state = this.requireStoredWallet(await this.loadWalletState());
921
+ for (const update of assets) {
922
+ const asset = state.watchedAssets.find((a) => a.contract === update.contract);
923
+ if (asset) {
924
+ if (update.hidden !== undefined) {
925
+ asset.hidden = update.hidden;
926
+ }
927
+ if (update.order !== undefined) {
928
+ asset.order = update.order;
929
+ }
930
+ }
931
+ }
932
+ await this.store.saveState(state);
933
+ return this.getPopupState();
934
+ }
935
+ async updateWatchedAssetDecimals(contract, decimals) {
936
+ const state = this.requireStoredWallet(await this.loadWalletState());
937
+ const idx = state.watchedAssets.findIndex((asset) => asset.contract === contract);
938
+ if (idx === -1) {
939
+ throw new Error(`asset ${contract} is not watched`);
940
+ }
941
+ const existing = state.watchedAssets[idx];
942
+ state.watchedAssets[idx] = { ...existing, decimals };
943
+ await this.store.saveState(state);
944
+ return this.getPopupState();
945
+ }
946
+ async estimateTransactionStamps(request) {
947
+ const state = this.requireStoredWallet(await this.loadWalletState());
948
+ const client = this.currentClient(state);
949
+ return client.estimateStamps({
950
+ sender: state.publicKey,
951
+ contract: request.contract,
952
+ function: request.function,
953
+ kwargs: request.kwargs
954
+ });
955
+ }
956
+ async sendDirectTransaction(intent) {
957
+ const state = this.requireStoredWallet(await this.loadWalletState());
958
+ await this.getUnlockedSigner();
959
+ const tx = await this.prepareTransaction(state, {
960
+ contract: intent.contract,
961
+ function: intent.function,
962
+ kwargs: intent.kwargs,
963
+ stamps: intent.stamps
964
+ });
965
+ return this.sendPreparedTransaction(state, tx, { mode: "commit" });
966
+ }
967
+ async getContractMethods(contract) {
968
+ const state = this.requireStoredWallet(await this.loadWalletState());
969
+ return this.currentClient(state).getContractMethods(contract);
970
+ }
971
+ async fetchAssetBalances(state, assets) {
972
+ const balances = {};
973
+ if (!state || assets.length === 0) {
974
+ return balances;
975
+ }
976
+ const client = this.currentClient(state);
977
+ const results = await Promise.allSettled(assets.map(async (asset) => {
978
+ const raw = await client.getBalance(state.publicKey, {
979
+ contract: asset.contract
980
+ });
981
+ return { contract: asset.contract, raw };
982
+ }));
983
+ for (const result of results) {
984
+ if (result.status === "fulfilled") {
985
+ const { contract, raw } = result.value;
986
+ balances[contract] =
987
+ raw != null ? String(raw) : null;
988
+ }
989
+ else {
990
+ // If a single balance fetch fails, mark it null rather than
991
+ // failing the entire popup state.
992
+ }
993
+ }
994
+ return balances;
995
+ }
721
996
  async createOrImportWallet(input) {
722
997
  const secret = await createWalletSecret({
723
998
  privateKey: input.privateKey,
@@ -731,17 +1006,10 @@ export class WalletController {
731
1006
  : undefined;
732
1007
  this.unlockedPrivateKey = secret.privateKey;
733
1008
  this.unlockedSigner = signer;
734
- const waiters = [...this.requestWaiters.values()];
735
- this.requestWaiters.clear();
736
- for (const waiter of waiters) {
737
- waiter.reject(new ProviderUnauthorizedError("wallet was replaced"));
738
- }
739
- for (const requestState of await this.store.listRequestStates()) {
740
- await this.store.deleteRequestState(requestState.requestId);
741
- }
742
- for (const approval of await this.store.listApprovalStates()) {
743
- await this.store.deleteApprovalState(approval.id);
744
- }
1009
+ this.unlockedMnemonic = secret.mnemonic ?? null;
1010
+ this.unlockedPassword = input.password;
1011
+ await this.persistUnlockedSession(secret.privateKey);
1012
+ await this.invalidatePendingRequests(new ProviderUnauthorizedError("wallet was replaced"));
745
1013
  const setupRpcUrl = trimOptionalString(input.rpcUrl) ?? DEFAULT_RPC_URL;
746
1014
  const setupDashboardUrl = trimOptionalString(input.dashboardUrl) ?? DEFAULT_DASHBOARD_URL;
747
1015
  const localPreset = createLocalNetworkPreset();
@@ -767,12 +1035,20 @@ export class WalletController {
767
1035
  const networkPresets = useLocalPreset
768
1036
  ? [localPreset]
769
1037
  : [localPreset, activePreset];
1038
+ const initialAccount = {
1039
+ index: 0,
1040
+ publicKey: signer.address,
1041
+ encryptedPrivateKey,
1042
+ name: "Account 1"
1043
+ };
770
1044
  const popupState = await this.persistWalletState({
771
1045
  publicKey: signer.address,
772
1046
  encryptedPrivateKey,
773
1047
  encryptedMnemonic,
774
1048
  seedSource: secret.seedSource,
775
1049
  mnemonicWordCount: secret.mnemonicWordCount,
1050
+ accounts: [initialAccount],
1051
+ activeAccountIndex: 0,
776
1052
  rpcUrl: activePreset.rpcUrl,
777
1053
  dashboardUrl: activePreset.dashboardUrl,
778
1054
  activeNetworkId: activePreset.id,
@@ -802,8 +1078,18 @@ export class WalletController {
802
1078
  }
803
1079
  this.unlockedPrivateKey = privateKey;
804
1080
  this.unlockedSigner = signer;
805
- const chainId = this.displayChainId(this.activeNetworkPreset(state), await this.safeGetChainId(state));
806
- await Promise.all(state.connectedOrigins.map((origin) => this.emitConnectionLifecycle(origin, chainId ?? "unknown", state.publicKey)));
1081
+ this.unlockedPassword = password;
1082
+ // Decrypt mnemonic into session for account switching
1083
+ if (state.encryptedMnemonic) {
1084
+ try {
1085
+ this.unlockedMnemonic = await decryptMnemonic(state.encryptedMnemonic, password);
1086
+ }
1087
+ catch {
1088
+ this.unlockedMnemonic = null;
1089
+ }
1090
+ }
1091
+ await this.persistUnlockedSession(privateKey);
1092
+ void this.notifyUnlockedOrigins(state);
807
1093
  return this.getPopupState();
808
1094
  }
809
1095
  async revealMnemonic(password) {
@@ -813,13 +1099,233 @@ export class WalletController {
813
1099
  }
814
1100
  return decryptMnemonic(state.encryptedMnemonic, password);
815
1101
  }
1102
+ async revealPrivateKey(password) {
1103
+ const state = this.requireStoredWallet(await this.loadWalletState());
1104
+ return decryptPrivateKey(state.encryptedPrivateKey, password);
1105
+ }
1106
+ async exportWallet(password) {
1107
+ const state = this.requireStoredWallet(await this.loadWalletState());
1108
+ const accounts = state.accounts ?? [
1109
+ { index: 0, publicKey: state.publicKey, encryptedPrivateKey: state.encryptedPrivateKey, name: "Account 1" }
1110
+ ];
1111
+ const backup = {
1112
+ version: 1,
1113
+ type: state.seedSource,
1114
+ accounts: accounts.map((a) => ({ index: a.index, name: a.name })),
1115
+ activeAccountIndex: state.activeAccountIndex ?? accounts[0]?.index ?? 0,
1116
+ activeNetworkId: state.activeNetworkId,
1117
+ networkPresets: state.networkPresets.filter((p) => !p.builtin),
1118
+ watchedAssets: state.watchedAssets
1119
+ };
1120
+ if (state.encryptedMnemonic) {
1121
+ backup.mnemonic = await decryptMnemonic(state.encryptedMnemonic, password);
1122
+ }
1123
+ else {
1124
+ backup.privateKey = await decryptPrivateKey(state.encryptedPrivateKey, password);
1125
+ }
1126
+ return backup;
1127
+ }
1128
+ async importWalletBackup(backup, password) {
1129
+ // Derive or use the provided private key
1130
+ let primaryKey;
1131
+ let mnemonic;
1132
+ if (backup.type === "mnemonic" && backup.mnemonic) {
1133
+ mnemonic = backup.mnemonic;
1134
+ primaryKey = await derivePrivateKeyFromMnemonic(mnemonic, 0);
1135
+ }
1136
+ else if (backup.privateKey) {
1137
+ primaryKey = backup.privateKey;
1138
+ }
1139
+ else {
1140
+ throw new Error("backup must contain a mnemonic or private key");
1141
+ }
1142
+ const encryptedMnemonic = mnemonic
1143
+ ? await encryptMnemonic(mnemonic, password)
1144
+ : undefined;
1145
+ // Build accounts list
1146
+ const accountEntries = backup.accounts ?? [{ index: 0, name: "Account 1" }];
1147
+ const accounts = [];
1148
+ const privateKeysByIndex = new Map();
1149
+ for (const entry of accountEntries) {
1150
+ const key = mnemonic
1151
+ ? await derivePrivateKeyFromMnemonic(mnemonic, entry.index)
1152
+ : primaryKey;
1153
+ privateKeysByIndex.set(entry.index, key);
1154
+ const acctSigner = new Ed25519Signer(key);
1155
+ accounts.push({
1156
+ index: entry.index,
1157
+ publicKey: acctSigner.address,
1158
+ encryptedPrivateKey: await encryptPrivateKey(key, password),
1159
+ name: entry.name
1160
+ });
1161
+ }
1162
+ const activeAccount = accounts.find((account) => account.index === backup.activeAccountIndex) ??
1163
+ accounts[0];
1164
+ if (!activeAccount) {
1165
+ throw new Error("backup must contain at least one account");
1166
+ }
1167
+ const activePrivateKey = privateKeysByIndex.get(activeAccount.index) ?? primaryKey;
1168
+ const signer = new Ed25519Signer(activePrivateKey);
1169
+ this.unlockedPrivateKey = activePrivateKey;
1170
+ this.unlockedSigner = signer;
1171
+ this.unlockedMnemonic = mnemonic ?? null;
1172
+ this.unlockedPassword = password;
1173
+ await this.persistUnlockedSession(activePrivateKey);
1174
+ await this.invalidatePendingRequests(new ProviderUnauthorizedError("wallet was replaced"));
1175
+ // Merge network presets
1176
+ const presets = [...DEFAULT_NETWORK_PRESETS];
1177
+ for (const p of backup.networkPresets ?? []) {
1178
+ if (!presets.some((existing) => existing.id === p.id)) {
1179
+ presets.push(p);
1180
+ }
1181
+ }
1182
+ const activePreset = presets.find((preset) => preset.id === backup.activeNetworkId) ??
1183
+ presets[0];
1184
+ if (!activePreset) {
1185
+ throw new Error("backup must contain at least one network preset");
1186
+ }
1187
+ const watchedAssets = backup.watchedAssets?.length
1188
+ ? backup.watchedAssets
1189
+ : [{ contract: "currency", name: "Xian", symbol: "XIAN" }];
1190
+ await this.persistWalletState({
1191
+ publicKey: activeAccount.publicKey,
1192
+ encryptedPrivateKey: activeAccount.encryptedPrivateKey,
1193
+ encryptedMnemonic,
1194
+ seedSource: backup.type,
1195
+ mnemonicWordCount: mnemonic ? mnemonic.split(" ").length : undefined,
1196
+ accounts,
1197
+ activeAccountIndex: activeAccount.index,
1198
+ rpcUrl: activePreset.rpcUrl,
1199
+ dashboardUrl: activePreset.dashboardUrl,
1200
+ activeNetworkId: activePreset.id,
1201
+ networkPresets: presets,
1202
+ watchedAssets,
1203
+ connectedOrigins: [],
1204
+ createdAt: new Date().toISOString()
1205
+ });
1206
+ return this.getPopupState();
1207
+ }
1208
+ async addAccount() {
1209
+ await this.restoreUnlockedSession();
1210
+ if (!this.unlockedMnemonic || !this.unlockedPassword) {
1211
+ throw new Error("wallet must be unlocked to add an account");
1212
+ }
1213
+ const state = this.requireStoredWallet(await this.loadWalletState());
1214
+ const accounts = state.accounts ?? [
1215
+ { index: 0, publicKey: state.publicKey, encryptedPrivateKey: state.encryptedPrivateKey, name: "Account 1" }
1216
+ ];
1217
+ const nextIndex = Math.max(...accounts.map((a) => a.index)) + 1;
1218
+ const privateKey = await derivePrivateKeyFromMnemonic(this.unlockedMnemonic, nextIndex);
1219
+ const signer = new Ed25519Signer(privateKey);
1220
+ const encrypted = await encryptPrivateKey(privateKey, this.unlockedPassword);
1221
+ accounts.push({
1222
+ index: nextIndex,
1223
+ publicKey: signer.address,
1224
+ encryptedPrivateKey: encrypted,
1225
+ name: `Account ${accounts.length + 1}`
1226
+ });
1227
+ state.publicKey = signer.address;
1228
+ state.encryptedPrivateKey = encrypted;
1229
+ state.activeAccountIndex = nextIndex;
1230
+ state.accounts = accounts;
1231
+ await this.store.saveState(state);
1232
+ this.unlockedPrivateKey = privateKey;
1233
+ this.unlockedSigner = signer;
1234
+ await this.persistUnlockedSession(privateKey);
1235
+ await this.emitSelectedAccountChangedForConnectedOrigins(state);
1236
+ return this.getPopupState();
1237
+ }
1238
+ async switchAccount(index) {
1239
+ const state = this.requireStoredWallet(await this.loadWalletState());
1240
+ const accounts = state.accounts ?? [
1241
+ { index: 0, publicKey: state.publicKey, encryptedPrivateKey: state.encryptedPrivateKey, name: "Account 1" }
1242
+ ];
1243
+ const target = accounts.find((a) => a.index === index);
1244
+ if (!target) {
1245
+ throw new Error("account not found");
1246
+ }
1247
+ // Update active account in state
1248
+ state.publicKey = target.publicKey;
1249
+ state.encryptedPrivateKey = target.encryptedPrivateKey;
1250
+ state.activeAccountIndex = index;
1251
+ await this.store.saveState(state);
1252
+ // If unlocked, switch the in-memory signer
1253
+ if (this.unlockedMnemonic) {
1254
+ const privateKey = await derivePrivateKeyFromMnemonic(this.unlockedMnemonic, index);
1255
+ this.unlockedPrivateKey = privateKey;
1256
+ this.unlockedSigner = new Ed25519Signer(privateKey);
1257
+ await this.persistUnlockedSession(privateKey);
1258
+ }
1259
+ else if (this.unlockedPrivateKey) {
1260
+ // No mnemonic in session — clear unlock (requires re-auth)
1261
+ await this.clearUnlockedSession();
1262
+ }
1263
+ await this.emitSelectedAccountChangedForConnectedOrigins(state);
1264
+ return this.getPopupState();
1265
+ }
1266
+ async renameAccount(index, name) {
1267
+ const state = this.requireStoredWallet(await this.loadWalletState());
1268
+ const accounts = state.accounts ?? [
1269
+ { index: 0, publicKey: state.publicKey, encryptedPrivateKey: state.encryptedPrivateKey, name: "Account 1" }
1270
+ ];
1271
+ const target = accounts.find((a) => a.index === index);
1272
+ if (!target) {
1273
+ throw new Error("account not found");
1274
+ }
1275
+ target.name = name;
1276
+ state.accounts = accounts;
1277
+ await this.store.saveState(state);
1278
+ return this.getPopupState();
1279
+ }
1280
+ async removeAccount(index) {
1281
+ const state = this.requireStoredWallet(await this.loadWalletState());
1282
+ if (index === 0) {
1283
+ throw new Error("cannot remove the primary account");
1284
+ }
1285
+ const accounts = state.accounts ?? [];
1286
+ const nextAccounts = accounts.filter((account) => account.index !== index);
1287
+ if (nextAccounts.length === 0) {
1288
+ throw new Error("cannot remove the last remaining account");
1289
+ }
1290
+ const removedActiveAccount = state.activeAccountIndex === index;
1291
+ state.accounts = nextAccounts;
1292
+ if (removedActiveAccount) {
1293
+ const nextActiveAccount = nextAccounts[0];
1294
+ state.publicKey = nextActiveAccount.publicKey;
1295
+ state.encryptedPrivateKey = nextActiveAccount.encryptedPrivateKey;
1296
+ state.activeAccountIndex = nextActiveAccount.index;
1297
+ if (this.unlockedMnemonic) {
1298
+ const privateKey = await derivePrivateKeyFromMnemonic(this.unlockedMnemonic, nextActiveAccount.index);
1299
+ this.unlockedPrivateKey = privateKey;
1300
+ this.unlockedSigner = new Ed25519Signer(privateKey);
1301
+ await this.persistUnlockedSession(privateKey);
1302
+ }
1303
+ else if (this.unlockedPrivateKey) {
1304
+ await this.clearUnlockedSession();
1305
+ }
1306
+ }
1307
+ await this.store.saveState(state);
1308
+ if (removedActiveAccount) {
1309
+ await this.emitSelectedAccountChangedForConnectedOrigins(state);
1310
+ }
1311
+ return this.getPopupState();
1312
+ }
816
1313
  async lockWallet() {
817
1314
  const state = await this.loadWalletState();
818
- this.unlockedPrivateKey = null;
819
- this.unlockedSigner = null;
1315
+ await this.clearUnlockedSession();
1316
+ if (state) {
1317
+ await Promise.all(state.connectedOrigins.map((origin) => this.emitDisconnectLifecycle(origin)));
1318
+ }
1319
+ return this.getPopupState();
1320
+ }
1321
+ async removeWallet() {
1322
+ const state = await this.loadWalletState();
1323
+ await this.clearUnlockedSession();
820
1324
  if (state) {
821
1325
  await Promise.all(state.connectedOrigins.map((origin) => this.emitDisconnectLifecycle(origin)));
822
1326
  }
1327
+ await this.invalidatePendingRequests(new ProviderUnauthorizedError("wallet was removed"));
1328
+ await this.store.clearState();
823
1329
  return this.getPopupState();
824
1330
  }
825
1331
  async updateSettings(input) {