Testnet live · contract 0.1.0‑α.6 · Updated 2026‑04‑30

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.

Audience  Engineers integrating a quoting stack Chain  injective‑888 (testnet) Time  ~30 min to first signed quote

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.

Signal flow · animated
Taker retail app RFQ Indexer WebSocket grpc‑web framing Market Maker that's you Injective Chain settlement request quote AcceptQuote
idle · hit play
TL;DR

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.

Lifecycle · sync path
Retail Indexer Market Maker Chain 1. request 2. broadcast 3. signed quote 4. deliver 5. AcceptQuote

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.

1

Generate an Injective wallet

You

Any secp256k1 key. The same key yields both 0x… (EVM) and inj1… (bech32) addresses.

2

Share your inj1… with the Injective team

You · Admin

Admin calls register_maker on the RFQ contract to whitelist your address.

3

Grant authz to the RFQ contract

You

Narrow, message‑type‑scoped grants. Contract cannot move arbitrary funds. One transaction.

4

Fund your exchange subaccount

You

USDC. This is the margin backing your quotes.

5

Connect to MakerStream

You

gRPC‑web over WebSocket. Start pinging once/sec or the server drops you.

6

Receive RFQ requests

You

Broadcast messages of message_type: "request". One per request, fanned to all MMs.

7

Sign & send quotes

You

Canonical JSON → keccak256secp256k1. 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.

wallet.py
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.

PermissionPurpose
/injective.exchange.v2.MsgPrivilegedExecuteContractContract opens your synthetic position.
/cosmos.bank.v1beta1.MsgSendContract 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.md enumerates every MsgType the contract is capable of invoking on a granter's behalf.
  • settlement.rs and authz.rs verify those grants at execution time.
  • orderbook.rs calls MsgBatchUpdateOrders only 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.

grant_authz.py
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.

Fund the nonce you registered

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.

500 USDC + 2 INJ · once per 24h per address

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
Service naming

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.

Heartbeat · running
MM Indexer ping pong

Protobuf (maker side)

The canonical .proto is injective-rfq-toolkit/src/rfq_test/proto/injective_rfq_rpc.proto. Your bidi stream uses MakerStream:

injective_rfq_rpc.proto
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_typePayloadMeaning
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.

Testnet path

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

1. MM connects with maker_address metadata 2. Indexer sends MakerChallenge { nonce, evm_chain_id, expires_at } 3. MM signs StreamAuthChallenge typed-data (digest below) 4. MM sends MakerStreamStreamingRequest { message_type:"auth", auth:MakerAuth } 5. Indexer streams request / quote_ack / settlement_update …

MakerChallenge · indexer → MM

#FieldTypeMeaning
1nonce string Hex‑encoded 32 bytes. Decode to raw bytes for signing.
2evm_chain_id uint64 Chain ID for the EIP‑712 domain (1439 testnet, 1776 mainnet).
3expires_at sint64 Unix milliseconds. Connect → sign → reply within ~30s.

StreamAuthChallenge typed‑data layout

#FieldTypeEncoding
1evmChainId uint64 Big‑endian, right‑aligned in 32 bytes (mirrors chainId in the domain).
2maker address 20 bytes from bech32_to_evm(maker_inj), left‑padded.
3nonce bytes32 Raw 32 bytes from MakerChallenge.nonce. Hex‑decoded, not keccak‑hashed.
4expiresAt 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

#FieldTypeMeaning
1evm_chain_id uint64 Same value as MakerChallenge.evm_chain_id.
2signature string 0x‑prefixed 65 bytes (r ‖ s ‖ v, v=0/1).

Wrap it in MakerStreamStreamingRequest{ message_type:"auth", auth:MakerAuth{…} } and send.

sign_maker_challenge · v2 EIP‑712
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.

05Receiving RFQ requests

A request message looks like this. Everything you need to price, size and reference‑back is here.

request.json
{
  "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
  }
}
FieldTypeMeaning
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)

Heads up — testnet signing requirement

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 fields chain_id and contract_address are now informational — the domain separator is what binds them. The quote wire chain_id is still the Cosmos chain ID (injective-888 testnet, injective-1 mainnet), not 1439 or 1776.
  • Message hash — a hand‑rolled SignQuote(...) type hash plus 16 fields, each encoded as a 32‑byte word. The first message field is evmChainId; it must match the domain chainId and the quote wire field evm_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

#FieldTypeEncoding
1evmChainIduint64Same EVM chain ID used in the domain separator and quote evm_chain_id.
2marketIdstringkeccak256(utf8(s))
3rfqIduint64big‑endian, right‑aligned in 32 bytes
4takeraddress20 bytes from bech32_to_evm, left‑padded
5takerDirectionuint80 = long, 1 = short
6takerMarginstringkeccak256(utf8(s))
7takerQuantitystringkeccak256(utf8(s))
8makeraddresssame as taker
9makerSubaccountNonceuint32big‑endian, right‑aligned
10makerQuantitystringkeccak256(utf8(s))
11makerMarginstringkeccak256(utf8(s))
12pricestringPre‑quantized to tick. keccak256(utf8(s))
13expiryKinduint80 = timestamp_ms, 1 = block_height
14expiryValueuint64the actual ms or height value
15minFillQuantitystringUse "0" when absent — never the empty string.
16bindingKinduint81 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.

Critical gotchas
  • 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's min_price_tick_size before signing, strip trailing zeros, then send the same string on the wire. See the to_canonical helper below.
  • EVM chainId, not Cosmos. The domain separator's chainId and the first SignQuote message field are the EVM chain ID (1439 on testnet, 1776 on mainnet) — not "injective-888".
  • Quote chain_id is Cosmos, not EVM. In the wire payload, quote.chain_id must be "injective-888" on testnet or "injective-1" on mainnet. In camelCase SDK objects, that is quote.chainId. Put 1439 / 1776 only in quote.evm_chain_id / quote.evmChainId and the EIP‑712 domain.
  • verifyingContract is 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" and evm_chain_id to the same value embedded in the domain separator (1439 testnet, 1776 mainnet). For TakerStream conditional orders, set conditional_order_sign_mode="v2" and conditional_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.ts is 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.

to_canonical · run every decimal field through this
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

1. domainSeparator = keccak256(encodedDomain) → 32 bytes (cached per env) 2. msgHash = keccak256(typeHash ‖ 16 fields) → 32 bytes 3. digest = keccak256(0x19 0x01 ‖ domain ‖ msgHash) 4. signature = secp256k1_sign(digest, mm_key) → 65 bytes (r ‖ s ‖ v, v=0/1)
sign_quote · v2 EIP‑712
# 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:

RFQQuoteType wire payload
{
  "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.

FieldTypeValue
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_nonceuint32Must 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 · optMust match minFillQuantity if signed (sign as "0" when absent — see §06).
quote.nonce uint64 · optBlind 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:

AspectLive RFQ quoteBlind / 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.

Current scope

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

ErrorCause
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

ServiceEndpoint
Chain
Chain gRPCtestnet.sentry.chain.grpc.injective.network:443
Chain LCDhttps://testnet.sentry.lcd.injective.network
Indexer
Indexer WSwss://rfq.ws.testnet.injective.network
MakerStream WSSwss://rfq.ws.testnet.injective.network/injective_rfq_rpc.InjectiveRfqRPC/MakerStream
TakerStream WSSwss://rfq.ws.testnet.injective.network/injective_rfq_rpc.InjectiveRfqRPC/TakerStream
Indexer gRPC‑webhttps://rfq.grpc-web.testnet.injective.network
Indexer gRPCrfq.grpc.testnet.injective.network:443
ServiceEndpoint
Chain
Chain gRPCsentry.chain.grpc.injective.network:443
Chain LCDhttps://sentry.lcd.injective.network
RFQ contractinj12stwq95jet57edcu4a65r48r46s9rzrs938n8k takes effect June 3
Indexer
Indexer WSwss://rfq.ws.injective.network
MakerStream WSSwss://rfq.ws.injective.network/injective_rfq_rpc.InjectiveRfqRPC/MakerStream
TakerStream WSSwss://rfq.ws.injective.network/injective_rfq_rpc.InjectiveRfqRPC/TakerStream
Indexer gRPC‑webhttps://rfq.grpc-web.injective.network
Indexer gRPCrfq.grpc.injective.network:443

Testnet chain reference

Chain ID
injective-888
Contract
inj1qw7jk82hjvf79tnjykux6zacuh9gl0z0wl3ruk
Contract version
0.1.0‑α.6
Mainnet contract
inj12stwq95jet57edcu4a65r48r46s9rzrs938n8k takes 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

environments/testnet.yaml
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 / symptomLikely causeFix
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_time at 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_margin to your intended amounts. Unfilled remainder follows the taker's unfilled_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-ws subprotocol, and reconnect with exponential backoff.

Quotes not reaching takers – checklist

  1. Whitelisted? Query list_makers (see runbook §4).
  2. Authz granted? See §03 / runbook §5.
  3. Signature verifies? Check field order and that id/e are JSON numbers/objects, not strings.
  4. Quote expired? Verify expiry and 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.

INJ · MM Onboarding Next: E2E Runbook →