Updated 2026‑04‑30 · Framework · injective-rfq-toolkit

E2E Testnet
Runbook.

Full cycle from RFQ request through on‑chain settlement, plus the TP/SL signed‑intent path. A narrative walkthrough – run each section, then verify before moving on.

Contract  inj1qw7jk82…3ruk · 0.1.0‑α.6 Chain  injective‑888 Time  ~45 min end‑to‑end

00 · PrepPrerequisites

Before running this, make sure you've read Onboarding §01 Architecture. This runbook assumes you already understand the three‑party protocol and the authz trust model; it only covers the commands.

Heads up — testnet signing requirement (EIP‑712 v2)

The indexer 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 close the stream with a validation error. Use sign_quote_v2 / sign_conditional_order_v2 from rfq_test.crypto.eip712, then set quote sign_mode="v2", evm_chain_id=1439 on testnet (1776 mainnet). Do not put those numeric EVM IDs in quote.chain_id / quote.chainId; that field is the Cosmos chain ID (injective-888 testnet, injective-1 mainnet). Full v2 spec lives in Onboarding §06.

Heads up — MakerStream auth handshake

When a maker connects to MakerStream the indexer issues a one‑shot EIP‑712 v2 challenge before forwarding any RFQ requests. Sign the StreamAuthChallenge typed‑data with the same EVM chain ID and RFQ contract address used for quote signing, then reply with MakerAuth{evm_chain_id, signature}. Until the reply is accepted the stream stays open but produces no request events. The injective-rfq-toolkit WebSocket MakerStreamClient handles this automatically when configured with auth_private_key, auth_evm_chain_id, and auth_contract_address; the testnet‑verified E2E references are examples/test_settlement.py for WebSocket/gRPC‑web and examples/test_settlement_grpc.py for native gRPC. Full protocol in Onboarding §04 · Auth handshake.

What you need on your box

RequirementDetails
Python3.11 or newer
Repoinjective-rfq-toolkit cloned locally
Testnet INJMM, retail (and relayer for TP/SL) need INJ for gas
Testnet USDCMM + retail need USDC erc20:0x0C382…84C5d – margin

Conventions

  • Role abbreviations. MM = market maker (you). Retail = taker. Indexer = the off‑chain WebSocket matcher.
  • Wallets. All examples use two wallets: TESTNET_MM_PRIVATE_KEY and TESTNET_RETAIL_PRIVATE_KEY. TP/SL adds a relayer.
  • Code. Python only in this PDF. The web version has a TypeScript tab.

01 · PrepSetup environment

1

Clone, venv, install

~1 min

Editable install puts the rfq_test helpers on the path – you'll use them directly from REPL cells below.

shell
cd injective-rfq-toolkit
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -e ".[dev]"

02Configure .env

2

Write injective-rfq-toolkit/.env

~30 sec

Only the MM + retail keys are mandatory for the sync path. Admin / relayer keys are optional, used only for specific flows.

.env
RFQ_ENV=testnet

TESTNET_MM_PRIVATE_KEY=<hex>
TESTNET_RETAIL_PRIVATE_KEY=<hex>
# TESTNET_ADMIN_PRIVATE_KEY=<hex>   # only for register_maker
# TESTNET_RELAYER_PRIVATE_KEY=<hex> # only for AcceptSignedIntent

Derive an inj1… from a key:

derive.py
from rfq_test.crypto.wallet import Wallet
print(Wallet.from_private_key("<hex>").inj_address)

03Verify balances

3

Check INJ + USDC for both wallets

~1 min

Each wallet needs INJ (gas) and at least the E2E margin in USDC (the sample runbook uses 200 USDC).

shell · LCD query
curl -s \
  "https://testnet.sentry.lcd.injective.network/cosmos/bank/v1beta1/balances/<address>" \
  | python3 -m json.tool

Look for inj and erc20:0x0C382e685bbeeFE5d3d9C29e29E341fEE8E84C5d (USDC).

04Verify MM is whitelisted

4

Confirm on list_makers

~30 sec

Your inj1… must appear under makers. If not, admin runs register_maker.

Pagination — the curl below returns only page 1 (max 20 makers)

If your address sorts past the first 20, the one‑liner will say "not registered" when it actually is. Either page through with start_after, or use the Python helper below which paginates automatically.

shell · first page only
curl -s \
  "https://testnet.sentry.lcd.injective.network/cosmwasm/wasm/v1/contract/inj1qw7jk82hjvf79tnjykux6zacuh9gl0z0wl3ruk/smart/$(echo -n '{\"list_makers\":{}}' | base64)" \
  | python3 -m json.tool
python · all pages
from rfq_test.config          import get_environment_config
from rfq_test.clients.contract import ContractClient
import asyncio, os
os.environ["RFQ_ENV"] = "testnet"

async def check():
    env = get_environment_config()
    c   = ContractClient(env.contract, env.chain)
    ok  = await c.is_maker_registered("<YOUR_MM_ADDR>")
    print("whitelisted ✓" if ok else "NOT whitelisted — ping admin")

asyncio.run(check())

05Grant authz permissions

5

One‑time · MM + retail

~2 min

If grants: [], run the script below. Every grant is one tx; sleep between them to avoid sequence collisions. The canonical surface (and why this working set omits MsgWithdraw) is documented in Onboarding §03 · Canonical grants.

Check existing grants

shell
# MM grants
curl -s "https://testnet.sentry.lcd.injective.network/cosmos/authz/v1beta1/grants?granter=<MM_ADDR>&grantee=inj1qw7jk82hjvf79tnjykux6zacuh9gl0z0wl3ruk" | python3 -m json.tool

# Retail grants
curl -s "https://testnet.sentry.lcd.injective.network/cosmos/authz/v1beta1/grants?granter=<RETAIL_ADDR>&grantee=inj1qw7jk82hjvf79tnjykux6zacuh9gl0z0wl3ruk" | python3 -m json.tool

Grant script (if empty)

Load .env into the process first — either set -a; . .env; set +a in the shell, or uncomment the dotenv line below. Standalone python3 grant_all.py otherwise sees None for every key.

grant_all.py
import asyncio, os
# from dotenv import load_dotenv; load_dotenv()  # uncomment if not pre-sourcing .env
os.environ["RFQ_ENV"] = "testnet"
from rfq_test.config       import get_environment_config
from rfq_test.clients.chain import ChainClient

env      = get_environment_config()
CONTRACT = env.contract.address

async def grant_all():
    chain = ChainClient(env.chain); await chain.connect()

    grants = [
        # MM
        (os.getenv("TESTNET_MM_PRIVATE_KEY"),     "/injective.exchange.v2.MsgPrivilegedExecuteContract"),
        (os.getenv("TESTNET_MM_PRIVATE_KEY"),     "/cosmos.bank.v1beta1.MsgSend"),
        # Retail
        (os.getenv("TESTNET_RETAIL_PRIVATE_KEY"), "/injective.exchange.v2.MsgPrivilegedExecuteContract"),
        (os.getenv("TESTNET_RETAIL_PRIVATE_KEY"), "/injective.exchange.v2.MsgBatchUpdateOrders"),
        (os.getenv("TESTNET_RETAIL_PRIVATE_KEY"), "/cosmos.bank.v1beta1.MsgSend"),
    ]
    for pk, msg_type in grants:
        tx = await chain.grant_authz(private_key=pk, grantee=CONTRACT, msg_type=msg_type)
        print(f"Granted {msg_type.split('.')[-1]}: {tx}")
        await asyncio.sleep(3)
    await chain.close()

asyncio.run(grant_all())

06Smoke tests

6

Sanity before the full cycle

~1 min

Expected: config loads, WebSocket connects, chain connects, signing primitive works. If any of these fail, stop and fix.

No @pytest.mark.smoke tests exist yet — run the four checks inline instead. Each should print a green tick.

Smoke scope

This smoke confirms config, connectivity, and that the local signing primitive runs. It does not exercise the MakerStream auth handshake — the WS client used here connects and closes without waiting for the MakerChallenge. To validate auth end‑to‑end, run §07, examples/test_settlement.py, or examples/test_settlement_grpc.py; a successful WebSocket run logs Answering MakerStream auth challenge before the maker receives RFQs.

smoke.py
import asyncio, os, time
os.environ["RFQ_ENV"] = "testnet"
from rfq_test.config            import get_environment_config
from rfq_test.clients            import ChainClient, MakerStreamClient
from rfq_test.crypto.wallet     import Wallet
from rfq_test.crypto.eip712     import sign_quote_v2

env = get_environment_config()
evm_chain_id, contract_addr = env.signing_context_v2
print(f"[1/4] config loads                 ✓  contract={contract_addr} evm_chain_id={evm_chain_id}")

async def ws_check():
    ws = MakerStreamClient(env.indexer.ws_endpoint, maker_address="<MM_ADDR>", timeout=10.0)
    await ws.connect(); await ws.close()
    print("[2/4] MakerStream WS connects      ✓")
asyncio.run(ws_check())

async def chain_check():
    c = ChainClient(env.chain); await c.connect(); await c.close()
    print(f"[3/4] chain client connects        ✓  chain_id={env.chain.chain_id}")
asyncio.run(chain_check())

w = Wallet.from_private_key(os.getenv("TESTNET_MM_PRIVATE_KEY"))
retail = Wallet.from_private_key(os.getenv("TESTNET_RETAIL_PRIVATE_KEY"))
sig = sign_quote_v2(
    private_key=w.private_key, evm_chain_id=evm_chain_id,
    verifying_contract_bech32=contract_addr,
    market_id=env.markets[0].id, rfq_id=1,
    taker=retail.inj_address, direction="long",
    taker_margin="1", taker_quantity="1",
    maker=w.inj_address, maker_subaccount_nonce=0,
    maker_margin="1", maker_quantity="1",
    price="1", expiry_ms=int(time.time() * 1000) + 2_000,
    min_fill_quantity=None,                              # omit; "" ≠ absent in digest
)
print(f"[4/4] sign_quote_v2 returns sig    ✓  len={len(sig)}")
✓ Expected

Four green ticks in under 10s. You're clear for the E2E cycle.

✗ If WebSocket fails

Check outbound :443 egress, then the exact service path – underscores, not dots. See Onboarding §04.

07 · FlowRun the E2E flow (sync AcceptQuote)

7

Full cycle, five messages, one tx

~30 sec live

Retail online, quotes within a single window, settlement immediately after. Reads mark price, quotes 1% better, uses a 5% slippage cap on taker side.

Full protocol trace lives in Onboarding §01 Architecture. Below is the minimum working Python that executes one complete cycle.

Three gotchas baked into the example
  • client_id must be a UUID — the indexer rejects numeric timestamps with message.client_id must be formatted as a uuid. The backend assigns rfq_id; read it off the ACK and the MM frame, don't invent one.
  • Prices + quantities must be quantized to the market's tick. INJ/USDC PERP is 0.01 on both. round(mark * 0.99, 6) produces values like 3.30843 that fail with price must be a multiple of the minimum price tick size.
  • Because the MakerStream fans out to every whitelisted MM, your stream sees other takers' RFQs too. Filter on your retail taker address plus rfq_id before signing; rfq_id alone is timestamp-like and can collide across takers.
Auth handshake — configure the maker signer

The MakerStreamClient below must be constructed with auth_private_key, auth_evm_chain_id, and auth_contract_address. With those fields set, it signs the inbound MakerChallenge, sends MakerAuth, then proceeds to wait_for_request. Without them the stream can connect but will not receive RFQ request events.

e2e_sync.py
import asyncio, os, time, uuid
from decimal import Decimal, ROUND_DOWN
os.environ["RFQ_ENV"] = "testnet"

from rfq_test.config            import get_environment_config
from rfq_test.crypto.wallet     import Wallet
from rfq_test.crypto.eip712     import sign_quote_v2
from rfq_test.clients.websocket import TakerStreamClient, MakerStreamClient
from rfq_test.clients.contract  import ContractClient
from rfq_test.models.types      import Direction
from rfq_test.factories.request import RequestFactory

env    = get_environment_config()
MARKET = env.markets[0]                                   # INJ/USDC PERP
TICK   = Decimal("0.01")                                  # INJ/USDC tick — query market for others
chain_id, contract_address = env.signing_context        # Cosmos chain ID for quote.chain_id
evm_chain_id, _            = env.signing_context_v2     # EVM chain ID: 1439 testnet, 1776 mainnet

MM_PK     = os.getenv("TESTNET_MM_PRIVATE_KEY")
RETAIL_PK = os.getenv("TESTNET_RETAIL_PRIVATE_KEY")
mm        = Wallet.from_private_key(MM_PK)
retail    = Wallet.from_private_key(RETAIL_PK)

snap = lambda x: format(Decimal(str(x)).quantize(TICK, rounding=ROUND_DOWN).normalize(), "f")

async def e2e():
    import httpx
    url = f"{env.chain.lcd_endpoint}/injective/exchange/v2/derivative/markets/{MARKET.id}"
    async with httpx.AsyncClient() as c:
        mark = float((await c.get(url, timeout=15)).json()["market"]["mark_price"])
    print(f"Mark: {mark:.6f}")

    margin, quantity = "200", "100"
    mm_price    = snap(mark * 0.99)                         # 1% better, snapped to tick
    worst_price = snap(mark * 1.05)                         # 5% slippage cap
    client_id   = str(uuid.uuid4())                         # correlator; backend assigns rfq_id

    mm_ws     = MakerStreamClient(env.indexer.ws_endpoint, maker_address=mm.inj_address,
                                  auth_private_key=MM_PK, auth_evm_chain_id=evm_chain_id,
                                  auth_contract_address=contract_address, timeout=15.0); await mm_ws.connect()
    retail_ws = TakerStreamClient(env.indexer.ws_endpoint, request_address=retail.inj_address, timeout=15.0); await retail_ws.connect()

    try:
        # 1. Retail sends the RFQ; the indexer ACK tells us the real rfq_id
        req = RequestFactory(default_market=MARKET).create_indexer_request(
            taker_address=retail.inj_address, market=MARKET, direction="long",
            margin=Decimal(margin), quantity=Decimal(quantity),
            worst_price=Decimal(worst_price), client_id=client_id,
        )
        ack = await retail_ws.send_request(req, wait_for_response=True, response_timeout=5.0)
        rid = str(ack["rfq_id"])
        print(f"1. Request ACK: rfq_id={rid}")

        # 2. MM stream sees every whitelisted MM's traffic; wait for OUR taker + rfq_id
        deadline = time.time() + 20
        while time.time() < deadline:
            try:
                r = await asyncio.wait_for(mm_ws.wait_for_request(timeout=3.0), timeout=3.5)
                if str(r["rfq_id"]) == rid and r.get("request_address") == retail.inj_address: break
            except asyncio.TimeoutError: continue
        else: raise RuntimeError(f"MM never saw rfq_id={rid}")
        print(f"2. MM received RFQ#{rid}")

        # 3. MM signs + sends quote (v2 EIP-712 — sign_mode="v2" required on the wire)
        expiry = int(time.time() * 1000) + 2_000
        sig = sign_quote_v2(
            private_key=mm.private_key, evm_chain_id=evm_chain_id,
            verifying_contract_bech32=contract_address,
            market_id=MARKET.id, rfq_id=int(rid),
            taker=retail.inj_address, direction="long",
            taker_margin=margin, taker_quantity=quantity,
            maker=mm.inj_address, maker_subaccount_nonce=0,
            maker_margin=margin, maker_quantity=quantity,
            price=mm_price, expiry_ms=expiry,
            min_fill_quantity=None,
        )
        quote_ack = await mm_ws.send_quote({
            "rfq_id": int(rid), "market_id": MARKET.id, "taker_direction": "long",
            "margin": margin, "quantity": quantity, "price": mm_price,
            "expiry": expiry, "maker": mm.inj_address, "maker_subaccount_nonce": 0,
            "taker": retail.inj_address,
            "signature": sig, "sign_mode": "v2", "evm_chain_id": evm_chain_id,
            "chain_id": chain_id, "contract_address": contract_address,
        }, wait_for_response=True, response_timeout=8.0)
        print(f"3. Quote ACK: {quote_ack['status']} (price={mm_price})")

        # quote_ack success means valid and routed, not accepted by the taker.
        # If no update arrives before this maker-set expiry, the taker did not accept it.

        # 4. Retail collects matching quotes
        quotes = await retail_ws.collect_quotes(rfq_id=int(rid), timeout=5.0, min_quotes=1)
        best   = quotes[0]
        print(f"4. Retail got {len(quotes)} quote(s), best={best['price']}")

        # 5. Accept on-chain — one tx, atomic
        contract = ContractClient(env.contract, env.chain)
        tx = await contract.accept_quote(
            private_key=RETAIL_PK,
            quotes=[{"maker": best["maker"], "margin": margin, "quantity": quantity,
                     "price": best["price"], "expiry": best["expiry"], "signature": best["signature"],
                     "sign_mode": best["sign_mode"], "evm_chain_id": best["evm_chain_id"],
                     "maker_subaccount_nonce": best.get("maker_subaccount_nonce", 0),
                     **({"min_fill_quantity": best["min_fill_quantity"]} if best.get("min_fill_quantity") else {})}],
            rfq_id=rid, market_id=MARKET.id, direction=Direction.LONG,
            margin=Decimal(margin), quantity=Decimal(quantity),
            worst_price=Decimal(worst_price), unfilled_action={"market": {}},
        )
        print(f"5. ✅ TX: {tx}")
    finally:
        await mm_ws.close(); await retail_ws.close()

asyncio.run(e2e())

Save and run the script

shell
set -a; . ./.env; set +a
python e2e_sync.py
Maker quote lifecycle

A successful quote_ack means the indexer accepted and routed a valid quote. It does not mean the taker accepted it, and makers do not receive a separate not-accepted notice. If no quote_update or settlement_update arrives before the maker-set quote expiry, the quote was not accepted by the taker. When the taker accepts and settlement is attempted, MakerStream sends settlement_update with the trade result or failure.

What success looks like

Five printed lines, ending with a ✅ TX hash. Open it in testnet.explorer.injective.network – you should see two new derivative positions (MM + retail) on the same market.

08 · AdvancedTP / SL – signed‑intent flow

8

Retail pre‑signs, relayer accepts on trigger

~2 min

Three wallets: MM, retail, and a relayer that holds the signed intent and submits AcceptSignedIntent when the mark price crosses the trigger.

Concept, participation modes, and failure‑mode matrix live in Onboarding §08 · Signed intents & TP/SL. Below are the prerequisites and the signing command.

Prerequisites

  • Retail has an open position to close.
  • Relayer wallet is funded (INJ for gas) and holds TESTNET_RELAYER_PRIVATE_KEY.
  • MM has either a live MakerStream subscription or a posted blind quote covering the closing side.

Sign an exit intent (retail side)

v2 EIP‑712 — same domain separator as quotes, different type hash (SignedTakerIntent). TakerStream wire frames require conditional_order_sign_mode="v2" and conditional_order_evm_chain_id. sign_conditional_order_v2(...) only signs the intent. TakerStreamClient.send_conditional_order(...) sets conditional_order_sign_mode and conditional_order_evm_chain_id when you pass evm_chain_id.

tpsl_intent.py · abridged
from rfq_test.crypto.eip712     import sign_conditional_order_v2
from rfq_test.clients.websocket import TakerStreamClient

rfq_id      = int(time.time() * 1000)
deadline_ms = rfq_id + 86_400_000                   # 24h, max 30d

intent_sig = sign_conditional_order_v2(
    private_key=retail.private_key,
    evm_chain_id=evm_chain_id, verifying_contract_bech32=contract_address,
    version=1, taker=retail.inj_address,
    epoch=1, lane_version=1, subaccount_nonce=0,
    rfq_id=rfq_id, market_id=MARKET.id, deadline_ms=deadline_ms,
    direction="short",                              # closing a long
    quantity="1", margin="0",                       # margin="0" = reduce-only
    worst_price="19.5",                            # worst acceptable fill
    min_total_fill_quantity="1",
    trigger_type="mark_price_lte",                  # take-profit on a long
    trigger_price="20.0",
)

# Submit the signed intent via TakerStream (conditional_order_sign_mode="v2" + conditional_order_evm_chain_id).
async with TakerStreamClient(env.indexer.ws_endpoint, request_address=retail.inj_address) as client:
    ack = await client.send_conditional_order(
        order_body={
            "version": 1, "chain_id": chain_id, "contract_address": contract_address,
            "taker": retail.inj_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": "19.5",
            "min_total_fill_quantity": "1",
            "trigger_type": "mark_price_lte", "trigger_price": "20.0",
            "unfilled_action": None, "cid": None, "allowed_relayer": None,
            "evm_chain_id": evm_chain_id,
        },
        signature=intent_sig, sign_mode="v2", evm_chain_id=evm_chain_id, wait_for_ack=True,
    )
print(f"SI ACK: rfq_id={ack['rfq_id']} status={ack['status']}")

Cancel a pending intent

Two on‑chain cancel paths — both bump a counter that invalidates any intent signed with the older value:

  • CancelIntentLane — invalidates everything for one (taker, market_id, subaccount_nonce) lane. After this, increment lane_version in future intents.
  • CancelAllIntents — invalidates every intent for this taker. After this, increment epoch in future intents.
cancel.py
from rfq_test.clients.contract import ContractClient
contract = ContractClient(env.contract, env.chain)

# Cancel everything in this market lane
tx = await contract.cancel_intent_lane(
    private_key=RETAIL_PK,
    market_id=MARKET.id, subaccount_nonce=0,
)
print(f"Cancelled lane: {tx}")

# Or nuke everything across all markets:
# tx = await contract.cancel_all_intents(private_key=RETAIL_PK)

Verifying the flow

  • Tx lands in the block explorer with two execute ops: subaccount_transfer and the derivative position close.
  • Retail's subaccount USDC balance rises by the position's remaining margin.
  • MM's subaccount shows the offsetting exposure (or, for a blind‑quote flow, the corresponding fill).
  • If the tx fails, read the failure against Onboarding §08 · Failure modes – all three signed‑intent errors (invalid_intent_signature, trigger_not_satisfied, quote_rfq_id mismatch) are protocol‑level, not operational.
INJ · E2E Runbook ← Back to Onboarding