EIP-712 Signatures
This document covers EIP-712 signature implementation in Kaizen, including cross-language compatibility between the TypeScript SDK and Rust backend.
Overview
Kaizen uses EIP-712 for typed structured data signing:
- Human-readable prompts in wallets (MetaMask, etc.)
- Replay protection via domain separator
- Type-safe message hashing
Signing Flow
Domain Separator
const DOMAIN = {
name: "Kaizen",
version: "1",
chainId: 1, // Fixed - Kaizen uses mainnet chainId for compatibility
};The domain separator is computed as:
function computeDomainSeparator(): Hex {
const typeHash = keccak256(
toHex("EIP712Domain(string name,string version,uint256 chainId)")
);
const nameHash = keccak256(toHex("Kaizen"));
const versionHash = keccak256(toHex("1"));
const chainIdBytes = encodeU256LE(1n); // Little-endian for Borsh!
return keccak256(concat([typeHash, nameHash, versionHash, chainIdBytes]));
}
// Result: 0x5db2fe127c2952c912f01052d7ee81802fd227c82f010ef3cfa2660ae3840690Type Hashes
Each transaction type has a unique type hash. All types use the Kaizen: prefix convention:
| Type | Type String |
|---|---|
| Kaizen | Kaizen:Transfer(uint64 timestamp,address from,address to,uint64 amount) |
| Kaizen | Kaizen:NominateApiWallet(uint64 timestamp,address from,address wallet,uint64 expiry) |
| Kaizen | Kaizen:RevokeApiWallet(uint64 timestamp,address from,address wallet) |
| Kaizen | Kaizen:Deposit(uint64 timestamp,address from,address user,uint64 amount,bytes32 externalTxHash) |
| Kaizen | Kaizen:Withdraw(uint64 timestamp,address from,uint64 amount,address destination) |
| Kaizen | Kaizen:ProcessWithdrawal(uint64 timestamp,address from,uint64 withdrawalId,bytes32 externalTxHash) |
| Kaizen | Kaizen:RfqSubmit(uint64 timestamp,address from,bytes32 quoteHash) |
| Kaizen | Kaizen:RfqSettle(uint64 timestamp,address from,uint64 thesisId) |
| Kaizen | Kaizen:SystemSettle(uint64 timestamp,address from,uint64 thesisId,uint8 settlementType,uint64 breachTimestamp,uint64 breachPrice) |
| Kaizen | Kaizen:GenericTx(uint64 timestamp,address from,bytes payload) |
Signing Methods
EIP-712 (User Wallets)
For MetaMask and hardware wallets:
// Using viem's account.sign for raw EIP-712 hash
const signature = await account.sign({ hash: messageHash });RawBorsh (API Wallets)
For server-side API wallets:
let hash = sha256(timestamp || from || borsh(payload));
let sig = secp256k1_sign(key, hash);Cross-Language Compatibility
Borsh Serialization
The SDK uses Borsh for binary serialization to match Rust.
Endianness Rules
| Type | Borsh Serialization | EIP-712 encodeData |
|---|---|---|
u8 | Single byte | Single byte |
u32, u64 | Little-endian | Big-endian (32 bytes) |
U256 | Little-endian | Big-endian (32 bytes) |
Address | 20 bytes raw | 32 bytes (right-aligned) |
// Borsh serialization (little-endian) - for TypedDataDomain.chainId
function encodeU256LE(value: bigint): Uint8Array {
const bytes = new Uint8Array(32);
let v = value;
for (let i = 0; i < 32; i++) {
// Start from index 0
bytes[i] = Number(v & 0xffn);
v = v >> 8n;
}
return bytes;
}
// EIP-712 encodeData (big-endian) - for struct fields in signing
function encodeU256BE(value: bigint): Uint8Array {
const bytes = new Uint8Array(32);
let v = value;
for (let i = 31; i >= 0; i--) {
// Start from index 31
bytes[i] = Number(v & 0xffn);
v = v >> 8n;
}
return bytes;
}Enum Variant Discriminant
Borsh enums include a variant discriminant byte:
// Rust enum:
// enum TxPayload {
// Transfer = 0,
// NominateApiWallet = 1,
// ...
// OracleFeed = 9,
// }
// TypeScript must include the discriminant via @variant decorator:
@variant(0)
export class TransferTxSchema { ... }
@variant(9)
export class OracleFeedTxSchema { ... }Signing Method
// ❌ WRONG - adds Ethereum Signed Message prefix
const sig = await account.signMessage({ message: { raw: messageHash } });
// ✅ CORRECT - signs the raw hash
const sig = await account.sign({ hash: messageHash });signMessage adds the prefix "\x19Ethereum Signed Message:\n32" which breaks EIP-712 verification.
Signature v Parameter
Rust expects v as 0 or 1 (parity), but viem returns 27 or 28:
const rawSignature = await account.sign({ hash: messageHash });
const sigBytes = hexToBytes(rawSignature);
// Convert v from 27/28 to 0/1
const v = sigBytes[64];
sigBytes[64] = v === 27 || v === 0x1b ? 0 : 1;Complete Example
import { keccak256, toHex, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { serialize } from "@dao-xyz/borsh";
async function signTransfer(
privateKey: Hex,
to: Address,
amount: bigint,
timestamp: bigint
): Promise<Hex> {
const account = privateKeyToAccount(privateKey);
// 1. Type hash (Convention: "Kaizen:{{method}}")
const typeHash = keccak256(
toHex(
"Kaizen:Transfer(uint64 timestamp,address from,address to,uint64 amount)"
)
);
// 2. Encode data (EIP-712: big-endian)
const encodedData = concatBytes(
encodeU256BE(timestamp),
encodeAddressAsBytes32(account.address),
encodeAddressAsBytes32(to),
encodeU256BE(amount)
);
// 3. Struct hash
const structHash = keccak256(
bytesToHex(concatBytes(hexToBytes(typeHash), encodedData))
);
// 4. Domain separator
const domainSeparator = computeDomainSeparator();
// 5. Final message hash
const messageHash = keccak256(
bytesToHex(
concatBytes(
new Uint8Array([0x19, 0x01]),
hexToBytes(domainSeparator),
hexToBytes(structHash)
)
)
);
// 6. Sign (NOT signMessage!)
const rawSig = await account.sign({ hash: messageHash });
// 7. Fix v parameter (27/28 → 0/1)
const sigBytes = hexToBytes(rawSig);
sigBytes[64] = sigBytes[64] === 27 ? 0 : 1;
// 8. Serialize with Borsh
const txSchema = new TransactionSchema({
from: account.address,
timestamp,
payload: new TransferTxSchema({ to, amount }),
signature: new EIP712SignatureSchema({
domain: new TypedDataDomainSchema({ chainId: 1 }),
signature: bytesToHex(sigBytes),
}),
});
return bytesToHex(serialize(txSchema));
}Troubleshooting
Signature Verification Fails
Check these in sequence:
1. Domain Separator
// Expected (chainId=1):
// 0x5db2fe127c2952c912f01052d7ee81802fd227c82f010ef3cfa2660ae3840690
console.log("Domain separator:", computeDomainSeparator());2. Type Hash
# Compare with Rust
cargo test -p kaizen-tx test_eip712_type_hashes -- --nocapture3. Encoded Data Format
- Integers: 32-byte big-endian
- Addresses: 32-byte right-aligned (12 zero bytes prefix)
function encodeAddressAsBytes32(address: Address): Uint8Array {
const bytes = new Uint8Array(32);
bytes.set(hexToBytes(address), 12); // Right-align
return bytes;
}4. Signing Method
Ensure account.sign(), not account.signMessage().
5. v Parameter
Ensure v is converted from 27/28 to 0/1.
Cross-Language Verification
# Generate test vectors from TypeScript
npx tsx sdk/scripts/generate-signed-vectors.ts > crates/auth/signed-test-vectors.json
# Verify in Rust
cargo test -p kaizen-auth test_sdk_signed_transactions -- --nocaptureCommon Pitfalls
| Issue | Symptom | Fix |
|---|---|---|
| Big-endian chainId in Borsh | Huge chainId number in Rust | Use little-endian for Borsh U256 |
Using signMessage() | Wrong recovered address | Use account.sign() |
| v = 27/28 | Rust recovery fails | Convert to 0/1 |
| Missing enum discriminant | Payload type mismatch | Add @variant decorator |
| Wrong type string | Type hash mismatch | Match Rust exactly |
