Skip to main content

EVM (eth-sign-request)

ur:eth-sign-requestur:eth-signatureEIP-155EIP-1559EIP-712

eth-sign-request​

KeyFieldTypeContent
1request-id37(bytes)16 random bytes (1–64 accepted)
2sign-databytesWhat to sign, by data type (below)
3data-typeuint1, 2, 3 or 4
4chain-iduintThe EVM chain ID. Required for transactions; recommended for everything
5derivation-path304(crypto-keypath)Full path, e.g. m/44'/60'/0'/0/0, with master fingerprint
6addressbytes (20)The signer's address as raw bytes, not 0x text
7origintextShown to the user, e.g. "BitTool Wallet"

Data types​

data-typesign-dataTypical source
1 transactionThe unsigned legacy transaction RLP with an EIP-155 chain ID: rlp([nonce, gasPrice, gas, to, value, data, chainId, 0, 0])eth_sendTransaction on chains where you use legacy gas
4 typed transactionThe unsigned EIP-2718 envelope: 0x02 ‖ rlp([...]) (EIP-1559) or 0x01 ‖ rlp([...]) (EIP-2930)eth_sendTransaction, most modern sends
3 personal messageThe raw message bytes, without the "\x19Ethereum Signed Message:\n" prefix and not hashedpersonal_sign
2 typed dataThe EIP-712 JSON as UTF-8: {types, primaryType, domain, message}eth_signTypedData_v4

Send bytes, never a hash. BitTool Signer parses what it signs and refuses to sign blind 32-byte digests.

Networks​

The chain ID in the transaction bytes, in key 4 and (for typed data) in domain.chainId must all agree, and must be one of:

Chain IDNetworkChain IDNetwork
1Ethereum10OP Mainnet
56BNB Chain137Polygon PoS
42161Arbitrum One100Gnosis
8453Base43114Avalanche C-Chain

Not supported​

Blob transactions (type 3), EIP-7702 transactions (type 4), unprotected legacy transactions without a chain ID, and eth_sign of a raw 32-byte hash. Requests for other chain IDs are rejected.

EIP-712 rules​

BitTool Signer hashes typed data exactly as declared and is strict about values, because a value that is displayed one way but hashed another is a classic phishing trick:

  • bool values must be JSON true/false, not strings.
  • Integers are JSON numbers only up to 253 − 1; larger values must be decimal strings without leading zeros, or 0x hex strings.
  • address values are 0x + 40 hex digits, and mixed-case addresses must have a valid EIP-55 checksum; bytesN must be exactly N bytes.
  • The message must have exactly the fields its type declares: no missing and no extra fields.
  • EIP712Domain may contain only name, version, chainId, verifyingContract and salt; the declared order is used for the domain separator.

Known structures get a full review: ERC-2612 / DAI Permit, Permit2 (single, batch, transfer-from), Safe SafeTx, and NFT marketplace orders (Seaport, LooksRare, Blur). Anything else is shown field by field and marked partially decoded.

Personal messages​

Any bytes from 1 to 8,192 long. Valid UTF-8 is shown as text; anything else as hex, marked opaque. A 32-byte message is flagged as high risk, because it is probably a hash the user cannot read.

eth-signature​

eth-signature = {
1: 37(request-id bytes), ; copied from the request
2: signature ; r (32) ‖ s (32) ‖ v (1 or more bytes)
}

The signature is not a signed transaction; your wallet assembles that. The v part depends on the data type:

data-typevLength
1 legacy transactionchainId × 2 + 35 + yParity (EIP-155), big-endian, as few bytes as needed65 bytes or more
4 typed transactionyParity (0 or 1)65
2, 3 messages27 + yParity65

So read v as an unsigned big-endian integer from byte 64 to the end. For example, on Avalanche (chain ID 43114) a legacy v is 86263 or 86264, three bytes, and the signature is 67 bytes long. Code that always takes byte 64 as v breaks on any chain ID of 110 or more.

To finish:

  1. Check the request ID.
  2. Recover the signer from the digest you expect (transaction hash, EIP-191 hash or EIP-712 hash) and compare it with the paired address.
  3. For transactions, insert r, s and the parity into the unsigned transaction, serialize, and check the transaction hash before broadcasting.

Code​

With ethers and Keystone's @keystonehq/bc-ur-registry-eth:

examples/evm.mjs
import { randomUUID } from 'node:crypto';
import { Transaction, Signature, getBytes } from 'ethers';
import { EthSignRequest, DataType, ETHSignature } from '@keystonehq/bc-ur-registry-eth';
import { URDecoder } from '@ngraveio/bc-ur';

export function buildEthRequest(tx, { path, masterFingerprint, address }) {
const unsigned = Transaction.from(tx); // no signature yet
const requestId = randomUUID();
const request = EthSignRequest.constructETHRequest(
Buffer.from(getBytes(unsigned.unsignedSerialized)), // the bytes to sign, not a hash
unsigned.type === 0 ? DataType.transaction : DataType.typedTransaction, // 1 or 4
path, // "m/44'/60'/0'/0/0"
masterFingerprint, // "73c5da0a"
requestId,
Number(unsigned.chainId),
address,
'BitTool Wallet', // origin, shown to the user
);
const encoder = request.toUREncoder(400); // fragment length in bytes
const frames = [];
for (let i = 0; i < encoder.fragmentsLength; i++) frames.push(encoder.nextPart().toUpperCase());
return { unsigned, requestId, frames };
}
examples/evm.mjs
export function readEthSignature(frames, { unsigned, requestId, address }) {
const decoder = new URDecoder();
for (const frame of frames) decoder.receivePart(frame);
if (!decoder.isSuccess()) throw new Error('Incomplete or invalid QR');
const ur = decoder.resultUR();
if (ur.type !== 'eth-signature') throw new Error(`Expected eth-signature, got ${ur.type}`);

const response = ETHSignature.fromCBOR(ur.cbor);
const id = response.getRequestId();
if (!id || Buffer.from(id).toString('hex') !== requestId.replace(/-/g, '')) throw new Error('Signature is for another request');

const sig = Buffer.from(response.getSignature()); // r (32) ‖ s (32) ‖ v (1+ bytes)
const r = '0x' + sig.subarray(0, 32).toString('hex');
const s = '0x' + sig.subarray(32, 64).toString('hex');
const v = BigInt('0x' + sig.subarray(64).toString('hex'));
const yParity = unsigned.type === 0 ? Number(v - unsigned.chainId * 2n - 35n) : Number(v);
if (yParity !== 0 && yParity !== 1) throw new Error('Unexpected v');

const signed = unsigned.clone();
signed.signature = Signature.from({ r, s, yParity });
if (signed.from !== address) throw new Error('Signed by an unexpected key');
return signed.serialized; // ready for eth_sendRawTransaction
}

Test vectors​

All from m/44'/60'/0'/0/0 (0x9858EfFD232B4033E47d90003D41EC34EcaEda94) of the test phrase.

Send 0.01 ETH on Ethereum, nonce 12, max fee 30 gwei, priority fee 1 gwei.

Request (your wallet → BitTool Signer)

eth-sign-request1 frames
UR:ETH-SIGN-REQUEST/OSADTPDAGDHECKNDDWKIFTGLJLLEBDCEDPFMGWHTADAOHDEHAOWSADBNLRFRNYSGAELPAMZTCNPSAELFGMAYMWAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEUEPMLTCNLNWZJLSEAEAELARTAXAAAAADAHTAADDYOEADLECSDWYKCSFNYKAEYKAEWKAEWKAOCYJKSKTNBKAMGHMKHDWSZCCNDNFZEOVEKIMHAEFSFPWPEEWPPLTNMWATJTFWINJYGHJLJLJZCXHGHSJZJZIHJYKEBDYTMH
CBOR fields
{
"1 request-id": {
"uuid (tag 37)": "5f1e9b2c7d3a4e6f8a0b1c2d3e4f5a01"
},
"2 sign-data": "02ef010c843b9aca008506fc23ac0082520894000000000000000000000000000000000000dead872386f26fc1000080c0",
"3 data-type": 4,
"4 chain-id": 1,
"5 derivation-path": {
"crypto-keypath (tag 304)": {
"1 components": [
44,
true,
60,
true,
0,
true,
0,
false,
0,
false
],
"2 source-fingerprint": 1942346250
}
},
"6 address": "9858effd232b4033e47d90003d41ec34ecaeda94",
"7 origin": "BitTool Wallet"
}
Show the QR text and raw CBOR
QR text · ur:eth-sign-request
UR:ETH-SIGN-REQUEST/OSADTPDAGDHECKNDDWKIFTGLJLLEBDCEDPFMGWHTADAOHDEHAOWSADBNLRFRNYSGAELPAMZTCNPSAELFGMAYMWAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEUEPMLTCNLNWZJLSEAEAELARTAXAAAAADAHTAADDYOEADLECSDWYKCSFNYKAEYKAEWKAEWKAOCYJKSKTNBKAMGHMKHDWSZCCNDNFZEOVEKIMHAEFSFPWPEEWPPLTNMWATJTFWINJYGHJLJLJZCXHGHSJZJZIHJYKEBDYTMH
UR CBOR (hex)
a701d825505f1e9b2c7d3a4e6f8a0b1c2d3e4f5a0102583102ef010c843b9aca008506fc23ac0082520894000000000000000000000000000000000000dead872386f26fc1000080c00304040105d90130a2018a182cf5183cf500f500f400f4021a73c5da0a06549858effd232b4033e47d90003d41ec34ecaeda94076e426974546f6f6c2057616c6c6574

Response (BitTool Signer → your wallet)

Decoded response · ur:eth-signature
{
"request-id": {
"uuid": "5f1e9b2c7d3a4e6f8a0b1c2d3e4f5a01"
},
"signature": "6d11ee93f7876a78ea19f4f9c8e551e4522b6deb8349a6572d076b4e67ceb4416db3f690b339bf658a5c8d2ad2f75cdf3252f07f940a821f65bb6cb91e9782a101"
}
Show the QR text and raw CBOR
QR text · ur:eth-signature
UR:ETH-SIGNATURE/OEADTPDAGDHECKNDDWKIFTGLJLLEBDCEDPFMGWHTADAOHDFPJNBYWYMUYLLTIMKSWDCFWKYTSPVWGYVEGMDNJNWMLSGAOLHGDPATJEGLIOTOQZFPJNQDYNMHQDESRSIHLEHHLGDRTDYLHHUREYGMWTLBMWBKLFCTIHRKJZRHCKMSLFOYADCWJNLUAY
UR CBOR (hex)
a201d825505f1e9b2c7d3a4e6f8a0b1c2d3e4f5a010258416d11ee93f7876a78ea19f4f9c8e551e4522b6deb8349a6572d076b4e67ceb4416db3f690b339bf658a5c8d2ad2f75cdf3252f07f940a821f65bb6cb91e9782a101

Assembled by your wallet

signedTxHex
0x02f872010c843b9aca008506fc23ac0082520894000000000000000000000000000000000000dead872386f26fc1000080c001a06d11ee93f7876a78ea19f4f9c8e551e4522b6deb8349a6572d076b4e67ceb441a06db3f690b339bf658a5c8d2ad2f75cdf3252f07f940a821f65bb6cb91e9782a1
txHash
0xd7e69271cce78ffdf924b78126b93fb3cdf4f797f7e216bcb6702c8d97b47ea3

Safe (multisig) owners​

To use BitTool Signer as one owner of a Safe, sign the SafeTx as EIP-712 (data type 2) from the owner's paired address; key 6 is the owner's address, not the Safe's. BitTool Signer decodes CALL/DELEGATECALL, MultiSend, owner/module/guard changes and gas refund parameters, and raises the risk level for the dangerous ones. Collecting, sorting and submitting owner signatures is your wallet's job.