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
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.