本页目录
EVM Signer
文档类型:接入指南 · 适用读者:SDK 接入开发者 · 内容状态:当前实现
代码范围:EVM 签名接口与 Pro2/Neo 固件 · 最后代码核验:2026-09-15
前置阅读:请求基础
EVM 硬件签名全链路指南,基于 @onekeyfe/hd-core 的能力,覆盖地址确认、EIP‑1559 / Legacy 交易、personal_sign、EIP‑712 等场景。
目录
工作原理
HardwareSDK 与 OneKey 固件通信,接口返回 Promise<{ success, payload }>。订阅 UI_EVENT 并按机型与传输方式处理相应的 UI_REQUEST 提示,参见请求基础。
应用构造请求后,SDK 将签名数据发送给设备。设备解析支持的格式并展示确认页面;可展示的详情取决于请求和固件,参见 Clear Signing。用户确认后返回签名,应用负责验签及交易的序列化和广播。
Clear Signing
Clear Signing(明文签名)让用户在硬件钱包上核对签名请求的含义:资产转给谁、哪个合约获得授权,以及正在签署哪些结构化内容。SDK 负责传输交易或 Typed Data,设备固件解析支持的格式并请求用户确认。
继续使用 evmSignTransaction 和 evmSignTypedData,无需单独的 Clear Signing 包,也没有 clearSigning 开关。公开交易接口不接受应用自行提供的代币元数据、合约标签或 domain 显示标签。
请求类型与核对内容
下表描述当前 SDK 接入方式及 Pro2/Neo 固件实现。页面布局、代币元数据和可识别格式取决于机型与固件,不代表所有机型或任意合约都有相同的展示能力。
| 请求 | 如何传参 | 支持该格式的设备上应核对什么 |
|---|---|---|
| 原生币 Transfer | evmSignTransaction:transaction.to 为收款人,value 为金额,data 为空 | 收款地址、金额、网络、手续费 |
| ERC-20 Transfer | to 为代币合约,原生币 value 为零,data 为 ABI 编码的 transfer(address,uint256) | 代币合约、实际收款人、代币金额、手续费 |
| Approve / Revoke | to 为代币合约,原生币 value 为零,data 为 ABI 编码的 approve(address,uint256);额度为 0 表示撤销授权 | Spender、代币合约、授权额度或撤销操作、手续费;最大 uint256 表示无限授权 |
| Token / Contract | 传入真实合约地址、Chain ID、金额及完整 calldata | 已知代币元数据和支持解析的字段;未知资产或调用可能显示地址、原始整数金额和 calldata |
| EIP-712 | evmSignTypedData,提供完整的 types、primaryType、domain、message | 解析签名流程中的域和结构化字段;参见哈希签名限制 |
| Safe Tx | evmSignTypedData,使用标准 SafeTx schema | Safe 地址、网络、目标地址、金额、操作类型、Nonce、Gas/退款字段及交易数据 |
转账与授权接入
ERC-20 转账的 transaction.to 是代币合约,实际收款人编码在 calldata 中。授权时编码的地址是 spender,不是转账收款人。金额使用代币最小单位的整数;这些 ERC-20 调用的原生币 value 为零。
以下片段假设已完成 SDK 和钱包初始化,使用 ethers v6。签名前请替换示例地址,并按实际账户和网络获取 Nonce、Gas Limit 与费率。
import { Interface } from 'ethers';
const tokenContract = '0x1111111111111111111111111111111111111111';
const spender = '0x2222222222222222222222222222222222222222';
const erc20 = new Interface(['function approve(address spender, uint256 amount)']);
const result = await HardwareSDK.evmSignTransaction(connectId, deviceId, {
useEmptyPassphrase: true,
path: "m/44'/60'/0'/0/0",
transaction: {
to: tokenContract,
value: '0x0',
data: erc20.encodeFunctionData('approve', [spender, 0n]), // Revoke
chainId: 1,
nonce: '0x0',
gasLimit: '0x186a0',
maxFeePerGas: '0x77359400',
maxPriorityFeePerGas: '0x3b9aca00',
},
});
if (!result.success) throw new Error(result.payload.error);将额度改为正整数即可授予对应额度。不要把用户指定的有限额度替换为无限授权。此示例只返回签名,应用仍需完成验签、序列化和广播。
代币与合约识别
识别依据是 Chain ID、合约地址与 calldata。熟悉的代币符号或服务商名称只是辅助信息,用户仍需核对实际地址。Pro2/Neo 对未知 ERC-20 代币使用通用代币标签和原始整数金额,因为无法确定其精度。应用自己的代币列表不会通过这些签名接口成为设备显示元数据。
签名成功不代表每个合约调用都已解析。未支持的调用可能展示原始 calldata,而不是可读的操作名称。Clear Signing 也不负责判断合约是否可信或预测执行结果。
EIP-712 与 Safe Tx
始终传入完整 Typed Data 和匹配的 metamaskV4Compat(v4 使用 true)。解析签名允许设备请求并展示结构化字段。设备或数据需要哈希签名时,无法提供同等的结构化审阅;提供哈希也不会强制启用 Clear Signing。具体退回条件见 evmSignTypedData。
Safe 使用标准 SafeTx 主类型,domain.chainId 和 domain.verifyingContract 分别为 Safe 所在网络和 Safe 地址,并提供全部十个交易字段。Pro2/Neo 实现使用 Safe v1.3.0 的域和交易 schema,不能把任意自定义结构命名为 SafeTx 后替代使用。参见 Safe Tx 示例。
返回的签名用于授权一笔 Safe 交易;收集多个 owner 的签名及提交 execTransaction 是应用后续的独立步骤。签署链上的 Safe approveHash 或 execTransaction 调用则使用 evmSignTransaction,支持的固件可以识别这些调用,但单独的 approveHash 请求无法展示该哈希所代表的交易内容。
接入验证
- 在设备上确认账户,签名使用相同的钱包上下文,并确保 Chain ID 与目标网络、EIP-712 域一致。
- 对每种支持的机型与固件验证原生币转账、代币转账、有限授权、撤销授权、EIP-712 和 Safe Tx;同时覆盖未知代币或合约及需要哈希签名的数据。
- 核对真实设备屏幕。UI 事件和
success: true都不能证明每个字段已解析,返回值也不包含 Clear Signing 状态字段。 - 使用签名前先检查
success,针对原始请求恢复并校验签名者,序列化时保留全部已签字段。用户拒签或数据不受支持时应明确提示,不要自动改写请求。
安装
本模块依赖
@onekeyfe/hd-core及连接层(如@onekeyfe/hd-common-connect-sdk),请先完成基础 SDK 安装。
npm i @onekeyfe/hd-core @onekeyfe/hd-common-connect-sdk初始化
示例统一使用标准钱包。隐藏钱包应在每次地址和签名调用中,以经过确认的 passphraseState 替代 useEmptyPassphrase: true。受保护调用前先注册 UI 处理器,使用返回数据前检查 success。含待提供交易参数的代码是接入片段,应用仍需实现 UI、设备选择与网络数据获取。
import HardwareSDK from '@onekeyfe/hd-common-connect-sdk';
const initialized = await HardwareSDK.init({ env: 'webusb', fetchConfig: true, debug: false });
if (!initialized) throw new Error('SDK initialization failed');
const devices = await HardwareSDK.searchDevices();
if (!devices.success) throw new Error(devices.payload.error);
if (!devices.payload.length) throw new Error('No device connected');
// Select explicitly in the application when more than one device is available.
if (devices.payload.length !== 1) throw new Error('Select exactly one device before continuing');
const { connectId } = devices.payload[0];
if (!connectId) throw new Error('Device connection unavailable');
const state = await HardwareSDK.getDeviceState(connectId);
if (!state.success) throw new Error(state.payload.error);
const deviceId = state.payload.identity.deviceId;
if (!deviceId) throw new Error('Device identity unavailable');保持固件为最新版本,确保 WebUSB/BLE 连接稳定;建议在签名前先完成一次
evmGetAddress(showOnOneKey: true)以确认路径与账户。
适用场景
evmGetAddress / evmSignTransaction / evmSignMessage / evmSignTypedData 对应独立用例,返回 Promise { success, payload }(用户交互通过事件通知,见下文)。
场景 1:获取地址
获取指定路径的 EVM 地址,可选择在设备上核对。
const res = await HardwareSDK.evmGetAddress(connectId, deviceId, {
useEmptyPassphrase: true,
path: "m/44'/60'/0'/0/0",
showOnOneKey: true,
chainId: 1,
});
if (!res.success) throw new Error(res.payload.error);
// res.payload: { address, path }参数
path(string | number[]): 必填,BIP44 路径,例如"m/44'/60'/0'/0/0"。showOnOneKey?(boolean): 可选,是否在设备上展示并让用户确认地址。chainId?(number): 可选,显示在设备上的链 ID,建议填真实网络。
返回
Promise<{ success, payload: { address, path } }>type GetAddressResult = {
address: `0x${string}`;
path: string;
};场景 2:签名交易
支持 EIP‑1559 与 Legacy 交易,需传入序列化前的字段。除 chainId 外,数值字段必须为 0x 十六进制字符串(SDK 会去掉前导 0)。
const { success, payload } = await HardwareSDK.evmSignTransaction(connectId, deviceId, {
path: "m/44'/60'/0'/0/0",
transaction: tx,
useEmptyPassphrase: true,
});
if (!success) throw new Error(payload.error);
// payload: { v, r, s }参数
path(string | number[]): 必填,签名路径。transaction(object): 必填,EIP‑1559 或 Legacy 字段:- 1559:
tovaluedatanoncegasLimitmaxFeePerGasmaxPriorityFeePerGaschainId - Legacy:
tovaluedatanoncegasLimitgasPricechainId
- 1559:
useEmptyPassphrase?(boolean): 可选,强制标准钱包。隐藏钱包请带openWalletSession返回的passphraseState。
transaction.chainId 为必填数字。SDK 根据费率字段选择 EIP-1559;type: 2 仅在应用序列化时使用。代币转账与授权参见 Clear Signing。
返回
Promise<{ success, payload: { v, r, s } }>可配合 ethers/viem 序列化为 raw tx。
场景 3:签名消息(personal_sign)
const res = await HardwareSDK.evmSignMessage(connectId, deviceId, {
useEmptyPassphrase: true,
path: "m/44'/60'/0'/0/0",
messageHex,
chainId: 1,
});
if (!res.success) throw new Error(res.payload.error);
// res.payload.signature参数
path:必填,签名路径。messageHex:必填,原文转 hex(不含0x或含0x皆可)。chainId?:可选,设备显示的链 ID。
返回
Promise<{ success, payload: { address, signature } }>signature 是不带 0x 前缀的十六进制字符串;传给 ethers 验签前需补齐前缀,见下方示例。
场景 4:签名 Typed Data(EIP-712)
const res = await HardwareSDK.evmSignTypedData(connectId, deviceId, {
useEmptyPassphrase: true,
path: "m/44'/60'/0'/0/0",
data: typedData,
metamaskV4Compat: true,
chainId: 1,
});
if (!res.success) throw new Error(res.payload.error);
// res.payload.signature参数
path:必填。data:必填,EIP‑712 JSON v4,包含domain/types/primaryType/message。metamaskV4Compat:必填,v4 使用true。chainId:可选;传入时应与data.domain.chainId一致。
Typed Data 结构、Safe Tx 示例和哈希签名条件参见 evmSignTypedData。
返回
Promise<{ success, payload: { address, signature } }>signature 是不带 0x 前缀的十六进制字符串;传给 ethers 验签前需补齐前缀,见下方示例。
交互与状态
- 接口以 Promise 结束;通过
UI_EVENT接收以UI_REQUEST标识的用户提示(解锁设备、确认地址/交易/消息/Typed Data/授权)。 - 同一设备请串行调用;多次签名复用
passphraseState(或useEmptyPassphrase: true)。keepSession仍是传输持有,不是钱包绑定。 - 在 UI 中监听
UI_EVENT并按请求类型引导用户操作,允许手动取消/重试。
示例
示例:EIP-1559 交易签名
复用上文初始化的 SDK 和 UI 处理。此序列化示例使用 @ethersproject/* v5;签名前应获取实际 Nonce、Gas 和费率。
import { serialize, TransactionTypes } from '@ethersproject/transactions';
const accountPath = "m/44'/60'/0'/0/0";
const addressResult = await HardwareSDK.evmGetAddress(connectId, deviceId, {
path: accountPath,
showOnOneKey: true,
useEmptyPassphrase: true,
});
if (!addressResult.success) throw new Error(addressResult.payload.error);
const tx = {
to: '0xd0d6d6c5fe4a677d343cc433536bb717bae167dd',
value: '0x0',
data: '0x',
nonce: '0x0',
gasLimit: '0x5208',
maxFeePerGas: '0x77359400',
maxPriorityFeePerGas: '0x3b9aca00',
chainId: 1,
};
const { success, payload: sig } = await HardwareSDK.evmSignTransaction(connectId, deviceId, {
path: accountPath,
transaction: tx,
useEmptyPassphrase: true,
});
if (!success) throw new Error(sig.error);
const rawTx = serialize({ ...tx, nonce: Number(tx.nonce), type: TransactionTypes.eip1559 }, {
r: sig.r,
s: sig.s,
v: Number(sig.v),
});
// Broadcast with your RPC provider, e.g. ethers.js provider.sendTransaction(rawTx)示例:Legacy 交易
const legacyTx = {
to: '0xd0d6d6c5fe4a677d343cc433536bb717bae167dd',
value: '0x2386f26fc10000', // 0.01 ETH
data: '0x',
nonce: '0x1',
gasLimit: '0x5208',
gasPrice: '0x3b9aca00', // 1 gwei
chainId: 1,
};
const sig = await HardwareSDK.evmSignTransaction(connectId, deviceId, {
useEmptyPassphrase: true,
path: accountPath,
transaction: legacyTx,
});
if (!sig.success) throw new Error(sig.payload.error);示例:personal_sign 消息签名
import { hexlify, toUtf8Bytes, verifyMessage } from 'ethers';
const message = 'Hello OneKey';
const messageHex = hexlify(toUtf8Bytes(message));
const res = await HardwareSDK.evmSignMessage(connectId, deviceId, {
useEmptyPassphrase: true,
path: accountPath,
messageHex,
chainId: 1,
});
if (!res.success) throw new Error(res.payload.error);
const signature = `0x${res.payload.signature}`;
const signer = verifyMessage(toUtf8Bytes(message), signature);
if (signer.toLowerCase() !== res.payload.address.toLowerCase()) {
throw new Error('Signature does not match the returned address');
}示例:Typed Data(EIP-712)
import { verifyTypedData } from 'ethers';
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: 'Ether Mail',
version: '1',
chainId: 1,
verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
},
message: {
from: { name: 'Cow', wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' },
to: { name: 'Bob', wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' },
contents: 'Hello, Bob!',
},
};
const res = await HardwareSDK.evmSignTypedData(connectId, deviceId, {
useEmptyPassphrase: true,
path: accountPath,
data: typedData,
metamaskV4Compat: true,
chainId: 1,
});
if (!res.success) throw new Error(res.payload.error);
const { EIP712Domain, ...messageTypes } = typedData.types;
const signature = `0x${res.payload.signature}`;
const signer = verifyTypedData(typedData.domain, messageTypes, typedData.message, signature);
if (signer.toLowerCase() !== res.payload.address.toLowerCase()) {
throw new Error('Signature does not match the returned address');
}验签与排障
- 验签:
- 交易:保留所有已签字段,用
ethers/viem重建交易并校验签名者与已确认账户一致;按设备实际显示核对地址、金额、网络与手续费。 - personal_sign:按上方示例为签名补齐
0x,使用原始消息字节调用verifyMessage。 - EIP‑712:为签名补齐
0x,从types中排除EIP712Domain后调用verifyTypedData;SDK 请求仍需保留完整types。
- 交易:保留所有已签字段,用
- 常见限制:超大
data或异常字段可能被拒;chainId必须与广播网络一致。向用户报告拒绝原因;任何交易改动都需重新审阅,不自动改写请求。 - 常见错误:
- 路径不一致:默认
m/44'/60'/0'/0/0,按账户/地址递增;务必与地址确认一致。 - Gas 字段缺失:1559 必填
maxFeePerGas + maxPriorityFeePerGas;Legacy 仅填gasPrice。 - 设备交互阻塞:串行调用同一设备;
openWalletSession一次后带上passphraseState。keepSession仍是传输持有,不是钱包绑定。 - 用户拒签/超时:提供重试/取消,不要自动重播。
- 路径不一致:默认
相关链方法文档:evmSignTransaction · evmSignMessage · evmSignTypedData。