区块链钱包开发(十一)—— 构建安全高可用的钱包数据持久化策略

为什么需要持久化

数据持久化的作用,是确保用户的重要数据在浏览器关闭、扩展重启、系统重启等情况下不会丢失,下次打开还能恢复之前的状态,例如:

KeyringController(密钥管理器)

  • 助记词加密后的 vault
  • 账户列表
  • 加密私钥

PreferencesController

  • 用户设置(如界面主题、语言、常用地址等)

NetworkController

  • 自定义网络配置
  • 当前选中的网络

TokensController

  • 用户添加的自定义代币列表

等等这些数据需要持久化。

持久化的实现

在每个 controller 的 metadata 字段里,会用 persist: true/false 或函数来标记哪些字段需要持久化。例如:

js 复制代码
const metadata = {
  vault: { persist: true}, // 需要持久化
  sessionToken: { persist: false}, // 不持久化
  uiPopupOpen: { persist: false}, // 不持久化
};

持久化的安全性实现

每当控制器的状态发生变化,如果是需要持久化的状态,都会触发持久化

js 复制代码
//background.js

  const { update, requestSafeReload } = getRequestSafeReload(persistenceManager);
  // 控制器状态变化时调用update方法持久化状态
  controller.store.on('update', update);

update方法是getRequestSafeReload方法返回的,我们来看一下getRequestSafeReload的实现:

js 复制代码
export function getRequestSafeReload(persistenceManager: PersistenceManager) {
  // 安全操作类
  const operationSafener = new OperationSafener({
    op: async (state: MetaMaskStateType) => {
      // 真正的更新存储操作
      await persistenceManager.set(state);
    },
    wait: 1000,
  });

  return {
    // 更新存储数据函数
    update: async (...params: Parameters<PersistenceManager['set']>) => {
      return operationSafener.execute(...params);
    }
  };
}

operationSafener对真正的更新存储函数做了包装,主要是为了防抖与并发安全机制,确保写入安全。具体实现如下:

js 复制代码
// 任意参数类型
type AnyParams = any[];

// 通用操作类型,支持同步或异步
export type Op = (...params: AnyParams) => unknown | Promise<unknown>;

// 配置类型,包含操作函数、等待时间、防抖选项
type Config<O extends Op> = {
  op: O; // 需要被安全执行的操作
  wait?: number; // 防抖等待时间,单位ms,默认0
  options?: DebounceSettings; // lodash防抖配置
};

// 操作安全器,保证高频操作安全、串行、防抖
export class OperationSafener<O extends Op = Op> {
  // 防抖包装后的操作函数,确保不会频繁执行
  #bouncer: DebouncedFunc<(...params: Parameters<O>) => Promise<ReturnType<O>>>;

  // 标记是否正在疏散(即不再接受新操作)
  #evacuating: Promise<void> | null = null;

  // 构造函数,初始化防抖操作和锁
  constructor({ op, wait, options }: Config<O>) {
    const opts: LockOptions = { mode: 'exclusive' };
    // 包装操作,确保同一时刻只有一个操作在执行
    const func = (...params: Parameters<O>) =>
      navigator.locks.request('operation-safener-lock', opts, () =>
        op(...params),
      );
    // 使用lodash防抖,合并高频操作
    this.#bouncer = debounce(func, wait, options);
  }

  // 执行操作(防抖),如果正在疏散则忽略
  execute = (...params: Parameters<O>) => {
    if (this.#evacuating) {
      log.warn('evacuating, ignoring call to `execute`');
      return false;
    }
    this.#bouncer(...params);
    return true;
  };
}
  • 利用debounce防抖机制,可以把频繁的数据更新操作合并,会在操作"停下来"一定时间(默认1s)后写入一次,避免了高频的数据更新操作对性能的影响,因为每次更新都会涉及到写入本地存储(localStorage、IndexedDB)会严重影响性能,甚至导致浏览器卡顿。

  • 还利用navigator.locks锁机制保证写操作的原子性,比如在恢复备份或做数据迁移时,必须保证迁移过程中的写入是独占的,不能被其他写入打断,否则可能导致迁移失败或数据不一致。

持久化的高可用实现

使用主(浏览器本地存储browser.storage.local)+ 备(indexedDB)数据库的方式保证数据的高可用:

1. 浏览器本地存储并不绝对可靠

  • 浏览器升级、异常关闭、扩展崩溃、用户清理缓存等操作,可能导致本地存储数据丢失或损坏。
  • 某些浏览器策略或第三方清理工具也可能误删本地存储。

2. IndexedDB 更适合做持久化备份

  • IndexedDB 是浏览器内置的高容量、结构化数据存储方案,适合存储较大或结构化的数据。
  • 它不容易被普通的"清理缓存"操作误删,且有更好的事务和数据一致性保障。

3. 主备结合,极大提升了数据恢复能力

  • 主库(localStore):用于日常读写,速度快,兼容性好。
  • 备库(IndexedDB):定期或每次关键数据变更时同步备份,只保存最关键的数据(如密钥和元数据)。
  • 当主库数据丢失或损坏时,可以检测到并提示用户用备份恢复,最大程度保障用户资产安全。
js 复制代码
/**
 * 备份对象类型定义,只包含需要持久化的关键控制器和元数据
 */
export type Backup = {
  KeyringController?: unknown;
  AppMetadataController?: unknown;
  meta?: MetaData;
};

/**
 * 需要备份的状态键名
 */
export const backedUpStateKeys = [
  'KeyringController',
  'AppMetadataController',
] as const;

/**
 * 持久化相关错误类型,包含错误发生时的备份数据
 */
export class PersistenceError extends Error {
  backup: object | null;

  constructor(message: string, backup: object | null) {
    super(message);
    this.name = 'PersistenceError';
    this.backup = backup;
  }
}

/**
 * 从 MetaMask 状态对象中提取需要备份的部分
 */
function makeBackup(state: MetaMaskStateType, meta: MetaData): Backup {
  return {
    KeyringController: state?.KeyringController,
    AppMetadataController: state?.AppMetadataController,
    meta,
  };
}

/**
 * 判断 state 是否包含 vault 字段,用于判断是否有有效的金库
 */
function hasVault(
  state?: MetaMaskStateType,
): state is { KeyringController: RuntimeObject & Record<'vault', unknown> } {
  const keyringController = state?.KeyringController;
  return (
    isObject(keyringController) &&
    hasProperty(keyringController, 'vault') &&
    Boolean(keyringController?.vault)
  );
}

const STATE_LOCK = 'state-lock';

/**
 * 持久化管理器,仅保留 get 和 set 方法及其相关依赖
 */
export class PersistenceManager {
  // 标记数据持久化是否失败
  #dataPersistenceFailing: boolean = false;
  // 当前元数据对象
  #metadata?: MetaData;
  // 扩展是否初始化
  #isExtensionInitialized: boolean = false;
  // 本地存储实例
  #localStore: BaseStore;
  // 备份数据库实例
  #backupDb: IndexedDBStore;
  // 备份内容字符串
  #backup?: string;
  // 数据库是否已打开
  #open: boolean = false;
  // 当前挂起的写入操作
  #pendingState: void | AbortController = undefined;

  constructor({ localStore }: { localStore: BaseStore }) {
    this.#localStore = localStore;
    this.#backupDb = new IndexedDBStore();
  }

  /**
   * 打开备份数据库
   */
  async open() {
    if (!this.#open) {
      await this.#backupDb.open('metamask-backup', 1);
      this.#open = true;
    }
  }

  /**
   * 设置元数据
   */
  setMetadata(metadata: MetaData) {
    this.#metadata = metadata;
  }
  
  function hasVault(
  state?: MetaMaskStateType,
): state is { KeyringController: RuntimeObject & Record<'vault', unknown> } {
  const keyringController = state?.KeyringController;
  return (
    isObject(keyringController) &&
    hasProperty(keyringController, 'vault') &&
    Boolean(keyringController?.vault)
  );
}

  /**
   * 设置本地存储状态,并自动备份
   */
  async set(state: MetaMaskStateType) {
    await this.open();
    const abortController = new AbortController();
    // 如果已有挂起写入,则中止,保证只写入最新状态
    this.#pendingState?.abort();
    this.#pendingState = abortController;

    await navigator.locks.request(
      STATE_LOCK,
      { mode: 'exclusive', signal: abortController.signal },
      async () => {
        this.#pendingState = undefined;
        try {
          // 原子性写入所有键
          await this.#localStore.set({
            data: state,
            meta,
          });

          const backup = makeBackup(state, meta);
          // 有 vault 时才备份
          if (hasVault(backup)) {
            const stringifiedBackup = JSON.stringify(backup);
            if (this.#backup !== stringifiedBackup) {
              await this.#backupDb.set(backup);
              this.#backup = stringifiedBackup;
            }
          }

          if (this.#dataPersistenceFailing) {
            this.#dataPersistenceFailing = false;
          }
        } catch (err) {
          if (!this.#dataPersistenceFailing) {
            this.#dataPersistenceFailing = true;
            captureException(err);
          }
          log.error('error setting state in local store:', err);
        } finally {
          this.#isExtensionInitialized = true;
        }
      },
    );
  }

  /**
   * 获取当前本地存储状态
   * @param options.validateVault 是否校验 vault
   * @returns 当前状态或 undefined
   * @throws PersistenceError 如果缺少 vault 但备份中存在
   */
  async get(): Promise<MetaMaskStorageStructure | undefined> {
    await this.open();

    return await navigator.locks.request(
      STATE_LOCK,
      { mode: 'shared' },
      async () => {
        const result = await this.#localStore.get();
        if (!hasVault(result?.data)) {
          const backup = await this.getBackup();
          if (Object.values(backup).some((value) => value !== undefined)) {
            throw new PersistenceError(MISSING_VAULT_ERROR, backup);
          }
        }
 
        return result;
      },
    );
  }

  /**
   * 获取备份对象
   */
  async getBackup(): Promise<Backup> {
    const [KeyringController, AppMetadataController, meta] =
      await this.#backupDb.get([...backedUpStateKeys, `meta`]);
    return {
      KeyringController,
      AppMetadataController,
      meta: meta as MetaData | undefined,
    };
  }
}

set方法

锁 + AbortController

这里我们使用了非常巧妙的方法既保证了写入的原子性,又保证了写入队列长度不超过1,只保留最新的写入请求在队列即可,因为中间的历史状态没有意义。这样做避免了频繁写入对性能的影响。

  • 正在进行的写入(已获得锁,正在执行)------无法被中断

    一旦进入 navigator.locks.request 的回调,已经获得锁,这个写入会一直执行到结束(除非代码内部主动检测 signal 并中断,但本例没有)。 此时调用 abort() 只会让后续等待锁的请求被取消,不会影响当前已在执行的写入。

  • 正在排队的写入(等待获得锁)------可以被取消

    如果有一个写入请求正在等待锁(即排队中),这时又有新的写入请求到来,旧的排队请求会被 abort() 取消掉。 新的写入请求会用自己的 AbortController 进入队列,成为唯一排队的写入。

  • 只保证最多有一个"最新的"写入请求在队列中

    任何时候,除了正在执行的写入,队列里最多只会有一个最新的写入请求。这样可以避免无意义的多次写入排队,始终只保留最新的状态写入。

场景举例

  • A 写入:正在执行(已获得锁)
  • B 写入:正在排队(等待锁)
  • C 写入:新到来
    此时,B 会被取消,C 进入队列。A 执行完后,C 会获得锁并执行,B 永远不会执行。

主备结合

我们会把所有需要持久化的数据写入到浏览器的本地存储(storage.local),对于备份存储(IndexedDB),我们进行条件备份:

  • 只备份核心钱包密钥数据KeyringController和应用元数据AppMetadataController
  • 并且具有有效的用户密钥(vault)数据,如果没有则表示用户还没有创建钱包或数据损坏,这种情况不需要备份

get方法

如果在localstorage获取的状态没有有效的 vault:

  • 会去 IndexedDB 备份库查找是否有备份数据。

  • 如果备份库有数据,说明用户曾经创建过钱包但主库丢失了,这时抛出 PersistenceError,并带上备份数据,提示前端可以引导用户恢复。

  • 如果备份库也没有数据,则说明用户本来就没创建过钱包,流程继续。

源码

本章涉及到的源码:

github.com/MetaMask/me...

github.com/MetaMask/me...

github.com/MetaMask/me...

github.com/MetaMask/me...

github.com/MetaMask/me...

github.com/MetaMask/co...

学习交流请添加vx: gh313061

本教程配套视频教程:space.bilibili.com/382494787/l...

下期预告:前后端状态同步机制

相关推荐
ithadoop3 小时前
Solidity智能合约开发全攻略
区块链·智能合约
allenXer3 小时前
Flask全栈入门:打造区块链艺术品交易所
python·flask·区块链
加速财经6 小时前
WEEX从注册到首单:新手入门完整操作流程指南
区块链
不可描述的两脚兽7 小时前
学习笔记《区块链技术与应用》第六天 问答 匿名技术 零知识证明
笔记·学习·区块链
运维开发王义杰8 小时前
Ethereum:拥抱开源,OpenZeppelin 未来的两大基石 Relayers 与 Monitor
开源·web3·区块链·智能合约
清霜之辰8 小时前
Android 区块链 + CleanArchitecture + MVI 架构实践
android·架构·区块链·mvi·architecture·clean
余_弦11 小时前
区块链钱包开发(十)—— 构建主控制器metamask-controller.js
区块链
数据与人工智能律师1 天前
智能合约漏洞导致的损失,法律责任应如何分配
大数据·网络·人工智能·算法·区块链
Amore05251 天前
Web3合约ABI,合约地址生成部署调用及创建,连接钱包,基础交易流程
web3·区块链·ethers