EVM签名流
这份 EVM 签名指南覆盖地址确认、交易签名、消息签名以及 EIP-712 Typed Data。核心目标是让应用提交的数据、设备屏幕展示内容,以及最终广播或验签结果彼此对得上。
目录
原理
EVM 相关方法通过示例中的 SDK 入口统一调用。应用准备路径、链 ID、交易或消息数据后,SDK 会把请求交给设备端审阅,用户确认后再返回签名结果。PIN、Passphrase、打开 App、确认地址、确认交易或消息等交互,都会通过 UI_REQUEST 事件通知应用。
EVM 签名流程可以概括为:
- 应用端准备路径、
chainId、交易字段或消息内容。 - SDK 建立会话,把待签名数据发送到 UKey Wallet。
- 设备展示地址、路径、金额、Gas、ChainId、消息摘要或 Typed Data 域。
- 用户在设备上确认后,SDK 返回
{ r, s, v }或完整十六进制签名。 - 应用端用
ethers、viem等工具完成序列化、验签或广播。
接入时请优先保证三件事:参数能被链工具库正确解析,设备显示内容与前端预期一致,返回签名可以被独立验签。
依赖准备
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 常用方法包括 evmGetAddress、evmSignTransaction、evmSignMessage 和 evmSignTypedData。所有方法都返回 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:
tovaluedatanoncegasLimitmaxFeePerGasmaxPriorityFeePerGaschainIdtype: 2 - Legacy gas price:
tovaluedatanoncegasLimitgasPricechainId
- EIP-1559:
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 结构,需包含domain、types、primaryType和message。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(...) 恢复签名者
验签与排障
| 场景 | 建议校验方式 |
|---|---|
| 交易签名 | 用 ethers 或 viem 按原交易字段重新序列化,再核对签名哈希是否与设备展示内容对应。 |
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 交易需要 maxFeePerGas 与 maxPriorityFeePerGas,旧版交易则只传 gasPrice。 |
| 设备端等待过久 | 同一设备不要并发发起多笔请求;连续操作可以复用 keepSession,减少重复 PIN 或密码短语交互。 |
| 用户拒签或超时 | 给用户明确的重试和取消入口,不要在应用里静默自动重播同一笔请求。 |
继续查看具体 API:evmSignTransaction · evmSignMessage · evmSignTypedData。