For teams building standalone MM (Market Maker) or retail trading scripts.
This guide helps you avoid common pitfalls and build correctly from day one. You do not need any separate RFQ testing framework—everything here is self-contained in injective-rfq-toolkit.
- Who This Is For
- Architecture Overview
- Grant Creation (Critical)
- Quote Signing
- MakerStream Auth Handshake
- Indexer Integration (WebSocket)
- Contract Expectations
- Conditional Orders (TP/SL)
- Error Handling
- Production Tips
- Quick Reference
- Market makers building Python bots that send quotes
- Retail integrators building request/accept flows
- Anyone implementing RFQ flows without using our test framework
What this guide covers: Grants, signing, indexer protocol, contract expectations, and lessons we learned the hard way.
What it does not cover: Full end-to-end examples (see examples/ for TypeScript, Go, and Python reference scripts).
Retail (Taker) Indexer (WebSocket) Contract (On-Chain)
| | |
|---- Create RFQ Request ----------->| |
|<--- Request ACK (rfq_id) ----------| |
| |<--- Request (MakerStream) -------| (MMs receive)
| | |
| |<--- Quote (MakerStream) ----------| (MM sends)
|<--- Quote -------------------------| |
| | |
|---- AcceptQuote (CosmWasm) ------------------------------------------>|
|<--- Tx confirmation --------------------------------------------------|
- Indexer: WebSocket (gRPC-over-WebSocket). TakerStream (retail) and MakerStream (MM) are separate endpoints.
- Contract: CosmWasm. Retail calls
AcceptQuotewith signed quotes; contract verifies signatures and settles.
Both MM and Retail must grant the RFQ contract permission to execute messages on their behalf. If you miss a grant, accept_quote fails with authorization not found.
| Role | Message Types |
|---|---|
| MM | MsgSend, MsgPrivilegedExecuteContract |
| Retail | MsgSend, MsgPrivilegedExecuteContract |
Retail needs both. A common mistake is granting only MsgSend—the contract also needs MsgPrivilegedExecuteContract to execute the trade.
Gas simulation underestimates gas for grant transactions. On some chains this causes panic or "out of gas". Always use gas heuristics for grant broadcasts:
from pyinjective.core.broadcaster import MsgBroadcasterWithPk
# DO: Use gas heuristics for grant transactions
broadcaster = MsgBroadcasterWithPk.new_using_gas_heuristics(
network=network,
private_key=private_key,
)
# DON'T: Use simulation for grant transactions
# broadcaster = MsgBroadcasterWithPk.new_using_simulation(...) # Avoid!The RFQ contract expects GenericAuthorization for all message types (including MsgSend). Do not use SendAuthorization with spend limits.
The contract expects grants with no expiration (expiration: null). The pyinjective msg_grant_generic() helper requires an expiration—so you must build the grant manually:
from pyinjective.proto.cosmos.authz.v1beta1 import authz_pb2, tx_pb2 as authz_tx_pb2
from google.protobuf import any_pb2
def create_grant_msg(granter: str, grantee: str, msg_type: str):
"""Create MsgGrant with expiration: null (permanent grant)."""
generic_authz = authz_pb2.GenericAuthorization()
generic_authz.msg = msg_type # e.g. "/cosmos.bank.v1beta1.MsgSend"
authz_any = any_pb2.Any()
authz_any.type_url = "/cosmos.authz.v1beta1.GenericAuthorization"
authz_any.value = generic_authz.SerializeToString()
grant = authz_pb2.Grant()
grant.authorization.CopyFrom(authz_any)
# Do NOT set grant.expiration — that creates expiration: null
grant_msg = authz_tx_pb2.MsgGrant()
grant_msg.granter = granter
grant_msg.grantee = grantee
grant_msg.grant.CopyFrom(grant)
return grant_msgMSG_TYPES = [
"/cosmos.bank.v1beta1.MsgSend",
"/injective.exchange.v2.MsgPrivilegedExecuteContract",
]
for msg_type in MSG_TYPES:
grant_msg = create_grant_msg(granter, contract_address, msg_type)
result = await broadcaster.broadcast([grant_msg])
# Always check tx_response.code == 0 (see Error Handling)The injective-rfq-toolkit standard is EIP-712 v2 signing. Every quote and conditional order MUST carry sign_mode: "v2" on the wire; missing or empty signing modes are rejected by the indexer.
TL;DR: Build the
SignQuotetyped-data digest, sign it with secp256k1 raw (no EIP-191 prefix), prepend0xto the signature, and putsign_mode: "v2"in the wire payload.
The signature does NOT bind the wire fields chain_id / contract_address. Those are bound by the EIP-712 domain separator instead:
domain = EIP712Domain(
name = "RFQ",
version = "1",
chainId = <EVM chain ID>, # 1439 testnet, 1776 mainnet
verifyingContract = <bech32_to_evm(contract)>, # 20-byte hex of the bech32 address
)
EVM chain ID is 1439 on testnet and 1776 on mainnet — bake it into your config, don't hardcode.
The SignQuote typed-data is custom — not eth_signTypedData_v4. The indexer hand-rolls the digest. Every field is right-aligned in a 32-byte word:
SignQuote(
uint64 evmChainId, // mirrors the chainId in the domain separator
string marketId,
uint64 rfqId,
address taker,
uint8 takerDirection, // 0=long, 1=short
string takerMargin,
string takerQuantity,
address maker,
uint32 makerSubaccountNonce,
string makerQuantity,
string makerMargin,
string price,
uint8 expiryKind, // 0=timestamp_ms, 1=block_height
uint64 expiryValue,
string minFillQuantity, // "0" if absent
uint8 bindingKind // 1 if taker is present, 0 for blind quotes
)
| Field type | Encoding |
|---|---|
string and decimal fields |
keccak256(utf8(s)) |
address |
20 bytes from bech32_to_evm, left-padded to 32 bytes |
uint8 / uint32 / uint64 |
big-endian, right-aligned in 32 bytes |
evmChainId |
first message-hash field; must equal the domain separator's chainId and the wire evm_chain_id |
bindingKind |
1 for taker-specific quotes, 0 for blind quotes |
minFillQuantity |
"0" when absent — never empty string |
The final digest is keccak256(0x19 || 0x01 || domainSeparator || msgHash).
Decimals are hashed as the raw UTF-8 string. "4.5" and "4.50" produce different digests, and the indexer expects canonical plain notation without trailing zeros. The wire price MUST equal the signed price byte-for-byte:
Correct order: compute price → quantize to tick → sign → send (wire = signed) Wrong order: compute price → sign → quantize → send ← signature mismatch!
Every other normalization the indexer cares about reduces to this one transform: quantize to the tick, then strip trailing zeros (and the dangling decimal point if there's nothing left after it).
from decimal import Decimal, ROUND_DOWN
def to_canonical(x, tick) -> str:
"""Quantize x to the market tick and emit the indexer-canonical string."""
return format(
Decimal(str(x)).quantize(Decimal(str(tick)), rounding=ROUND_DOWN).normalize(),
"f", # plain notation, never scientific
)
# 4.50 → "4.5" (any market with a fractional tick)
# 76462.0 → "76462" (BTC/USDC perp, tick "1")
# 110.00 → "110" (INJ/USDC perp, tick "0.01")The whole-integer case ("76462.0" → "76462") is the one that bites partners
most often: a price computed with f"{mark:.1f}" or str(float(...)) will carry
a trailing .0 and the indexer rejects it with
price "76462.0": not in canonical decimal form (use plain notation without trailing zeros or scientific notation).
Run every decimal field (price, taker_margin, taker_quantity, maker_margin,
maker_quantity, min_fill_quantity, trigger_price, …) through to_canonical
before signing, then send those exact strings on the wire.
from rfq_test.crypto.eip712 import sign_quote_v2
signature = sign_quote_v2(
private_key=mm_private_key,
evm_chain_id=1439, # config.signing_context_v2[0]
verifying_contract_bech32="inj1qw7jk82h...", # config.signing_context_v2[1]
market_id="0xdc70...",
rfq_id=int(rfq_id),
taker=taker_inj_addr,
direction="long", # or "short"
taker_margin="100",
taker_quantity="1",
maker=mm_inj_addr,
maker_margin="100",
maker_quantity="1",
price="4.5", # quantized to tick beforehand
expiry_ms=int(time.time() * 1000) + 20_000,
maker_subaccount_nonce=0,
min_fill_quantity=None,
)
# Already prefixed with "0x"; pass through to MakerStream + REST as-is.
# The helper derives bindingKind from `taker`: taker set -> taker-bound,
# taker empty/None -> blind. Do not pass binding_kind or nonce to this helper.If you can't import rfq_test, this is the byte-compatible reference (mirrors service/rfq/signature/eip712.go in injective-indexer):
import bech32
from eth_account import Account
from eth_hash.auto import keccak
EIP712_DOMAIN_TYPE = (
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
)
SIGN_QUOTE_TYPE = (
b"SignQuote(uint64 evmChainId,string marketId,uint64 rfqId,address taker,uint8 takerDirection,"
b"string takerMargin,string takerQuantity,address maker,uint32 makerSubaccountNonce,"
b"string makerQuantity,string makerMargin,string price,uint8 expiryKind,"
b"uint64 expiryValue,string minFillQuantity,uint8 bindingKind)"
)
def bech32_to_evm(addr: str) -> bytes:
hrp, data = bech32.bech32_decode(addr)
assert hrp == "inj" and data is not None
raw = bech32.convertbits(data, 5, 8, False)
assert raw is not None and len(raw) == 20
return bytes(raw)
def _u(n: int, width: int) -> bytes: # right-aligned big-endian in 32 bytes
return b"\x00" * (32 - width) + n.to_bytes(width, "big")
def _s(s: str) -> bytes: return keccak(s.encode("utf-8"))
def _addr(a: bytes) -> bytes: return b"\x00" * 12 + a
def _direction(d: str) -> int: return {"long": 0, "short": 1}[d.lower()]
def domain_separator(evm_chain_id: int, contract_bech32: str) -> bytes:
return keccak(b"".join((
keccak(EIP712_DOMAIN_TYPE),
_s("RFQ"),
_s("1"),
_u(evm_chain_id, 8),
_addr(bech32_to_evm(contract_bech32)),
)))
def sign_quote_v2(
*, private_key, evm_chain_id, verifying_contract_bech32,
market_id, rfq_id, taker, direction,
taker_margin, taker_quantity, maker, maker_subaccount_nonce,
maker_quantity, maker_margin, price, expiry_ms,
min_fill_quantity=None,
) -> str:
msg = b"".join((
keccak(SIGN_QUOTE_TYPE),
_u(int(evm_chain_id), 8),
_s(market_id), _u(int(rfq_id), 8),
_addr(bech32_to_evm(taker)), _u(_direction(direction), 1),
_s(str(taker_margin)), _s(str(taker_quantity)),
_addr(bech32_to_evm(maker)), _u(int(maker_subaccount_nonce), 4),
_s(str(maker_quantity)), _s(str(maker_margin)),
_s(str(price)),
_u(0, 1), # expiryKind=timestamp
_u(int(expiry_ms), 8),
_s(str(min_fill_quantity) if min_fill_quantity is not None else "0"),
_u(1, 1), # bindingKind = 1 because taker is present
))
digest = keccak(b"\x19\x01" + domain_separator(evm_chain_id, verifying_contract_bech32) + keccak(msg))
pk = bytes.fromhex(private_key.removeprefix("0x"))
sig = Account.from_key(pk).unsafe_sign_hash(digest)
v = sig.v - 27 if sig.v >= 27 else sig.v
return "0x" + (sig.r.to_bytes(32, "big") + sig.s.to_bytes(32, "big") + bytes([v])).hex()For block-height expiries pass _u(1, 1) (expiryKind=height) and the height as expiryValue.
The subaccount index used when your address was registered as a maker. Default 0. Contact the platform operator if you registered with a non-default nonce.
Whatever path you use to send the quote (REST /v1/quote, MakerStream WS, or gRPC), include the signature, the sign-mode literal, and the EVM chain ID that you embedded in the EIP-712 domain separator:
quote = {
# ...all the fields you signed above (chain_id/contract_address/market_id/etc)...
"signature": signature,
"sign_mode": "v2",
"evm_chain_id": 1439, # 1439 testnet, 1776 mainnet
}If sign_mode is missing or empty the indexer closes the stream with a signing-mode validation error:
ConnectionClosedError(Close(code=1011, reason='invalid request:
missing or unsupported sign_mode'))
evm_chain_id(proto field 24) is required whensign_mode="v2". It tells the indexer which chain ID to use when reconstructing the domain separator and must match one of the indexer's configured chain IDs. The same field exists onRFQQuoteType, the conditional-order request, theMakerAuthenvelope, and the indexer'sMakerChallengemessage — wherever a v2 signature appears, the chain ID rides alongside it. The field is ignored whensign_mode="v1".
sign_modedefaults. Whensign_modeis omitted on the wire, the indexer falls back to"v1"for backward compatibility. Setsign_mode="v2"explicitly on every payload —"v1"is deprecated and will be removed at launch.
Before the indexer streams RFQ requests to a maker, the maker must prove control of the bech32 address it announced in connection metadata. The handshake is a one-shot challenge-response signed with EIP-712 v2 against the same domain separator as SignQuote.
TL;DR: Open the stream → server sends a
MakerChallenge→ signStreamAuthChallengetyped-data → reply withMakerAuth{evm_chain_id, signature}→ normal request/quote loop begins.
MM Indexer
│ │
├── connect (metadata: maker_address) ────▶│
│ │
│◀───── MakerChallenge {nonce, │
│ evm_chain_id, expires_at} ─────────│ one-shot, ~30s validity
│ │
│ sign StreamAuthChallenge typed-data │
│ │
├── MakerStreamStreamingRequest{ │
│ message_type: "auth", │
│ auth: MakerAuth{ │
│ evm_chain_id, signature │
│ } │
│ } ────────────────────────────────────▶│
│ │
│◀──── request / quote_ack / settlement ──┤ stream loop continues
The challenge is single-use. If you reconnect, expect a fresh challenge with a new nonce.
The signed struct has 4 fields:
StreamAuthChallenge(
uint64 evmChainId, // mirrors the value in the domain separator
address maker, // 20-byte EVM form of your inj1… (bech32_to_evm)
bytes32 nonce, // raw 32 bytes from MakerChallenge.nonce (hex-decoded)
uint64 expiresAt // milliseconds since epoch, matches MakerChallenge
)
| Field | Encoding |
|---|---|
evmChainId |
big-endian, right-aligned in 32 bytes (8 bytes wide) |
maker |
20 bytes from bech32_to_evm(maker_inj), left-padded to 32 bytes |
nonce |
the 32 raw bytes — passed through verbatim, not keccak-hashed |
expiresAt |
big-endian, right-aligned in 32 bytes (8 bytes wide) |
The final digest is keccak256(0x19 || 0x01 || domainSeparator || msgHash) with the same domain separator as SignQuote (name="RFQ", version="1", chainId=evm_chain_id, verifyingContract=bech32_to_evm(contract_address)). Sign the digest with secp256k1 raw, normalise v to 0/1, and emit 0x-prefixed r ‖ s ‖ v.
The proto adds two messages and a new message_type value:
// indexer → maker, sent once after the stream opens
message MakerChallenge {
string nonce = 1; // hex-encoded 32 bytes
uint64 evm_chain_id = 2;
sint64 expires_at = 3; // unix milliseconds
}
// maker → indexer, sent once in response to MakerChallenge
message MakerAuth {
uint64 evm_chain_id = 1;
string signature = 2; // 0x-prefixed r||s||v
}
MakerStreamStreamingRequest gains auth = 3 (MakerAuth) alongside the existing quote = 2. MakerStreamResponse gains challenge = 7 (MakerChallenge) and a new message_type value "challenge".
from eth_utils import keccak
from eth_keys import keys
from bech32 import bech32_decode, convertbits
STREAM_AUTH_CHALLENGE_TYPE = (
b"StreamAuthChallenge(uint64 evmChainId,address maker,bytes32 nonce,uint64 expiresAt)"
)
def _u(n: int, width: int) -> bytes:
return b"\x00" * (32 - width) + n.to_bytes(width, "big")
def _addr(b20: bytes) -> bytes:
return b"\x00" * 12 + b20
def bech32_to_evm(addr: str) -> bytes:
hrp, data = bech32_decode(addr)
return bytes(convertbits(data, 5, 8, False))
def sign_maker_challenge_v2(
*,
private_key: str,
contract_address: str, # bech32, used in domain separator
maker_inj: str,
evm_chain_id: int,
nonce_hex: str, # from MakerChallenge.nonce
expires_at: int, # unix ms, from MakerChallenge.expires_at
) -> str:
nonce = bytes.fromhex(nonce_hex.removeprefix("0x"))
if len(nonce) != 32:
raise ValueError(f"expected 32-byte nonce, got {len(nonce)}")
msg = b"".join((
keccak(primitive=STREAM_AUTH_CHALLENGE_TYPE),
_u(int(evm_chain_id), 8),
_addr(bech32_to_evm(maker_inj)),
nonce, # raw bytes — not keccak-hashed
_u(int(expires_at), 8),
))
digest = keccak(
primitive=b"\x19\x01"
+ domain_separator(evm_chain_id, contract_address) # same as sign_quote_v2
+ keccak(primitive=msg)
)
pk = keys.PrivateKey(bytes.fromhex(private_key.removeprefix("0x")))
sig = pk.sign_msg_hash(digest)
v = sig.v - 27 if sig.v >= 27 else sig.v
return "0x" + (sig.r.to_bytes(32, "big") + sig.s.to_bytes(32, "big") + bytes([v])).hex()domain_separator(...) is the helper from the Standalone v2 signing section above — same bytes, same chain ID. Reuse it.
async for resp in maker_stream:
if resp.message_type == "challenge":
cha = resp.challenge
sig = sign_maker_challenge_v2(
private_key=mm_pk,
contract_address=contract_address,
maker_inj=maker_addr,
evm_chain_id=int(cha.evm_chain_id),
nonce_hex=cha.nonce,
expires_at=int(cha.expires_at),
)
await send_queue.put(MakerStreamStreamingRequest(
message_type="auth",
auth={"evm_chain_id": int(cha.evm_chain_id), "signature": sig},
))
elif resp.message_type == "request":
... # normal quoting loopThe testnet-verified Python E2E implementations live in
examples/test_settlement.py for WebSocket/gRPC-web and
examples/test_settlement_grpc.py for native gRPC.
The reusable WebSocket MM reference is
examples/python-mm/main.py; it connects with
maker_address, lets MakerStreamClient answer MakerChallenge using
auth_private_key, auth_evm_chain_id, and auth_contract_address, then signs and
sends v2 quotes. The native gRPC maker reference in
examples/python-mm/main-grpc.py shows the same
challenge-response protocol without the reusable client wrapper.
| Symptom | Cause |
|---|---|
Stream closes immediately after auth send |
Signature mismatch — re-check evm_chain_id, maker derivation, and the domain separator |
nonce decode fails |
The proto carries the nonce as a hex string; decode it before feeding into the digest |
expired_challenge |
expires_at already past at the indexer — connect, sign, and reply within ~30s |
| Stream silent after connect | No challenge arrived — verify you set maker_address in connection metadata |
| Environment | WebSocket Base URL |
|---|---|
| Testnet | wss://testnet.rfq.ws.injective.network/injective_rfq_rpc.InjectiveRfqRPC |
Append /TakerStream or /MakerStream to the base URL.
- gRPC-over-WebSocket with subprotocol
grpc-ws - Messages are protobuf-encoded with gRPC-web framing:
[1 byte compression][4 bytes length BE][payload] - Send ping messages periodically (e.g. every 1–2 seconds) to keep the connection alive
- URL:
{base_url}/TakerStream - Connection metadata: Send
request_address(taker's Injective address) as a header when connecting. The indexer uses this to associate requests with the correct taker. - Request: Send
CreateRFQRequestTypewithclient_id,market_id,direction,margin,quantity,worst_price,request_address, andexpiry.client_idis the taker-supplied correlation ID; the indexer returns the realrfq_idin the request ACK. - Direction: Use
"long"or"short"(string). Do not use0/1or numeric values.
- URL:
{base_url}/MakerStream - Connection metadata: Send
maker_addressas a header when connecting. The indexer issues an EIP-712 v2 challenge against this address — see MakerStream Auth Handshake for the protocol. - Auth handshake: First inbound message after connect is
MakerChallenge. Sign and reply withMakerAuthbefore any quoting. Skipping this step keeps the stream open but produces norequestevents. The PythonMakerStreamClienthandles this automatically when configured withauth_private_key,auth_evm_chain_id, andauth_contract_address. - Optional subscriptions: Set
subscribe_to_quotes_updates: trueandsubscribe_to_settlement_updates: trueas headers to receive those maker stream updates. - Receive: Requests arrive as stream messages
- Quote update scope:
quote_updateevents are sent for quotes whosemakermatchesmaker_address. - Settlement update scope:
settlement_updateevents are sent when a settlement includes at least one quote frommaker_address, whether that specific quote was executed or not. - Quote status meaning: In
quote_update,status="accepted"means the quote was used;status="rejected"means it was considered but not used. - Executed fields: In
quote_update,executed_quantityandexecuted_marginare the actually filled amount and margin for that quote. - Send: Quotes as
RFQQuoteTypewith fields:chain_id,contract_address,market_id,rfq_id,taker_direction,margin,quantity,price,expiry,maker,taker,signature
The indexer expects a specific field order for RFQQuoteType. If you encode in the wrong order, the indexer may reject with "rfq_id is required" or similar. Match the canonical order:
| Field # | Name | Type | Notes |
|---|---|---|---|
| 1 | chain_id | string | Wire-only — not bound by v2 signature |
| 2 | contract_address | string | Wire-only — not bound by v2 signature |
| 3 | market_id | string | |
| 4 | rfq_id | uint64 | |
| 5 | taker_direction | string | "long" or "short" |
| 6 | margin | string | FPDecimal |
| 7 | quantity | string | FPDecimal |
| 8 | price | string | FPDecimal — must equal the signed price byte-for-byte |
| 9 | expiry | RFQExpiryType | nested: timestamp (uint64 ms) or height (uint64) |
| 10 | maker | string | |
| 11 | taker | string | |
| 12 | signature | string | hex with 0x, 65 bytes (r‖s‖v) |
| 19 | maker_subaccount_nonce | uint32 | included in v2 digest as makerSubaccountNonce |
| 20 | min_fill_quantity | string | optional — included in v2 digest as "0" if absent |
| 23 | sign_mode | string | Required. "v2" for everything this client signs. Defaults to "v1" when omitted (deprecated). |
| 24 | evm_chain_id | uint64 | Required when sign_mode="v2". Same value embedded in the EIP-712 domain separator (1439 testnet, 1776 mainnet). |
All numeric fields (margin, quantity, price, worst_price) use FPDecimal: human-readable decimal strings in JSON. Examples: "5", "5.1", "1.461". Do not send 1e6-scaled integers.
- Long:
worst_pricemust be ≤mark_price × 1.1(10% slippage) - Short:
worst_pricemust be ≥mark_price × 0.9
Fetch mark price from the chain LCD endpoint and set worst_price accordingly:
GET {lcd}/injective/exchange/v1beta1/derivative/markets/{market_id}
The response includes perpetualMarketInfo.markPrice (or similar field depending on the market type).
Critical pitfall: Every price and quantity used in a quote or conditional order MUST be quantized to the market's tick sizes before you sign it. The signed value and the value sent in AcceptQuote (and to the indexer) must be byte-for-byte identical. If you sign an unquantized price and then quantize before sending, the signature verification will fail. If you skip quantization entirely, the exchange will reject the synthetic trade with an error such as
"invalid price tick"— even though the signature was valid.Correct order: compute price → quantize to tick → sign → send Wrong order: compute price → sign → quantize → send ← signature mismatch!
Each derivative market has tick size constraints:
| Parameter | Description |
|---|---|
min_price_tick_size |
Prices must be exact multiples of this value |
min_quantity_tick_size |
Quantities must be exact multiples of this value |
| Minimum notional | Some markets enforce price × quantity ≥ min_notional |
Fetching tick sizes from the chain:
GET {lcd}/injective/exchange/v2/derivative/markets/{market_id}
Inside the response look for market.market.min_price_tick_size and market.market.min_quantity_tick_size. These are returned as human-readable decimal strings (not scaled by 1e18).
Using the SDK helpers (MM quote price):
The rounding direction for the MM's quote price matters. The contract checks:
- For LONG direction (taker buys):
mm_price ≤ taker_worst_price→ MM should round DOWN (ROUND_FLOOR) to stay within the taker's limit - For SHORT direction (taker sells):
mm_price ≥ taker_worst_price→ MM should round UP (ROUND_CEILING) to stay above the taker's floor
from decimal import Decimal, ROUND_FLOOR, ROUND_CEILING
from rfq_test.utils.price import (
get_market_tick_sizes,
quantize_to_tick,
quantize_quantity,
)
# Fetch once per session (PriceFetcher also caches this as a side-effect)
ticks = await get_market_tick_sizes(lcd_endpoint, market_id)
price_tick = ticks.get("min_price_tick") # e.g. Decimal("0.001")
qty_tick = ticks.get("min_qty_tick") # e.g. Decimal("0.001")
# Apply before signing — NOT after
spread = mark_price * Decimal("0.005") # 0.5% spread
# MM's quote price: direction-aware rounding
if direction == "long": # taker buys → MM sells at higher price
raw_price = mark_price + spread
quote_price = quantize_to_tick(raw_price, price_tick, rounding=ROUND_FLOOR) # stay ≤ worst_price
else: # taker sells → MM buys at lower price
raw_price = mark_price - spread
quote_price = quantize_to_tick(raw_price, price_tick, rounding=ROUND_CEILING) # stay ≥ worst_price
# Quantities always floor-round (never exceed taker's requested amount)
quantity = quantize_quantity(raw_quantity, qty_tick)
# Now sign using these quantized strings — SAME strings sent in the proto message
signature = sign_quote_v2(
price=quote_price, # ← quantized, signed as keccak256(utf8(s))
maker_quantity=quantity, # ← quantized
evm_chain_id=evm_chain_id,
verifying_contract_bech32=contract_address,
...
)
# Send quote_price and quantity unchanged in the RFQQuoteType proto message
# along with sign_mode="v2".If you don't use the helpers, implement the quantization yourself:
from decimal import Decimal, ROUND_FLOOR, ROUND_CEILING
def quantize_to_tick(value, tick_size, rounding=ROUND_FLOOR):
d = Decimal(str(value))
t = Decimal(str(tick_size))
quantized = (d / t).to_integral_value(rounding=rounding) * t
return format(quantized.normalize(), "f")
# LONG direction: round down so MM's price ≤ taker's worst_price
quote_price = quantize_to_tick(raw_price, "0.001", rounding=ROUND_FLOOR)
# sign quote_price, then send quote_price unchangedMinimum notional check:
Some markets require price × quantity ≥ min_notional. Verify before sending:
if Decimal(quote_price) * Decimal(quantity) < min_notional:
raise ValueError("Order below minimum notional — increase price or quantity")min_notional is available in the same market info endpoint under market.market.minNotional (may appear as min_notional in v2).
Use "long" or "short" (lowercase string) in JSON messages.
If the taker requests quantity X and the MM only quotes Y < X, the contract can:
- Settle Y with the MM
- Post (X − Y) to the orderbook if the taker provides
unfilled_action(e.g.{"market": {}}or{"limit": {"price": "..."}})
Conditional orders let a taker pre-sign a trade that executes automatically when the mark price crosses a threshold. This enables take-profit and stop-loss strategies without requiring the taker to be online.
The RFQ indexer monitors mark prices and triggers the order when the condition is met — it then acts as the RFQ requester on the taker's behalf.
| Trigger type | Fires when | Typical use |
|---|---|---|
mark_price_gte |
mark price ≥ trigger_price | Take-profit for short, stop-loss for long |
mark_price_lte |
mark price ≤ trigger_price | Take-profit for long, stop-loss for short |
| Field | Type | Description |
|---|---|---|
version |
uint8 | Protocol version — use 1 |
chain_id |
string | Cosmos chain ID (wire-only — not bound by v2 signature) |
contract_address |
string | RFQ contract address (wire-only — bound via the v2 domain separator) |
taker |
string | Taker's Injective address |
epoch |
uint64 | Incremented by CancelAllIntents. Start at 1; increment after each global cancel. |
rfq_id |
uint64 | Conditional-order intent ID. For live RFQ requests this is backend-assigned in the request ACK; for pre-signed intents use a fresh nonce such as current Unix timestamp in ms. |
market_id |
string | Derivative market ID (0x hex) |
subaccount_nonce |
uint32 | Subaccount index (default 0) |
lane_version |
uint64 | Incremented by CancelIntentLane. Start at 1; increment after each per-market cancel. |
deadline_ms |
uint64 | Order expiry as Unix ms timestamp. Maximum 30 days from creation. |
direction |
string | "long" or "short" |
quantity |
string | Order quantity (FPDecimal) |
margin |
string | Must be "0" for reduce-only (close-position) orders |
worst_price |
string | Worst acceptable fill price |
min_total_fill_quantity |
string | Minimum quantity that must be filled for the order to settle |
trigger_type |
string | "mark_price_gte", "mark_price_lte", or "immediate" |
trigger_price |
string | Price threshold (use "0" for immediate) |
unfilled_action |
object/null | Optional. Bound by the v2 signature as (unfilledActionKind, unfilledActionPrice). null → (0, "0"); {"market": {}} → (2, "0"); {"limit": {"price": "X"}} → (1, "X"). Match the wire value to what you signed. |
cid |
string/null | Optional client ID. Bound by the v2 signature. |
allowed_relayer |
string/null | Optional. Bound by the v2 signature. |
sign_mode |
string | Required on the wire. Use "v2". Defaults to "v1" (deprecated) if omitted. |
evm_chain_id |
uint64 | Required when sign_mode="v2". Same value as the domain separator's chainId (1439 testnet, 1776 mainnet). On TakerStream the field name is conditional_order_evm_chain_id (proto field 6); on direct gRPC/REST creates it is evm_chain_id (proto field 4). |
SignedTakerIntent mirrors SignQuote — same domain separator, custom typed-data digest, secp256k1 sign of the digest. The wire unfilled_action field is bound by the v2 signature as the pair (unfilledActionKind, unfilledActionPrice). Sign the same unfilled_action value you send on the wire, or the digest will not match.
Use the library:
from rfq_test.crypto.eip712 import sign_conditional_order_v2
signature = sign_conditional_order_v2(
private_key=PRIVATE_KEY,
evm_chain_id=evm_chain_id,
verifying_contract_bech32=contract_address,
version=1,
taker=taker_address,
epoch=1,
rfq_id=rfq_id,
market_id=MARKET_ID,
subaccount_nonce=0,
lane_version=1,
deadline_ms=deadline_ms,
direction="short",
quantity="1",
margin="0", # reduce-only
worst_price="132",
min_total_fill_quantity="1",
trigger_type="mark_price_gte", # or "mark_price_lte" / "immediate"
trigger_price="120",
cid=None,
allowed_relayer=None,
)The SignedTakerIntent typed-data layout (mirrors eip712.go):
SignedTakerIntent(
uint8 version,
address taker,
uint64 epoch,
uint64 rfqId,
string marketId,
uint32 subaccountNonce,
uint64 laneVersion,
uint64 deadlineMs,
uint8 direction, // 0=long, 1=short
string quantity,
string margin,
string worstPrice,
string minTotalFillQuantity,
uint8 triggerKind, // 0=immediate, 1=mark_price_gte, 2=mark_price_lte
string triggerPrice, // "0" for immediate
uint8 unfilledActionKind, // 0=none (null), 1=limit, 2=market
string unfilledActionPrice, // limit price for kind=1, "0" otherwise
string cid, // "" if null (still hashed)
address allowedRelayer // zero-address if null
)
Use message_type = "conditional_order" with the order in field 3, signature in field 4, conditional_order_sign_mode = "v2" in field 5, and conditional_order_evm_chain_id in field 6. The library's send_conditional_order defaults to sign_mode="v2" and requires an EVM chain ID for v2.
from rfq_test.clients.websocket import TakerStreamClient
order_body = {
"version": 1, "chain_id": chain_id, "contract_address": contract_address,
"taker": taker_address, "epoch": 1, "rfq_id": rfq_id,
"market_id": MARKET_ID, "subaccount_nonce": 0, "lane_version": 1,
"deadline_ms": deadline_ms, "direction": "short",
"quantity": "1", "margin": "0", "worst_price": "132",
"min_total_fill_quantity": "1",
"trigger_type": "mark_price_gte", "trigger_price": "120",
"unfilled_action": None, "cid": None, "allowed_relayer": None,
"evm_chain_id": evm_chain_id,
}
async with TakerStreamClient(ws_base_url, request_address=taker_address) as client:
result = await client.send_conditional_order(
order_body=order_body,
signature=signature, # from sign_conditional_order_v2 above
wait_for_ack=True,
evm_chain_id=evm_chain_id,
# sign_mode="v2" is the default
)
print(f"ACK: rfq_id={result['rfq_id']} status={result['status']}")import httpx
async with httpx.AsyncClient() as http:
resp = await http.post(
f"{indexer_http_endpoint}/v1/conditionalOrder",
json={
"order": order_body,
"signature": signature,
"sign_mode": "v2", # required — same string the digest expects
},
)
resp.raise_for_status()
print(resp.json())async with httpx.AsyncClient() as http:
resp = await http.get(
f"{indexer_http_endpoint}/conditionalOrders",
params={"taker": taker_address},
)
orders = resp.json()There are two on-chain cancellation methods. Both work by incrementing a counter that invalidates any orders signed with the old counter value.
CancelIntentLane — cancels all orders for one (taker, market_id, subaccount_nonce) lane:
from rfq_test.clients.contract import ContractClient
client = ContractClient(contract_config, chain_config)
tx_hash = await client.cancel_intent_lane(
private_key=PRIVATE_KEY,
market_id=MARKET_ID,
subaccount_nonce=0,
)After this call, increment lane_version by 1 in all future orders for this market lane.
CancelAllIntents — cancels all orders across every market for the taker:
tx_hash = await client.cancel_all_intents(private_key=PRIVATE_KEY)After this call, increment epoch by 1 in all future conditional orders.
- Start
epochat1. Increment by 1 after eachCancelAllIntentscall. - Start
lane_versionat1. Increment by 1 after eachCancelIntentLanecall on that market. - The indexer tracks the current values on-chain. Orders signed with a stale
epochorlane_versionare rejected.
scripts/conditional_order_example.py in this repo demonstrates the full create → list → cancel flow.
After broadcasting a transaction, check the response:
result = await broadcaster.broadcast([msg])
tx_response = result.txResponse # or result.tx_response
code = getattr(tx_response, "code", 0)
if code != 0:
raw_log = getattr(tx_response, "rawLog", "") or getattr(tx_response, "raw_log", "")
raise Exception(f"Tx failed: code={code} raw_log={raw_log}")- Indexer: Returns stream errors (e.g.
quote_failed: ...) before closing the stream. Log the error message. - Contract: Returns error in
rawLogon non-zero code. Parse it for the cause (signature, slippage, maker not registered, etc.).
- Async I/O: Use
asyncioandwebsocketsfor WebSocket connections. Blocking calls will hurt latency. - Retries: Implement retries for transient failures (timeouts, connection drops). Use exponential backoff.
- Connection lifecycle: Reconnect on close. Handle stream errors and re-establish the stream.
- Logging: Log the exact JSON you sign, and the exact payload you send. This helps debug signature and proto mismatches.
- Rate limiting: Respect indexer and chain rate limits. Don't blast requests.
| Topic | Do | Don't |
|---|---|---|
| Grants | Use gas heuristics; both MsgSend + MsgPrivilegedExecuteContract for MM and Retail; expiration: null; GenericAuthorization | Use simulation for grants; use SendAuthorization; grant only MsgSend for Retail |
| v2 Signing | Use sign_quote_v2 / sign_conditional_order_v2; bind via EIP-712 domain (chainId=1439 testnet, 1776 mainnet, verifyingContract = bech32→evm); quantize prices BEFORE signing (decimals are hashed as keccak256(utf8(s))); lowercase direction |
Reorder fields; sign unquantized prices; build eth_signTypedData_v4 payloads (it's a custom typed-data layout) |
| Wire payload | Set sign_mode: "v2" on every quote and conditional order; include maker_subaccount_nonce + min_fill_quantity exactly as signed; signature with 0x prefix |
Omit sign_mode (indexer rejects empty values); send a different price/qty than you signed |
| Indexer | request_address header for TakerStream; maker_address + optional subscription headers for MakerStream; "long"/"short" strings |
Use numeric direction; omit required headers |
| Contract | FPDecimal strings; worst_price within 10% of mark; prices quantized to min_price_tick_size before signing; check tx_response.code |
Use 1e6 integers; ignore tick sizes; sign then quantize; assume tx success from hash only |
| Errors | Check code == 0; read rawLog on failure; treat signing-mode validation errors as missing or unsupported sign_mode in your client |
Assume success from tx hash |
| Conditional Orders | Use sign_conditional_order_v2; margin="0" for reduce-only; track epoch / lane_version; pass sign_mode="v2" + evm_chain_id (default) on TakerStream + REST; sign the same unfilled_action you send on the wire |
Flat trigger fields; non-zero margin; reuse stale epoch/lane_version after cancel; mismatch unfilled_action between sign and send |
pyinjective>=1.0.0
websockets>=12.0
eth-account>=0.11.0
eth-hash[pycryptodome]>=0.5.0
protobuf>=4.0