Ethereum API

The EthereumApp class provides Ethereum address retrieval, message signing, transaction signing, EIP-712 typed data signing, and EIP-7702 authorization signing.

Import

import {
  EthereumApp,
  DEFAULT_ETH_PATH,
  type SignatureResult,
} from '@openloop/sdk-core'

Initialization

const eth = new EthereumApp(transport)

transport is any transport instance that implements the ITransport interface.

Methods

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)

getAddress

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...")
}>

Parameters

Name Type Description
path string BIP44 path (e.g. "44'/60'/0'/0/0")

Example

import { DEFAULT_ETH_PATH } from '@openloop/sdk-core'

const { address, publicKey } = await eth.getAddress(DEFAULT_ETH_PATH)
// address: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18"
// publicKey: "04a1b2c3d4..."

Default Path

DEFAULT_ETH_PATH = "44'/60'/0'/0/0"


signPersonalMessage

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>

Parameters

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

Returns: 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)

Example

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.


signTransaction

Signs an RLP-encoded Ethereum transaction.

async signTransaction(
  path: string,
  rawTx: string
): Promise<SignatureResult>

Parameters

Name Type Description
path string BIP44 path
rawTx string RLP-encoded transaction (hex, with or without the 0x prefix)

Example

const sig = await eth.signTransaction(
  DEFAULT_ETH_PATH,
  'f86c0a8502540be400825208...'
)

signTypedData

Signs EIP-712 typed data. Pass the pre-hashed domainSeparator and message.

async signTypedData(
  path: string,
  domainSeparatorHash: string,
  messageHash: string,
  chainId?: number
): Promise<SignatureResult>

Parameters

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

Example

const sig = await eth.signTypedData(
  DEFAULT_ETH_PATH,
  'aabbccddee...', // domainSeparatorHash
  '1122334455...', // messageHash
  1
)

signTypedDataStreaming

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>

Why there are two

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.

Parameters

Name Type Default Description
path string — BIP44 path
typedData TypedDataLike — { domain, types, primaryType, message }
chainId number 1 Chain ID

Notes

Example

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)
}

isEip3009

Returns whether typed data is EIP-3009 (TransferWithAuthorization / ReceiveWithAuthorization).

function isEip3009(typedData: TypedDataLike): boolean

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


signAuthorization

Signs an EIP-7702 authorization — Set Code for an EOA.

async signAuthorization(
  path: string,
  authorizationRlp: string
): Promise<SignatureResult>

Parameters

Name Type Description
path string BIP44 path
authorizationRlp string Hex of RLP([chain_id, address, nonce])

Example

const sig = await eth.signAuthorization(
  DEFAULT_ETH_PATH,
  'c3010a...' // RLP([1, "0x...", 0])
)

ethers.js Integration

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)

viem Integration

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}`

Next Steps