Page cover

messagesMessage Signing

Overview

When interacting with the Ethereal exchange, many operations like trading and account management require cryptographic signatures to authenticate and authorize your actions.

Why Sign Messages?

The trading API uses cryptographic signatures for authentication instead of traditional JSON Web Tokens (JWTs). When making an API request, rather than including a JWT obtained from a centralized authentication service, the requesting client signs an EIP-712 structured message using their private key. The signature proves the client's identity and authorizes them to access the endpoint.

Majority of endpoints are read-only public facing. However, for endpoints that mutate data such as order placement and cancelations, these calls are authenticated and authorised via signatures in the form of EIP-712 messages (https://eips.ethereum.org/EIPS/eip-712arrow-up-right).

Each authenticated endpoint requires a different message type to sign. Ethereal has a few message types including: LinkSigner, RevokeLinkedSigner, RefreshLinkedSigner, ExtendLinkedSigner, EIP712Auth, InitiateWithdraw, TradeOrder , and CancelOrder. Once a signature is created, they are sent along with the rest of the HTTP payload, validated, stored, and the relayer batches these operations onchain at a later time.

Signature Types and Domain

Message signing is one of the trickier parts of integrating with the API. It sits at the boundary between onchain message structures and the HTTP API. The /v1/rpc/config endpoint provides the EIP-712 domain and type definitions you need to get started.

curl -X 'GET' \
  'https://api.ethereal.trade/v1/rpc/config' \
  -H 'accept: application/json'
{
  "domain": {
    "name": "Ethereal",
    "version": "1",
    "chainId": 5064014,
    "verifyingContract": "0xB3cDC82035C495c484C9fF11eD5f3Ff6d342e3cc"
  },
  "signatureTypes": {
    "LinkSigner": "address sender,address signer,bytes32 subaccount,uint64 nonce,uint64 signedAt",
    "TradeOrder": "address sender,bytes32 subaccount,uint128 quantity,uint128 price,bool reduceOnly,uint8 side,uint8 engineType,uint32 productId,uint64 nonce,uint64 signedAt",
    "InitiateWithdraw": "address account,bytes32 subaccount,address token,uint256 amount,uint64 nonce,uint64 signedAt,bytes32 destinationAddress,uint32 destinationEndpointId",
    "RevokeLinkedSigner": "address sender,address signer,bytes32 subaccount,uint64 nonce,uint64 signedAt",
    "EIP712Auth": "address sender,uint8 intent,uint64 signedAt",
    "CancelOrder": "address sender,bytes32 subaccount,uint64 nonce",
    "RefreshLinkedSigner": "address sender,address signer,uint64 nonce,uint64 signedAt",
    "ExtendLinkedSigner": "address sender,uint64 nonce,uint64 signedAt"
  }
}
circle-exclamation

There are 2 components to this response: domain and signatureTypes.

Domain

The domain object provides context for the signed message and helps prevent cross-application replay attacks. It includes:

  • name: The name of the signing application or protocol (e.g., "Ethereal")

  • version: The current version of the contract/application

  • chainId: The chain ID where the signature is valid

  • verifyingContract: The address of the contract that will verify the signature

This domain information creates a unique context for each application, ensuring that signatures created for one application cannot be reused in another.

Signature Types

The signatureTypes, messageTypes (or types) defines the structure of the data being signed. It's an object containing named structures with their respective fields and types. In the example above, LinkSigner has the following shape:

Which, when parsed gives the following:

What is a nonce?

Every message type includes a uint64 nonce. On Ethereal, the nonce functions as a uniqueness parameter that prevents replay attacks by ensuring each signed message can only be processed once. Unlike traditional implementations using sequential counters, Ethereal uses the current timestamp in nanoseconds, providing a high-precision identifier that guarantees no two legitimate transactions will share the same nonce, even when submitted rapidly.

When signing orders or executing operations, this nanosecond timestamp becomes part of the signed data structure. The exchange validates each signature by verifying the timestamp is within an acceptable window and hasn't been previously processed, automatically rejecting any attempt to reuse a signature. This approach supports high-frequency trading without compromising security, as users can generate multiple valid signatures quickly without tracking on-chain state changes.

To generate a nonce, we recommend simply just retrieving the current time in nanoseconds and adding some randomness at the end of the timestamp.

Message Expiry via signedAt

Message nonces are tracked to prevent reuse. Each signed message includes a timestamp (signedAt) that the exchange validates against a tolerance window. If the message is too old, it is rejected regardless of whether the signature is otherwise valid. This prevents replay attacks over extended periods, even if a previously signed message is intercepted.

For operations that are batched and verified onchain, the nonce provides an additional layer of protection during the delay between signing and onchain confirmation.

Walkthrough

Below is a TypeScript guide with concrete examples on how to sign messages. The examples below use viemarrow-up-right as the only dependency. All snippets are self-contained TypeScript that should be able run with ts-node or any bundler.

Prerequisites

Before signing any message, you need two things: a wallet and the EIP-712 domain.

Fetch the domain once and reuse it for all subsequent signatures. It only changes if the exchange migrates to a new contract.

Message Timings: nonce and signedAt

Every signed message includes timing fields. These serve different purposes and use different units. Mixing them up is one of the most common integration mistakes.

  • nonce nanoseconds since Unix Epoch. Used for replay protection and uniqueness. Sent as a string because nanosecond timestamps exceed JavaScript's safe integer range.

  • signedAt seconds since Unix Epoch. Used to check message freshness. Sent as a number.

Both are validated against the server's clock: nonce must be within 1 hour, and signedAt must be within 1 hour in the past and 10 seconds in the future.

Subaccount name Encoding

Subaccounts are identified by a bytes32 value. This is most likely a UTF-8 name right-padded with zeros to 32 bytes. If you made a deposit on app.ethereal.trade, the default subaccount name is "primary". Read through Subaccounts if you are unfamiliar with subaccounts on Ethereal.

Decimal Precision

All quantities and prices on Ethereal use 9 decimal places of precision. The API request body accepts human-readable decimal strings like "5.5", but the EIP-712 signed message requires the raw bigint representation.

circle-info

A common mistake is using 18 decimals (ETH wei). Ethereal uses 9.

circle-exclamation

Order Placement/Cancelation

Order placement is the most common signing operation. The signed message type is TradeOrder, and the EIP-712 type definition looks like this:

circle-info

The type definitions here are hardcoded for clarity. You can also parse them dynamically from the signatureTypes field in the /v1/rpc/config response.

circle-info

The side and engineType fields are numeric enums. side=0=Buy, side=1=Sell, engineType=0=Perp, engineType=1=Spot. As of writing Ethereal only supports engineType=0.

To place a limit order, sign the TradeOrder message with the desired price and quantity, then send it alongside the order details in the request body.

A few important differences between the signed message and the request body to be aware of:

  • productId in the signature corresponds to onchainId in the body

    • You can find out a product's onchainId by listing products via the REST API

  • price and quantity are bigint values in the signature but decimal strings in the body

  • nonce is a bigint in the signature but a string in the body

  • signedAt is a bigint in the signature but a number in the body

Market orders use the same TradeOrder signature type, but with one critical difference: the price must be 0n (zero) in the signed message. In the request body, set type: "MARKET" and omit the price field entirely.

circle-info

Signing a market order with a non-zero price is one of the most common causes of 4xx errors. The signature will not match what the server expects.

The CancelOrder signature is simpler than TradeOrder. It only requires the sender, subaccount, and nonce. The specific orders to cancel are listed in the request body, not in the signed message. This means a single signature can cancel up to 200 orders at once.

You can specify orders an API assigned UUID via orderIds, your clientOrderIds, or both. The combined count of both arrays cannot exceed 200.

Delegated Trading via LinkedSigner

Linked signers enable delegated trading. This is a secondary private key that can place orders and cancel orders on behalf of your account without needing your primary wallet to approve every action. This is what powers one-click trading in the Ethereal exchange app.

Key properties of linked signers:

  • They can place orders and cancel orders for the subaccount they're linked to

  • They cannot withdraw funds - only the account owner retains withdrawal control

  • They expire after 90 days of inactivity

  • A subaccount can have multiple linked signers (useful for multi-device setups or bots)

To link a signer, both parties must sign the same LinkSigner message: the account owner (proving they authorize this delegation) and the new signer (proving they control the signer key). The client typically generates the signer's private key locally.

triangle-exclamation

Once linked, trading with a signer works exactly like trading with your main account. The only difference is that the sender field must be the linked signer's address (not the account owner's), and you sign with the signer's private key.

circle-info

A common mistake is setting sender to the account owner's address when signing with a linked signer. The sender must always be the address of whoever is producing the signature.

Smart Contract Wallets

Smart contract wallets (e.g., Safe/Gnosis multisigs) are supported via EIP-1271arrow-up-right. The exchange automatically detects whether the sender address is a contract and calls isValidSignature on it rather than performing ECDSA recovery. No changes to the request format are needed from the caller's side.

One restriction: linked signers must always be EOA wallets. Smart contract wallets can be account owners, but they cannot be used as linked signers.

Common Issues & Troubleshooting

Floating-point precision loss (401)

This is the single most common issue. When the decimal string in your request body doesn't exactly match the bigint value in your signed message, the server recomputes a different hash and signature verification fails.

The root cause is floating-point arithmetic. In most programming languages, operations on decimal numbers introduce tiny rounding errors. For example, a price that should be "1234.5" might end up as "1234.500000000000000003" after passing through floating-point math. The server parses this string into a bigint and gets a different value than what you signed.

The safest approach:

  • Pass prices and quantities as strings throughout your entire pipeline. If you receive a number from an upstream source, convert it to a string with explicit precision before doing anything else.

  • Derive both the signed bigint and the request body string from the same string literal. For example, define const qty = "5.5", then use toGwei(qty) in the signature and qty in the body.

  • Never use floating-point arithmetic (addition, multiplication, division) on prices or quantities. If you need to compute a value, do it in bigint space and format back to a string.

Signature verification failed (401)

A 401 with signature verification failure means the server recovered a different address from the signature than the sender you specified. Common causes:

Market order signed with a non-zero price. For market orders, the price field in the signed message must be 0n. The request body should set type: "MARKET" and omit price entirely.

Wrong decimal precision. Ethereal uses 9 decimal places, not 18. If you're coming from an ETH/ERC-20 background where parseUnits(value, 18) is the norm, this is easy to get wrong.

Sender mismatch. The sender address in the signed message must be the address of whoever is signing. When using a linked signer, this must be the signer's address and not the account owner's address.

Stale EIP-712 domain. If you've hardcoded the domain rather than fetching from /v1/rpc/config, it may be outdated after a contract migration.

Non-standard v value. Ethereal only accepts signature v values of 27 or 28. viem produces this format by default. If you're using a different library that returns v as 0 or 1, add 27.

Timestamp or nonce rejected (400)

Clock skew. The signedAt timestamp must be within 1 hour in the past and 10 seconds in the future relative to the server's clock. Ensure your system clock is NTP-synced.

Wrong nonce unit. The nonce must be in nanoseconds, not seconds or milliseconds:

nonce and signedAt are validated before signature verification. If they fall outside the allowed range, the request is rejected with a 400 validation error. You won't even reach signature checking.

Linked signer issues (400/401)

Signer expired. Linked signers expire after 90 days of inactivity. Check the signer's status via GET /v1/linked-signer/address/{address}. Use POST /linked-signer/extend (signed by the signer) or POST /linked-signer/refresh (signed by the account owner) to reactivate.

Wrong subaccount. A linked signer is scoped to the subaccount it was linked to. It cannot sign orders for a different subaccount.

Revoking with open orders. All resting orders must be canceled before a linked signer can be revoked.

Validation errors (400)

Any kind of input validation occur leading to 400 can prevent order placement. There are many but the common errors we see include:

  • Subaccount not 32 bytes. The subaccount must be a 0x-prefixed hex string representing exactly 32 bytes (66 characters total). Use toHex(bytes, { size: 32 }) to ensure correct padding

  • Cancel batch too large. A single cancel request can target at most 200 orders (orderIds and clientOrderIds combined)

  • onchainId vs productId. The request body uses onchainId, while the signed message uses productId

  • Order expiry out of range. If you set expiresAt, it must be greater than signedAt and at most signedAt + 6652800 (~77 days)

  • postOnly requires GTD. If postOnly is true, the timeInForce must be "GTD"

  • close only on market orders. The close flag (to close an entire position) is only valid on market orders with reduceOnly: true and quantity: "0"

circle-info

For more examples, read through Python SDK as it has utility functions to assist with message signing.

Last updated