Wallets & AddressesMessage & Typed-Data Signing

Message & Typed-Data Signing

signMessage() for EIP-191 and Solana personal messages and signTypedData() for EIP-712, including the strict domain and type-string validation that refuses payloads no counterparty could verify.

An Address signs off-chain payloads directly, with the sealed key staying inside custody. signMessage covers the common personal-message case on both chains; signMessageEncoded exposes the specific encodings; and signTypedData implements EIP-712 for EVM with strict validation that refuses any payload whose signature no conforming verifier could reproduce. All three are synchronous and require the account to be unlocked.

signMessage

signMessage(message: string): string signs a plain personal message. The encoding is chosen by the address's VM:

VMSchemeReturn value
"evm"EIP-191 personal_sign (the "\x19Ethereum Signed Message:\n" prefix over the message)0x-prefixed 65-byte signature (r ‖ s ‖ v, v = 27 | 28)
"svm"ed25519 detached signature over the raw UTF-8 message bytesbase58 string
sign-message.ts
const evm = acc.wallets[0].addresses.find((a) => a.vm === "evm");
const svm = acc.wallets[0].addresses.find((a) => a.vm === "svm");

const evmSig = evm.signMessage("hello"); // "0x…" — 65-byte, 132-char string
const svmSig = svm.signMessage("hello"); // base58, no 0x prefix

EVM signing is deterministic (RFC 6979), so a given key and message always produce the same signature — the library pins this with a known-answer vector:

// m/44'/60'/0'/0/0 of the all-zero mnemonic signing "hello"
evm.signMessage("hello");
// "0x22f6b9cd7ff4f321e11181c4fe64adeea9469908fb514fbb6001fe022002dfda…1c"

A non-string message throws PARAMETER_ERROR; a locked account throws ACCOUNT_LOCKED; a VM with no signer (the Sui placeholder) throws UNSUPPORTED_OP.

signMessageEncoded

signMessageEncoded(message, encoding) selects the encoding explicitly and returns both the signature and the digest it was computed over: { signature: string; messageHash: string }.

encodingVM requiredWhat is signedmessageHash
"personal_sign""evm"EIP-191 personal message (with prefix)the EIP-191 hash
"raw""evm"keccak256(message_bytes) with no prefix0x + that keccak hash
"ed25519""svm"the raw UTF-8 message bytes (ed25519 has no prehash)0x + the hex of the signed bytes

The VM/encoding pairing is enforced: "personal_sign" and "raw" require an EVM address and "ed25519" requires an SVM address, otherwise UNSUPPORTED_OP. Any other encoding string throws PARAMETER_ERROR.

signTypedData

signTypedData(typedData, chainId) implements EIP-712 typed structured-data signing. It is EVM-only — an SVM or other address throws UNSUPPORTED_OP. It returns { signature: string; domainSeparator: string; structHash: string }.

typedData must conform to the EIP-712 JSON shape { domain, types, primaryType, message }. chainId (a number or bigint) is supplied by the caller and cross-checked against domain.chainId.

sign-typed-data.ts
const evm = acc.wallets[0].addresses.find((a) => a.vm === "evm");

const { signature, domainSeparator, structHash } = evm.signTypedData(
  {
    domain: {
      name: "Ether Mail",
      version: "1",
      chainId: 1,
      verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC",
    },
    types: {
      Person: [
        { name: "name", type: "string" },
        { name: "wallet", type: "address" },
      ],
      Mail: [
        { name: "from", type: "Person" },
        { name: "to", type: "Person" },
        { name: "contents", type: "string" },
      ],
    },
    primaryType: "Mail",
    message: {
      from: { name: "Alice", wallet: evm.publicKey },
      to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" },
      contents: "Hello, Bob!",
    },
  },
  1, // must equal domain.chainId
);

If chainId is supplied and domain.chainId is present, they must match; a mismatch throws PARAMETER_ERROR. domain.chainId may be a number, bigint, or a numeric string (a JSON-decoded or viem-built domain carries "1"), and all are compared as integers.

Domain rules

EIP-712 defines exactly five domain members, and signTypedData accepts only these keys:

KeyEIP-712 type
namestring
versionstring
chainIduint256
verifyingContractaddress
saltbytes32

domain is required (a missing or null domain throws PARAMETER_ERROR). The domain separator is always computed from the domain object, using whichever of the five fields are present. Any enumerable, non-null key that is not one of the five is refused:

// Refused — a mis-cased or misspelled key would be dropped from the
// separator, binding the signature to no chain and no contract:
{ name, version, chainID: 1, verifyingContract } // capital "D"
{ chainid: 1, verifyingcontract: "0x…" }         // JSON-casing bug

This is the validation the description calls out: rather than silently signing a chain-agnostic payload, the offending key throws so the mistake surfaces at the call that made it.

Type and type-string rules

types must be a plain object mapping each type name to an array of { name: string, type: string } fields. Each field entry is checked, and the following are all refused with PARAMETER_ERROR because each would emit a type string, or hash a value, that a conforming verifier could not reproduce:

  • primaryType that is not a defined struct — naming a primitive ("uint256") or an array form ("Mail[]") is rejected; it must name a struct present in types.
  • A referenced struct with no definition — e.g. Mail referencing Person while Person is undefined.
  • A self-referential struct — a struct that reaches itself has no finite encoding.
  • A types key that names a primitive — e.g. types.bytes32 = []; it would be encoded as an empty struct on one path and as a primitive on another, yielding a signature over a digest the shown value never produced.
  • A duplicate field name within a type — matching ethers, which refuses "duplicate variable name".
  • A malformed type name — a valid name is a base followed by zero or more array suffixes ("[]" or "[2]") and nothing after them; a token like "uint256[]extra" is rejected.

The validator takes a single snapshot of domain and types and hashes that snapshot, so a caller-controlled getter cannot answer one value to a check and another to the hash.

The EIP712Domain list

Two conventions disagree about types.EIP712Domain: the JSON-RPC eth_signTypedData_v4 shape requires it, while ethers and viem derive it from the domain fields. This build derives the field list from the domain object regardless, and treats a supplied list as follows:

  • An empty EIP712Domain: [] is accepted (viem emits it for a domain it cannot sniff) and ignored.
  • A non-empty list must agree with the domain — the same fields, in the same order — otherwise it is refused. Honouring a disagreeing list would drop chainId or verifyingContract from the hash and produce a signature that the caller's own verifyTypedData would reject.

bytes handling

EIP-712 field values are hashed by their declared type, and the two byte-shaped cases are strict:

  • A "bytes" field accepts a Uint8Array (hashed as its bytes) or a 0x-prefixed hex string of whole bytes. A non-hex string is refused, because keccak-over-hex would read "hi" and "0x6869" as different inputs — the same ambiguity signMessageEncoded("raw") refuses.
  • A "bytesN" fixed type (bytes1bytes32) is an EIP-712 atomic type.
  • A "string" field is always hashed as its UTF-8 bytes — including 0x-looking strings and the empty string — so it is never mistaken for hex.
types: {
  Blob: [
    { name: "payload", type: "bytes" },   // 0x-hex string OR Uint8Array
    { name: "root", type: "bytes32" },     // 0x + 64 hex chars
    { name: "label", type: "string" },     // hashed as UTF-8
  ],
}

Last updated on