跳到主要内容

EVM签名流

这份 EVM 签名指南覆盖地址确认、交易签名、消息签名以及 EIP-712 Typed Data。核心目标是让应用提交的数据、设备屏幕展示内容,以及最终广播或验签结果彼此对得上。

目录

  1. 原理
  2. 依赖准备
  3. 启动连接
  4. 常用场景
  5. 交互流程
  6. 代码示范
  7. 验签与排障

原理

EVM 相关方法通过示例中的 SDK 入口统一调用。应用准备路径、链 ID、交易或消息数据后,SDK 会把请求交给设备端审阅,用户确认后再返回签名结果。PIN、Passphrase、打开 App、确认地址、确认交易或消息等交互,都会通过 UI_REQUEST 事件通知应用。

EVM 签名流程可以概括为:

  1. 应用端准备路径、chainId、交易字段或消息内容。
  2. SDK 建立会话,把待签名数据发送到 UKey Wallet。
  3. 设备展示地址、路径、金额、Gas、ChainId、消息摘要或 Typed Data 域。
  4. 用户在设备上确认后,SDK 返回 { r, s, v } 或完整十六进制签名。
  5. 应用端用 ethersviem 等工具完成序列化、验签或广播。

接入时请优先保证三件事:参数能被链工具库正确解析,设备显示内容与前端预期一致,返回签名可以被独立验签。

依赖准备

EVM 签名依赖连接层和核心事件定义。请先完成传输接入,再处理签名请求。

npm install @ukeyfe/hardware-core @ukeyfe/hardware-common-connect-sdk

启动连接

import ukeySdk from "@ukeyfe/hardware-common-connect-sdk";
// 也可以直接改用 connect-sdk 封装好的 ukeySdk

await ukeySdk.init({ env: "webusb", debug: false, fetchConfig: true });

const [{ connectId }] = await ukeySdk.searchDevices();
const deviceId = (await ukeySdk.getFeatures(connectId)).payload?.device_id;

签名前建议先用 evmGetAddress(showOnUKey: true) 让用户在设备上核对地址,避免路径或账户选择错误。

常用场景

EVM 常用方法包括 evmGetAddressevmSignTransactionevmSignMessageevmSignTypedData。所有方法都返回 Promise,并通过 { success, payload } 表示结果。


用例 1:读取地址

读取指定路径下的 EVM 地址。涉及收款、账户绑定或首次使用时,建议开启设备端展示。

const sampleAccountPath = ["m", "44'", "60'", "3'", "0", "1"].join("/");
const opResult = await ukeySdk.evmGetAddress(connectId, deviceId, {
path: sampleAccountPath,
showOnUKey: true,
chainId: 1,
});
// 返回字段参考片段:opResult.payload.address, publicKey?, chainCode?

入参清单

  • path (string | number[]): 用来定位 EVM 账户的派生路径,例如 "44'/60'/2'/0/0"
  • showOnUKey? (boolean): 需要让用户在 UKey Wallet 上核对地址时开启。
  • chainId? (number): 展示给设备端的网络 ID,建议传入真实目标链。

返回

Promise<{ success; payload: { address; path; publicKey?; chainCode? } }>;
type GetAddressResult = {
address: `0x${string}`;
path: string;
publicKey?: string;
chainCode?: string;
};

用例 2:交易签名

支持 EIP‑1559 (type: 2) 与旧版交易,需传入序列化前的字段。除 chainId 外,数值字段必须为 0x 十六进制字符串(SDK 会去掉前导 0)。

const { success, payload } = await ukeySdk.evmSignTransaction(
connectId,
deviceId,
{
path: sampleAccountPath,
transaction: tx,
keepSession: true,
},
);
// 返回对象形如 { v, r, s, authorizationSignatures? }

入参清单

  • path (string | number[]): 本次签名所使用账户的派生路径。
  • transaction (object): 待签交易对象,按交易类型传入对应字段:
    • EIP-1559:to value data nonce gasLimit maxFeePerGas maxPriorityFeePerGas chainId type: 2
    • Legacy gas price:to value data nonce gasLimit gasPrice chainId
  • keepSession? (boolean): 连续签名时保留当前设备会话,减少重复确认。
  • domain? (string): 可展示的人类可读交易标签,例如 ENS 名称;设备支持时会显示。

返回

Promise<{ success; payload: { v; r; s } }>;

可配合 ethers/viem 序列化为 raw tx。


用例 3:消息签名(personal_sign)

const opResult = await ukeySdk.evmSignMessage(connectId, deviceId, {
path: sampleAccountPath,
messageHex,
chainId: 1,
});

入参清单

  • path:用于产生消息签名的账户路径。
  • messageHex:消息正文的十六进制编码,带不带 0x 都可以。
  • chainId?:仅用于设备端上下文展示的链 ID。

返回

返回值可能是完整的十六进制签名,也可能拆成 { r, s, v } 三段;验签时可交给 ethers.verifyMessage 处理。


用例 4:TypedData 签名(EIP-712)

const opResult = await ukeySdk.evmSignTypedData(connectId, deviceId, {
path: sampleAccountPath,
data: typedData,
chainId: 1,
});

入参清单

  • path:执行 Typed Data 签名的账户路径。
  • data:EIP-712 v4 结构,需包含 domaintypesprimaryTypemessage
  • chainId:网络标识,需要与 typed-data domain 保持一致。

Typed Data 可以按下面的对象骨架组织:

interface TypedData {
domain: {
name?: string;
version?: string;
chainId?: number;
verifyingContract?: string;
salt?: string;
};
types: Record<string, Array<{ name: string; type: string }>>;
primaryType: string;
message: Record<string, unknown>;
}

返回

Promise<{ success; payload: { signature } }>;

验签时可以把返回的签名交给 ethers.verifyTypedData 恢复签名地址。

交互流程

关注点接入建议
请求结果SDK 方法最终仍通过 Promise 返回;解锁设备、打开 EVM App、确认地址/交易/消息/Typed Data/授权等中间步骤,会以 UI_REQUEST 事件交给应用处理。
调用步骤面向同一台设备时保持串行调用;需要连续签名时,再用 keepSession 降低重复 PIN 或密码短语确认的次数。
UI 处理应用界面需要订阅 UI_REQUEST,把用户引导到对应操作,同时保留取消和重新发起的入口。

代码示范

参考写法:1559签交易

import ukeySdk from "@ukeyfe/hardware-common-connect-sdk";
import { UI_REQUEST, UI_RESPONSE } from "@ukeyfe/hardware-core";
import { serialize, TransactionTypes } from "@ethersproject/transactions";

const [{ connectId }] = await ukeySdk.searchDevices();
const deviceId = (await ukeySdk.getFeatures(connectId)).payload?.device_id;

ukeySdk.on(UI_REQUEST.REQUEST_PIN, () => {
// 提示用户在设备填写 PIN,或在自定义 UI 收集后调用 uiResponse
});

await ukeySdk.evmGetAddress(connectId, deviceId, {
path: sampleAccountPath,
showOnUKey: true,
});

const tx = {
to: "0x2b4d6f8091a3c5e7f9b1d3f507192b4d6f8091a3",
value: "0x2386f26fc10000",
data: "0x",
nonce: "0x2",
gasLimit: "0x5dc0",
maxFeePerGas: "0x4a817c800",
maxPriorityFeePerGas: "0x77359400",
chainId: 1,
};

const { success, payload: sig } = await ukeySdk.evmSignTransaction(
connectId,
deviceId,
{
path: sampleAccountPath,
transaction: tx,
keepSession: true,
},
);

const rawTx = serialize(
{ ...tx, type: TransactionTypes.eip1559 },
{
r: sig.r,
s: sig.s,
v: Number(sig.v),
},
);

// 广播阶段可以走 RPC,比如 ethers.js 的 provider.sendTransaction(rawTx)

参考写法:旧版签交易

const legacyTx = {
to: "0xc4d2e1f0a9876543210fedcba9876543210abcde",
value: "0x2386f26fc10000", // 金额参考值:0.01 ETH
data: "0x",
nonce: "0x1",
gasLimit: "0x5208",
gasPrice: "0x3b9aca00", // 参考 gas 单价:1 gwei
chainId: 1,
};

const signatureData = await ukeySdk.evmSignTransaction(connectId, deviceId, {
path: sampleAccountPath,
transaction: legacyTx,
});

参考写法:消息签署

const message = "EVM 示例签名消息";
const messageHex = Buffer.from(message).toString("hex");

const opResult = await ukeySdk.evmSignMessage(connectId, deviceId, {
path: sampleAccountPath,
messageHex,
chainId: 1,
});
// 参考代码:opResult.payload.signature -> 用 ethers.verifyMessage 校验

参考写法:Typed签署

const typedData = {
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Mail: [
{ name: "from", type: "Person" },
{ name: "to", type: "Person" },
{ name: "contents", type: "string" },
],
Person: [
{ name: "name", type: "string" },
{ name: "wallet", type: "address" },
],
},
primaryType: "Mail",
domain: {
name: "UKey Wallet 示例邮件",
version: "1",
chainId: 1,
verifyingContract: "0x2b4d6f8091a3c5e7f9b1d3f507192b4d6f8091a3",
},
message: {
from: { name: "Ava", wallet: "0x4b6d8f0123456789abcdef0123456789abcdef01" },
to: { name: "Liam", wallet: "0xc4d2e1f0a9876543210fedcba9876543210abcde" },
contents: "示例类型化数据负载",
},
};

const opResult = await ukeySdk.evmSignTypedData(connectId, deviceId, {
path: sampleAccountPath,
data: typedData,
chainId: 1,
});
// 参考代码:opResult.payload.signature -> 用 ethers.verifyTypedData(...) 恢复签名者

验签与排障

场景建议校验方式
交易签名ethersviem 按原交易字段重新序列化,再核对签名哈希是否与设备展示内容对应。
personal_sign调用 ethers.verifyMessage(message, sig.signature) 恢复地址并与预期账户比较。
EIP-712使用 ethers.verifyTypedData(domain, types, message, sig.signature) 验证结构化数据签名。
问题类别处理方向
数据过大或字段异常设备可能拒绝超大 data 或非预期字段;签名前先在应用侧做长度和格式核验。
网络环境不一致chainId 要和最终广播的网络环境保持一致,否则签名结果可能无法按预期上链。
路径说明选错默认路径说明是 m/44'/60'/6'/0/2,后续账户/地址按索引递增;签名前最好先做地址确认。
Gas 参数不完整EIP-1559 交易需要 maxFeePerGasmaxPriorityFeePerGas,旧版交易则只传 gasPrice
设备端等待过久同一设备不要并发发起多笔请求;连续操作可以复用 keepSession,减少重复 PIN 或密码短语交互。
用户拒签或超时给用户明确的重试和取消入口,不要在应用里静默自动重播同一笔请求。

继续查看具体 API:evmSignTransaction · evmSignMessage · evmSignTypedData