Skip to Content

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

  1. How it works
  2. Clear Signing
  3. Installation
  4. Initialization
  5. Common scenarios
  6. Interaction & Status
  7. Examples
  8. Validation & Troubleshooting

How it works

  • HardwareSDK communicates with the OneKey firmware; APIs return Promise<{ success, payload }>.
  • Subscribe to UI_EVENT and handle the relevant UI_REQUEST prompts 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.

RequestWhat to passWhat users should review on a supporting device
Native TransferevmSignTransaction: recipient in transaction.to, amount in value, empty dataRecipient, amount, network, and fee
ERC-20 TransferToken contract in to, zero native value, ABI-encoded transfer(address,uint256) in dataToken contract, actual recipient, token amount, and fee
Approve / RevokeToken contract in to, zero native value, ABI-encoded approve(address,uint256) in data; amount 0 revokes an allowanceSpender, token contract, allowance or revocation, and fee; maximum uint256 is an unlimited allowance
Token / ContractThe actual contract address, chain ID, value, and full calldataKnown token metadata and supported decoded fields; unknown assets or calls may show addresses, raw amounts, and calldata
EIP-712evmSignTypedData with the complete types, primaryType, domain, and messageDomain and structured fields in the parsed signing flow; see the hash-signing limitations
Safe TxevmSignTypedData with the standard SafeTx schemaSafe 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: true do not attest that every field was decoded; the response has no Clear Signing status field.
  • Check success before 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-sdk

Initialization

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: to value data nonce gasLimit maxFeePerGas maxPriorityFeePerGas chainId
    • Legacy: to value data nonce gasLimit gasPrice chainId
  • transaction.chainId: required; a number inside the transaction.
  • useEmptyPassphrase?: optional; force the standard wallet. Hidden wallet: pass passphraseState from openWalletSession.

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.signature

Parameters

  • path: required.
  • messageHex: required; message to hex (with/without 0x).
  • 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.signature

Parameters

  • path: required.
  • data: required; EIP‑712 JSON v4 with domain/types/primaryType/message.
  • metamaskV4Compat: required; use true for v4.
  • chainId: optional; keep it consistent with data.domain.chainId when 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_EVENT for prompts identified by UI_REQUEST types.
  • 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 (or useEmptyPassphrase: true). keepSession still 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 uses gasPrice only.
  • 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 with showOnOneKey.
  • ChainId mismatch: must match the target network.
  • Unsupported or oversized data may be rejected. Return the error; any change to the intended transaction needs a new user review.
  • Repeated passphrase prompts: call openWalletSession once, then pass passphraseState on later calls. Serialize per device. keepSession still 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.
Last updated on