Wallets & AddressesMessage & Typed-Data Signing

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:

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 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 }.

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

"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.

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).

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.

FieldValue
r0x-prefixed 32 bytes
s0x-prefixed 32 bytes, in the lower half of the curve order (EIP-2)
recovery0 or 1
signature0x-prefixed 65 bytes, r ‖ s ‖ v with v = 27 + recovery
sign-digest.ts
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 broadcast

The 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.

FieldValue
signaturethe 64-byte signature, base58
signatureHexthe same 64 bytes, 0x-prefixed hex
sign-bytes.ts
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").

ConditionError
signDigest on a non-EVM address, signBytes on a non-SVM addressUNSUPPORTED_OP
input of the wrong type or encoding, or a digest that is not 32 bytesPARAMETER_ERROR
account lockedACCOUNT_LOCKED
wallet disabledWALLET_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.

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 (bytes1 … bytes32) 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
  ],
}

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 supplied params.address that does not match this signer is a PARAMETER_ERROR — the library never attests ownership of an address it does not control.
  • domain is required. If you pass expectedDomain, it is enforced against the message domain (a mismatch is PARAMETER_ERROR) — an anti-phishing hook, since a headless library cannot check the browser origin itself; that stays the embedder's responsibility.
  • A server-supplied nonce is required on both chains — it is what makes each attestation single-use, so a missing or empty nonce throws PARAMETER_ERROR (on EVM/SIWE it must be at least 8 alphanumeric characters). On EVM, SIWE also requires uri. SignInParams types 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).

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) returns true, or you pass acknowledgeBlindSign: true to sign without inspection. Neither is a PARAMETER_ERROR; a confirm that returns non-true is TX_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_OP unless you opt in — a 7702 delegation hands over your account's code, so it is gated behind allow7702Delegation, and a blob behind allowBlobTx.

SignRawOptions:

FieldEffect
confirm?(decoded) => boolean | Promise<boolean>Inspect the re-decoded DecodedTx and return true to approve. Required unless acknowledgeBlindSign.
acknowledgeBlindSign?: booleanSign with no confirm callback — an explicit opt-in to blind-signing for a headless caller.
allow7702Delegation?: booleanPermit signing an EIP-7702 (type 4) delegation (refused otherwise).
allowBlobTx?: booleanPermit 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.

sign-foreign-tx.ts
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:

FieldMeaning
actionstrongest verdict: refuse beats confirm beats warn beats allow
code / severitythe deciding DestinationCode / severity for a refuse/confirm
chain / address / assetwhat was screened (asset is "native" or "token")
warningsevery WARN-tier finding (DestinationWarning[])
rpcCheckedfalse 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.

Last updated on