Backend EIP-712 Signing Guide

Last updated 20 Dec 2025, 09:27

Version: 1.0 Created: December 2025 For: Zenpower Backend Services


Overview

This guide explains how to implement EIP-712 typed data signing for Zenpower smart contracts. All signature-protected functions now use EIP-712 for:

  • Chain ID binding (prevents cross-chain replay)
  • Contract address binding (prevents cross-contract replay)
  • Clear message structure (users can verify what they're signing)
  • Nonce tracking (prevents replay attacks)

Contract Signatures Required

1. TheForge - Start Forge

Function: startForge(ForgePath path, uint256 zenCommitted, bytes signature)

EIP-712 Type:

ForgeStart(address forger,uint8 path,uint256 zenCommitted,bytes32 forgeId,uint256 nonce)

Domain:

{
  name: "TheForge",
  version: "2",
  chainId: 8453, // Base mainnet (or 84532 for Base Sepolia)
  verifyingContract: "0x..." // TheForge contract address
}

Implementation (ethers.js v6):

import { ethers } from "ethers";

const FORGE_DOMAIN = {
  name: "TheForge",
  version: "2",
  chainId: 8453,
  verifyingContract: THEFORGE_ADDRESS,
};

const FORGE_START_TYPES = {
  ForgeStart: [
    { name: "forger", type: "address" },
    { name: "path", type: "uint8" },
    { name: "zenCommitted", type: "uint256" },
    { name: "forgeId", type: "bytes32" },
    { name: "nonce", type: "uint256" },
  ],
};

async function signForgeStart(
  signer: ethers.Signer,
  forger: string,
  path: number,
  zenCommitted: bigint,
  forgeId: string,
  nonce: bigint
): Promise<string> {
  const message = {
    forger,
    path,
    zenCommitted: zenCommitted.toString(),
    forgeId,
    nonce: nonce.toString(),
  };

  return await signer.signTypedData(FORGE_DOMAIN, FORGE_START_TYPES, message);
}

Python (eth-account):

from eth_account import Account
from eth_account.messages import encode_typed_data

FORGE_DOMAIN = {
    "name": "TheForge",
    "version": "2",
    "chainId": 8453,
    "verifyingContract": THEFORGE_ADDRESS,
}

FORGE_START_TYPES = {
    "EIP712Domain": [
        {"name": "name", "type": "string"},
        {"name": "version", "type": "string"},
        {"name": "chainId", "type": "uint256"},
        {"name": "verifyingContract", "type": "address"},
    ],
    "ForgeStart": [
        {"name": "forger", "type": "address"},
        {"name": "path", "type": "uint8"},
        {"name": "zenCommitted", "type": "uint256"},
        {"name": "forgeId", "type": "bytes32"},
        {"name": "nonce", "type": "uint256"},
    ],
}

def sign_forge_start(
    private_key: str,
    forger: str,
    path: int,
    zen_committed: int,
    forge_id: bytes,
    nonce: int,
) -> str:
    message = {
        "forger": forger,
        "path": path,
        "zenCommitted": zen_committed,
        "forgeId": forge_id.hex() if isinstance(forge_id, bytes) else forge_id,
        "nonce": nonce,
    }

    signable = encode_typed_data(
        domain_data=FORGE_DOMAIN,
        message_types=FORGE_START_TYPES,
        message_data=message,
    )

    signed = Account.sign_message(signable, private_key)
    return signed.signature.hex()

2. TheForge - Claim Forge

Function: claimForge(bytes32 forgeId, bytes completionSignature)

EIP-712 Type:

ForgeClaim(bytes32 forgeId,address forger,uint256 nonce)

Implementation (ethers.js v6):

const FORGE_CLAIM_TYPES = {
  ForgeClaim: [
    { name: "forgeId", type: "bytes32" },
    { name: "forger", type: "address" },
    { name: "nonce", type: "uint256" },
  ],
};

async function signForgeClaim(
  signer: ethers.Signer,
  forgeId: string,
  forger: string,
  nonce: bigint
): Promise<string> {
  const message = {
    forgeId,
    forger,
    nonce: nonce.toString(),
  };

  return await signer.signTypedData(FORGE_DOMAIN, FORGE_CLAIM_TYPES, message);
}

3. PunkSeed - Mint with Signature

Function: mintWithSignature(bytes signature)

Message Format: Simple signed message (not EIP-712)

keccak256(abi.encodePacked(to, nonce, "MINT_PUNK_SEED"))

Implementation (ethers.js v6):

async function signPunkSeedMint(
  signer: ethers.Signer,
  toAddress: string,
  nonce: bigint
): Promise<string> {
  // Create message hash
  const messageHash = ethers.solidityPackedKeccak256(
    ["address", "uint256", "string"],
    [toAddress, nonce, "MINT_PUNK_SEED"]
  );

  // Sign the hash
  return await signer.signMessage(ethers.getBytes(messageHash));
}

Python:

from eth_account import Account
from eth_account.messages import encode_defunct
from web3 import Web3

def sign_punk_seed_mint(private_key: str, to_address: str, nonce: int) -> str:
    # Create message hash
    message_hash = Web3.solidity_keccak(
        ["address", "uint256", "string"],
        [to_address, nonce, "MINT_PUNK_SEED"]
    )

    # Sign the hash
    signable = encode_defunct(message_hash)
    signed = Account.sign_message(signable, private_key)
    return signed.signature.hex()

Generating Forge IDs

The forge ID is generated on-chain as:

forgeId = keccak256(abi.encodePacked(forger, path, timestamp, nonce));

Backend must predict this:

function generateForgeId(
  forger: string,
  path: number,
  timestamp: bigint,
  nonce: bigint
): string {
  return ethers.solidityPackedKeccak256(
    ["address", "uint8", "uint256", "uint256"],
    [forger, path, timestamp, nonce]
  );
}

Important: The timestamp should match block.timestamp when the user's transaction is mined. For best UX:

  1. Get current block timestamp from RPC
  2. Add small buffer (e.g., 10-30 seconds)
  3. Include in signature
  4. User has window to submit transaction

Nonce Management

Getting User Nonce

const forge = new ethers.Contract(THEFORGE_ADDRESS, THEFORGE_ABI, provider);
const nonce = await forge.getNonce(userAddress);
nonce = forge_contract.functions.getNonce(user_address).call()

Best Practices

  1. Always fetch fresh nonce before signing
  2. Cache nonces for short periods (< 5 seconds)
  3. Handle nonce collisions if user has pending transactions
  4. Increment locally after successful signature

Security Checklist

Signer Key Management

  • Store signer private key in HSM or secure vault
  • Never log or expose signer key
  • Rotate keys periodically
  • Use separate keys for testnet/mainnet
  • Monitor for unauthorized signature requests

Request Validation

  • Validate user address format
  • Check user has required ZEN$ balance (off-chain)
  • Rate limit signature requests (10/min/address)
  • Log all signature requests with timestamps
  • Implement cooldown between forges

Signature Storage

  • Store issued signatures with metadata
  • Track which signatures have been used on-chain
  • Implement signature expiration (optional)
  • Alert on signature reuse attempts

API Endpoints

Request Forge Signature

POST /api/forge/start
Content-Type: application/json

{
  "forger": "0x...",
  "path": 0,
  "zenCommitted": "100000000000000000000"
}

Response:
{
  "forgeId": "0x...",
  "signature": "0x...",
  "nonce": 5,
  "expiresAt": 1703145600
}

Request Claim Signature

POST /api/forge/claim
Content-Type: application/json

{
  "forgeId": "0x...",
  "forger": "0x..."
}

Response:
{
  "signature": "0x...",
  "nonce": 6
}

Request PunkSeed Mint Signature

POST /api/punk-seed/mint
Content-Type: application/json

{
  "address": "0x..."
}

Response:
{
  "signature": "0x...",
  "nonce": 0
}

Error Handling

Common Errors

Error Cause Solution
InvalidSignature Wrong signer key or malformed message Check signer address matches contract
SignatureAlreadyUsed Replay attempt Fetch fresh nonce
Nonce mismatch Stale nonce Refetch nonce from contract
Chain ID mismatch Wrong network Update domain chainId

Debugging

  1. Verify domain separator:

    const contractDomain = await forge.domainSeparator();
    const localDomain = ethers.TypedDataEncoder.hashDomain(FORGE_DOMAIN);
    console.log("Match:", contractDomain === localDomain);
    
  2. Log signature components:

    console.log("Message hash:", ethers.TypedDataEncoder.hash(FORGE_DOMAIN, FORGE_START_TYPES, message));
    console.log("Signature:", signature);
    console.log("Recovered:", ethers.recoverAddress(hash, signature));
    

Testing

Unit Test Template

describe("Forge Signing", () => {
  it("should generate valid signature", async () => {
    const signer = new ethers.Wallet(TEST_SIGNER_KEY);
    const signature = await signForgeStart(
      signer,
      USER_ADDRESS,
      0, // LABOR path
      ethers.parseEther("100"),
      forgeId,
      0n
    );

    // Verify on contract
    const forge = new ethers.Contract(THEFORGE_ADDRESS, THEFORGE_ABI, provider);
    const tx = await forge.connect(user).startForge(0, ethers.parseEther("100"), signature);
    await tx.wait();
  });
});

Migration from v1

If upgrading from non-EIP-712 signatures:

  1. Deploy new contract with EIP-712 support
  2. Update backend signing code
  3. Update frontend to fetch signatures from new endpoint
  4. Migrate active forges (if needed)
  5. Deprecate old endpoint after migration period

References


Document Owner: Zenpower Backend Team Last Updated: December 2025