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:
| VM | Scheme | Return 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 bytes | base58 string |
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 prefixEVM 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 }.
encoding | VM required | What is signed | messageHash |
|---|---|---|---|
"personal_sign" | "evm" | EIP-191 personal message (with prefix) | the EIP-191 hash |
"raw" | "evm" | keccak256(message_bytes) with no prefix | 0x + 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.
The "raw" encoding requires an even-length 0x-hex string. keccak-over-hex hashes the decoded bytes of a 0x-hex string but the UTF-8 bytes of a plain string, so "0xdeadbeef" and "deadbeef" would hash differently — a dangerous ambiguity in a signing primitive. To sign a UTF-8 message, use "personal_sign".
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.
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:
| Key | EIP-712 type |
|---|---|
name | string |
version | string |
chainId | uint256 |
verifyingContract | address |
salt | bytes32 |
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 bugThis 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:
primaryTypethat is not a defined struct — naming a primitive ("uint256") or an array form ("Mail[]") is rejected; it must name a struct present intypes.- A referenced struct with no definition — e.g.
MailreferencingPersonwhilePersonis undefined. - A self-referential struct — a struct that reaches itself has no finite encoding.
- A
typeskey 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
chainIdorverifyingContractfrom the hash and produce a signature that the caller's ownverifyTypedDatawould 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 aUint8Array(hashed as its bytes) or a0x-prefixed hex string of whole bytes. A non-hex string is refused, becausekeccak-over-hex would read"hi"and"0x6869"as different inputs — the same ambiguitysignMessageEncoded("raw")refuses. - A
"bytesN"fixed type (bytes1…bytes32) is an EIP-712 atomic type. - A
"string"field is always hashed as its UTF-8 bytes — including0x-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
],
}A locked account throws ACCOUNT_LOCKED from every signing method before any payload is read, so a shape error never masks a lock. Errors are WativeError instances carrying a code — see Errors.