Market Maker
Onboarding.
Everything an external market maker needs to start receiving RFQs and returning signed quotes on the INJ RFQ system. Read top to bottom; check items off as you go.
01 · OrientationArchitecture
The RFQ system is an off‑chain quoting network with on‑chain settlement. You
connect to an indexer over WebSocket, receive request broadcasts, sign a
quote with secp256k1, and return it. The taker submits the
settlement transaction – not you.
As an MM you only: connect, receive, sign, send. The contract enforces your signature cryptographically, so you cannot be misquoted.
Sequence view
Four actors, five messages, one transaction. Each lane is a role; left‑to‑right is time.
Two settlement paths
Since contract 0.1.0‑α.6 there are two settlement entrypoints. The signing primitive and wire shape are identical; the difference is who submits the transaction.
AcceptQuote – synchronous
Taker is online. They pick your quote and submit the settlement tx themselves. Default path. Covered in the whole flow below.
AcceptSignedIntent – conditional
Take‑profit / stop‑loss. A relayer submits when a mark‑price trigger fires. You can participate via pre‑posted blind quotes or live response. See §07.
02Quick‑start checklist
Seven steps. Everything below the line is one‑time setup. Check them off as you go – progress is saved to this browser.
Generate an Injective wallet
YouAny secp256k1 key. The same key yields both 0x… (EVM) and inj1… (bech32) addresses.
Share your inj1… with the Injective team
You · Admin
Admin calls register_maker on the RFQ contract to whitelist your address.
Grant authz to the RFQ contract
YouNarrow, message‑type‑scoped grants. Contract cannot move arbitrary funds. One transaction.
Fund your exchange subaccount
YouUSDC. This is the margin backing your quotes.
Connect to MakerStream
YougRPC‑web over WebSocket. Start pinging once/sec or the server drops you.
Receive RFQ requests
YouBroadcast messages of message_type: "request". One per request, fanned to all MMs.
Sign & send quotes
YouCanonical JSON → keccak256 → secp256k1. Wrap in a quote frame. Done.
03 · SetupOne‑time setup, in detail
Generate a wallet
Standard Ethereum‑compatible secp256k1. Same private key, two address encodings.
from eth_account import Account import bech32 account = Account.create() private_key = account.key.hex() # store securely addr_bytes = bytes.fromhex(account.address[2:]) inj_address = bech32.bech32_encode("inj", bech32.convertbits(addr_bytes, 8, 5)) print(f"Private Key: {private_key}") print(f"INJ Address: {inj_address}")
import { PrivateKey } from "@injectivelabs/sdk-ts"; const privateKey = PrivateKey.generate(); const injAddress = privateKey.toBech32(); console.log(`Private Key: ${privateKey.toHex()}`); console.log(`INJ Address: ${injAddress}`);
Get whitelisted
Send your inj1… to the Injective team. The contract admin calls register_maker(maker: <your_addr>) to add you to the maker set.
You can confirm yourself at any point by querying list_makers on the contract – see the runbook for the exact cURL.
Grant authz permissions
The RFQ contract settles trades on your behalf using two narrow MsgGrants. The contract cannot move arbitrary funds – only the message types below, and only through its settlement handler.
| Permission | Purpose |
|---|---|
/injective.exchange.v2.MsgPrivilegedExecuteContract | Contract opens your synthetic position. |
/cosmos.bank.v1beta1.MsgSend | Contract moves margin funds as part of settlement. |
Canonical grants, and why the working set differs
The complete grant surface the contract can exercise includes messages it rarely uses in practice. Reading the source:
admin.mdenumerates everyMsgTypethe contract is capable of invoking on a granter's behalf.settlement.rsandauthz.rsverify those grants at execution time.orderbook.rscallsMsgBatchUpdateOrdersonly on the retail side during the merge‑with‑book path.
The canonical list contains MsgWithdraw too, but the working testnet grant set in §05 of the runbook omits it — the current contract path doesn't exercise it, and granting it creates an unused attack surface. If you see unauthorized on a message type not listed in the runbook, cross‑check this paragraph before adding a new grant.
from google.protobuf import any_pb2 from pyinjective.core.broadcaster import MsgBroadcasterWithPk from pyinjective.core.network import Network from pyinjective.proto.cosmos.authz.v1beta1 import authz_pb2, tx_pb2 as authz_tx network = Network.testnet() CONTRACT = "inj1qw7jk82hjvf79tnjykux6zacuh9gl0z0wl3ruk" # testnet α.6 granted = [ "/injective.exchange.v2.MsgPrivilegedExecuteContract", "/cosmos.bank.v1beta1.MsgSend", ] # Permanent grant: leave expiration unset (expiration: null). msg_grant_generic # forces a finite expiry that silently lapses and breaks the maker; AuthZ grants # can be revoked any time, so no-expiry is the safe default. def make_grant(granter, grantee, msg_type): authz = any_pb2.Any( type_url = "/cosmos.authz.v1beta1.GenericAuthorization", value = authz_pb2.GenericAuthorization(msg=msg_type).SerializeToString(), ) grant = authz_pb2.Grant() grant.authorization.CopyFrom(authz) # no grant.expiration => never expires msg = authz_tx.MsgGrant(granter=granter, grantee=grantee) msg.grant.CopyFrom(grant) return msg msgs = [make_grant(YOUR_INJ_ADDRESS, CONTRACT, m) for m in granted] bc = MsgBroadcasterWithPk.new_using_gas_heuristics(network=network, private_key=YOUR_PK) result = await bc.broadcast(msgs) print(f"TxHash: {result['txResponse']['txhash']}")
Fund your subaccount
Deposit USDC into your Injective exchange subaccount. This is the margin backing every quote you settle. Topping up is a standard MsgDeposit; no RFQ‑specific plumbing.
The RFQ contract settles against the maker subaccount nonce recorded in list_makers. If your registration has subaccount_nonce: null, use nonce 0 in quotes and keep margin available for that default maker account. If a nonzero nonce is registered for you on the smart contract, fund and quote against that same nonce.
Testnet faucet
Drop 500 USDC + 2 INJ on a fresh wallet in one call. Paste an inj1… or an 0x… — same key, same cooldown. Once per 24 hours per address. Testnet only.
04 · ProtocolConnecting to MakerStream
Bidirectional WebSocket, gRPC‑web framed, protobuf payloads. The server drops idle connections, so you must ping roughly once per second from the moment you connect.
- Protocol
- gRPC‑web over WebSocket (subprotocol
grpc-ws) - Serialization
- Protobuf, binary framed
- Framing
[1 byte flags][4 byte length BE][protobuf payload]- Keep‑alive
- ping every ~1s – required
- Service
injective_rfq_rpc.InjectiveRfqRPC- MM method
MakerStream(bidi)- Testnet URL
wss://rfq.ws.testnet.injective.network/injective_rfq_rpc.InjectiveRfqRPC/MakerStream
The indexer's internal proto declares rfq.v1.RfqService / StreamRequest|StreamQuote. Public traffic is fronted under the InjectiveRfqRPC alias with method names MakerStream / TakerStream. Use the public alias – it's what the deployment accepts and what every working client uses.
Animated ping / pong
Drives an idle connection indefinitely. Start pinging immediately after the socket opens.
Protobuf (maker side)
The canonical .proto is injective-rfq-toolkit/src/rfq_test/proto/injective_rfq_rpc.proto. Your bidi stream uses MakerStream:
service InjectiveRfqRPC { // bidi: receive requests, send quotes rpc MakerStream (stream MakerStreamStreamingRequest) returns (stream MakerStreamResponse); // also: TakerStream, Request, StreamRequest, Quote, StreamQuote … }
What you send – a MakerStreamStreamingRequest with message_type = "ping" or "quote" (the nested RFQQuoteInputType carries the quote). What you receive – a MakerStreamResponse tagged by message_type:
| message_type | Payload | Meaning |
|---|---|---|
pong | – | Heartbeat ACK. |
challenge | MakerChallenge | One‑shot auth challenge — see §04 · Auth handshake. |
request | RFQRequestType | An RFQ broadcast to all makers. |
quote_ack | StreamAck | Indexer accepted your quote. |
error | StreamError | Something went wrong – see code. |
Auth handshake (EIP‑712 v2)
Before the indexer streams RFQ requests it issues a one‑shot challenge against the bech32 you announced in connection metadata. Sign it with EIP‑712 v2 against the same domain separator as SignQuote — same EVM chain ID and same RFQ contract address — then reply with MakerAuth. Until you do, the stream stays open but no request events arrive.
The WebSocket MakerStreamClient in injective-rfq-toolkit handles MakerChallenge automatically when configured with auth_private_key, auth_evm_chain_id, and auth_contract_address. The testnet‑verified E2E scripts are 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; the native gRPC reference is examples/python-mm/main-grpc.py.
Flow
MakerChallenge · indexer → MM
| # | Field | Type | Meaning |
|---|---|---|---|
| 1 | nonce | string | Hex‑encoded 32 bytes. Decode to raw bytes for signing. |
| 2 | evm_chain_id | uint64 | Chain ID for the EIP‑712 domain (1439 testnet, 1776 mainnet). |
| 3 | expires_at | sint64 | Unix milliseconds. Connect → sign → reply within ~30s. |
StreamAuthChallenge typed‑data layout
| # | Field | Type | Encoding |
|---|---|---|---|
| 1 | evmChainId | uint64 | Big‑endian, right‑aligned in 32 bytes (mirrors chainId in the domain). |
| 2 | maker | address | 20 bytes from bech32_to_evm(maker_inj), left‑padded. |
| 3 | nonce | bytes32 | Raw 32 bytes from MakerChallenge.nonce. Hex‑decoded, not keccak‑hashed. |
| 4 | expiresAt | uint64 | Big‑endian, right‑aligned in 32 bytes. |
Final digest: keccak256(0x19 || 0x01 || domainSeparator || keccak256(typeHash ‖ encoded fields above)). Same domain separator as quotes — reuse the helper from §06.
MakerAuth · MM → indexer
| # | Field | Type | Meaning |
|---|---|---|---|
| 1 | evm_chain_id | uint64 | Same value as MakerChallenge.evm_chain_id. |
| 2 | signature | string | 0x‑prefixed 65 bytes (r ‖ s ‖ v, v=0/1). |
Wrap it in MakerStreamStreamingRequest{ message_type:"auth", auth:MakerAuth{…} } and send.
from eth_utils import keccak from eth_keys import keys STREAM_AUTH_CHALLENGE_TYPE = ( b"StreamAuthChallenge(uint64 evmChainId,address maker,bytes32 nonce,uint64 expiresAt)" ) def sign_maker_challenge_v2(*, private_key, contract_address, maker_inj, evm_chain_id, nonce_hex, expires_at) -> str: nonce = bytes.fromhex(nonce_hex.removeprefix("0x")) assert len(nonce) == 32 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) + keccak(primitive=msg)) sig = keys.PrivateKey(bytes.fromhex(private_key.removeprefix("0x"))).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() # Inside the maker stream loop: if resp.message_type == "challenge": sig = sign_maker_challenge_v2( private_key=mm_pk, contract_address=CONTRACT, maker_inj=maker_addr, evm_chain_id=int(resp.challenge.evm_chain_id), nonce_hex=resp.challenge.nonce, expires_at=int(resp.challenge.expires_at), ) await send_queue.put(MakerStreamStreamingRequest( message_type="auth", auth={"evm_chain_id": int(resp.challenge.evm_chain_id), "signature": sig}, ))
import { keccak256, getBytes, toUtf8Bytes, SigningKey, hexlify } from "ethers"; const STREAM_AUTH_CHALLENGE_TYPE = "StreamAuthChallenge(uint64 evmChainId,address maker,bytes32 nonce,uint64 expiresAt)"; export function signMakerChallengeV2(args: { privateKey: string; contractAddress: string; makerInj: string; evmChainId: bigint; nonceHex: string; expiresAt: bigint; }): string { const nonce = getBytes("0x" + args.nonceHex.replace(/^0x/, "")); if (nonce.length !== 32) throw new Error("nonce must be 32 bytes"); const msgHash = getBytes(keccak256(cat( s(STREAM_AUTH_CHALLENGE_TYPE), u(args.evmChainId, 8), a(bech32ToEvm(args.makerInj)), nonce, // raw bytes — not keccak-hashed u(args.expiresAt, 8), ))); const ds = domainSeparator(args.evmChainId, args.contractAddress); const digest = keccak256(cat(getBytes("0x1901"), ds, msgHash)); const key = new SigningKey("0x" + args.privateKey.replace(/^0x/, "")); const sig = key.sign(digest); return hexlify(cat(getBytes(sig.r), getBytes(sig.s), new Uint8Array([sig.yParity & 1]))); }
Helpers _u / _addr / bech32_to_evm / domain_separator / cat / s / a / u are the same primitives used in §06. Reuse them — don't redefine.
Reference end‑to‑end: examples/test_settlement.py for WebSocket/gRPC‑web and examples/test_settlement_grpc.py for native gRPC. Reusable MM examples: python WebSocket, python gRPC, go gRPC, typescript gRPC.
05Receiving RFQ requests
A request message looks like this. Everything you need to price, size and reference‑back is here.
{
"message_type": "request",
"request": {
"client_id": "7d7f8c28-1df6-40d3-bf29-0f9d2c2f3d10",
"rfq_id": 1770848375348,
"market_id": "0x17ef48032cb2…eb00abe6",
"direction": "long",
"margin": "100",
"quantity": "10",
"worst_price": "100",
"request_address": "inj1abc…xyz",
"expiry": 1770848675348
}
}| Field | Type | Meaning |
|---|---|---|
client_id | string | Taker-supplied UUID correlation ID from the request create call. |
rfq_id | uint64 | Backend-assigned RFQ ID from the request ACK / RFQ frame. Retail supplies client_id; do not invent this value for live requests. |
market_id | string | Derivative market ID, hex. |
direction | string | Taker direction: "long" or "short". |
margin | string | Taker's margin (decimal string, USDC). |
quantity | string | Taker's desired quantity. |
worst_price | string | Max acceptable price (long) or min (short). |
request_address | string | Taker's inj1…. |
expiry | uint64 | Request expiration, Unix ms. |
Your job is mechanical: decide your price, build a quote, sign it, return it.
06Building & signing quotes (EIP‑712 v2)
The indexer now requires sign_mode and evm_chain_id on every quote and on direct conditional‑order creates. TakerStream conditional orders use conditional_order_sign_mode + conditional_order_evm_chain_id. Omitted or empty signing fields are not valid for this EIP‑712 path and are rejected. This guide is EIP‑712 v2 only — that's the path you should be on for everything new.
Decide your price
Taker going long → you fill the short side (sell higher). Taker going short → you fill the long side (buy lower). Your quoted maker_margin and maker_quantity may differ from the taker's – that's a partial fill.
What v2 binds (and what it doesn't)
v2 is EIP‑712 typed‑data. Two pieces compose into the digest:
- Domain separator — pins your signature to a specific chain + contract:
{ name: "RFQ", version: "1", chainId: <EVM chainId>, verifyingContract: <bech32→evm> }. Wire fieldschain_idandcontract_addressare now informational — the domain separator is what binds them. The quote wirechain_idis still the Cosmos chain ID (injective-888testnet,injective-1mainnet), not1439or1776. - Message hash — a hand‑rolled
SignQuote(...)type hash plus 16 fields, each encoded as a 32‑byte word. The first message field isevmChainId; it must match the domainchainIdand the quote wire fieldevm_chain_id.
EVM chain ID is 1439 on testnet and 1776 on mainnet. Pull it from your environment config — don't hardcode.
SignQuote typed‑data layout
| # | Field | Type | Encoding |
|---|---|---|---|
| 1 | evmChainId | uint64 | Same EVM chain ID used in the domain separator and quote evm_chain_id. |
| 2 | marketId | string | keccak256(utf8(s)) |
| 3 | rfqId | uint64 | big‑endian, right‑aligned in 32 bytes |
| 4 | taker | address | 20 bytes from bech32_to_evm, left‑padded |
| 5 | takerDirection | uint8 | 0 = long, 1 = short |
| 6 | takerMargin | string | keccak256(utf8(s)) |
| 7 | takerQuantity | string | keccak256(utf8(s)) |
| 8 | maker | address | same as taker |
| 9 | makerSubaccountNonce | uint32 | big‑endian, right‑aligned |
| 10 | makerQuantity | string | keccak256(utf8(s)) |
| 11 | makerMargin | string | keccak256(utf8(s)) |
| 12 | price | string | Pre‑quantized to tick. keccak256(utf8(s)) |
| 13 | expiryKind | uint8 | 0 = timestamp_ms, 1 = block_height |
| 14 | expiryValue | uint64 | the actual ms or height value |
| 15 | minFillQuantity | string | Use "0" when absent — never the empty string. |
| 16 | bindingKind | uint8 | 1 when taker is present. 0 is reserved for blind quotes, where taker is the zero address. |
Final digest: keccak256(0x19 || 0x01 || domainSeparator || keccak256(typeHash ‖ encoded fields above)). Sign that with secp256k1 raw — no eth_sign, no personal_sign, no JSON canonicalisation.
- Decimal‑as‑string trap. Every decimal field is hashed as
keccak256(utf8(s))."4.5"and"4.50"produce different digests, and the indexer expects canonical plain notation without trailing zeros. Quantize to the market'smin_price_tick_sizebefore signing, strip trailing zeros, then send the same string on the wire. See theto_canonicalhelper below. - EVM chainId, not Cosmos. The domain separator's
chainIdand the firstSignQuotemessage field are the EVM chain ID (1439on testnet,1776on mainnet) — not"injective-888". - Quote
chain_idis Cosmos, not EVM. In the wire payload,quote.chain_idmust be"injective-888"on testnet or"injective-1"on mainnet. In camelCase SDK objects, that isquote.chainId. Put1439/1776only inquote.evm_chain_id/quote.evmChainIdand the EIP‑712 domain. verifyingContractis bech32→evm. Decode the bech32 RFQ contract address (inj1qw7jk82h…) into its 20 raw bytes; that's the EVM address. The hex form goes in the domain separator.minFillQuantity = "0"when unset — never empty string. Empty string changes the digest.- Wire signing mode is mandatory. Set quote
sign_mode="v2"andevm_chain_idto the same value embedded in the domain separator (1439testnet,1776mainnet). For TakerStream conditional orders, setconditional_order_sign_mode="v2"andconditional_order_evm_chain_id. Omitted or empty values are not valid for this EIP‑712 path. - This is NOT
eth_signTypedData_v4. The protocol uses a custom typed‑data layout with hand‑rolled type hashes; MetaMask's typed‑data flow won't reproduce the digest. Sign the digest bytes directly. e.tsis milliseconds since epoch. Block time lags wall clock by ~0.5–1s — keep live MM quote expiries above ~2s or settlement will reject.- The quote window is short. Taker UIs can collect quotes for ~500ms before submitting settlement. Keep mark price and tick data cached, sign on the hot path, and sync your host clock so your absolute ms expiry survives collection plus chain broadcast.
Canonicalize every decimal before signing
One helper, applied to every decimal field — price, taker_margin, taker_quantity, maker_margin, maker_quantity, min_fill_quantity, trigger_price. Quantize to the market tick, strip trailing zeros, never emit scientific notation. The whole‑integer case (BTC perp, tick 1) is the one partners hit most often: a price computed with f"{mark:.1f}" or str(float(…)) carries a trailing .0 that the indexer rejects with not in canonical decimal form.
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 perp, tick "1") # 110.00 → "110" (INJ perp, tick "0.01")
import { Decimal } from "decimal.js"; // or your decimal lib of choice export function toCanonical(x: string | number, tick: string): string { // Quantize down to the tick, then drop trailing zeros and the dangling "." const q = new Decimal(x).toDecimalPlaces(new Decimal(tick).decimalPlaces(), Decimal.ROUND_DOWN); const s = q.toFixed(); // plain notation, no exponent return s.includes(".") ? s.replace(/0+$/, "").replace(/\.$/, "") : s; } // "4.50" → "4.5" (any market with a fractional tick) // "76462.0" → "76462" (BTC perp, tick "1") // "110.00" → "110" (INJ perp, tick "0.01")
Sign
# Recommended: use the helper that ships with injective-rfq-toolkit. # It mirrors the indexer's `service/rfq/signature/eip712.go` byte-for-byte. from rfq_test.crypto.eip712 import sign_quote_v2 sig = sign_quote_v2( private_key = MM_PRIVATE_KEY, evm_chain_id = 1439, # testnet (mainnet=1776) verifying_contract_bech32 = CONTRACT_ADDRESS, # bech32; bound via domain market_id = market_id, rfq_id = int(rfq_id), taker = taker_address, direction = "long", taker_margin = "100", taker_quantity = "10", maker = mm_address, maker_margin = "100", maker_quantity = "10", price = "4.5", # tick‑quantized expiry_ms = int(time.time() * 1000) + 2_000, maker_subaccount_nonce = 0, min_fill_quantity = None, # sent as "0" in the digest ) # sig is already "0x"-prefixed; sign_mode="v2" is required on the wire. # The helper derives bindingKind from taker; don't pass binding_kind or nonce here. await mm_ws.send_quote({..., "signature": sig, "sign_mode": "v2", "evm_chain_id": 1439})
// Standalone v2 signer — port of the indexer's eip712.go import { keccak256, getBytes, toUtf8Bytes, SigningKey, hexlify } from "ethers"; import { bech32 } from "bech32"; const DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; const SIGN_QUOTE_TYPE = "SignQuote(uint64 evmChainId,string marketId,uint64 rfqId,address taker,uint8 takerDirection," + "string takerMargin,string takerQuantity,address maker,uint32 makerSubaccountNonce," + "string makerQuantity,string makerMargin,string price,uint8 expiryKind," + "uint64 expiryValue,string minFillQuantity,uint8 bindingKind)"; // 5-bit bech32 → 8-bit bytes (the canonical EVM form of an inj1… address) function bech32ToEvm(addr: string): Uint8Array { const { prefix, words } = bech32.decode(addr); if (prefix !== "inj") throw new Error(`bad hrp ${prefix}`); return new Uint8Array(bech32.fromWords(words)); } const u = (n: bigint, w: number) => { const b = new Uint8Array(32); for (let i = w; i > 0; i--) { b[32 - (w - i + 1)] = Number(n & 0xffn); n >>= 8n; } return b; }; const s = (x: string) => getBytes(keccak256(toUtf8Bytes(x))); const a = (b20: Uint8Array) => { const o = new Uint8Array(32); o.set(b20, 12); return o; }; const cat = (...xs: Uint8Array[]) => { const n = xs.reduce((t, x) => t + x.length, 0); const o = new Uint8Array(n); let p = 0; for (const x of xs) { o.set(x, p); p += x.length; } return o; }; function domainSeparator(evmChainId: bigint, contractBech32: string): Uint8Array { return getBytes(keccak256(cat( s(DOMAIN_TYPE), s("RFQ"), s("1"), u(evmChainId, 8), a(bech32ToEvm(contractBech32)) ))); } function signQuoteV2(q: SignQuoteInput, pkHex: string): string { const directionByte = q.direction === "long" ? 0n : 1n; const mfq = q.minFillQuantity ?? "0"; const [expiryKind, expiryValue] = q.expiryHeight !== undefined ? [1n, BigInt(q.expiryHeight)] : [0n, BigInt(q.expiryMs!)]; const msgHash = getBytes(keccak256(cat( s(SIGN_QUOTE_TYPE), u(BigInt(q.evmChainId), 8), s(q.marketId), u(BigInt(q.rfqId), 8), a(bech32ToEvm(q.taker)), u(directionByte, 1), s(q.takerMargin), s(q.takerQuantity), a(bech32ToEvm(q.maker)), u(BigInt(q.makerSubaccountNonce), 4), s(q.makerQuantity), s(q.makerMargin), s(q.price), u(expiryKind, 1), u(expiryValue, 8), s(mfq), u(1n, 1), // bindingKind = 1 (taker-specific) ))); const ds = domainSeparator(BigInt(q.evmChainId), q.contractAddress); const digest = keccak256(cat(getBytes("0x1901"), ds, msgHash)); const key = new SigningKey("0x" + pkHex.replace(/^0x/, "")); const sig = key.sign(digest); return hexlify(cat(getBytes(sig.r), getBytes(sig.s), new Uint8Array([sig.yParity & 1]))); // 0x-prefixed r ‖ s ‖ v, v=0/1 }
Then on the wire the quote carries sign_mode: "v2" and evm_chain_id alongside the signature:
{
"chain_id": "injective-888", // Cosmos chain ID; mainnet="injective-1" (never 1439/1776)
"contract_address": "inj1qw7jk82hjvf79tnjykux6zacuh9gl0z0wl3ruk",
"market_id": "0x...",
"rfq_id": 1770848375348,
"taker_direction": "long",
"margin": "10", // must equal makerMargin you signed
"quantity": "10", // must equal makerQuantity you signed
"price": "4.5", // must equal price you signed (byte-for-byte)
"expiry": { "timestamp": 1770848395000 },
"maker": "inj1def…maker",
"taker": "inj1abc…taker",
"signature": "0x...", // 65 bytes, r||s||v
"maker_subaccount_nonce": 0,
"sign_mode": "v2", // REQUIRED
"evm_chain_id": 1439 // REQUIRED when sign_mode="v2" (1439 testnet, 1776 mainnet)
}07Sending quotes
Wrap the signed payload in a MakerStreamStreamingRequest with message_type = "quote". Any field covered by the signature must match exactly, or the contract rejects it at settlement.
| Field | Type | Value |
|---|---|---|
message_type | string | "quote" |
quote.chain_id | string | Cosmos chain ID: "injective-888" on testnet, "injective-1" on mainnet. In camelCase SDK objects, this is quote.chainId. Do not pass the EVM chain ID here; 1776 belongs in quote.evm_chain_id / quote.evmChainId and the EIP‑712 domain. |
quote.contract_address | string | RFQ contract. |
quote.rfq_id | uint64 | From request. |
quote.market_id | string | From request. |
quote.taker_direction | string | "long" / "short". |
quote.margin | string | Your margin. |
quote.quantity | string | Your quantity. |
quote.price | string | Tick‑quantized – same string you signed. |
quote.expiry | uint64 | Unix ms. 2s for live MM quotes. Blind / TP‑SL quotes live minutes–hours. |
quote.maker | string | Your inj1…. |
quote.maker_subaccount_nonce | uint32 | Must match makerSubaccountNonce you signed. |
quote.taker | string | From request. |
quote.signature | string | Hex, 0x‑prefixed (65 bytes r ‖ s ‖ v). |
quote.sign_mode | string | Required. Always "v2" for the EIP‑712 path in §06. |
quote.evm_chain_id | uint64 | Required when sign_mode="v2". Same value as the EIP‑712 domain chainId (1439 testnet, 1776 mainnet). In camelCase SDK objects, this is quote.evmChainId. |
quote.min_fill_quantity | string · opt | Must match minFillQuantity if signed (sign as "0" when absent — see §06). |
quote.nonce | uint64 · opt | Blind quotes only. See §08. |
Quote ACK
✓ Success
{"message_type":"quote_ack","quote_ack":{"rfq_id":123,"status":"success"}}
✗ Error
{"message_type":"error","error":{"code":"…","message":"…"}}
Quote and settlement updates
Subscribe to both quote_update and settlement_update events on MakerStream. quote_ack.status="success" only means the indexer accepted and routed a valid quote; it is not a taker fill or decline signal. There is intentionally no separate "not accepted" event. If no quote_update or settlement_update arrives before the maker-set expiry, treat the quote as not accepted by the taker. Makers choose the quote expiry themselves, so local quote state should expire on that timestamp or height. If the taker accepts and settlement is attempted, the maker receives settlement_update with the trade result or failure. Log and key local quote state by taker address plus rfq_id; rfq_id alone is not globally unique. Also log executed_quantity, executed_margin, and settlement quotes for every live maker run.
08 · AdvancedSigned intents & TP/SL
Since contract 0.1.0‑alpha.6 the RFQ system supports signed taker intents — trades that fire when a mark‑price trigger crosses, submitted by a relayer, not the taker. TP/SL (take‑profit / stop‑loss) is the first user of this path.
Two quote bindings
The contract accepts two kinds of binding. Both end in the same MsgPrivilegedExecuteContract:
| Aspect | Live RFQ quote | Blind / pre‑posted quote |
|---|---|---|
| Initiated by | Retail RFQ (or relayer firing an RFQ on retail's behalf) | MM posting |
| Bound to | Specific rfq_id + taker |
Market + side + range |
| Lifetime | < 2 min typical | Minutes–hours, MM‑set |
| Cancel | N/A (indexer‑forwarded) | CancelBlindQuote message |
Three participation modes for an MM
A. Pre‑post blind quotes
Identical signing structure to a live quote, but with a wildcard taker (or an allow‑list) and a wider quantity band. Full reference in the contract's execute::post_blind_quote doc. Any matching retail AcceptQuote can consume them without a live RFQ cycle.
B. Live response on trigger
The relayer fires an RFQ the moment the trigger crosses. You behave exactly as §04–§07 of this document; the relayer, not the taker, submits AcceptSignedIntent.
C. “Do nothing”
TP/SL is opt‑in. If the taker is exiting and another MM has blind‑quote coverage at an acceptable price, the protocol settles without you. You are never obligated to participate in a signed‑intent event, and not responding has no reputational cost.
Zero‑margin and no unfilled_action fallback — protective‑exit only. General‑market‑making blind books are roadmapped for 0.2.x.
Working signing code (tpsl_blind.py, cancel.py, verification steps) lives in the E2E Testnet Runbook, §08.
Failure modes
| Error | Cause |
|---|---|
invalid_intent_signature / stale epoch / stale lane |
Signed‑intent envelope was constructed with a chain_id / contract / lane ID that no longer matches the contract. Re‑read the signing context at the start of every signing cycle; never cache it across a contract upgrade. |
trigger_not_satisfied |
Relayer submitted AcceptSignedIntent before the mark price crossed the trigger. The contract re‑reads the mark at execution time — relayer‑side clock skew or mid‑block price reversion both produce this error. The signed intent itself is still valid; the relayer must wait and retry. |
quote_rfq_id mismatch |
The quote's taker address plus rfq_id does not match the signed intent. Happens when a relayer pairs a stale blind quote with a freshly‑signed intent. Always re‑query for a quote bound to the same taker plus rfq_id as the intent. |
09Reference & troubleshooting
Endpoints
| Service | Endpoint |
|---|---|
| Chain | |
| Chain gRPC | testnet.sentry.chain.grpc.injective.network:443 |
| Chain LCD | https://testnet.sentry.lcd.injective.network |
| Indexer | |
| Indexer WS | wss://rfq.ws.testnet.injective.network |
| MakerStream WSS | wss://rfq.ws.testnet.injective.network/injective_rfq_rpc.InjectiveRfqRPC/MakerStream |
| TakerStream WSS | wss://rfq.ws.testnet.injective.network/injective_rfq_rpc.InjectiveRfqRPC/TakerStream |
| Indexer gRPC‑web | https://rfq.grpc-web.testnet.injective.network |
| Indexer gRPC | rfq.grpc.testnet.injective.network:443 |
| Service | Endpoint |
|---|---|
| Chain | |
| Chain gRPC | sentry.chain.grpc.injective.network:443 |
| Chain LCD | https://sentry.lcd.injective.network |
| RFQ contract | inj12stwq95jet57edcu4a65r48r46s9rzrs938n8k takes effect June 3 |
| Indexer | |
| Indexer WS | wss://rfq.ws.injective.network |
| MakerStream WSS | wss://rfq.ws.injective.network/injective_rfq_rpc.InjectiveRfqRPC/MakerStream |
| TakerStream WSS | wss://rfq.ws.injective.network/injective_rfq_rpc.InjectiveRfqRPC/TakerStream |
| Indexer gRPC‑web | https://rfq.grpc-web.injective.network |
| Indexer gRPC | rfq.grpc.injective.network:443 |
Testnet chain reference
- Chain ID
injective-888- Contract
inj1qw7jk82hjvf79tnjykux6zacuh9gl0z0wl3ruk- Contract version
- 0.1.0‑α.6
- Mainnet contract
inj12stwq95jet57edcu4a65r48r46s9rzrs938n8ktakes effect June 3- USDC (ERC‑20 denom)
erc20:0x0C382e685bbeeFE5d3d9C29e29E341fEE8E84C5d- Market: INJ/USDC PERP
0xdc70164d7120529c3cd84278c98df4151210c0447a65a2aab03459cf328de41e- Market: BTC/USDC PERP
0xfd704649cf3a516c0c145ab0111717c44640d8dbe52a462ae35cadf2f6df1515- Market: LINK/USDC PERP
0xdbb9bb072015238096f6e821ee9aab7affd741f8662a71acc14ac30ee6b687a5- Market: ETH/USDC PERP
0x135de28700392fb1c17d40d5170a74f30055a4ad522feddafec42fbbbb780897- Explorer
https://testnet.explorer.injective.network
Reference implementations
injective-rfq-toolkit use this
Canonical Python harness — the tests in this repo run end‑to‑end against testnet and are the ground truth for the examples in this document.
For a live maker starting point, use examples/python-mm/mark_quote_loop.py. It quotes mark ± edge bps, supports fixed-price tests, handles MakerStream auth, and logs quote/settlement updates.
Config sample
chain: chain_id: injective-888 grpc_endpoint: testnet.sentry.chain.grpc.injective.network:443 lcd_endpoint: https://testnet.sentry.lcd.injective.network contract: address: inj1qw7jk82hjvf79tnjykux6zacuh9gl0z0wl3ruk version: 0.1.0-alpha.6 indexer: ws_endpoint: wss://rfq.ws.testnet.injective.network grpc_web_endpoint: https://rfq.grpc-web.testnet.injective.network grpc_endpoint: rfq.grpc.testnet.injective.network:443 markets: - id: "0xdc70164d7120529c3cd84278c98df4151210c0447a65a2aab03459cf328de41e" symbol: INJ/USDC PERP - id: "0xfd704649cf3a516c0c145ab0111717c44640d8dbe52a462ae35cadf2f6df1515" symbol: BTC/USDC PERP - id: "0xdbb9bb072015238096f6e821ee9aab7affd741f8662a71acc14ac30ee6b687a5" symbol: LINK/USDC PERP - id: "0x135de28700392fb1c17d40d5170a74f30055a4ad522feddafec42fbbbb780897" symbol: ETH/USDC PERP tokens: usdc: erc20:0x0C382e685bbeeFE5d3d9C29e29E341fEE8E84C5d
Troubleshooting matrix
| Error / symptom | Likely cause | Fix |
|---|---|---|
invalid_signature (indexer) |
Signed‑payload field mismatches sent field | Diff both; usual culprit is trailing‑zero in price. |
not_whitelisted |
MM address not in list_makers |
Ask admin to whitelist; re‑query list_makers. |
expired |
Quote expiry ≤ block time at settlement (contract rejects) |
Use now_ms + 2_000 (2s — the production live‑quote standard). |
nonce too old or No quote was filled |
Quote maker_subaccount_nonce does not match the nonce registered for that maker. |
Query list_makers and use the registered subaccount_nonce. If it is null, sign and send nonce 0. |
unknown_market |
Market ID wrong or not enabled on testnet | Confirm via reference table above. |
503 on WS connect |
Service path incorrect | Use injective_rfq_rpc.InjectiveRfqRPC (underscores). |
Stream connects, pongs return, no request events |
Auth challenge ignored or signature wrong | Handle message_type:"challenge" and reply with MakerAuth using the same evm_chain_id. See §04 · Auth handshake. |
Stream closes shortly after auth reply |
Signature mismatch — wrong domain, nonce hashed instead of raw, or stale challenge | Use the same domain separator as SignQuote; pass nonce as raw 32 bytes, not keccak(nonce); reply within the expires_at window. |
unauthorized on‑chain |
MM authz grants missing or revoked | Re‑run grant script, verify via /cosmos/authz/v1beta1/grants. |
insufficient funds on‑chain |
MM USDC balance < margin | Transfer USDC from a funded wallet. |
account sequence mismatch |
Fast successive txs outpace LCD | Add asyncio.sleep(3) between grants. |
worst price exceeds limit |
Mark price misread as 1e18‑scaled | Mark price is already human‑scale on v2 endpoints. |
| Retail receives 0 quotes | Signature invalid, MM not whitelisted, or wrong request_address |
Check MM logs for quote_ack; verify retail WS subscribed as taker. |
FAQ
- Tx per trade?
- No. You sign quotes off‑chain. The taker submits
AcceptQuote; the contract verifies your signature. - Quote expires early?
- Live MM quotes are 2s. The contract checks
expiry > block_timeat settlement; nothing else enforces a floor. Taker collection can be ~500ms, so keep clocks synced and avoid slow network calls after receiving the RFQ. Blind / TP‑SL quotes live minutes–hours. - Partial fills?
- Yes – set
maker_quantity/maker_marginto your intended amounts. Unfilled remainder follows the taker'sunfilled_action. - Reduce‑only quotes?
- Pass
margin: "0"on the RFQ. With no capital committed, the only valid fill is one that closes or shrinks existing exposure — the protocol can't open new size against zero margin. No separate flag needed. - No response penalty?
- None. No quotes → no trade. Your response‑rate does affect standing, however.
- How do positions settle?
- As synthetic positions on the Injective exchange module between your subaccount and the taker's. Real margin, PnL, liquidation.
- Connection drops?
- Confirm ping cadence (~1s), the
grpc-wssubprotocol, and reconnect with exponential backoff.
Quotes not reaching takers – checklist
- Whitelisted? Query
list_makers(see runbook §4). - Authz granted? See §03 / runbook §5.
- Signature verifies? Check field order and that
id/eare JSON numbers/objects, not strings. - Quote expired? Verify
expiryand clock sync.
Competition window
After a request is sent, the indexer waits for quotes before delivering to the taker. Testnet ~30–45s; mainnet ~2–10s. Best price wins – lowest for long, highest for short.