ProvenPin Install

ethers v6 offline wallet signing: working versions (tested 2026-10-15)

Sign and verify plain messages and EIP-712 typed data with an ethers v6 Wallet, encode/decode ERC-20 transfer calldata, and convert token units, all fully offline with no provider.

passed5 passed · 0 failed2026-10-15 22:58 UTC
Status
passed
Last passed
2026-10-15 22:58 UTC
Tests
5 / 5
Language
TypeScript
Runtime
node v22.23.3
pinned versions3
ethers@6.17.0@types/node@26.6.5tsx@4.23.15
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38820570375

The code

Exactly the files that passed. Your agent can pull them with get_recipe("ethers-v6-wallet-signing") (install), or see them on GitHub.

package.json
{
  "name": "ethers-v6-wallet-signing",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "ethers": "6.17.0"
  },
  "devDependencies": {
    "@types/node": "26.6.5",
    "tsx": "4.23.15"
  }
}
src/wallet.ts
import {
  Interface,
  Wallet,
  formatUnits,
  parseUnits,
  verifyMessage,
  verifyTypedData,
  type TypedDataDomain,
  type TypedDataField,
} from "ethers";

export const TEST_PRIVATE_KEY =
  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";

export const TEST_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";

export const ERC20_ABI = [
  "function transfer(address to, uint256 amount) returns (bool)",
  "function balanceOf(address owner) view returns (uint256)",
];

export function createTestWallet(): Wallet {
  return new Wallet(TEST_PRIVATE_KEY);
}

export function createErc20Interface(): Interface {
  return new Interface(ERC20_ABI);
}

export const TYPED_DATA_DOMAIN: TypedDataDomain = {
  name: "ProvenPin",
  version: "1",
  chainId: 1,
  verifyingContract: "0x000000000000000000000000000000000000dEaD",
};

export const MAIL_TYPES: Record<string, TypedDataField[]> = {
  Mail: [
    { name: "from", type: "address" },
    { name: "to", type: "address" },
    { name: "contents", type: "string" },
  ],
};

export type Mail = {
  from: string;
  to: string;
  contents: string;
};

export async function signMessage(wallet: Wallet, message: string): Promise<string> {
  return wallet.signMessage(message);
}

export function recoverMessageSigner(message: string, signature: string): string {
  return verifyMessage(message, signature);
}

export async function signMail(wallet: Wallet, mail: Mail): Promise<string> {
  return wallet.signTypedData(TYPED_DATA_DOMAIN, MAIL_TYPES, mail);
}

export function recoverMailSigner(mail: Mail, signature: string): string {
  return verifyTypedData(TYPED_DATA_DOMAIN, MAIL_TYPES, mail, signature);
}

export function encodeErc20Transfer(to: string, amount: bigint): string {
  return createErc20Interface().encodeFunctionData("transfer", [to, amount]);
}

export function decodeErc20Transfer(data: string): { to: string; amount: bigint } {
  const decoded = createErc20Interface().decodeFunctionData("transfer", data);
  return { to: decoded[0] as string, amount: decoded[1] as bigint };
}

export function parseErc20Transfer(data: string): {
  name: string;
  to: string;
  amount: bigint;
} {
  const parsed = createErc20Interface().parseTransaction({ data });
  if (!parsed) {
    throw new Error("calldata does not match the ERC-20 transfer selector");
  }
  return {
    name: parsed.name,
    to: parsed.args[0] as string,
    amount: parsed.args[1] as bigint,
  };
}

export function toBaseUnits(value: string, decimals = 18): bigint {
  return parseUnits(value, decimals);
}

export function fromBaseUnits(value: bigint, decimals = 18): string {
  return formatUnits(value, decimals);
}
test/wallet.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { getAddress } from "ethers";
import {
  TEST_ADDRESS,
  createTestWallet,
  decodeErc20Transfer,
  encodeErc20Transfer,
  fromBaseUnits,
  parseErc20Transfer,
  recoverMailSigner,
  recoverMessageSigner,
  signMail,
  signMessage,
  toBaseUnits,
} from "../src/wallet.ts";

test("derives a deterministic address from the fixed private key", () => {
  const wallet = createTestWallet();
  assert.equal(getAddress(wallet.address), getAddress(TEST_ADDRESS));
});

test("signs and verifies a plain message", async () => {
  const wallet = createTestWallet();
  const message = "Hello ProvenPin";
  const signature = await signMessage(wallet, message);

  assert.equal(recoverMessageSigner(message, signature), getAddress(TEST_ADDRESS));
  assert.notEqual(recoverMessageSigner("tampered message", signature), getAddress(TEST_ADDRESS));
});

test("signs and verifies EIP-712 typed data", async () => {
  const wallet = createTestWallet();
  const mail = {
    from: TEST_ADDRESS,
    to: "0x000000000000000000000000000000000000dEaD",
    contents: "gm",
  };

  const signature = await signMail(wallet, mail);

  assert.equal(recoverMailSigner(mail, signature), getAddress(TEST_ADDRESS));
  assert.notEqual(
    recoverMailSigner({ ...mail, contents: "gn" }, signature),
    getAddress(TEST_ADDRESS),
  );
});

test("encodes and decodes an ERC-20 transfer call", () => {
  const to = "0x000000000000000000000000000000000000dEaD";
  const amount = 1_000_000n;
  const data = encodeErc20Transfer(to, amount);

  assert.ok(data.startsWith("0xa9059cbb"), "uses the transfer(address,uint256) selector");

  const decoded = decodeErc20Transfer(data);
  assert.equal(getAddress(decoded.to), getAddress(to));
  assert.equal(decoded.amount, amount);

  const parsed = parseErc20Transfer(data);
  assert.equal(parsed.name, "transfer");
  assert.equal(getAddress(parsed.to), getAddress(to));
  assert.equal(parsed.amount, amount);
});

test("parses and formats token units", () => {
  assert.equal(toBaseUnits("1.5", 18), 1_500_000_000_000_000_000n);
  assert.equal(toBaseUnits("1", 6), 1_000_000n);
  assert.equal(fromBaseUnits(1_500_000_000_000_000_000n, 18), "1.5");
  assert.equal(fromBaseUnits(1_000_000n, 6), "1.0");
});

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.