ProvenPin Install

viem offline sign and verify: working versions (tested 2026-10-15)

Create a local viem account from a fixed test private key fully offline: sign and verify a message, sign EIP-712 typed data and recover the signer, and round-trip an ERC-20 transfer call with encodeFunctionData/decodeFunctionData and parseUnits/formatUnits.

passed6 passed · 0 failed2026-10-15 22:58 UTC
Status
passed
Last passed
2026-10-15 22:58 UTC
Tests
6 / 6
Language
TypeScript
Runtime
node v22.23.3
pinned versions3
viem@2.57.7@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("viem-offline-sign-verify") (install), or see them on GitHub.

package.json
{
  "name": "viem-offline-sign-verify",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "viem": "2.57.7"
  },
  "devDependencies": {
    "@types/node": "26.6.5",
    "tsx": "4.23.15"
  }
}
src/sign.ts
import { privateKeyToAccount } from "viem/accounts";
import {
  decodeFunctionData,
  encodeFunctionData,
  erc20Abi,
  formatUnits,
  parseUnits,
  recoverTypedDataAddress,
  verifyMessage,
  type Address,
  type Hex,
} from "viem";

export const TEST_PRIVATE_KEY =
  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const;

export const account = privateKeyToAccount(TEST_PRIVATE_KEY);

export async function signAndVerifyMessage(
  message: string,
): Promise<{ signature: Hex; valid: boolean }> {
  const signature = await account.signMessage({ message });
  const valid = await verifyMessage({
    address: account.address,
    message,
    signature,
  });
  return { signature, valid };
}

export type TransferIntent = {
  token: Address;
  to: Address;
  value: bigint;
  nonce: bigint;
};

export async function signTypedDataAndRecover(
  intent: TransferIntent,
): Promise<{ signature: Hex; recovered: Address }> {
  const typedData = {
    domain: {
      name: "ProvenPinToken",
      version: "1",
      chainId: 1,
      verifyingContract: intent.token,
    },
    types: {
      Transfer: [
        { name: "to", type: "address" },
        { name: "value", type: "uint256" },
        { name: "nonce", type: "uint256" },
      ],
    },
    primaryType: "Transfer",
    message: {
      to: intent.to,
      value: intent.value,
      nonce: intent.nonce,
    },
  } as const;

  const signature = await account.signTypedData(typedData);
  const recovered = await recoverTypedDataAddress({ ...typedData, signature });
  return { signature, recovered };
}

export function encodeTransfer(to: Address, amount: bigint): Hex {
  return encodeFunctionData({
    abi: erc20Abi,
    functionName: "transfer",
    args: [to, amount],
  });
}

export function decodeTransfer(data: Hex): { to: Address; amount: bigint } {
  const decoded = decodeFunctionData({ abi: erc20Abi, data });
  if (decoded.functionName !== "transfer") {
    throw new Error(`unexpected function selector: ${decoded.functionName}`);
  }
  const [to, amount] = decoded.args as readonly [Address, bigint];
  return { to, amount };
}

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

export function fromBaseUnits(amount: bigint, decimals = 18): string {
  return formatUnits(amount, decimals);
}
test/sign.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { verifyMessage } from "viem";
import {
  TEST_PRIVATE_KEY,
  account,
  decodeTransfer,
  encodeTransfer,
  fromBaseUnits,
  signAndVerifyMessage,
  signTypedDataAndRecover,
  toBaseUnits,
} from "../src/sign.ts";

const EXPECTED_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";

test("derives a deterministic account offline from the fixed key", () => {
  assert.equal(TEST_PRIVATE_KEY.length, 66);
  assert.equal(account.address.toLowerCase(), EXPECTED_ADDRESS.toLowerCase());
});

test("signs a message and verifies it offline", async () => {
  const { signature, valid } = await signAndVerifyMessage("hello provenpin");
  assert.match(signature, /^0x[0-9a-f]+$/);
  assert.equal(valid, true);
});

test("verifyMessage rejects a tampered message", async () => {
  const { signature } = await signAndVerifyMessage("hello provenpin");
  const valid = await verifyMessage({
    address: account.address,
    message: "hello tampered",
    signature,
  });
  assert.equal(valid, false);
});

test("signs EIP-712 typed data and recovers the signer", async () => {
  const { signature, recovered } = await signTypedDataAndRecover({
    token: "0x0000000000000000000000000000000000000001",
    to: "0x0000000000000000000000000000000000000002",
    value: 1_000_000n,
    nonce: 7n,
  });
  assert.match(signature, /^0x[0-9a-f]+$/);
  assert.equal(recovered.toLowerCase(), account.address.toLowerCase());
});

test("encodes and decodes an ERC-20 transfer call offline", () => {
  const amount = toBaseUnits("1.5", 18);
  assert.equal(amount, 1_500_000_000_000_000_000n);
  const data = encodeTransfer(EXPECTED_ADDRESS, amount);
  assert.equal(data.slice(0, 10), "0xa9059cbb");
  const decoded = decodeTransfer(data);
  assert.equal(decoded.to.toLowerCase(), EXPECTED_ADDRESS.toLowerCase());
  assert.equal(decoded.amount, amount);
  assert.equal(fromBaseUnits(amount, 18), "1.5");
});

test("parseUnits/formatUnits round-trip for 6 decimals", () => {
  const units = toBaseUnits("12.34", 6);
  assert.equal(units, 12_340_000n);
  assert.equal(fromBaseUnits(units, 6), "12.34");
});

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.