On this page
EVM Signer
Type: Integration guide · Audience: SDK integrators · Status: Current implementation
Scope: EVM signing APIs and Pro2/Neo firmware · Last code verification: 2026-09-15
Prerequisite: Request Basics
End-to-end EVM signing guide. Covers address confirmation, EIP‑1559/Legacy transactions, personal_sign, and EIP‑712 typed data. The SDK returns Promise results and emits UI events for device interaction.
Index
- How it works
- Clear Signing
- Installation
- Initialization
- Common scenarios
- Interaction & Status
- Examples
- Validation & Troubleshooting
How it works
HardwareSDKcommunicates with the OneKey firmware; APIs returnPromise<{ success, payload }>.- Subscribe to
UI_EVENTand handle the relevantUI_REQUESTprompts for the device and transport. See Request Basics. - The device parses supported signing data and presents confirmation screens. The available detail depends on the request and firmware; see Clear Signing.
- The application verifies the signature and serializes/broadcasts transactions via RPC.
Clear Signing
Clear Signing lets users review the meaning of a signing request on the hardware wallet: who receives an asset, which contract receives an allowance, or which structured message is being signed. The SDK sends the transaction or typed data; the device firmware parses supported formats and asks the user to confirm.
Use the existing evmSignTransaction and evmSignTypedData methods. There is no separate Clear Signing package or clearSigning switch. The public transaction API does not accept host-supplied token metadata, contract labels, or a domain label.
Supported requests and review details
The following describes the current SDK integration and the Pro2/Neo firmware implementation. Screen layout, token metadata, and recognized formats depend on the device and firmware; this is not a guarantee that every model or contract has the same display.
| Request | What to pass | What users should review on a supporting device |
|---|---|---|
| Native Transfer | evmSignTransaction: recipient in transaction.to, amount in value, empty data | Recipient, amount, network, and fee |
| ERC-20 Transfer | Token contract in to, zero native value, ABI-encoded transfer(address,uint256) in data | Token contract, actual recipient, token amount, and fee |
| Approve / Revoke | Token contract in to, zero native value, ABI-encoded approve(address,uint256) in data; amount 0 revokes an allowance | Spender, token contract, allowance or revocation, and fee; maximum uint256 is an unlimited allowance |
| Token / Contract | The actual contract address, chain ID, value, and full calldata | Known token metadata and supported decoded fields; unknown assets or calls may show addresses, raw amounts, and calldata |
| EIP-712 | evmSignTypedData with the complete types, primaryType, domain, and message | Domain and structured fields in the parsed signing flow; see the hash-signing limitations |
| Safe Tx | evmSignTypedData with the standard SafeTx schema | Safe address, network, destination, value, operation, nonce, gas/refund fields, and transaction data |
Transfer and allowance integration
For an ERC-20 transfer, transaction.to is the token contract; the recipient is encoded in calldata. For approval, the encoded address is the spender, not the recipient of a transfer. Amounts are integers in the token’s smallest unit. Native value remains zero for these ERC-20 calls.
The following fragment assumes the SDK and wallet are initialized. It uses ethers v6; replace the example addresses and fetch nonce, gas limit, and fees for the selected account and network before signing.
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);Changing the amount to a positive integer grants that allowance. Do not substitute an unlimited amount for a requested finite allowance. This example returns a signature; the application still verifies, serializes, and broadcasts the transaction.
Token and contract recognition
Recognition uses the chain ID, contract address, and calldata. A familiar token symbol or provider name is supplementary information: users must still check the actual addresses. On Pro2/Neo, an unknown ERC-20 token uses a generic token label and raw integer amount because its decimals are unavailable. The host application’s token list does not supply device display metadata through these signing APIs.
A successful signature does not mean every contract call was decoded. Unsupported calls may expose raw calldata instead of a human-readable action. Clear Signing also does not determine whether a contract is trustworthy or predict its execution result.
EIP-712 and Safe Tx
Always pass the complete typed data and the matching metamaskV4Compat value (true for v4). Parsed signing lets the device request and display structured fields. Devices or payloads that require hash signing cannot provide the same structured review; supplying hashes does not force Clear Signing. See evmSignTypedData for the exact fallback conditions.
For Safe, use the standard SafeTx primary type with domain.chainId and domain.verifyingContract set to the Safe’s network and address. Include all ten Safe transaction fields. The Pro2/Neo implementation uses the Safe v1.3.0 domain and transaction schema; arbitrary custom schemas named SafeTx are not interchangeable. See the Safe Tx example.
The returned signature authorizes a Safe transaction. Collecting owners’ signatures and submitting execTransaction are separate application steps. Signing an on-chain Safe approveHash or execTransaction call uses evmSignTransaction; supporting firmware can recognize those calls, but an approveHash request alone cannot show the transaction represented by that hash.
Integration checks
- Confirm the account on the device, use the same wallet context for signing, and keep the chain ID consistent with the target network and EIP-712 domain.
- Check a native transfer, token transfer, finite approval, revocation, EIP-712 message, and Safe Tx on each supported device/firmware combination. Include an unknown token or contract and a payload that requires hash signing.
- Review the actual device screens. UI events and
success: truedo not attest that every field was decoded; the response has no Clear Signing status field. - Check
successbefore using the signature, recover/verify the signer against the exact request, and preserve all signed fields when serializing. Surface rejection or unsupported data to the user instead of changing the request automatically.
Installation
npm i @onekeyfe/hd-core @onekeyfe/hd-common-connect-sdkInitialization
The examples use the standard wallet consistently. For a hidden wallet, replace useEmptyPassphrase: true with the verified passphraseState on every address and signing call. Register UI handlers before protected calls, and check success before using any payload. Examples containing declared transaction inputs are preparation fragments, not standalone runnable applications.
- Keep firmware up to date; Web requires HTTPS + user gestures; BLE needs proper permission.
- Before signing, call
evmGetAddress(showOnOneKey: true)once to confirm path/account on device.
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');Common scenarios
Scenario 1: Get Address
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 }Parameters
path(string | number[]): required; BIP44 path.showOnOneKey?(boolean): optional; display and confirm on device.chainId?(number): optional; chainId shown on device.
Returns
Promise<{ success, payload: { address, path } }>Scenario 2: Sign Transaction
Supports EIP‑1559 and Legacy transactions.
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 }Parameters
path: required.transaction: required.- 1559:
tovaluedatanoncegasLimitmaxFeePerGasmaxPriorityFeePerGaschainId - Legacy:
tovaluedatanoncegasLimitgasPricechainId
- 1559:
transaction.chainId: required; a number inside the transaction.useEmptyPassphrase?: optional; force the standard wallet. Hidden wallet: passpassphraseStatefromopenWalletSession.
The SDK selects EIP-1559 from its fee fields; type: 2 is only needed by the host serializer. See Clear Signing for token transfers and approvals.
Returns
Promise<{ success, payload: { v, r, s } }>Scenario 3: Sign Message (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.signatureParameters
path: required.messageHex: required; message to hex (with/without0x).chainId?: optional; shown on device.
Returns
Promise<{ success, payload: { address, signature } }>signature is a hex string without the 0x prefix. Add the prefix before ethers verification, as shown below.
Scenario 4: Sign 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.signatureParameters
path: required.data: required; EIP‑712 JSON v4 withdomain/types/primaryType/message.metamaskV4Compat: required; usetruefor v4.chainId: optional; keep it consistent withdata.domain.chainIdwhen present.
For the typed-data schema, Safe Tx example, and hash-signing conditions, see evmSignTypedData.
Returns
Promise<{ success, payload: { address, signature } }>signature is a hex string without the 0x prefix. Add the prefix before ethers verification, as shown below.
Interaction & Status
- APIs resolve when the operation finishes; subscribe to
UI_EVENTfor prompts identified byUI_REQUESTtypes. - Common prompts include unlocking the device and confirming an address, transaction, or message. Handle events as described in Request Basics.
- For repeated calls, serialize per device and reuse
passphraseState(oruseEmptyPassphrase: true).keepSessionstill holds the transport; it is not a wallet binding.
Examples
EIP-1559 transaction
Use the initialized SDK and UI handlers from above. This serialization example uses @ethersproject/* v5. Fetch current nonce, gas, and fees before signing.
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 transaction (gasPrice)
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);Message (personal_sign / EIP-191)
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');
}Validation & Troubleshooting
- Verify on device: path/account, recipient, amount, chainId, fee summary; ensure they match your UI.
- Gas: 1559 requires
maxFeePerGas + maxPriorityFeePerGas; Legacy usesgasPriceonly. - Message/Typed Data: show clear summaries/hashes in UI to avoid phishing; recover signer with
ethers.verifyMessage/ethers.verifyTypedData. - Common issues:
- Path mismatch: default
m/44'/60'/0'/0/0, increment account/address as needed; confirm once withshowOnOneKey.
- Path mismatch: default
- ChainId mismatch: must match the target network.
- Unsupported or oversized
datamay be rejected. Return the error; any change to the intended transaction needs a new user review. - Repeated passphrase prompts: call
openWalletSessiononce, then passpassphraseStateon later calls. Serialize per device.keepSessionstill holds the transport; it is not a wallet binding. - User reject/timeout: surface retry/cancel; do not auto-replay. See chain docs: evmSignTransaction · evmSignMessage · evmSignTypedData.