区块链钱包开发(十八)—— 构建批准控制器(ApprovalController)

概述

approval-controller 负责管理所有需要用户审批的请求。它提供了一个统一的接口来处理各种类型的审批流程,包括交易签名、权限授予、连接请求等。

源码:github.com/MetaMask/co...

主要功能

  • 请求管理:添加、接受、拒绝审批请求
  • 流程控制:管理多步骤审批流程
  • 速率限制:防止重复请求和滥用
  • 结果处理:处理审批后的成功/失败结果
  • 状态管理:维护审批请求的状态和计数

架构图

graph TD subgraph "Requester" R["请求方(其他控制器/功能模块)"] --> M["RestrictedMessenger"] end M -->|add / addAndShow / has / accept / reject / updateRequestState / clear / startFlow / endFlow / setFlowLoadingText / showSuccess / showError| AC["ApprovalController"] subgraph "ApprovalController" S["State\n- pendingApprovals\n- pendingApprovalCount\n- approvalFlows"] I["内存索引\n- #approvals: id → callbacks\n- #origins: origin → (type → count)\n- typesExcludedFromRateLimiting"] AC --> S AC --> I AC --> UI UI["UI"] --> AC end subgraph "Add Flow" A1["参数校验\n#validateAddParams"] --> A2["限流检查\n(origin + type)"] A2 --> A3["缓存 Promise 解析器\n#approvals.set(id, { resolve, reject })"] A3 --> A4["计数 +1\n#addPendingApprovalOrigin"] A4 --> A5["落库到状态\n#addToStore → pendingApprovals / count"] end AC -->|add / addAndShow| A1 subgraph "Accept Flow" C1["定位请求与回调\nget(id), #getCallbacks"] --> C2["waitForResult?"] C2 -->|否| C3["立即 resolve 调用方"] C2 -->|是且expectsResult| C4["返回 resultCallbacks 给创建方"] C2 -->|是但不支持| C5["抛出 ApprovalRequestNoResultSupportError"] C3 --> C6["删除请求\n#delete(id)"] C4 --> C7["创建方完成后 success()/error()\n最终 resolve/reject"] C7 --> C6 end AC -->|accept| C1 subgraph "Reject Flow" R1["获取回调\n#getCallbacks"] --> R2["删除请求\n#delete(id)"] --> R3["reject Promise"] end AC -->|reject| R1 subgraph "Clear Flow" CL1["遍历所有 id: reject(id, error)"] --> CL2["清空 #origins"] CL2 --> CL3["重置 pendingApprovals / count"] end AC -->|clear| CL1 subgraph "Flow API" F1["startFlow(id?, loadingText?, show?)"] --> F2["setFlowLoadingText(id, text)"] F1 --> F3["success()/error() → #result()\n(内部发起结果页请求)"] F3 --> F4["endFlow(id)(栈顶校验,LIFO)"] end AC -->|startFlow / setFlowLoadingText / success / error / endFlow| F1

核心状态模型

控制器维护三部分状态,均通过 this.update 原子更新:

  • pendingApprovals: Record<string, ApprovalRequest>:所有待批请求(以 id 为 key)
  • pendingApprovalCount: number:待批总数(便于订阅 UI)
  • approvalFlows: ApprovalFlowState[]:流程栈,用于"成功/错误"等结果页展示

同时有两个内存结构(非持久化)用于控制行为:

  • #approvals: Map<string, ApprovalCallbacks>:每个请求的 Promise 解析器(resolve/reject)
  • #origins: Map<origin, Map<type, count>>:按来源与类型的限流计数

请求实体 ApprovalRequest 包含:

  • id:请求唯一标识
  • origin:来源(如 'metamask' 或 dapp host)
  • type:请求类型(字符串)
  • time:发起时间戳
  • requestData:附加的只读数据
  • requestState:可变状态(UI 可能更新)
  • expectsResult: boolean:请求方是否需要"延迟结果回传"(见下)

去重与限流策略

  • 默认:同一 origin 下,同一 type 同时只能有一个挂起的请求。再次发起会抛错:
    • 错误文案:Request of type '${type}' already pending for origin ${origin}. Please wait.
  • 例外:typesExcludedFromRateLimiting 列表中的类型不做限流(可并行多个)。

实现点:

  • 调用 add/addAndShowApprovalRequest 时,内部 #add 会检查 #origins;不符合策略直接抛错。
  • #addPendingApprovalOrigin / #delete 会递增/递减计数。

消息系统绑定

构造器会注册一组 action handler,供外界调用(例如扩展的脚本/其他控制器通过 messenger 调用):

  • :clearRequestsclear
  • :addRequestaddAndShowApprovalRequest(可选展示 UI),或 add
  • :hasRequesthas
  • :acceptRequestaccept
  • :rejectRequestreject
  • :updateRequestStateupdateRequestState
  • :startFlow / :endFlow / :setFlowLoadingText → 流程控制(成功/错误页)

请求全生命周期

  1. 发起
  • add(opts):仅创建请求并返回 Promise(创建方持有)
  • addAndShowApprovalRequest(opts):创建请求并触发 UI 显示(返回 Promise)
  • 这两者本质都调用了内部 #add,区别是是否立即 showApprovalRequest()(UI 侧展示)
  1. UI 展示
  • UI 订阅 pendingApprovalspendingApprovalCount 来渲染待批项
  • UI 点击"允许/拒绝"时调用 accept(id, value, options)reject(id, error)
  1. 结束
  • accept
    • 立即删除请求或延迟删除(取决于 options
    • 如果 options.waitForResult = true
      • 且请求的 expectsResult = true:创建方期望收到"延迟结果回调"
      • 控制器把一组 resultCallbackssuccess(error))通过 AddResult 回传给请求创建方
      • 创建方拿到回调后,在其后续业务完成时再回调,控制器才最终 resolve 外部 Promise
    • 如果 options.waitForResult = false:控制器立即 resolve 外部 Promise
  • reject:删除请求并 reject 外部 Promise
  • clear:将所有挂起请求全部 reject,常用于全局关闭/切换场景
  1. 结果页(可选)
  • startFlow({ id?, loadingText?, show? }) → 新开一个流程(默认会触发 UI 展示)
  • success(opts) / error(opts) → 展示结果页(本质是内部再发起一个 ORIGIN_METAMASK 的结果请求)
  • endFlow({ id }):结束流程(后进先出,校验 id 必须为栈顶流程)

关键方法解读

add

  • 校验参数合法性(#validateAddParams
  • 做限流校验(除非 type 允许并发)
  • 创建 Promise,缓存 resolve/reject 至 #approvals
  • 落库:#addToStore 更新 pendingApprovalspendingApprovalCount
  • addAndShowApprovalRequest 还会触发 showApprovalRequest(),让 UI 显示

返回值有两种形态:

  • expectsResult = truePromise<AddResult>,包含 resultCallbacks(用于延迟"二段确认")
  • 否则:Promise<unknown>,值为 accept 时传入的 value
js 复制代码
function addAndShowApprovalRequest(opts) -> Promise<unknown | AddResult> {
  const promise = #add(
    opts.origin,
    opts.type,
    opts.id,
    opts.requestData,
    opts.requestState,
    opts.expectsResult,
  )
  // 触发 UI 展示(由宿主传入)
  #showApprovalRequest()
  return promise
}

function add(opts) -> Promise<unknown | AddResult> {
  return #add(
    opts.origin,
    opts.type,
    opts.id,
    opts.requestData,
    opts.requestState,
    opts.expectsResult,
  )
}

function #add(origin, type, id = nanoid(), requestData?, requestState?, expectsResult?) {
  #validateAddParams(id, origin, type, requestData, requestState)

  // 限流:同一 origin+type 默认只能挂 1 个(除非该 type 在豁免列表)
  if (!typesExcludedFromRateLimiting.includes(type) && has({ origin, type })) {
    throw resourceUnavailable(`Request of type '${type}' already pending for origin ${origin}. Please wait.`)
  }

  return new Promise((resolve, reject) => {
    // 1) 记住 promise 的解析器
    #approvals.set(id, { resolve, reject })

    // 2) 计入限流索引
    #addPendingApprovalOrigin(origin, type)

    // 3) 落库到控制器状态,便于 UI 渲染
    #addToStore(id, origin, type, requestData, requestState, expectsResult)
  })
}

accept

  • 读取 ApprovalRequest 并获取该 id 对应的 Promise 解析器
  • 根据 options 决定是否先删除 pendingApprovals
  • waitForResult = true
    • approval.expectsResult !== true → 抛 ApprovalRequestNoResultSupportError
    • resultCallbacks 传回给创建方;等待创建方在后续业务结束后调用 success/error
    • 创建方回调后,accept 返回的 Promise 才最终 resolve
  • waitForResult = false:立即 resolve
  • 最后确保请求已删除(若之前未删)

常见用法:

  • "两阶段确认":发起方希望 UI 同意后还要"再等业务执行结果"(如发起链上交易后确认上链成功才真正完成)
js 复制代码
function accept(id, value?, options?: { waitForResult?, deleteAfterResult? }) -> Promise<{ value?: unknown }> {
  const approval = get(id)                       // 读取请求体(若不存在会抛错)
  const callbacks = #getCallbacks(id)            // 取出 promise 解析器
  let deleted = false

  // 若不需要等"二段结果",或不指定 deleteAfterResult -> 立即删除
  if (!options?.deleteAfterResult || !options?.waitForResult) {
    #delete(id)
    deleted = true
  }

  return new Promise((resolve, reject) => {
    const resultCallbacks = {                    // "二段结果"回调,由创建方在后续执行
      success: (finalVal?) => resolve({ value: finalVal }),
      error: (err) => reject(err),
    }

    // 需要等待结果,但请求未声明 expectsResult -> 抛不支持错误
    if (options?.waitForResult && !approval.expectsResult) {
      reject(new ApprovalRequestNoResultSupportError(id))
      return
    }

    // 生成返回给创建方的值:
    // - expectsResult=true:返回 { value, resultCallbacks }
    // - 否则直接返回 value
    const resolveValue = approval.expectsResult
      ? { value, resultCallbacks: (options?.waitForResult ? resultCallbacks : undefined) }
      : value

    // 通知创建方:UI 已同意(第一阶段)
    callbacks.resolve(resolveValue)

    // 不等待结果则立即 resolve 调用方
    if (!options?.waitForResult) {
      resolve({ value: undefined })
    }
  }).finally(() => {
    // 如果之前没删,这里兜底删除
    if (!deleted) #delete(id)
  })
}

reject

  • 删除请求并 reject 外部 Promise
js 复制代码
function reject(id, error) {
  const callbacks = #getCallbacks(id) // 若不存在抛 ApprovalRequestNotFoundError
  #delete(id)                         // 从状态与索引中删除
  callbacks.reject(error)             // 拒绝 promise
}

has / get / getApprovalCount / getTotalApprovalCount

  • has:查询某 id,或某 origin/type 组合是否存在待批
  • get:按 id 取请求体
  • getApprovalCount:按 origin、type 或二者组合统计
  • getTotalApprovalCount:全量统计
js 复制代码
function get(id) -> ApprovalRequest | undefined {
  return state.pendingApprovals[id]
}

function has({ id?, origin?, type? } = {}) -> boolean {
  if (id) return #approvals.has(id)
  if (origin && type) return Boolean(#origins.get(origin)?.get(type))
  if (origin) return #origins.has(origin)
  if (type) return Object.values(state.pendingApprovals).some(a => a.type === type)
  throw new Error('Must specify a valid combination of id, origin, and type.')
}

function getApprovalCount({ origin?, type? } = {}) -> number {
  if (!origin && !type) throw new Error('Must specify origin, type, or both.')
  if (origin && type) return #origins.get(origin)?.get(type) || 0
  if (origin) {
    // 累加该 origin 的全部 type 数量
    return sum(Array.from(#origins.get(origin)?.values() ?? []))
  }
  // 仅按 type 统计
  return count(Object.values(state.pendingApprovals).filter(a => a.type === type))
}

function getTotalApprovalCount() -> number {
  return state.pendingApprovalCount
}

流程(Flow)系统

Flow 是一个轻量的"页面流程栈",典型用于结果页(成功/错误)的展示与收尾。

  • startFlow({ id?, loadingText?, show? }):开启流程,默认 show=true 会触发 UI 显示
  • setFlowLoadingText({ id, loadingText }):更新提示文案
  • success(opts) / error(opts):展示成功/错误页(内部通过 #result 再发起一个结果型批准请求)
  • endFlow({ id }):结束流程。若传入 id 不是栈顶,会抛 EndInvalidFlowError,避免意外打断其他流程

注意:

  • Flow 与普通批准请求的生命周期层级相互独立,但常一起使用(比如一个交易批准→成功页)
js 复制代码
function startFlow({ id = nanoid(), loadingText = null, show = true } = {}) -> { id, loadingText } {
  update(draft => draft.approvalFlows.push({ id, loadingText }))
  if (show !== false) #showApprovalRequest()
  return { id, loadingText }
}

function endFlow({ id }) {
  if (state.approvalFlows.length === 0) throw new NoApprovalFlowsError()
  const current = state.approvalFlows[state.approvalFlows.length - 1]
  if (id !== current.id) throw new EndInvalidFlowError(id, state.approvalFlows.map(f => f.id))
  update(draft => { draft.approvalFlows.pop() })
}

function setFlowLoadingText({ id, loadingText }) {
  const idx = state.approvalFlows.findIndex(f => f.id === id)
  if (idx === -1) throw new MissingApprovalFlowError(id)
  update(draft => { draft.approvalFlows[idx].loadingText = loadingText })
}

推荐用法与最佳实践

  • 简单批准(一次性):

    1. 发起方:addAndShowApprovalRequest(或 add 并自行触发 UI)
    2. UI:根据 pendingApprovals 展示,调用 accept(id)reject(id)
    3. 发起方:await 返回值,继续后续逻辑
  • 两阶段批准(等待外部结果):

    1. 发起方:addAndShowApprovalRequest({ expectsResult: true }),得到 AddResult
    2. UI:accept(id, value, { waitForResult: true })
    3. 发起方:拿到 resultCallbacks 后执行业务;业务成功调用 resultCallbacks.success(data),失败调用 resultCallbacks.error(error)
  • 结果页:

    1. const { id } = startFlow({ loadingText: 'Processing...' })
    2. 成功:await success({ message: 'Done', flowToEnd: id });失败:await error({ error: 'Failed', flowToEnd: id })
  • 去重豁免:

    • 某些类型(如系统级提示)可加入 typesExcludedFromRateLimiting,允许同源并发请求
  • 清理与中断:

    • 场景切换(如网络切换/锁屏)可调用 clear(rpcErrors.internal('...')) 统一中断

端到端示例(两阶段批准)

发起方模块:

ts 复制代码
// 创建请求,要求"二段确认"
const addResult = await messenger.call('ApprovalController:addRequest', {
  origin: 'my-feature',
  type: 'do-something',
  requestData: { payload },
  expectsResult: true,
}, true /* shouldShowRequest */);

// UI 同意后,这里才会拿到 resultCallbacks。现在执行业务:
try {
  const result = await doHeavyWork(payload); // 比如链上操作
  addResult.resultCallbacks?.success(result); // 触发最终 resolve
} catch (e) {
  addResult.resultCallbacks?.error(e as Error); // 触发最终 reject
}

UI 模块:

ts 复制代码
// 展示 pendingApprovals,用户点"同意"
await messenger.call('ApprovalController:acceptRequest', id, { ok: true }, {
  waitForResult: true,        // 告诉控制器:外部要等"二段确认"
  deleteAfterResult: true,    // 二段完成后再删除
});
// 或者"拒绝"
messenger.call('ApprovalController:rejectRequest', id, rpcErrors.userRejectedRequest());

小结

  • ApprovalController 将"发起请求---展示---同意/拒绝---(可选)二段结果---结束"完整闭环收口为简洁一致的 API。
  • 通过 expectsResult/waitForResult 精准控制 Promise 的"何时 resolve",支持复杂链路的"二段确认"。
  • 内置去重限流、结果页流程栈、统一清理等能力,适用于浏览器扩展的多模块协作与复杂 UX 场景。

学习交流请添加vx: gh313061

下期预告:构建账户控制器(AccountsController)

相关推荐
辰九九36 分钟前
Uncaught URIError: URI malformed 报错如何解决?
前端·javascript·浏览器
小高0071 小时前
React useMemo 深度指南:原理、误区、实战与 2025 最佳实践
前端·javascript·react.js
LuckySusu1 小时前
【js篇】深入理解类数组对象及其转换为数组的多种方法
前端·javascript
LuckySusu1 小时前
【js篇】数组遍历的方法大全:前端开发中的高效迭代
前端·javascript
LuckySusu1 小时前
【js篇】for...in与 for...of 的区别:前端开发中的迭代器选择
前端·javascript
小高0072 小时前
协商缓存和强缓存
前端·javascript·面试
前端Hardy2 小时前
HTML&CSS&JS:超酷炫的一键登录页面
前端·javascript·css
该用户已不存在2 小时前
2025年,Javascript后端应该用 Bun、Node.js 还是 Deno?
javascript·后端
拭心3 小时前
一键生成 Android 适配不同分辨率尺寸的图片
android·开发语言·javascript