> ## Documentation Index
> Fetch the complete documentation index at: https://docs.derive.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Action Signing

> The EIP-712 scheme every state-changing action must carry.

The easiest way to begin signing is to either use the SDKs directly.

* [TypeScript SDK](https://github.com/derivexyz/derive-ts)
* [Rust SDK](https://github.com/derivexyz/derive-rs)
* [Python SDK](https://github.com/derivexyz/derive-py)

Continue reading for a deep dive into the signing scheme, its parameters, and how to debug signature issues.

### Why sign actions?

Every state-changing action — placing an order, transferring
spot, withdrawing, creating a session key — carries its own **EIP-712** signature
over an `Action` struct. This ensures the protocol is self-custodial.

<Note>
  Action signing is **not** [session login](/authentication/session-login).
  Login (EIP-191) authenticates a connection; it does not authorize actions.
  Each action is signed and verified on its own. Keep the two layers distinct.
</Note>

<Accordion title="Example Order Signed Action">
  ```jsonc theme={null}
  {
    "subaccount_id": 9,
    "nonce": 1720512000000123456,      // ms timestamp * 1e6 + random suffix
    "module": "0xB8D2...5e7b",          // TRADE_MODULE
    "expiry": 1720512300,               // unix seconds
    "owner": "0xYourWallet",
    "signer": "0xYourWallet",           // or a session key
    "data": {                           // ABI-encoded into `encoded_data`
      "asset": "0xETHOptionAsset",      // base_asset_address of the instrument
      "sub_id": "39614082545622357598554435968",  // packed expiry/strike/is_call; 0 for perps
      "limit_price": "310e18",
      "amount": "1e18",
      "max_fee": "0.01e18",
      "recipient_id": 9,
      "is_bid": true
    }
  }
  ```

  The seven `data` words are hashed into the `Action` struct, which is hashed
  with the domain separator into the digest `signer` signs.
</Accordion>

## Worked example: a buy order

An option buy order signed by the wallet directly (`owner == signer`). The SDK
ABI-encodes the payload, builds and signs the EIP-712 digest, and submits it in a
single call — amounts and prices are plain decimals, with e18 scaling handled
internally. The last tab does the same thing without an SDK, one step at a time:

<CodeGroup>
  ```typescript TypeScript (SDK) theme={null}
  import { DeriveClient } from '@derivexyz/derive-ts';

  const client = new DeriveClient({
    network: 'mainnet',
    wallet: process.env.PRIVATE_KEY!,
  });
  await client.connect();
  await client.login();

  // Signed by the wallet key held in the client (owner == signer). The nonce,
  // signature, and signature_expiry_sec are generated and signed for you.
  const { order } = await client.orders.place({
    subaccountId: 9,
    instrumentName: 'ETH-20260626-3000-C',
    direction: 'buy',
    amount: '1',
    limitPrice: '310',
    maxFee: '0.01',
  });
  console.log(order.order_id);

  ```

  ```python Python (SDK) theme={null}
  import asyncio
  from decimal import Decimal

  from derive_py import WebSocketClient
  from derive_py.data_types import Direction


  async def main():
      client = WebSocketClient.from_env()
      await client.connect()

      # Signed by the key held in the client. The nonce, signature, and
      # signature_expiry_sec are generated and signed for you.
      response = await client.orders.create(
          instrument_name="ETH-20260626-3000-C",
          direction=Direction.buy,
          amount=Decimal("1"),
          limit_price=Decimal("310"),
          max_fee=Decimal("0.01"),
      )
      print(response.order.order_id)

      await client.disconnect()


  asyncio.run(main())
  ```

  ```rust Rust (SDK) theme={null}
  use bigdecimal::BigDecimal;
  use derive_rs::{
      Environment, WsClient,
      actions::OrderArgs,
      models::{Direction, OrderType, TimeInForce},
  };

  #[tokio::main]
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
      let client = WsClient::new(
          Environment::Mainnet,
          Some(std::env::var("DERIVE_PRIVATE_KEY")?),
          Some(std::env::var("DERIVE_WALLET")?),
          Some(9),
      )
      .await?;
      client.login().await?;

      // Signed by the wallet key held in the client (owner == signer). The nonce,
      // signature, and signature_expiry_sec are generated and signed for you.
      let order = OrderArgs::builder()
          .instrument_name("ETH-20260626-3000-C".to_string())
          .direction(Direction::Buy)
          .order_type(OrderType::Limit)
          .time_in_force(TimeInForce::Gtc)
          .amount(BigDecimal::from(1))
          .limit_price(BigDecimal::from(310))
          .build();

      let response = client.orders().place(order).await?;
      println!("{}", response.order.order_id);
      Ok(())
  }
  ```

  ```python Python (manual) theme={null}
  # No SDK: every step done by hand. pip install eth-abi eth-account requests
  import os, random, time
  from decimal import Decimal

  import requests
  from eth_abi import encode as abi_encode
  from eth_account import Account
  from eth_account.messages import encode_defunct
  from eth_utils import keccak, to_checksum_address

  BASE_URL = "https://api.derive.xyz/v3"
  CHAIN_ID = 1  # Sepolia (11155111) on testnet
  MATCHING = "0xeB8d770ec18DB98Db922E9D83260A585b9F0DeAD"
  TRADE_MODULE = "0xB8D20c2B7a1Ad2EE33Bc50eF10876eD3035b5e7b"

  # keccak("Action(uint256 subaccountId,uint256 nonce,address module,bytes data,
  #         uint256 expiry,address owner,address signer)") — same on every deployment.
  ACTION_TYPEHASH = bytes.fromhex("4d7a9f27c403ff9c0f19bce61d76d82f9aa29f8d6d4b0c5474607d9770d1af17")

  session_key = Account.from_key(os.environ["PRIVATE_KEY"])  # signer
  owner = to_checksum_address(os.environ["OWNER"])
  subaccount_id = 9


  def e18(value: str) -> int:
      """The exchange rejects, not truncates, sub-1e12 precision."""
      scaled = int(Decimal(value) * 10**18)
      if scaled % 10**6:
          raise ValueError("more than 12 decimal places can never verify")
      return scaled


  # 1. The action signs the asset, not the instrument name.
  instrument = requests.post(
      f"{BASE_URL}/public/get_instrument",
      json={"instrument_name": "ETH-20260626-3000-C"},
  ).json()["result"]

  # 2. Action data: seven static words, decimals e18-scaled.
  encoded_data = abi_encode(
      ["address", "uint256", "int256", "int256", "uint256", "uint256", "bool"],
      [
          to_checksum_address(instrument["base_asset_address"]),
          int(instrument["base_asset_sub_id"]),
          e18("310"),   # limit_price
          e18("1"),     # amount
          e18("0.01"),  # max_fee
          subaccount_id,  # recipient_id
          True,           # is_bid
      ],
  )

  # 3. EIP-712 struct hash of the Action envelope. Step 5 resends these two
  #    values verbatim, so they have to be bound before signing.
  nonce = int(time.time() * 1000) * 10**6 + random.randrange(10**6)  # nonce
  expiry_sec = int(time.time()) + 300  # expiry_sec

  action_hash = keccak(
      abi_encode(
          ["bytes32", "uint256", "uint256", "address", "bytes32", "uint256", "address", "address"],
          [
              ACTION_TYPEHASH,
              subaccount_id,
              nonce,
              to_checksum_address(TRADE_MODULE),
              keccak(encoded_data),
              expiry_sec,
              owner,
              session_key.address,  # signer; pass owner here if the wallet signs directly
          ],
      )
  )

  # 4. Domain separator, then the digest to sign.
  domain_separator = keccak(
      abi_encode(
          ["bytes32", "bytes32", "bytes32", "uint256", "address"],
          [
              keccak(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
              keccak(b"Matching"),
              keccak(b"1.0"),
              CHAIN_ID,
              to_checksum_address(MATCHING),
          ],
      )
  )
  digest = keccak(b"\x19\x01" + domain_separator + action_hash)
  signature = "0x" + Account.unsafe_sign_hash(digest, session_key.key).signature.hex()

  # 5. Submit. Wire decimals are formatted from the signed e18 words so the
  #    exchange re-derives the exact bytes the signature commits to.
  login_ts = str(int(time.time() * 1000))
  login_sig = Account.sign_message(encode_defunct(text=login_ts), session_key.key).signature
  response = requests.post(
      f"{BASE_URL}/private/order",
      headers={
          "X-DeriveWallet": owner,
          "X-DeriveTimestamp": login_ts,
          "X-DeriveSignature": "0x" + login_sig.hex(),
      },
      json={
          "subaccount_id": subaccount_id,
          "instrument_name": "ETH-20260626-3000-C",
          "direction": "buy",
          "limit_price": "310",
          "amount": "1",
          "max_fee": "0.01",
          "nonce": str(nonce),  # decimal string on the wire
          "signer": session_key.address,
          "signature": signature,
          "signature_expiry_sec": expiry_sec,
          "order_type": "limit",
          "time_in_force": "gtc",
      },
  ).json()
  print(response["result"]["order"]["order_id"])
  ```
</CodeGroup>

<Info>
  The instrument name above is an option, named `<CURRENCY>-<YYYYMMDD>-<STRIKE>-<C|P>` (perps are `<CURRENCY>-PERP`).
  See [Instrument names](/trading/instrument-names) for the full grammar.
</Info>

## Nonce and expiry

<ParamField path="nonce" type="uint256">
  The `nonce` param (decoded as a UTC timestamp in nanoseconds \* 6-digit suffix) has some special rules depending on the action.
</ParamField>

| Action type | Nonce window (relative to server clock) | Must increase? |
| - | - | - |
| Signed actions — withdraw, transfer, session key, whitelist, liquidation | ± 1 hour | Yes |
| Orders | 120 days before → 1 hour after | No |
| RFQs | ± 1 hour | No |
| Vault actions | 60 days before → 1 hour after | Yes |

<ParamField path="expiry" type="uint256">
  The Action's `expiry`, in unix seconds; rejected once `now > expiry`. The exchange enforces certain minimums depending on the action type to ensure the action is valid long enough.
</ParamField>

| Action type | Minimum expiry (from server clock) | Maximum expiry (from server clock) |
| - | - | - |
| Orders | 10 seconds | 120 days — or 15 minutes if the order is MMP |
| RFQ quotes — maker quote and taker execute | 60 seconds past the RFQ's expiry (an RFQ is valid for 30 minutes from creation) | 1 day |
| Position transfers | 60 seconds (both sides; the taker's expiry must also cover the maker's) | — |
| Withdraw, spot transfer, external transfer, liquidation, whitelist | None — must simply be unexpired when verified | — |
| Vault actions | None — must simply be unexpired when verified | 30 days |
| Set session key | None on the signature — but the key's own `expiry_sec` must be ≥ 5 minutes | — |

## Debugging signature issues

A rejected signature is almost always one of three things:

* **Bad action data encoding** — your ABI-encoded `encoded_data` bytes are
  wrong. Check them with
  [`public/decode_action`](/api-reference/system/publicdecode_action).
* **Bad action envelope encoding** — the action data is fine, but something the
  signature also commits to (module, nonce, expiry, owner, signer, domain
  separator) differs from what the exchange rebuilt. Byte-compare each stage
  against the relevant `private/*_debug` routes
  (e.g. [`private/order_debug`](/api-reference/orderbook/privateorder_debug)).
* **Wallet issues** — the encoding is right on both counts, but the wrong key
  signed, or `signer` does not match the recovered address. This is often because
  you passed in the `owner` instead of the `signer`.

## Constants

### `ACTION_TYPEHASH`

The EIP-712 type hash of the `Action` struct. It is the same on every network.

```text theme={null}
ACTION_TYPEHASH = keccak256(
    "Action(uint256 subaccountId,uint256 nonce,address module,bytes data,uint256 expiry,address owner,address signer)"
)
                = 0x4d7a9f27c403ff9c0f19bce61d76d82f9aa29f8d6d4b0c5474607d9770d1af17
```

An action's struct hash encodes its fields in that order, with `data` replaced by its keccak:

```text theme={null}
actionHash = keccak256(abi.encode(
    ACTION_TYPEHASH, subaccountId, nonce, module, keccak256(data), expiry, owner, signer
))
```

### Domain separator

The action domain is a standard four-field `EIP712Domain` with no salt. Only `chainId` differs
between networks.

| Field | Value |
| - | - |
| `name` | `Matching` |
| `version` | `1.0` |
| `chainId` | `1` on mainnet, `11155111` on testnet |
| `verifyingContract` | `0xeB8d770ec18DB98Db922E9D83260A585b9F0DeAD` |

<Warning>
  `verifyingContract` is the v2 mainnet `Matching` address, fixed on every network. It is not
  one of the v3 [contracts](/getting-started/contracts). Using the `ACTION_MANAGER` here
  produces a signature that never verifies.
</Warning>

```text theme={null}
DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256("Matching"),
    keccak256("1.0"),
    chainId,
    0xeB8d770ec18DB98Db922E9D83260A585b9F0DeAD
))
```

| Network | `chainId` | `DOMAIN_SEPARATOR` |
| - | - | - |
| Mainnet | `1` | `0xda616dfabb88681b08e1592820a41d55ddc62d68de110e327ae99d734506fe19` |
| Testnet (Sepolia) | `11155111` | `0x24d674cd5f2b9d564691c51e9d88f649b99246a2244dd74ce27b96578d773e85` |

The `signer` signs the digest `keccak256(0x1901 || DOMAIN_SEPARATOR || actionHash)`.

<Tip>
  The SDKs set both values for each network. You only need these formulas to sign without one.
</Tip>

Every `private/*_debug` route returns the `domain_separator` and `action_typehash` the exchange
verifies against, e.g. [`private/order_debug`](/api-reference/orderbook/privateorder_debug).
Compare yours against them when a signature is rejected.

### Modules

The `module` field names the contract whose ABI layout `data` follows. Module
addresses are fixed protocol constants — identical across every deployment, safe
to hardcode:

| Action | Module | Address | Purpose |
| - | - | - | - |
| Order / trade | `TRADE_MODULE` | `0xB8D20c2B7a1Ad2EE33Bc50eF10876eD3035b5e7b` | Place an order |
| Spot transfer | `TRANSFER_MODULE` | `0x01259207A40925b794C8ac320456F7F6c8FE2636` | Move a spot ERC-20 between subaccounts |
| Withdraw | `WITHDRAW_MODULE` | `0x9d0E8f5b25384C7310CB8C6aE32C8fbeb645d083` | Withdraw to L1 |
| RFQ / position transfer | `RFQ_MODULE` | `0x9371352CCef6f5b36EfDFE90942fFE622Ab77F1D` | RFQ quotes; position transfers book as RFQ trades |
| External transfer | `EXTERNAL_TRANSFER_MODULE` | `0x8F9B8f12ddA05FB1F0DDDDe8f5af8cECF54f8aC9` | External spot transfer |
| Whitelisted recipient | `WHITELISTED_RECIPIENT_MODULE` | `0xB86D6DE1b76c9839e4BA860848CD98A1dABd6B54` | Recipient allow-list |
| Liquidation | `LIQUIDATION_MODULE` | `0x66d23e59DaEEF13904eFA2D4B8658aeD05f59a92` | Bid into a Dutch auction |
| Vault | `VAULT_MODULE` | `0x2885c174ebf5524aED9c721d60c12b1537685186` | Vault actions |
| Set session key | `SET_SESSION_KEY_MODULE` | `0xe330CF64ff6EbF41699aad344Cb21d78db1D2bb6` | Register a delegated session key |

### owner vs signer

`owner` is always the wallet that owns the subaccount. `signer` is whoever
produced the signature — the wallet itself (`owner == signer`) or a delegated
[session key](/authentication/session-keys), in which case `signer` is the key's address and
`owner` stays the wallet. The protocol recovers the ECDSA signer and requires it
to equal `signer`; if `signer != owner`, it loads the session key and checks its
scopes cover the action.


## Related topics

- [Introduction](/getting-started/introduction.md)
- [Quickstart](/getting-started/quickstart.md)
- [Contracts](/getting-started/contracts.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.