Skip to content

paulmillr/micro-eth-signer

Repository files navigation

micro-eth-signer

Minimal library for Ethereum transactions, addresses and smart contracts.

  • 🔓 Secure: audited noble cryptography, no network code, hedged signatures
  • 🔻 Tree-shakeable: unused code is excluded from your builds
  • 🔍 Reliable: 800MB of test vectors from EIPs, ethers and viem
  • ✍️ Core: Transactions, addresses, messages
  • 🦺 Type-safe ABI, RLP, SSZ, KZG, PeerDAS, BLS validator keys, Clear Signing
  • 🌍 Archive node connector
  • 🪶 32KB (gzipped) for core+deps: 4x smaller than alternatives

Check out all web3 utility libraries: ETH, BTC, SOL

Usage

npm install micro-eth-signer

jsr add jsr:@paulmillr/micro-eth-signer

We support all major platforms and runtimes. For React Native, you may need a polyfill for getRandomValues. If you don't like NPM, a standalone eth-signer.js is also available.

Core

import { addr, authorization, Transaction } from 'micro-eth-signer';
import { eip191Signer, recoverPublicKeyTyped, signTyped, verifyTyped } from 'micro-eth-signer';
import { amounts, ethHex, ethHexNoLeadingZero, weieth, weigwei } from 'micro-eth-signer';

Create random wallet

import { addr } from 'micro-eth-signer';
const random = addr.random(); // Secure: uses CSPRNG
console.log(random.privateKey, random.address);
// '0x17ed046e6c4c21df770547fad9a157fd17b48b35fe9984f2ff1e3c6a62700bae'
// '0x26d930712fd2f612a107A70fd0Ad79b777cD87f6'

Transactions: create, sign

import { addr, Transaction, weigwei, weieth } from 'micro-eth-signer';
const random = addr.random();
const tx = Transaction.prepare({
  to: '0xdf90dea0e0bf5ca6d2a7f0cb86874ba6714f463e',
  value: weieth.decode('1.1'), // 1.1eth in wei
  maxFeePerGas: weigwei.decode('100'), // 100gwei in wei (priority fee is 1 gwei)
  nonce: 0n,
});
// Uses `random` from example above. Alternatively, pass 0x hex string or Uint8Array
const signedTx = tx.signBy(random.privateKey);
console.log('signed tx', signedTx, signedTx.toHex());
console.log('fee', signedTx.fee);

// Hedged signatures, with extra noise / security
const tx2 = tx.signBy(random.privateKey, true); // default, same as above
const tx3 = tx.signBy(random.privateKey, false); // disable

// Send whole account balance. See Security section for caveats
const CURRENT_BALANCE = '1.7182050000017'; // in eth
const txSendingWholeBalance = tx.setWholeAmount(weieth.decode(CURRENT_BALANCE));

We support legacy, EIP2930, EIP1559, EIP4844 and EIP7702 transactions.

Signing is done with noble-curves, using RFC 6979. Hedged signatures are also supported - check out the blog post Deterministic signatures are not your friends.

Addresses: create, checksum

import { addr } from 'micro-eth-signer';
const priv = '0x0687640ee33ef844baba3329db9e16130bd1735cbae3657bd64aed25e9a5c377';
const pub = '030fba7ba5cfbf8b00dd6f3024153fc44ddda93727da58c99326eb0edd08195cdb';
const nonChecksummedAddress = '0x0089d53f703f7e0843953d48133f74ce247184c2';
const checksummedAddress = addr.addChecksum(nonChecksummedAddress);
console.log(
  checksummedAddress, // 0x0089d53F703f7E0843953D48133f74cE247184c2
  addr.isValid(checksummedAddress), // true
  addr.isValid(nonChecksummedAddress), // also true
  addr.fromPrivateKey(priv),
  addr.fromPublicKey(pub)
);

Messages: sign, verify

There are two messaging standards: EIP-191 & EIP-712.

EIP-191

import { eip191Signer } from 'micro-eth-signer';

// Example message
const message = 'Hello, Ethereum!';
const privateKey = '0x4c0883a69102937d6231471b5dbb6204fe512961708279f1d7b1b8e7e8b1b1e1';

// Sign the message
const signature = eip191Signer.sign(message, privateKey);
console.log('Signature:', signature);

// Verify the signature
const address = '0xYourEthereumAddress';
const isValid = eip191Signer.verify(signature, message, address);
console.log('Is valid:', isValid);

EIP-712

import { addr, ethHex, recoverPublicKeyTyped, signTyped, verifyTyped } from 'micro-eth-signer';
import type { EIP712Domain, TypedData } from 'micro-eth-signer';

const types = {
  Person: [
    { name: 'name', type: 'string' },
    { name: 'wallet', type: 'address' },
  ],
  Mail: [
    { name: 'from', type: 'Person' },
    { name: 'to', type: 'Person' },
    { name: 'contents', type: 'string' },
  ],
};

// Define the domain
const domain: EIP712Domain = {
  name: 'Ether Mail',
  version: '1',
  chainId: 1n,
  verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
  salt: ethHex.decode('0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'),
};

// Define the message
const message = {
  from: {
    name: 'Alice',
    wallet: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
  },
  to: {
    name: 'Bob',
    wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',
  },
  contents: 'Hello, Bob!',
};

// Create the typed data
const typedData: TypedData<typeof types, 'Mail'> = {
  types,
  primaryType: 'Mail',
  domain,
  message,
};

// Sign the typed data
const privateKey = '0x4c0883a69102937d6231471b5dbb6204fe512961708279f1d7b1b8e7e8b1b1e1';
const signature = signTyped(typedData, privateKey);
console.log('Signature:', signature);

// Verify the signature
const address = addr.fromPrivateKey(privateKey);
const isValid = verifyTyped(signature, typedData, address);
console.log('Is valid:', isValid);

// Recover the public key
const publicKey = recoverPublicKeyTyped(signature, typedData);
console.log('Recovered:', publicKey);

Archive node connector

npm install micro-ftch

Init network

eth-signer is network-free and makes it easy to audit network-related code: all requests are done with user-provided function, conforming to built-in fetch(). We recommend using micro-ftch, which implements kill-switch, logging, batching / concurrency and other features.

Most APIs (chainlink, uniswap) expect instance of Web3Provider. The call stack would look like this:

  • Chainlink => Web3Provider => jsonrpc => fetch

To initialize Web3Provider, do the following:

// Requests are made with fetch(), a built-in method
import { jsonrpc } from 'micro-ftch';
import { Web3Provider } from 'micro-eth-signer/net.js';
const RPC_URL = 'http://localhost:8545';
const prov = new Web3Provider(jsonrpc(fetch, RPC_URL));

// Example using mewapi RPC
const RPC_URL_2 = 'https://nodes.mewapi.io/rpc/eth';
const prov2 = new Web3Provider(
  jsonrpc(fetch, RPC_URL_2, { Origin: 'https://www.myetherwallet.com' })
);

Fetch balances & history

Note

Basic data can be fetched from any node. Uses trace_filter & requires Erigon, others are too slow.

import { jsonrpc } from 'micro-ftch';
import { Web3Provider } from 'micro-eth-signer/net.js';

const prov = new Web3Provider(jsonrpc(fetch, 'http://localhost:8545'));
const addr = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045';
async function main() {
  const block = await prov.blockInfo(await prov.height());
  console.log('current block', block.number, block.timestamp, block.baseFeePerGas);
  console.log('info for addr', addr, await prov.unspent(addr));
}

// Other methods of Web3Provider:
// blockInfo(block: number): Promise<BlockInfo>; // {baseFeePerGas, hash, timestamp...}
// height(): Promise<number>;
// internalTransactions(address: string, opts?: TraceOpts): Promise<any[]>;
// ethLogsSingle(topics: Topics, opts: LogOpts): Promise<Log[]>;
// ethLogs(topics: Topics, opts?: LogOpts): Promise<Log[]>;
// tokenTransfers(address: string, opts?: LogOpts): Promise<[Log[], Log[]]>;
// wethTransfers(address: string, opts?: LogOpts): Promise<[Log[]]>;
// txInfo(txHash: string, opts?: TxInfoOpts): Promise<{
//   type: "legacy" | "eip2930" | "eip1559" | "eip4844"; info: any; receipt: any; raw: string | undefined;
// }>;
// tokenInfo(address: string): Promise<TokenInfo | undefined>;
// transfers(address: string, opts?: TraceOpts & LogOpts): Promise<TxTransfers[]>;
// allowances(address: string, opts?: LogOpts): Promise<TxAllowances>;
// tokenBalances(address: string, tokens: string[]): Promise<Record<string, bigint>>;

Fetch Chainlink oracle prices

import { jsonrpc } from 'micro-ftch';
import { Chainlink, Web3Provider } from 'micro-eth-signer/net.js';

const prov = new Web3Provider(jsonrpc(fetch, 'http://localhost:8545'));
const link = new Chainlink(prov);
async function main() {
  const btc = await link.coinPrice('BTC');
  const bat = await link.tokenPrice('BAT');
  console.log({ btc, bat }); // BTC 19188.68870991, BAT 0.39728989 in USD
}

Resolve ENS address

import { jsonrpc } from 'micro-ftch';
import { ENS, Web3Provider } from 'micro-eth-signer/net.js';

const prov = new Web3Provider(jsonrpc(fetch, 'http://localhost:8545'));
const ens = new ENS(prov);
async function main() {
  const vitalikAddr = await ens.nameToAddress('vitalik.eth');
}

Swap tokens with Uniswap

Btw cool tool, glad you built it!

Uniswap Founder

Swap 12.12 USDT to BAT with uniswap V3 defaults of 0.5% slippage, 30 min expiration.

import { jsonrpc } from 'micro-ftch';
import { tokenFromSymbol } from 'micro-eth-signer/advanced/abi.js';
import { UniswapV3, Web3Provider } from 'micro-eth-signer/net.js'; // or UniswapV2

const prov = new Web3Provider(jsonrpc(fetch, 'http://localhost:8545'));

const USDT = tokenFromSymbol('USDT');
const BAT = tokenFromSymbol('BAT');
const u3 = new UniswapV3(prov); // or new UniswapV2(provider)
const fromAddress = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045';
const toAddress = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045';
async function main() {
  const swap = await u3.swap(USDT, BAT, '12.12', { slippagePercent: 0.5, ttl: 30 * 60 });
  if (!swap) throw new Error('No swap route found');
  const swapData = await swap.tx(fromAddress, toAddress);
  console.log(swapData.amount, swapData.expectedAmount, swapData.allowance);
}

Advanced

ABI parsing

The ABI is type-safe when as const is specified:

import { createContract } from 'micro-eth-signer/advanced/abi.js';
const PAIR_CONTRACT = [
  {
    type: 'function',
    name: 'getReserves',
    outputs: [
      { name: 'reserve0', type: 'uint112' },
      { name: 'reserve1', type: 'uint112' },
      { name: 'blockTimestampLast', type: 'uint32' },
    ],
  },
] as const;

const contract = createContract(PAIR_CONTRACT);
type Contract = typeof contract;
// Contract type:
// {
//   getReserves: {
//     encodeInput: () => Uint8Array;
//     decodeOutput: (b: Uint8Array) => {
//       reserve0: bigint;
//       reserve1: bigint;
//       blockTimestampLast: bigint;
//     };
//   };
// }

We're parsing values as:

// no inputs
{} -> encodeInput();
// single input
{inputs: [{type: 'uint'}]} -> encodeInput(bigint);
// all inputs named
{inputs: [{type: 'uint', name: 'lol'}, {type: 'address', name: 'wut'}]} -> encodeInput({lol: bigint, wut: string})
// at least one input is unnamed
{inputs: [{type: 'uint', name: 'lol'}, {type: 'address'}]} -> encodeInput([bigint, string])
// Same applies for output!

There are following limitations:

  • Fixed size arrays can have 999 elements at max: string[], string[1], ..., string[999]
  • Fixed size 2d arrays can have 39 elements at max: string[][], string[][1], ..., string[39][39]
  • Which is enough for almost all cases
  • ABI must be described as constant value: [...] as const
  • We're not able to handle contracts with method overload (same function names with different args) — the code will still work, but not types

Check out src/net/ens.ts for type-safe contract execution example.

Clear Signing

The library supports Clear Signing through ERC-7730 descriptor maps via decodeTx, decodeData, and eip712. The previous transaction-display strings are now ERC-7730 descriptors in OURS.

ERC-7730 descriptor maps

CLEARSIG_REPO is the batteries-included descriptor map: the generic ERC interfaces (erc20/erc721/erc4626/...), curated and legacy contracts (uniswap v2/v3, kyber, the metamask swap router, weth), and the built-in token registry already bound to them - including an ERC-2612 permit binding per token.

import { CLEARSIG_REPO, addTokens } from 'micro-eth-signer/advanced/abi.js';
import { CLEARSIG_REPO_FULL } from 'micro-eth-signer/advanced/clearsig-repo-full.js';

// basic: generic ERCs + curated contracts + built-in tokens
const base = CLEARSIG_REPO;

// your own tokens: binds erc20/erc721 interfaces + an ERC-2612 permit per token
const mine = addTokens(CLEARSIG_REPO, {
  '0x0000000000000000000000000000000000000123': {
    abi: 'ERC20',
    symbol: 'MTK',
    decimals: 18,
  },
}); // chainId is optional, defaults to mainnet (1)

// full: every descriptor from the upstream registry on top.
// CLEARSIG_REPO_FULL is about 500KB of generated source; the normal ABI facade
// does not re-export it, so import this separate subpath only when needed.
const full = { ...CLEARSIG_REPO, ...CLEARSIG_REPO_FULL };

ERC-7730 transactions

decodeTx decodes a raw transaction through the built-in ABI and clear-signing registries; matched transactions carry a clearSig promise with the rendered intent and fields. Use decodeData(to, data, value, opts) when you already have RPC calldata fields instead of full transaction hex.

Both default to CLEARSIG_REPO when you omit clearSig; pass { clearSig } to override it - with addTokens(...) output or your own descriptor map. Each call returns the exact decoded call (carrying clearSig), an array of ABI-shape guesses when no exact contract matches (these never carry clearSig), or undefined for unknown selectors and contract creation - so guard with out && !Array.isArray(out) before reading clearSig.

/// <reference types="node" />
import { deepStrictEqual } from 'node:assert';
import { decodeTx } from 'micro-eth-signer/advanced/abi.js';

const tx =
  '0xf8a901851d1a94a20082c12a94dac17f958d2ee523a2206206994597c13d831ec780b844a9059cbb000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000054259870025a066fcb560b50e577f6dc8c8b2e3019f760da78b4c04021382ba490c572a303a42a0078f5af8ac7e11caba9b7dc7a64f7bdc3b4ce1a6ab0a1246771d7cc3524a7200';
const decoded = decodeTx(tx);
if (!decoded || Array.isArray(decoded)) throw new Error('expected exact ABI match');
const clear = await decoded.clearSig;
deepStrictEqual(decoded.value, {
  to: '0xdac17f958d2ee523a2206206994597c13d831ec7',
  value: 22588000000n,
});
deepStrictEqual(clear, {
  intent: 'Send',
  interpolatedIntent: 'Transfer 22588 USDT to 0xdac17f958d2ee523a2206206994597c13d831ec7',
  structuredIntent: [
    'Transfer ',
    { value: '22588 USDT', format: 'tokenAmount', rawValue: 22588000000n },
    ' to ',
    {
      value: '0xdac17f958d2ee523a2206206994597c13d831ec7',
      format: 'addressName',
      rawValue: '0xdac17f958d2ee523a2206206994597c13d831ec7',
    },
  ],
  fields: {
    Amount: { value: '22588 USDT', format: 'tokenAmount', rawValue: 22588000000n },
    To: {
      value: '0xdac17f958d2ee523a2206206994597c13d831ec7',
      format: 'addressName',
      rawValue: '0xdac17f958d2ee523a2206206994597c13d831ec7',
    },
  },
});

So, a user will see Transfer 22588 USDT to 0xdac17f958d2ee523a2206206994597c13d831ec7 instead of 0xf8a901851d1a94a20082c12a94dac17f958d2ee523a2206206994597c13d831ec780b844a9059cbb000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000054259870025a066fcb560b50e577f6dc8c8b2e3019f760da78b4c04021382ba490c572a303a42a0078f5af8ac7e11caba9b7dc7a64f7bdc3b4ce1a6ab0a1246771d7cc3524a7200.

The result drives the signing screen: render intent as the headline and fields (label -> { value, format, rawValue }) as the detail rows. When the descriptor defines an interpolatedIntent, the result also carries interpolatedIntent (a ready-to-print sentence) and structuredIntent (that sentence split into literal strings and formatted field objects, for inline highlighting); both are absent otherwise - the EIP-712 permit further down renders with only intent and fields, so treat those two keys as optional.

Unsigned transactions decode through the same decodeTx - here with a custom token bound via addTokens:

/// <reference types="node" />
import { deepStrictEqual } from 'node:assert';
import { Transaction } from 'micro-eth-signer';
import { CLEARSIG_REPO, addTokens, decodeData, decodeTx } from 'micro-eth-signer/advanced/abi.js';

const to = '0x7a250d5630b4cf539739df2c5dacb4c659f2488d';
const data =
  '7ff36ab5000000000000000000000000000000000000000000000000ab54a98ceb1f0ad30000000000000000000000000000000000000000000000000000000000000080000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045000000000000000000000000000000000000000000000000000000006fd9c6ea0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000106d3c66d22d2dd0446df23d7f5960752994d600';
const value = 100000000000000000n;
// With custom tokens/contracts
const customContracts = {
  '0x106d3c66d22d2dd0446df23d7f5960752994d600': { abi: 'ERC20', symbol: 'LABRA', decimals: 9 },
} as const;

const decodedData = decodeData(to, data, value, { customContracts })!;
if (Array.isArray(decodedData) || !decodedData.clearSig)
  throw new Error('expected exact ABI match');
const { clearSig: _decodedClearSig, ...decodedCall } = decodedData;
deepStrictEqual(decodedCall, {
  name: 'swapExactETHForTokens',
  signature: 'swapExactETHForTokens(uint256,address[],address,uint256)',
  value: {
    amountOutMin: 12345678901234567891n,
    path: [
      '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
      '0x106d3c66d22d2dd0446df23d7f5960752994d600',
    ],
    to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
    deadline: 1876543210n,
  },
});

const customClearSig = addTokens(CLEARSIG_REPO, customContracts);
const unsigned = Transaction.prepare({
  to,
  value,
  data,
  nonce: 0n,
  maxFeePerGas: 2000000000n,
  gasLimit: 250000n,
}).toHex(false);
const decodedSwap = decodeTx(unsigned, { clearSig: customClearSig });
// Arrays are ABI shape guesses used when no exact contract match is available.
if (!decodedSwap || Array.isArray(decodedSwap)) throw new Error('expected exact ABI match');
deepStrictEqual(await decodedSwap.clearSig, {
  intent: 'Swap',
  interpolatedIntent:
    'Swap 0.1 ETH for at least 12345678901.234567891 LABRA. Expires at Tue, 19 Jun 2029 06:00:10 GMT',
  structuredIntent: [
    'Swap ',
    { value: '0.1 ETH', format: 'amount', rawValue: 100000000000000000n },
    ' for at least ',
    {
      value: '12345678901.234567891 LABRA',
      format: 'tokenAmount',
      rawValue: 12345678901234567891n,
    },
    '. Expires at ',
    { value: 'Tue, 19 Jun 2029 06:00:10 GMT', format: 'date', rawValue: 1876543210n },
  ],
  fields: {
    'Amount to Send': {
      value: '0.1 ETH',
      format: 'amount',
      rawValue: 100000000000000000n,
    },
    'Minimum to Receive': {
      value: '12345678901.234567891 LABRA',
      format: 'tokenAmount',
      rawValue: 12345678901234567891n,
    },
    Beneficiary: {
      value: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
      format: 'addressName',
      rawValue: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
    },
    Deadline: {
      value: 'Tue, 19 Jun 2029 06:00:10 GMT',
      format: 'date',
      rawValue: 1876543210n,
    },
  },
});

ERC-7730 does not describe plain value transfers (data: '0x'), so decodeTx produces no clearSig for them; word those in the wallet itself (e.g. "Send 0.5 ETH to ...") instead of showing an unknown-transaction fallback.

Network-backed metadata

Web3Provider.discoverTx wires decodeTx to archive-node callbacks. The clear-signing renderer stays no-network by default; this path adds trusted token metadata, names, NFT metadata, block timestamps, and factory proofs when a provider is available.

import { Web3Provider } from 'micro-eth-signer/net.js';

async function reviewTx(prov: InstanceType<typeof Web3Provider>, txHex: string) {
  const decoded = await prov.discoverTx(txHex);
  if (!decoded || Array.isArray(decoded)) throw new Error('expected exact ABI match');
  return decoded.clearSig;
}

Resolvers are independent of the network path: any ClearSigOpt callback - resolveAddress, resolveToken, resolveNft, resolveBlock, resolveChain - can be passed to decodeTx/eip712 alongside clearSig, e.g. { clearSig: CLEARSIG_REPO, resolveAddress: async ({ address }) => book[address] }. resolveAddress is intentionally left out of discoverTx's bundle - what counts as a trusted name is wallet policy. To teach the renderer about a non-token contract, merge your own ERC-7730 descriptor files into the map ({ ...CLEARSIG_REPO, ...myDescriptors }); descriptor maps are plain Record<string, ClearSigDef>.

EIP-712 typed data

eip712 defaults to CLEARSIG_REPO like decodeTx. Signature requests render through the same repository. addTokens gives every ERC-20 an ERC-2612 permit binding (the upstream permit descriptor ships without deployments, so out of the box it matches nothing), and the bound token metadata makes amounts render offline:

/// <reference types="node" />
import { deepStrictEqual } from 'node:assert';
import { eip712 } from 'micro-eth-signer/advanced/abi.js';
import type { ClearSigTypedInput } from 'micro-eth-signer/advanced/abi.js';

const typed = {
  types: {
    Permit: [
      { name: 'owner', type: 'address' },
      { name: 'spender', type: 'address' },
      { name: 'value', type: 'uint256' },
      { name: 'nonce', type: 'uint256' },
      { name: 'deadline', type: 'uint256' },
    ],
  },
  primaryType: 'Permit',
  domain: {
    name: 'USD Coin',
    version: '2',
    chainId: 1,
    verifyingContract: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
  },
  message: {
    owner: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
    spender: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
    value: 25000000n,
    nonce: 0n,
    deadline: 1893456000n,
  },
} as const;
const clear = (await eip712(typed as unknown as ClearSigTypedInput))!;
deepStrictEqual(clear, {
  intent: 'Authorize spending of tokens',
  fields: {
    Spender: {
      value: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
      format: 'raw',
      rawValue: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
    },
    'Max spending amount': {
      value: '25 USDC',
      format: 'tokenAmount',
      rawValue: 25000000n,
    },
    'Valid until': {
      value: 'Tue, 01 Jan 2030 00:00:00 GMT',
      format: 'date',
      rawValue: 1893456000n,
    },
  },
});

Decoding events

Receipt logs are post-transaction facts, not ERC-7730 signing prompts. Minimal event hints still exist for decoded token events:

/// <reference types="node" />
import { deepStrictEqual } from 'node:assert';
import { decodeEvent } from 'micro-eth-signer/advanced/abi.js';

const to = '0x0d8775f648430679a709e98d2b0cb6250d2887ef';
const topics = [
  '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925',
  '0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045',
  '0x000000000000000000000000e592427a0aece92de3edee1f18e0157c05861564',
];
const data = '0x00000000000000000000000000000000000000000000003635c9adc5dea00000';
const event = decodeEvent(to, topics, data)!;
// Arrays are ABI topic guesses used when no exact contract match is available.
if (Array.isArray(event)) throw new Error('expected exact event match');
deepStrictEqual(event, {
  name: 'Approval',
  signature: 'Approval(address,address,uint256)',
  value: {
    value: 1000000000000000000000n,
    owner: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
    spender: '0xe592427a0aece92de3edee1f18e0157c05861564',
  },
  hint: 'Allow 0xe592427a0aece92de3edee1f18e0157c05861564 spending up to 1000 BAT from 0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
});

BLS EIP-2333 validator keys

micro-eth-signer/advanced/bls.js implements EIP-2333, EIP-2334, and EIP-2335 for Ethereum consensus validator keys.

Online demo: eip2333-tool

npm install @scure/bip39 for mnemonic-to-seed helpers

import { mnemonicToSeedSync } from '@scure/bip39';
import { createDerivedEIP2334Keystores } from 'micro-eth-signer/advanced/bls.js';

const password = 'my_password';
const mnemonic = 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above';
const keyType = 'signing'; // or 'withdrawal'
const indexes = [0, 1, 2, 3]; // create 4 keys

const keystores = createDerivedEIP2334Keystores(
  password,
  'scrypt',
  mnemonicToSeedSync(mnemonic, ''),
  keyType,
  indexes
);

RLP & SSZ

packed allows us to implement RLP in just 100 lines of code, and SSZ in 1500 lines.

SSZ includes EIP-7688 progressive containers.

import { RLP } from 'micro-eth-signer/core/rlp.js';
// More RLP examples in test/rlp.test.ts
RLP.decode(RLP.encode('dog'));
import * as ssz from 'micro-eth-signer/advanced/ssz.js';
// More SSZ examples in test/ssz.test.ts

KZG & PeerDAS

Allows to create & verify KZG EIP-4844 proofs. Supports PeerDAS from EIP-7594.

npm install @paulmillr/trusted-setups

import { KZG } from 'micro-eth-signer/advanced/kzg.js';
// 400kb, 4-sec init
import { trustedSetup } from '@paulmillr/trusted-setups/small-kzg.js';

// 800kb, instant init
// import { trustedSetup } from '@paulmillr/trusted-setups/fast-kzg.js';
// PeerDAS EIP-7594
// import { trustedSetup } from '@paulmillr/trusted-setups/small-peerdas.js';
// import { trustedSetup } from '@paulmillr/trusted-setups/fast-peerdas.js';

// More KZG examples in
// https://github.com/ethereumjs/ethereumjs-monorepo

const kzg = new KZG(trustedSetup);

// Example blob and scalar
const blob = new Array(4096).fill(0n);
const commitment = kzg.blobToKzgCommitment(blob);
const z = 1n;

// Compute and verify proof
const [proof, y] = kzg.computeProof(blob, z);
console.log('Commitment:', commitment);
console.log('Proof:', proof);
console.log('Y:', y);
const isValid = kzg.verifyProof(commitment, z, y, proof);
console.log('Is valid:', isValid);

const blobProof = kzg.computeBlobProof(blob, commitment);
console.log('Blob proof:', blobProof);
console.log('Blob proof valid:', kzg.verifyBlobProof(blob, commitment, blobProof));

Security

  • Commits are signed with PGP keys to prevent forgery. Be sure to verify the commit signatures
  • Releases are made transparently through token-less GitHub CI and Trusted Publishing. Be sure to verify the provenance logs for authenticity.

Main points to consider when auditing the library:

  • ABI correctness
    • All ABI JSON should be compared to some external source
    • There are different databases of ABI: one is hosted by Etherscan, when you open contract page
  • Network access
    • There must be no network calls in the library
    • Some functionality requires network: these need external network interface, conforming to Web3Provider
    • createContract(abi) should create purely offline contract
    • createContract(abi, net) would create contract that calls network using net, using external interface
  • Skipped test vectors
    • There is SKIPPED_ERRORS, which contains list of test vectors from other libs that we skip
    • They are skipped because we consider them invalid, or so
    • If you believe they're skipped for wrong reasons, investigate and report

The library is cross-tested against other libraries (last update on 25 Feb 2024):

  • ethereum-tests v13.1
  • ethers 6.11.1
  • viem v2.7.13

Check out article ZSTs, ABIs, stolen keys and broken legs about caveats of secure ABI parsing found during development of the library.

Privacy considerations

Default priority fee is 1 gwei, which matches what other wallets have. However, it's recommended to fetch recommended priority fee from a node.

Sending whole balance

There is a method setWholeAmount which allows to send whole account balance:

import { Transaction, weigwei, weieth } from 'micro-eth-signer';
const tx = Transaction.prepare({
  to: '0xdf90dea0e0bf5ca6d2a7f0cb86874ba6714f463e',
  value: weieth.decode('1.1'),
  maxFeePerGas: weigwei.decode('100'),
  nonce: 0n,
});
const CURRENT_BALANCE = '1.7182050000017'; // in eth
const txSendingWholeBalance = tx.setWholeAmount(weieth.decode(CURRENT_BALANCE));

It does two things:

  1. amount = accountBalance - maxFeePerGas * gasLimit
  2. maxPriorityFeePerGas = maxFeePerGas

Every eth block sets a fee for all its transactions, called base fee. maxFeePerGas indicates how much gas user is able to spend in the worst case. If the block's base fee is 5 gwei, while user is able to spend 10 gwei in maxFeePerGas, the transaction would only consume 5 gwei. That means, base fee is unknown before the transaction is included in a block.

By setting priorityFee to maxFee, we make the process deterministic: maxFee = 10, maxPriority = 10, baseFee = 5 would always spend 10 gwei. In the end, the balance would become 0.

Warning

Using the method would decrease privacy of a transfer, because payments for services have specific amounts, and not the whole amount.

Speed

npm run bench

Note

The first call of sign will take 20ms+ due to noble-curves secp256k1 BASE point precompute.

decodeTxFrom ethers x 1,014 ops/sec @ 985μs/op
decodeTxFrom micro-eth-signer x 1,035 ops/sec @ 966μs/op

decodeTxHash ethers x 15,716 ops/sec @ 63μs/op
decodeTxHash micro-eth-signer x 24,597 ops/sec @ 40μs/op

sign ethers x 5,477 ops/sec @ 182μs/op
sign viem x 6,427 ops/sec @ 155μs/op
sign micro-eth-signer x 5,339 ops/sec @ 187μs/op

# KZG and PeerDAS
init micro-eth-signer 4ms
init kzg-wasm 190ms

# micro-eth-signer
blobToKzgCommitment x 1 ops/sec @ 550ms/op
computeProof x 135 ops/sec @ 7ms/op
computeBlobProof x 1 ops/sec @ 558ms/op ± 1.42% (557ms..559ms)
verifyProof x 539 ops/sec @ 1ms/op
verifyBlogProof x 146 ops/sec @ 6ms/op
verifyBlobProofBatch x 17 ops/sec @ 56ms/op

# compared to pure WASM kzg-wasm
blobToKZGCommitment x 5 ops/sec @ 192ms/op
computeBlobProof x 5 ops/sec @ 197ms/op ± 1.06% (195ms..201ms)
verifyProof x 377 ops/sec @ 2ms/op
verifyBlogProof x 164 ops/sec @ 6ms/op
verifyBlobProofBatch x 23 ops/sec @ 43ms/op

Contributing

Make sure to use recursive cloning for the eth-vectors submodule:

git clone --recursive https://github.com/paulmillr/micro-eth-signer.git

License

MIT License

Copyright (c) 2021 Paul Miller (https://paulmillr.com)