Agara

Pre-signed orders

Sign orders locally with the wallet's private key and submit them in a single round-trip. The fast path for high-frequency Agara market making.

Pre-signed orders let a bot sign locally with the account owner's key and submit the completed envelope to a dedicated endpoint. The request still requires PAT authentication, but it does not wait for server-side signing. Measure the path in your deployment rather than depending on an undocumented latency target.

For signed inventory and withdrawal operations, see account batches.

When to use it

Pre-signed orders are worth the setup if:

  • You're running a market-making bot or any high-frequency strategy where the per-order signing round-trip is your bottleneck.
  • You can hold a private key in your bot's process or KMS.

If you're placing one-off orders from a browser, or your throughput is already comfortably under the regular endpoint's limits, don't bother — server-signed orders are simpler and the latency difference won't matter to you.

One-time setup

1. Export the wallet's private key

  1. Open Settings in the web app.
  2. Find the Wallet details card. The bottom row is labeled Export private key with a Reveal button.
  3. Click Reveal. A secure modal opens — this is run by Privy on a separate origin, so neither the web app nor anything else on the page can read the key as it's displayed.
  4. Copy the 0x-prefixed hex string out of the modal and close it. The modal can be reopened later if you lose the copy.

Store the key somewhere your bot process can read at startup — KMS, an encrypted secret manager, an environment variable on a hardened host. Treat it as a high-impact signing credential: anyone who reads it can sign on behalf of the wallet, place signed orders, and authorize on-chain account batches.

The key is the EOA owner of the deposit wallet, not the deposit wallet itself. Rotating it means moving funds to a fresh wallet through normal onboarding; there's no in-place rotation today.

2. Mint a PAT with the orders:place_signed scope

In Settings → API Tokens, create a new token and check the Place pre-signed orders scope. Make sure the token's exchange is set to AGARA.

You can grant this scope alongside the usual ones (orders:cancel, portfolio:read, account:stream) — same token covers the whole bot loop.

3. Note the deposit-wallet address and chain settings

The bot signs against the deposit wallet, not the EOA directly. Grab the AGARA deposit wallet address from the same Wallet details card — the row labeled "AGARA deposit wallet — holds funds" has a copy button next to it.

The chain ID and standard exchange address are deployment-specific signing values. This deployment uses chain ID 8453 and standard exchange 0xD06f3fA925D35077A11dB42c6614D684f10B2aD6. Confirm both against CTF tokens.

The signed-order validator uses this standard exchange as the verifying contract. Do not substitute the neg-risk exchange address.

Sending an order

POST /trade/v1/orders/signed

Scope: orders:place_signed

Request

{
  "token_id": "21742633143463906290569050155826241533067272736897614950488156847949938836455",
  "side": "BUY",
  "type": "LIMIT",
  "time_in_force": "GTC",
  "price_micro": "600000",
  "shares_micro": "1000000",
  "post_only": false,
  "salt": "84629103847263918473",
  "maker": "0xCdEf012345...",
  "chain_token_id": "21742633143463906290569050155826241533067272736897614950488156847949938836455",
  "maker_amount": "600000",
  "taker_amount": "1000000",
  "side_u8": 0,
  "timestamp": "0",
  "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000",
  "builder": "0x0000000000000000000000000000000000000000000000000000000000000000",
  "order_hash": "0xfe738cc74603ab47...",
  "signature": "0x4f3a8e9b..."
}

chain_token_id must encode the same unsigned integer as token_id. A mismatch returns 400.

The first block of fields (token_id through post_only) is identical to the regular Place an order body. The second block is the EIP-712 envelope your bot signed over.

side_u8, timestamp, metadata, and builder are canonical envelope values produced by sign_limit_order and should not be hand-constructed. timestamp and metadata are zero placeholders today; builder is unused. If you're implementing without the SDK, mirror these values exactly.

The server recomputes the order hash from the envelope fields, asserts it matches the order_hash you sent, recovers the signing address from signature, and confirms it matches the wallet's owner. Any mismatch returns 400.

What the server checks

Each pre-signed order is validated before it reaches the book. Envelope and request validation failures return 400; live balance, inventory, post-only, and fillability refusals return 422.

If…you get
type isn't LIMITpre-signed endpoint accepts LIMIT orders only
chain_token_idtoken_id as numberschain_token_id does not match token_id
maker isn't your deposit-wallet addressmaker must equal the wallet's deposit-wallet address
order_hash ≠ the server's recomputeorder_hash does not match the recomputed EIP-712 digest
the signature is high-ssignature s is not canonical (high-s)
the signature doesn't recover to the wallet ownersignature does not recover to the wallet's EOA
notional is outside the beta bandorder notional is below the $0.10 beta minimum / …exceeds the $100,000 beta maximum

If every order fails with the hash-mismatch message, check the chain ID and standard exchange address first. The signature is bound to both.

The active values are chain ID 8453 and standard exchange 0xD06f3fA925D35077A11dB42c6614D684f10B2aD6.

Response

Identical to the regular endpoint — 202 Accepted with the order ID:

{
  "order_id": "9c4a3e7d-12b4-4f8e-9a3c-d2c7f0a45e1b",
  "source": "AGARA",
  "status": "PENDING",
  "pending_operation": "SUBMIT",
  "as_of": "2026-05-21T17:20:01.633Z"
}

Track the order through account_events the same way you would for server-signed orders.

Because you know each order's order_hash before you submit it, you don't have to rely on this response to correlate what happens next: every order event on the account stream carries the order_hash, and you can fetch an order by it at any time with GET /trade/v1/orders/by-hash/{order_hash}. This is the reliable way to tie acks and fills back to the right submission when you fire many orders at once, and your recovery path if you ever miss this response.

Submitting the same signed envelope again returns 409 rather than creating a second order. Look up its original state with GET /trade/v1/orders/by-hash/{order_hash}. Use a fresh salt for each distinct order, but preserve the original signed envelope when retrying an uncertain request.

Restrictions

  • LIMIT only. A market order's result depends on live orderbook depth, so send market orders to the regular endpoint.
  • Time-in-force. GTC, GTD, FAK, and FOK all work. A GTD order needs expiration_unix_seconds at least 30 seconds in the future — same rules as the regular endpoint's time in force.
  • Order size. During beta, an order's notional (price_micro × shares_micro / 1_000_000) must be at least 0.10 collateral units and at most 100,000 collateral units. Outside that band the order is rejected with 400 before it reaches the book.
  • Price and size grid. price_micro and shares_micro must fit the market's tick and lot size, or the order is rejected during placement. If you build orders by hand, round to the market's grid from Markets first.

Sending many orders at once

When you re-quote a whole ladder each tick, send the orders together instead of one request per order.

POST /trade/v1/orders/signed/batch

Scope: orders:place_signed

Submit up to 32 pre-signed orders in one call. The request is a single orders array, where each element is exactly the body you'd send to POST /trade/v1/orders/signed:

{
  "orders": [
    { "token_id": "2174263314...", "side": "BUY", "type": "LIMIT", "...": "..." },
    { "token_id": "2174263314...", "side": "SELL", "type": "LIMIT", "...": "..." }
  ]
}

Response

Every order is checked on its own, so one bad order never sinks the rest. You get back one result per order, in the order you sent them:

{
  "results": [
    {
      "index": 0,
      "outcome": "accepted",
      "order_id": "9c4a3e7d-12b4-4f8e-9a3c-d2c7f0a45e1b",
      "source": "AGARA",
      "status": "PENDING",
      "pending_operation": "SUBMIT",
      "as_of": "2026-05-21T17:20:01.633Z"
    },
    {
      "index": 1,
      "outcome": "rejected",
      "code": "duplicate_order_hash",
      "message": "duplicate order hash"
    }
  ]
}

An accepted result carries the same fields as a single-order response; track it through account_events the same way. A rejected result carries a code and message — a re-submitted order (same order_hash) comes back as duplicate_order_hash, an order that fails validation comes back with the matching reason. Walk the results array and act on each outcome; don't assume all-or-nothing.

Sending an empty array or more than 32 orders is rejected outright with 400 — the whole call, before any order is processed.

Batch calls are metered under their own budget, separate from single-order placement, so a ladder re-quote can't exhaust the rate budget your one-off placements draw from. If you exceed it you'll get a 429 with a Retry-After — back off and retry the same batch (already accepted orders dedupe by order_hash, so a retry is safe).

Implementing without the SDK

If you sign in a language the SDK doesn't cover, reproduce exactly what sign_limit_order does. The order_hash/signature you submit are an EIP-712 signature over the exchange's Order struct — get any field wrong and the server's recompute won't match.

Domain — bind the signature to the exact deployment and exchange.

{
  "name": "Agara CTF Exchange",
  "version": "1",
  "chainId": 8453,
  "verifyingContract": "0xD06f3fA925D35077A11dB42c6614D684f10B2aD6"
}

Type — the Order struct has exactly these nine fields, in this order:

Order(
  uint256 salt,
  address maker,
  uint256 tokenId,
  uint256 makerAmount,
  uint256 takerAmount,
  uint8 side,
  uint256 timestamp,
  bytes32 metadata,
  bytes32 builder
)

Field values:

  • salt — a fresh random uint256 per order (it's the only thing that makes otherwise-identical orders hash differently).
  • maker — your deposit-wallet address; it's both the source of funds and the account the signature is validated against.
  • tokenId — the outcome's numeric token id (same value as chain_token_id).
  • side0 for BUY, 1 for SELL.
  • makerAmount / takerAmount — what you give / receive, where collateral = shares_micro × price_micro / 1_000_000:
    • BUY: makerAmount = collateral, takerAmount = shares_micro
    • SELL: makerAmount = shares_micro, takerAmount = collateral
  • timestamp, metadata, builder — advisory: they're signed over but have no on-chain effect. Always send 0 / 32 zero bytes / 32 zero bytes. (They're part of the struct, so you can't omit them — the hash is computed over all nine fields.)

Signature — sign the EIP-712 digest with the deposit wallet's owner key. It must be a 65-byte r‖s‖v ECDSA signature in canonical low-s form (EIP-2); the deposit wallet validates it via ERC-1271 by recovering the owner. Put the digest in order_hash and the signature in signature, alongside the salt/maker/amounts you signed.

Cancelling and other operations

Cancel, cancel-all, list, and the account stream all work the same way with the same scopes. Only placement has a different endpoint.

Security considerations

  • The private key authorises orders and account batches. Anyone who reads it can authorize trades or move the wallet's assets. Store it as a high-impact signing credential.
  • The PAT alone is not enough to forge orders — every request must also carry a valid signature from the wallet's owner. A leaked PAT is far less dangerous on this path than on the server-signed path.
  • Revoking the PAT in Settings → API Tokens cuts off submissions without affecting the key itself.
  • Rotating the key is out of scope here — it means moving funds to a new wallet through the normal onboarding flow.