Message & Typed-Data Signing
signMessage() for EIP-191 and Solana personal messages, signTypedData() for EIP-712 with strict validation, and signDigest() / signBytes() for signing bytes you built yourself.
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; signTypedData implements EIP-712 for EVM with strict validation that refuses any payload whose signature no conforming verifier could reproduce; and signDigest / signBytes sign bytes you assembled yourself, exactly as given. These off-chain signers are synchronous and require the account to be unlocked and the wallet enabled.
Beyond off-chain payloads, an address also attests a sign-in (signIn), batch-signs your own transactions (signAll), signs a foreign transaction (signRawTransaction), previews a foreign transaction (estimate), and screens a transfer destination (checkDestination). Those are covered at the end of this page; they reach the network or run asynchronously, as noted.
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 registered signer 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 |
"ed25519" takes its message as text, so it can only sign bytes that are valid UTF-8. To sign binary data such as a serialized Solana transaction message, use signBytes.
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".
signDigest and signBytes
signDigest and signBytes sign exactly the bytes you give them. Neither hashes its input, adds a prefix, or touches the network. They are for callers that build and broadcast transactions themselves and only need a signature (added in 2.5.2).
These sign anything the key could sign, with none of the checks signTypedData applies. Only pass digests and bytes you assembled yourself.
signDigest (EVM)
signDigest(digest: Uint8Array | string) signs a 32-byte digest with the address's secp256k1 key, exactly as given: no keccak, no EIP-191 prefix.
| Field | Value |
|---|---|
r | 0x-prefixed 32 bytes |
s | 0x-prefixed 32 bytes, in the lower half of the curve order (EIP-2) |
recovery | 0 or 1 |
signature | 0x-prefixed 65 bytes, r ‖ s ‖ v with v = 27 + recovery |
import { keccak256, Transaction } from "ethers";
const evm = acc.wallets[0].addresses.find((a) => a.vm === "evm");
// Encode the transaction yourself, then sign its hash.
const unsigned = Transaction.from({ to, value, nonce, gasLimit, maxFeePerGas, maxPriorityFeePerGas, chainId, type: 2 });
const { r, s, recovery } = evm.signDigest(keccak256(unsigned.unsignedSerialized));
unsigned.signature = { r, s, yParity: recovery };
// unsigned.serialized is now the signed transaction, ready to broadcastThe 65-byte signature uses the message-signing convention for v. A typed (EIP-2930 / EIP-1559) transaction takes recovery as its y-parity; a legacy EIP-155 transaction takes v = 35 + 2 * chainId + recovery. Build v from recovery for those.
The digest must be exactly 32 bytes. Anything shorter or longer throws PARAMETER_ERROR rather than being padded or cut.
signBytes (SVM)
signBytes(message: Uint8Array | string) signs arbitrary bytes with the address's ed25519 key, exactly as given. Any length is accepted, including an empty message.
| Field | Value |
|---|---|
signature | the 64-byte signature, base58 |
signatureHex | the same 64 bytes, 0x-prefixed hex |
const svm = acc.wallets[0].addresses.find((a) => a.vm === "svm");
// A transaction message compiled by your own Solana tooling
const messageBytes = transaction.compileMessage().serialize();
const { signature } = svm.signBytes(messageBytes);Input and errors
Both methods take a Uint8Array (a Buffer works too) or a 0x-prefixed hex string of whole bytes. Nothing else is accepted: hex without the prefix, base64, an odd number of hex digits, other typed arrays and plain arrays all throw PARAMETER_ERROR. Many strings are valid as both hex and base64 and decode to different bytes, so a base64 value must be decoded before the call, for example with Buffer.from(value, "base64").
| Condition | Error |
|---|---|
signDigest on a non-EVM address, signBytes on a non-SVM address | UNSUPPORTED_OP |
| input of the wrong type or encoding, or a digest that is not 32 bytes | PARAMETER_ERROR |
| account locked | ACCOUNT_LOCKED |
| wallet disabled | WALLET_DISABLED |
Error messages never include the value that was passed in.
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. A disabled wallet throws WALLET_DISABLED the same way. Errors are WativeError instances carrying a code — see Errors.
signIn
signIn(params?: SignInParams): SignInResult produces a Sign-In-With-X attestation — it builds the canonical SIWE (EIP-4361) message on EVM, or the SIWS (Wallet-Standard) message on SVM, from params, signs it with this address's key, and returns { message, signature } (a verifier needs the exact signed text). Signing reuses signMessage, so it is EIP-191 personal_sign on EVM and raw ed25519 over the UTF-8 text on SVM.
- The signer's own address fills
address; a suppliedparams.addressthat does not match this signer is aPARAMETER_ERROR— the library never attests ownership of an address it does not control. domainis required. If you passexpectedDomain, it is enforced against the messagedomain(a mismatch isPARAMETER_ERROR) — an anti-phishing hook, since a headless library cannot check the browser origin itself; that stays the embedder's responsibility.- A server-supplied
nonceis required on both chains — it is what makes each attestation single-use, so a missing or emptynoncethrowsPARAMETER_ERROR(on EVM/SIWE it must be at least 8 alphanumeric characters). On EVM, SIWE also requiresuri.SignInParamstypes both as optional, but they are enforced at call time —signIn({ domain })alone throws.
signAll
signAll<T extends Transaction>(transactions: readonly T[]): Promise<readonly T[]> signs a batch in parallel and returns the same transactions, now signed. Ownership is checked on each (a foreign tx.from throws PARAMETER_ERROR).
Every EVM transaction in the batch must carry an explicit nonce. Auto-nonce fills from eth_getTransactionCount(pending), and the per-sender watermark only advances after a broadcast — so batch-signing auto-nonce EVM transactions would hand them all the same nonce, and only one could land. A missing nonce is a PARAMETER_ERROR; set one on each, or sign them one at a time as each is broadcast. Two with the same explicit nonce are allowed (replace-by-fee). SVM has no such counter.
signRawTransaction
signRawTransaction(input: RawTx | string, opts?: SignRawOptions): Promise<RawTx> puts your signature on a foreign serialized transaction — one a dApp, Jupiter, a Blink, or WalletConnect handed you that this library did not build. Signing bytes you did not build is blind-signing, so it is fail-closed. It returns the signed bytes as a RawTx ({ vm, serialized, encoding }).
What it does — and the effects to expect:
- Re-decodes the bytes itself (never trusting a decode passed alongside them), enforcing the size caps and trailing-junk guard, then hands the decoded intent to
confirm(decoded). - Produces no signature unless
confirm(decoded)returnstrue, or you passacknowledgeBlindSign: trueto sign without inspection. Neither is aPARAMETER_ERROR; aconfirmthat returns non-trueisTX_ABORTED. - EVM — re-signs the exact bytes (accessList / blob / 7702 fields survive) and refuses a tx already signed by a different sender.
- SVM — partial-signs at your required-signer slot, preserving co-signers, and refuses a tx you are not a required signer of.
- A blob (type 3) or EIP-7702 delegation (type 4) EVM tx is refused with
UNSUPPORTED_OPunless you opt in — a 7702 delegation hands over your account's code, so it is gated behindallow7702Delegation, and a blob behindallowBlobTx.
SignRawOptions:
| Field | Effect |
|---|---|
confirm?(decoded) => boolean | Promise<boolean> | Inspect the re-decoded DecodedTx and return true to approve. Required unless acknowledgeBlindSign. |
acknowledgeBlindSign?: boolean | Sign with no confirm callback — an explicit opt-in to blind-signing for a headless caller. |
allow7702Delegation?: boolean | Permit signing an EIP-7702 (type 4) delegation (refused otherwise). |
allowBlobTx?: boolean | Permit signing a blob (type 3) transaction (refused otherwise). |
A locked account throws ACCOUNT_LOCKED; a RawTx whose vm does not match this address throws PARAMETER_ERROR.
const signed = await evm.signRawTransaction(serializedFromDapp, {
confirm: (decoded) => decoded.to === expectedContract && decoded.value === 0n,
});
// signed: { vm: "evm", serialized: "0x…", encoding: "hex" }estimate
estimate(input: RawTx | string, opts?: { rpcUrl?: string }): Promise<SimulationResult> pre-flights a foreign serialized transaction against this address's network without building or signing it — EVM estimates as this address (an RLP carries no sender), SVM simulates the tx as-is (signatures are not required to simulate). It is a read: no unlock needed, it rides the read pool with failover, and it takes a one-time rpcUrl override. For your own transactions, use buildTransaction(...).simulate() instead.
checkDestination
checkDestination(req: TransferRequest, opts?: DestinationGuardOptions): Promise<DestinationReport> is the read/advisory counterpart to the send-path gate in buildTransferPayload. It runs the offline classes and the RPC-backed ones (EVM eth_getCode → contract / EIP-7702; SVM account info → mint / program / token account), so a wallet UI can warn a user before the transaction is built.
Unlike the gate, it does not throw for a classification outcome — it returns a DestinationReport:
| Field | Meaning |
|---|---|
action | strongest verdict: refuse beats confirm beats warn beats allow |
code / severity | the deciding DestinationCode / severity for a refuse/confirm |
chain / address / asset | what was screened (asset is "native" or "token") |
warnings | every WARN-tier finding (DestinationWarning[]) |
rpcChecked | false when only offline checks ran (an offline refuse, or the RPC tier was unavailable) — lets a UI tell "clean" from "not fully vetted" |
It reports the raw verdict and does not apply forceUnsafeTransfer / acknowledgeRiskyDestination (a preflight tells the truth; the caller compares against its own state). Only a malformed address throws (PARAMETER_ERROR); RPC failure is fail-open (rpcChecked: false plus an RPC_CHECK_UNAVAILABLE warning). Pass opts.screen (a DestinationScreener) to plug in sanctions / blocklist / address-poisoning screening — deliberately kept out of core, since a bundled list rots — and opts.onWarning to receive each WARN-tier finding.