Signing Due Signables from Kernel v3 Smart Accounts

This guide explains how to properly sign Due's Permit and PayoutIntent messages when your account is a Kernel v3 (ZeroDev) smart contract account, including accounts set up via EIP-7702.

Why Standard Signatures Don't Work

Both Due's contracts and modern token contracts (e.g. USDC) use SignatureChecker for signature verification, which supports:

  • EOA accounts - standard ECDSA recovery (ecrecover)
  • Smart contract accounts - ERC-1271 isValidSignature(hash, signature)

When your address has contract code (Kernel via EIP-7702), the verifying contract detects this and calls isValidSignature(hash, signature) on your account instead of using ecrecover. Kernel then wraps the original hash in its own EIP-712 domain before verifying the ECDSA signature. This means you must sign the wrapped hash, not the original one.

How Kernel's Signature Verification Works

When isValidSignature(bytes32 hash, bytes signature) is called on your Kernel account:

  1. The first byte of signature is stripped as the mode byte (validation routing)
  2. The original hash is wrapped in Kernel's EIP-712 typed data:
    wrappedHash = keccak256(0x19 0x01 || kernelDomainSeparator || keccak256(abi.encode(KERNEL_WRAPPER_TYPE_HASH, hash)))
  3. The remaining signature bytes are verified via ECDSA against the wrappedHash

The Kernel EIP-712 domain is:

{
  name: "Kernel",
  version: "0.3.3",
  chainId: <chain ID>,
  verifyingContract: <your Kernel account address>
}

The wrapper type:

Kernel(bytes32 hash)

Tip: You can query the domain parameters directly from your Kernel account by calling eip712Domain() (EIP-5267). This is more robust than hardcoding values, especially if Kernel versions change.

Step-by-Step Signing Process

For each signable returned by Due's API:

1. Get the original hash

Due's API returns a TransferIntent with signables[]. Each signable has:

  • payload - the full EIP-712 typed data (domain, types, message)
  • hash - the EIP-712 digest to sign

2. Sign the hash using Kernel's EIP-712 wrapper

Instead of signing hash directly, sign it as the hash field of a Kernel EIP-712 message:

import { TypedDataEncoder } from "ethers"

// The hash from Due's signable
const originalHash: string = signable.hash

// Sign using Kernel's wrapper domain
const kernelSignature = await signer.signTypedData(
  {
    name: "Kernel",
    version: "0.3.3",
    chainId: chainId,
    verifyingContract: yourKernelAccountAddress,
  },
  {
    Kernel: [{ name: "hash", type: "bytes32" }],
  },
  {
    hash: originalHash,
  },
)

3. Prepend the mode byte

Kernel uses the first byte of the signature to determine the validation mode. For accounts using the root ECDSA validator (the default for most Kernel 7702 accounts), the mode byte is 0x00:

import { concat, hexlify } from "ethers"

const finalSignature = concat([
  hexlify(new Uint8Array([0x00])),  // sudo/root mode
  kernelSignature,
])

The resulting signature is 66 bytes: 1 byte mode + 65 bytes ECDSA.

4. Return as the signable's signature

Set signable.signature = finalSignature and submit the TransferIntent back to Due's API.

Complete Code Example

import { HDNodeWallet, concat, hexlify } from "ethers"

/**
 * Signs a Due signable hash for a Kernel v3 smart account.
 *
 * @param signer              EOA private key holder of the Kernel account
 * @param kernelAccountAddress Your Kernel smart account address
 * @param chainId             Chain ID of the network
 * @param originalHash        The `hash` field from Due's signable
 * @param modeByte            Kernel validation mode (default 0x00 = sudo/root)
 * @returns                   66-byte signature ready to submit to Due
 */
async function signForKernel(
  signer: HDNodeWallet,
  kernelAccountAddress: string,
  chainId: bigint,
  originalHash: string,
  modeByte: number = 0x00,
): Promise<string> {
  const signature = await signer.signTypedData(
    {
      name: "Kernel",
      version: "0.3.3",
      chainId,
      verifyingContract: kernelAccountAddress,
    },
    {
      Kernel: [{ name: "hash", type: "bytes32" }],
    },
    { hash: originalHash },
  )

  return concat([hexlify(new Uint8Array([modeByte])), signature])
}

Usage with viem

import { hashTypedData, signTypedData, concat, toHex } from "viem"
import { privateKeyToAccount } from "viem/accounts"

async function signForKernelViem(
  privateKey: `0x${string}`,
  kernelAccountAddress: `0x${string}`,
  chainId: number,
  originalHash: `0x${string}`,
  modeByte: number = 0x00,
): Promise<`0x${string}`> {
  const account = privateKeyToAccount(privateKey)

  const signature = await signTypedData({
    privateKey,
    domain: {
      name: "Kernel",
      version: "0.3.3",
      chainId: BigInt(chainId),
      verifyingContract: kernelAccountAddress,
    },
    types: {
      Kernel: [{ name: "hash", type: "bytes32" }],
    },
    primaryType: "Kernel",
    message: { hash: originalHash },
  })

  return concat([toHex(modeByte, { size: 1 }), signature])
}

Common Pitfalls

  1. Wrong verifyingContract - Must be your Kernel account address (the smart account), not the Kernel implementation contract address.

  2. Wrong version string - Must exactly match what your Kernel contract returns from _domainNameAndVersion(). Current version is "0.3.3". You can verify by calling eip712Domain() on your account.

  3. Missing mode byte - The signature must start with the mode byte (0x00 for sudo mode). Without it, Kernel will misparse the signature.

  4. Double-hashing - Do NOT compute the EIP-712 hash yourself from the typed data. Use the hash field from Due's signable directly. The token/payout contract computes the hash on-chain, and isValidSignature receives that exact hash.

  5. Signing the typed data directly - You must NOT call signTypedData with the original permit/payout typed data. Instead, compute the hash first and sign it through Kernel's wrapper. The original typed data is only used to display the signing request to the user.

Validation Mode Bytes

Kernel v3 supports different validation modes via the first signature byte:

ModeByteDescription
Sudo/Root0x00Uses the account's root validator (default ECDSA)
Validator0x01Routes to a specific validator (21-byte prefix)
Permission0x02Routes to a permission policy (5-byte prefix)

Most EIP-7702 Kernel accounts use 0x00 (sudo mode) with the root ECDSA validator.

Reference