Skip to content

Latest commit

 

History

History
565 lines (442 loc) · 19.2 KB

README.md

File metadata and controls

565 lines (442 loc) · 19.2 KB

micro-eth-signer

Minimal library for Ethereum transactions, addresses and smart contracts.

  • 🔓 Secure: 3 deps, audited noble cryptography, no network code
  • 🔻 Tree-shakeable: unused code is excluded from your builds
  • 🔍 Reliable: 150MB of test vectors from EIPs, ethers and viem
  • ✍️ Core: transactions, addresses, messages
  • 🌍 Network-related: execute Uniswap & Chainlink, fetch tx history
  • 🦺 Advanced: type-safe ABI parsing, RLP, SSZ, KZG, Verkle
  • 🪶 29KB gzipped (1300 lines) for core

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

Usage

npm install micro-eth-signer

We support all major platforms and runtimes. For Deno, ensure to use npm specifier. 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

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 { Transaction, weigwei, weieth } from 'micro-eth-signer';
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);

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

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

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 * as typed from 'micro-eth-signer/typed-data';

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

// Sign the message
const signature = typed.personal.sign(message, privateKey);
console.log("Signature:", signature);

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

EIP-712

import * as typed from 'micro-eth-signer/typed-data';

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: typed.EIP712Domain = {
  name: 'Ether Mail',
  version: '1',
  chainId: 1,
  verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
  salt: '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: typed.TypedData<typeof types, 'Mail'> = {
  types,
  primaryType: 'Mail',
  domain,
  message
};

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

// Verify the signature
const address = "0xYourEthereumAddress";
const isValid = typed.verifyTyped(signature, typedData, address);

// Recover the public key
const publicKey = typed.recoverPublicKeyTyped(signature, typedData);

Network-related

A common problem in web3 libraries is how complex they are to audit with regards to network calls.

In eth-signer, all network calls are done with user-provided function, conforming to built-in fetch():

  1. This makes library network-free, which simplifies auditability
  2. User fully controls all network requests

It's recommended to use micro-ftch, which works on top of fetch and implements killswitch, logging, concurrency limits and other features.

Init network

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';
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.

const addr = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045';
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 { Chainlink } from 'micro-eth-signer/net';
const link = new Chainlink(prov);
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 { ENS } from 'micro-eth-signer/net';
const ens = new ENS(prov);
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 { tokenFromSymbol } from 'micro-eth-signer/abi';
import { UniswapV3 } from 'micro-eth-signer/net'; // or UniswapV2

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

Advanced

Type-safe ABI parsing

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

import { createContract } from 'micro-eth-signer/abi';
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);
// Would create following typescript 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.

Human-readable transaction hints

The transaction sent ERC-20 USDT token between addresses. The library produces a following hint:

Transfer 22588 USDT to 0xdac17f958d2ee523a2206206994597c13d831ec7

import { decodeTx } from 'micro-eth-signer/abi';

const tx =
  '0xf8a901851d1a94a20082c12a94dac17f958d2ee523a2206206994597c13d831ec780b844a9059cbb000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000054259870025a066fcb560b50e577f6dc8c8b2e3019f760da78b4c04021382ba490c572a303a42a0078f5af8ac7e11caba9b7dc7a64f7bdc3b4ce1a6ab0a1246771d7cc3524a7200';
// Decode tx information
deepStrictEqual(decodeTx(tx), {
  name: 'transfer',
  signature: 'transfer(address,uint256)',
  value: {
    to: '0xdac17f958d2ee523a2206206994597c13d831ec7',
    value: 22588000000n,
  },
  hint: 'Transfer 22588 USDT to 0xdac17f958d2ee523a2206206994597c13d831ec7',
});

Or if you have already decoded tx:

import { decodeData } from 'micro-eth-signer/abi';

const to = '0x7a250d5630b4cf539739df2c5dacb4c659f2488d';
const data =
  '7ff36ab5000000000000000000000000000000000000000000000000ab54a98ceb1f0ad30000000000000000000000000000000000000000000000000000000000000080000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045000000000000000000000000000000000000000000000000000000006fd9c6ea0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000106d3c66d22d2dd0446df23d7f5960752994d600';
const value = 100000000000000000n;

deepStrictEqual(decodeData(to, data, value, { customContracts }), {
  name: 'swapExactETHForTokens',
  signature: 'swapExactETHForTokens(uint256,address[],address,uint256)',
  value: {
    amountOutMin: 12345678901234567891n,
    path: [
      '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
      '0x106d3c66d22d2dd0446df23d7f5960752994d600',
    ],
    to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
    deadline: 1876543210n,
  },
});

// With custom tokens/contracts
const customContracts = {
  '0x106d3c66d22d2dd0446df23d7f5960752994d600': { abi: 'ERC20', symbol: 'LABRA', decimals: 9 },
};
deepStrictEqual(decodeData(to, data, value, { customContracts }), {
  name: 'swapExactETHForTokens',
  signature: 'swapExactETHForTokens(uint256,address[],address,uint256)',
  value: {
    amountOutMin: 12345678901234567891n,
    path: [
      '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
      '0x106d3c66d22d2dd0446df23d7f5960752994d600',
    ],
    to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
    deadline: 1876543210n,
  },
  hint: 'Swap 0.1 ETH for at least 12345678901.234567891 LABRA. Expires at Tue, 19 Jun 2029 06:00:10 GMT',
});

Human-readable event hints

Decoding the event produces the following hint:

Allow 0xe592427a0aece92de3edee1f18e0157c05861564 spending up to 1000 BAT from 0xd8da6bf26964af9d7eed9e03e53415d37aa96045

import { decodeEvent } from 'micro-eth-signer/abi';

const to = '0x0d8775f648430679a709e98d2b0cb6250d2887ef';
const topics = [
  '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925',
  '0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045',
  '0x000000000000000000000000e592427a0aece92de3edee1f18e0157c05861564',
];
const data = '0x00000000000000000000000000000000000000000000003635c9adc5dea00000';
const einfo = decodeEvent(to, topics, data);
console.log(einfo);

RLP & SSZ

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

SSZ includes EIP-7495 stable containers.

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

KZG & Verkle

Allows to create & verify KZG EIP-4844 proofs.

import * as verkle from 'micro-eth-signer/verkle';

import { KZG } from 'micro-eth-signer/kzg';
// 400kb, 4-sec init
import { trustedSetup } from '@paulmillr/trusted-setups';
// 800kb, instant init
import { trustedSetup as fastSetup } from '@paulmillr/trusted-setups/fast.js';

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

const kzg = new KZG(trustedSetup);

// Example blob and scalar
const blob = '0x1234567890abcdef'; // Add actual blob data
const z = '0x1'; // Add actual scalar

// Compute and verify proof
const [proof, y] = kzg.computeProof(blob, z);
console.log('Proof:', proof);
console.log('Y:', y);
const commitment = '0x1234567890abcdef'; // Add actual commitment
const z = '0x1'; // Add actual scalar
// const y = '0x2'; // Add actual y value
const proof = '0x3'; // Add actual proof
const isValid = kzg.verifyProof(commitment, z, y, proof);
console.log('Is valid:', isValid);

// Compute and verify blob proof
const blob = '0x1234567890abcdef'; // Add actual blob data
const commitment = '0x1'; // Add actual commitment
const proof = kzg.computeBlobProof(blob, commitment);
console.log('Blob proof:', proof);
const isValidB = kzg.verifyBlobProof(blob, commitment, proof);

Security

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:

const CURRENT_BALANCE = '1.7182050000017'; // in eth
const txSendingWholeBalance = unsignedTx.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.

Performance

Transaction signature matches noble-curves sign() speed, which means over 4000 times per second on an M2 mac.

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

To run benchmarks, execute npm run bench.

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)