Skip to content

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

EIP-712 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: 0x5db2fe127c2952c912f01052d7ee81802fd227c82f010ef3cfa2660ae3840690

Type Hashes

Each transaction type has a unique type hash. All types use the Kaizen: prefix convention:

TypeType 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

TypeBorsh SerializationEIP-712 encodeData
u8Single byteSingle byte
u32, u64Little-endianBig-endian (32 bytes)
U256Little-endianBig-endian (32 bytes)
Address20 bytes raw32 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 -- --nocapture

3. 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 -- --nocapture

Common Pitfalls

IssueSymptomFix
Big-endian chainId in BorshHuge chainId number in RustUse little-endian for Borsh U256
Using signMessage()Wrong recovered addressUse account.sign()
v = 27/28Rust recovery failsConvert to 0/1
Missing enum discriminantPayload type mismatchAdd @variant decorator
Wrong type stringType hash mismatchMatch Rust exactly

References