Backend EIP-712 Signing Guide
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:
- Get current block timestamp from RPC
- Add small buffer (e.g., 10-30 seconds)
- Include in signature
- 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
- Always fetch fresh nonce before signing
- Cache nonces for short periods (< 5 seconds)
- Handle nonce collisions if user has pending transactions
- 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
Verify domain separator:
const contractDomain = await forge.domainSeparator(); const localDomain = ethers.TypedDataEncoder.hashDomain(FORGE_DOMAIN); console.log("Match:", contractDomain === localDomain);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:
- Deploy new contract with EIP-712 support
- Update backend signing code
- Update frontend to fetch signatures from new endpoint
- Migrate active forges (if needed)
- Deprecate old endpoint after migration period
References
Document Owner: Zenpower Backend Team Last Updated: December 2025