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.
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.
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.
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
| Requirement | Details |
|---|---|
| Python | 3.11 or newer |
| Repo | injective-rfq-toolkit cloned locally |
| Testnet INJ | MM, retail (and relayer for TP/SL) need INJ for gas |
| Testnet USDC | MM + 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_KEYandTESTNET_RETAIL_PRIVATE_KEY. TP/SL adds a relayer. - Code. Python only in this PDF. The web version has a TypeScript tab.
Concept Onboarding §01 · Architecture
01 · PrepSetup environment
Clone, venv, install
~1 minEditable install puts the rfq_test helpers on the path – you'll use them directly from REPL cells below.
cd injective-rfq-toolkit python3 -m venv .venv source .venv/bin/activate pip install -U pip pip install -e ".[dev]"
02Configure .env
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.
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:
from rfq_test.crypto.wallet import Wallet print(Wallet.from_private_key("<hex>").inj_address)
Concept Onboarding §02 · Quick‑start · §09 · Reference for every endpoint this resolves to
03Verify balances
Check INJ + USDC for both wallets
~1 minEach wallet needs INJ (gas) and at least the E2E margin in USDC (the sample runbook uses 200 USDC).
curl -s \
"https://testnet.sentry.lcd.injective.network/cosmos/bank/v1beta1/balances/<address>" \
| python3 -m json.toolLook for inj and erc20:0x0C382e685bbeeFE5d3d9C29e29E341fEE8E84C5d (USDC).
04Verify MM is whitelisted
Confirm on list_makers
~30 sec
Your inj1… must appear under makers. If not, admin runs register_maker.
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.
curl -s \
"https://testnet.sentry.lcd.injective.network/cosmwasm/wasm/v1/contract/inj1qw7jk82hjvf79tnjykux6zacuh9gl0z0wl3ruk/smart/$(echo -n '{\"list_makers\":{}}' | base64)" \
| python3 -m json.toolfrom 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())
Concept Onboarding §03 · Get whitelisted
05Grant authz permissions
One‑time · MM + retail
~2 minIf 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
# 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.
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
Sanity before the full cycle
~1 minExpected: 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.
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.
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)
Full cycle, five messages, one tx
~30 sec liveRetail 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.
client_idmust be a UUID — the indexer rejects numeric timestamps withmessage.client_id must be formatted as a uuid. The backend assignsrfq_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 like3.30843that fail withprice 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_idbefore signing;rfq_idalone is timestamp-like and can collide across takers.
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.
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
set -a; . ./.env; set +a python e2e_sync.py
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.
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.
Concept Onboarding §06 · Building & signing quotes · §07 · Sending quotes
08 · AdvancedTP / SL – signed‑intent flow
Retail pre‑signs, relayer accepts on trigger
~2 minThree 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.
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, incrementlane_versionin future intents.CancelAllIntents— invalidates every intent for this taker. After this, incrementepochin future intents.
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_transferand 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.
Concept Onboarding §08 · Signed intents & TP/SL — three participation modes and the failure‑mode table