The EthereumApp class provides Ethereum address
retrieval, message signing, transaction signing, EIP-712 typed data
signing, and EIP-7702 authorization signing.
import {
EthereumApp,
DEFAULT_ETH_PATH,
type SignatureResult,
} from '@openloop/sdk-core'const eth = new EthereumApp(transport)transport is any transport instance that implements the
ITransport interface.
| Method | Description |
|---|---|
getAddress(path) |
Retrieves the address and public key |
signPersonalMessage(path, message, chainId?) |
EIP-191 personal_sign |
signTransaction(path, rawTx) |
Signs an RLP-encoded transaction |
signTypedData(path, domainHash, msgHash, chainId?) |
Signs EIP-712 typed data |
signAuthorization(path, authorizationRlp) |
Signs an EIP-7702 authorization |
signTypedDataStreaming(path, typedData, chainId?) |
Sign EIP-712 typed data by streaming it (the device can show the contents) |
isEip3009(typedData) |
Whether typed data is EIP-3009 (used to choose the path) |
Retrieves the Ethereum address and public key from the device.
async getAddress(path: string): Promise<{
publicKey: string // uncompressed public key (hex, 65 bytes)
address: string // checksummed address ("0x...")
}>| Name | Type | Description |
|---|---|---|
path |
string |
BIP44 path (e.g. "44'/60'/0'/0/0") |
import { DEFAULT_ETH_PATH } from '@openloop/sdk-core'
const { address, publicKey } = await eth.getAddress(DEFAULT_ETH_PATH)
// address: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18"
// publicKey: "04a1b2c3d4..."DEFAULT_ETH_PATH = "44'/60'/0'/0/0"
Signs a message with EIP-191 personal_sign. It uses the Openloop-specific extended protocol that is aware of the chain ID.
async signPersonalMessage(
path: string,
message: string,
chainId?: number
): Promise<SignatureResult>| Name | Type | Default | Description |
|---|---|---|---|
path |
string |
— | BIP44 path |
message |
string |
— | The message — a UTF-8 string or 0x-prefixed hex |
chainId |
number |
1 |
EVM chain ID |
SignatureResult| Field | Type | Description |
|---|---|---|
v |
number |
Recovery ID (27 or 28) |
r |
string |
R value (32 bytes hex, no 0x) |
s |
string |
S value (32 bytes hex, no 0x) |
const sig = await eth.signPersonalMessage(
DEFAULT_ETH_PATH,
'Hello, Openloop!',
1
)
// sig.v = 27
// sig.r = "a1b2c3..."
// sig.s = "d4e5f6..."Note: Long messages are split into chunks automatically before they are sent to the device — 150 bytes for the first chunk, then 255 bytes each.
Signs an RLP-encoded Ethereum transaction.
async signTransaction(
path: string,
rawTx: string
): Promise<SignatureResult>| Name | Type | Description |
|---|---|---|
path |
string |
BIP44 path |
rawTx |
string |
RLP-encoded transaction (hex, with or without the 0x
prefix) |
const sig = await eth.signTransaction(
DEFAULT_ETH_PATH,
'f86c0a8502540be400825208...'
)Signs EIP-712 typed data. Pass the pre-hashed domainSeparator and message.
async signTypedData(
path: string,
domainSeparatorHash: string,
messageHash: string,
chainId?: number
): Promise<SignatureResult>| Name | Type | Default | Description |
|---|---|---|---|
path |
string |
— | BIP44 path |
domainSeparatorHash |
string |
— | EIP-712 domain separator hash (32 bytes hex) |
messageHash |
string |
— | EIP-712 message hash (32 bytes hex) |
chainId |
number |
1 |
EVM chain ID |
const sig = await eth.signTypedData(
DEFAULT_ETH_PATH,
'aabbccddee...', // domainSeparatorHash
'1122334455...', // messageHash
1
)Signs EIP-712 typed data by sending the data itself instead of its hashes, so the device can show what is actually being signed.
async signTypedDataStreaming(
path: string,
typedData: TypedDataLike,
chainId?: number
): Promise<SignatureResult>| What reaches the device | Device screen | |
|---|---|---|
signTypedData |
Two hashes | Hashes only |
signTypedDataStreaming |
The typed data | The contents (supported types only) |
signTypedData is enough when the dApp shows the same
hash (Safe, for example) — the user compares the two and is done.
For a gasless stablecoin transfer (EIP-3009) the signer is an ordinary customer, and asking them to compare 64 hex characters is not realistic: they would sign without ever seeing the amount or the recipient. That is what this method is for.
| Name | Type | Default | Description |
|---|---|---|---|
path |
string |
— | BIP44 path |
typedData |
TypedDataLike |
— | { domain, types, primaryType, message } |
chainId |
number |
1 |
Chain ID |
import { isEip3009 } from '@openloop/sdk-core'
// Only route payloads worth showing through the streaming path
if (isEip3009(typedData)) {
const sig = await eth.signTypedDataStreaming(DEFAULT_ETH_PATH, typedData, 137)
} else {
const sig = await eth.signTypedData(DEFAULT_ETH_PATH, domainHash, messageHash, 137)
}Returns whether typed data is EIP-3009
(TransferWithAuthorization /
ReceiveWithAuthorization).
function isEip3009(typedData: TypedDataLike): booleanIt checks the type name, the field order, and the
message contents, and returns true
only on an exact match. One difference anywhere gives
false.
The rule is identical to the device’s. If the two disagreed, the app could promise a readable screen the device does not deliver, or the other way round.
This decides which path to send on — never whether a signature is allowed. Typed data that is not EIP-3009 still signs exactly as before.
Signs an EIP-7702 authorization — Set Code for an EOA.
async signAuthorization(
path: string,
authorizationRlp: string
): Promise<SignatureResult>| Name | Type | Description |
|---|---|---|
path |
string |
BIP44 path |
authorizationRlp |
string |
Hex of RLP([chain_id, address, nonce]) |
const sig = await eth.signAuthorization(
DEFAULT_ETH_PATH,
'c3010a...' // RLP([1, "0x...", 0])
)import { ethers } from 'ethers'
import { EthereumApp, DEFAULT_ETH_PATH } from '@openloop/sdk-core'
import { WebHidTransport } from '@openloop/transport-webhid'
const transport = await WebHidTransport.connect()
const eth = new EthereumApp(transport)
// Get the address
const { address } = await eth.getAddress(DEFAULT_ETH_PATH)
// Provider (connection to an Ethereum node)
const provider = new ethers.JsonRpcProvider('https://eth.llamarpc.com')
// Build the transaction
const tx = {
to: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
value: ethers.parseEther('0.01'),
gasLimit: 21000n,
gasPrice: (await provider.getFeeData()).gasPrice,
nonce: await provider.getTransactionCount(address),
chainId: 1n,
}
// RLP-encode and sign
const unsignedTx = ethers.Transaction.from(tx).unsignedSerialized
const sig = await eth.signTransaction(DEFAULT_ETH_PATH, unsignedTx)
// Assemble the signed transaction
const signedTx = ethers.Transaction.from({
...tx,
signature: {
v: sig.v,
r: '0x' + sig.r,
s: '0x' + sig.s,
},
})
// Broadcast
const txResponse = await provider.broadcastTransaction(signedTx.serialized)import { createPublicClient, http, parseEther } from 'viem'
import { mainnet } from 'viem/chains'
import { EthereumApp, DEFAULT_ETH_PATH } from '@openloop/sdk-core'
import { WebHidTransport } from '@openloop/transport-webhid'
const transport = await WebHidTransport.connect()
const eth = new EthereumApp(transport)
const { address } = await eth.getAddress(DEFAULT_ETH_PATH)
// personal_sign
const sig = await eth.signPersonalMessage(
DEFAULT_ETH_PATH,
'Hello from viem!',
1
)
// Convert to viem's signature format
const signature = `0x${sig.r}${sig.s}${sig.v.toString(16)}` as `0x${string}`