Skip to content

feat(fix): FIX protocol foundation (codec + session engine + auth) — part of #402#422

Merged
TexasCoding merged 3 commits into
mainfrom
feat/402-fix-foundation
Jun 6, 2026
Merged

feat(fix): FIX protocol foundation (codec + session engine + auth) — part of #402#422
TexasCoding merged 3 commits into
mainfrom
feat/402-fix-foundation

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

Summary

Foundation layer (PR-1) of the FIX protocol epic (#402): a hand-rolled, async-first, dependency-free FIX engine (FIXT.1.1 transport / FIX50SP2 application) shared by the prediction and perps (margin) products. One Kalshi FIX dictionary (v1.03) covers both; the margin product is a subset (no RFQ / settlement / post-trade) that always quotes fixed-point dollars.

This PR delivers the engine only. Order entry, drop copy, market data, and RFQ/settlement message flows follow as separate sub-issues of #402.

What's included

Area Module(s)
Wire codec kalshi/fix/codec.pyBodyLength/3-digit CheckSum/SOH framing, length-prefixed data fields read by byte count, incremental FixParser with false-8= resync + bounded buffering
Tags / enums kalshi/fix/tags.py, enums.py (full dictionary v1.03 surface)
Typed messages kalshi/fix/messages/ — tag-metadata-driven Pydantic framework + session/admin messages
Auth kalshi/fix/auth.pyFixSigner (RSA-PSS logon RawData) reusing the existing cryptography key; adds an additive KalshiAuth.private_key property
Connectivity kalshi/fix/config.py — prod/demo × prediction/margin host+port tables, TLS + host guards, retransmission + UseDollars policy
Session engine kalshi/fix/connection.py, session.py — signed logon, heartbeat/test-request liveness, sequence tracking with buffered gap-fill/resend, reset-on-logon, full-jitter reconnect, graceful logout
Facades kalshi/fix/client.py (FixClient, prediction) + kalshi/perps/fix/ (MarginFixClient, margin) over a shared base
Tests tests/fix/ — 90 tests incl. an in-memory mock acceptor driving the full state machine

Design decisions

  • Hand-rolled codec + session engine (no quickfix/simplefix): zero new deps, exact DollarDecimal money precision, async-native TLS, end-to-end typing — matches the existing hand-rolled WS subsystem. quickfix's threaded model, double-based pricing, and stunnel-only TLS conflict with this SDK's invariants.
  • Shared core + thin facades: one engine in kalshi.fix, a margin facade in kalshi.perps.fix (forces UseDollars, NR/RT/DC/MD only, KALSHI_PERPS_* creds).
  • Async-first, mirroring the async-only WebSocket subsystem.

Review & hardening

Reviewed twice before this PR:

  1. Internal multi-agent adversarial pass — 21 confirmed findings fixed.
  2. Multi-LLM review (codex + gemini + grok) — all CRITICAL/HIGH + most MED/LOW fixed: codec data-field adjacency + SOH-injection rejection + corrupt-frame resync; session ValidationError recovery (malformed admin → Reject, no wedge), SequenceReset gap-validation/no-rewind, outbound-seq reserve-before-send, close() timeout + task ordering, logon lower-seq → fatal, inbound-ResendRequest gating, strict Y/N booleans; per-product heartbeat floor; TLS-to-gateway enforcement.

Verification

  • uv run ruff check . — clean
  • uv run mypy kalshi/ (strict) — clean (147 files)
  • uv run pytest tests/fix/90 passed

Out of scope (follow-up sub-issues of #402)

Order Entry · Drop Copy · Market Data · Order Groups · RFQ/Quoting (prediction) · Market Settlement/Post-Trade (prediction). Repeating-group codec support and caller-supplied advanced logon options (SkipPendingExecReports/ListenerSession) land with the phases that need them.

Note: a full local pytest tests/ run shows 4 pre-existing from_env unit-test failures caused by tests/integration/conftest.py session fixtures leaking KALSHI_PRIVATE_KEY_PATH — unrelated to this PR (reproduces with zero FIX code) and not seen in CI's clean environment.

Part of #402.

🤖 Generated with Claude Code

…part of #402

Hand-rolled, async-first, dependency-free FIX engine (FIXT.1.1 transport /
FIX50SP2 application) shared by the prediction and perps (margin) products.
Foundation layer only — order entry, drop copy, market data, and
RFQ/settlement message flows land in later phases (sub-issues of #402).

- codec: tag=value framing (BodyLength / 3-digit CheckSum / SOH), length-
  prefixed data fields read by byte count, incremental FixParser with
  false-"8=" resync and bounded buffering
- typed Pydantic message framework (tag-metadata driven) + session/admin
  messages (Logon/Logout/Heartbeat/TestRequest/ResendRequest/SequenceReset/Reject)
- FixSigner: RSA-PSS logon RawData signature reusing the existing
  cryptography key (adds an additive KalshiAuth.private_key property)
- FixConfig: prod/demo x prediction/margin host+port tables, TLS + host
  guards, retransmission + UseDollars policy
- async FixSession state machine: signed logon, heartbeat/test-request
  liveness, inbound sequence tracking with buffered gap-fill/resend,
  reset-on-logon policy, AWS full-jitter reconnect, graceful logout
- FixClient (prediction, 6 sessions) + MarginFixClient (margin, KALSHI_PERPS_*
  creds, UseDollars on) over a shared core
- 90 tests: codec round-trip/framing, message round-trip, logon signature,
  config resolution/guards, and the async session state machine driven by an
  in-memory mock acceptor

Hardened via an internal multi-agent adversarial review and the multi-LLM
review (codex/gemini/grok): codec resync + data-field adjacency + SOH-injection
rejection + corrupt-frame resync; session ValidationError recovery,
SequenceReset gap-validation/no-rewind, outbound-seq reserve-before-send,
close() timeout + task ordering, logon lower-seq fatal, inbound-ResendRequest
gating, strict Y/N booleans; config per-product heartbeat floor + TLS-to-
gateway enforcement.

Part of #402.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown

Code Review — FIX Protocol Foundation

This is a substantial, well-prepared PR. The PR description is accurate: the multi-agent pre-review pass clearly improved the codec's security posture (SOH-injection rejection, data-field adjacency guards, BodyLength-bounded buffering). The architecture is a good fit for this codebase — it mirrors the WebSocket subsystem's structure and the existing dual-transport/async-first patterns.

Findings are ordered HIGH → MEDIUM → LOW.


HIGH

1. In-place mutation of the caller's Logon object in _send (session.py, _send method)

if isinstance(message, Logon):
    message.raw_data = self._signer.sign_logon(...)

The FixMessage base uses extra="forbid" to make phantom keys fail at construction, but Pydantic does allow post-construction field assignment on a non-frozen model. The mutation is intentional and technically works, but it silently modifies the object the caller holds. A caller that reuses the same Logon instance (e.g. across reconnects) or inspects raw_data after send() will see stale, session-specific signature bytes from the prior call.

Suggested fix: construct a new Logon with the correct raw_data rather than mutating the argument:

if isinstance(message, Logon):
    message = Logon.model_copy(message, update={"raw_data": self._signer.sign_logon(...)})

Or freeze FixMessage (frozen=True) to prevent accidental post-construction mutation across the board — though that requires a different signing strategy here.


2. _fix_fields() classmethod has no caching — called on every encode/decode (messages/base.py)

@classmethod
def _fix_fields(cls) -> list[tuple[str, int, FixType]]:
    out: list[tuple[str, int, FixType]] = []
    for name, field in cls.model_fields.items():
        ...
    return out

This rebuilds the list on every to_body_fields() and from_raw() call. For session-layer admin messages that is negligible, but once order-entry/market-data application messages land (the next phases), this will be on the hot path for every outbound order and inbound execution report. The result is per-class and fully deterministic, so caching is zero-risk:

@classmethod
@functools.lru_cache(maxsize=None)
def _fix_fields(cls) -> list[tuple[str, int, FixType]]:
    ...

MEDIUM

3. ssl_context: Any = None in FixSession.__init__ (and object | None in _BaseFixClient.__init__)

Both use a loose type to avoid importing ssl at the top level, but ssl is stdlib — the import cost is zero. Using object or Any silently accepts any value and only fails at asyncio.open_connection(). Correct annotation: ssl.SSLContext | None.


4. Importing a private helper across module boundaries (session.py)

from kalshi.fix.messages.base import FixMessage, _format_utc_timestamp

_format_utc_timestamp is prefixed _ (module-private by convention) and is imported by session.py. It should either be moved to a shared internal utility module or promoted to a public export. The current pattern creates an implicit cross-module coupling that refactors won't catch.


5. FixConfig.margin(use_dollars=False) is silently accepted; effective_use_dollars ignores the field

@property
def effective_use_dollars(self) -> bool:
    if self.product is FixProduct.MARGIN:
        return True  # use_dollars field value is silently ignored
    ...

A caller who passes FixConfig.margin(use_dollars=False) gets no error, but the field is ignored. The __post_init__ should enforce this:

if self.product is FixProduct.MARGIN and self.use_dollars is False:
    raise ValueError("use_dollars=False is not allowed for the margin product")

6. Mid-session inbound Logon is silently dropped, no warning (session.py, _process)

if mt == MsgType.LOGON:
    # Mid-session Logon is unexpected once ACTIVE; ignore.
    return

A mid-session Logon from the acceptor is a protocol error (the FIX spec suggests closing the connection). The current behavior is too lenient — not even a logger.warning is emitted. At minimum, log a warning; depending on Kalshi's gateway behavior, closing the connection may be more appropriate.


LOW

7. acceptor fixture return type is object instead of MockAcceptor (tests/fix/conftest.py)

@pytest.fixture
async def acceptor() -> object:

All tests access MockAcceptor-specific attributes (acceptor.port, acceptor.first("A"), etc.) on a value typed as object. Correct type: AsyncGenerator[MockAcceptor, None] (with yield) or MockAcceptor (if using pytest-asyncio's asynccontextmanager approach). This causes mypy to flag every test that uses the fixture.


8. on_state_change callback has zero test coverage

StateChangeHandler is wired in, and tests verify session.state values, but no test asserts that the on_state_change callback is actually invoked with the correct (old, new) pair. Suggest adding at least one test covering the DISCONNECTED → CONNECTING → LOGGING_ON → ACTIVE and ACTIVE → CLOSED transitions.


9. _FIX_SHA256 = hashes.SHA256() instantiated at module level (fix/auth.py)

The same hashes.SHA256() instance is passed to both MGF1(...) and the outer sign() call. The cryptography library treats hash objects as stateful contexts. This pattern also exists in kalshi/auth.py and appears to work in practice, but it is relying on the library not mutating the algorithm object during use. Worth a note so it is not silently broken on a cryptography upgrade.


Summary

The codec and session-layer logic is sound, the test coverage is solid for a foundation PR, and the security posture is good (SOH injection prevention, TLS enforcement, credential redaction in __repr__). The critical issue to resolve before merge is the in-place Logon mutation (item 1), which will be a subtle bug source in the reconnect paths added by future phases. The _fix_fields() caching (item 2) is cheap to add now and avoids a retroactive performance investigation when order-entry lands. Everything else is polish or low-risk.

Resolves the automated code-review findings on PR #422:

- _send signs onto a Logon COPY (model_copy) instead of mutating the
  caller's object — avoids stale signature bytes on reuse across reconnects
- cache the per-class FIX field map (_fix_fields) — deterministic per class
  and on the hot path once order-entry/market-data application messages land
- annotate ssl_context as ssl.SSLContext | None on FixSession and the client
  facades (was Any / object)
- move the UTCTimestamp helpers to kalshi/fix/_timefmt.py so session.py no
  longer imports a module-private name from messages.base
- FixConfig.__post_init__ rejects margin with use_dollars=False (previously
  silently ignored)
- log a warning on an unexpected mid-session inbound Logon
- type the acceptor test fixture (AsyncIterator[MockAcceptor]); add an
  on_state_change transition-coverage test
- note SHA256 algorithm-descriptor reuse safety in fix/auth.py

ruff + mypy --strict clean; 91 FIX tests pass.

Part of #402.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@TexasCoding

Copy link
Copy Markdown
Owner Author

Thanks for the thorough review — all nine findings are addressed in a648c08. ruff + mypy --strict clean; 91 FIX tests pass (added one for #8).

HIGH

  1. In-place Logon mutation_send now signs onto a copy via message.model_copy(update={"raw_data": ...}); the caller's object is no longer modified.
  2. _fix_fields() caching — added a per-class cache (_FIX_FIELDS_CACHE, keyed by class) so to_body_fields()/from_raw() no longer re-walk model_fields on the hot path. Used a plain class-keyed dict rather than lru_cache-on-classmethod to keep mypy-strict clean.

MEDIUM

  1. ssl_context typing — now ssl.SSLContext | None on FixSession and both client facades (import ssl is stdlib/zero-cost).
  2. Cross-module private import — moved the UTCTimestamp helpers to a new internal module kalshi/fix/_timefmt.py (format_utc_timestamp / parse_utc_timestamp); session.py and messages/base.py both import from there, so no more _-prefixed cross-module import.
  3. margin(use_dollars=False)FixConfig.__post_init__ now raises ValueError when product is MARGIN and use_dollars is False instead of silently ignoring it.
  4. Mid-session inbound Logon — now emits logger.warning(...) before ignoring (kept lenient rather than force-closing, since the gateway drives lifecycle via Logout; easy to harden to a close later if Kalshi's behavior warrants).

LOW

  1. acceptor fixture type — now AsyncIterator[MockAcceptor].
  2. on_state_change coverage — added test_on_state_change_callback_fires, asserting CONNECTING → LOGGING_ON → ACTIVE … → CLOSED and that each transition's old chains from the prior new.
  3. Module-level SHA256() — added a comment noting it's a stateless algorithm descriptor (not a live hashing context), safe to reuse for both MGF1 and the outer sign() — matching the existing kalshi/auth.py pattern.

@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown

Code Review — PR #422: FIX Protocol Foundation

Scope: 4 322 additions across kalshi/fix/, kalshi/perps/fix/, and tests/fix/. Foundation layer only (codec + session engine + auth); order-entry, drop-copy, market-data, and RFQ flows are explicitly out of scope.


Overview

A well-designed, async-first FIX engine that closely mirrors the existing WebSocket subsystem. The codec, session state machine, and test coverage are all at a high quality bar. The two adversarial review passes the PR description mentions are visible in the code — SOH injection, credential redaction, TLS enforcement, sequence-number invariants, and malformed-admin recovery are all handled correctly.

Issues below are ordered by severity.


Medium — Worth addressing before merge

1. Gap-fill SequenceReset arriving out-of-order on RT sessions is silently discarded

session.py_handle_sequence_reset:

if bool(sr.gap_fill_flag) and seq is not None and seq > self._in_seq:
    if self._supports_retransmission:
        await self._request_resend()
        return          # ← returns without buffering the SequenceReset

When a SequenceReset/GapFill arrives at a sequence number ahead of _in_seq on an RT session, the code issues a ResendRequest and returns — but the SequenceReset message is never buffered in _pending. Once the gap fills and _drain_pending runs, there is no SequenceReset left to apply, so _in_seq is never advanced to new_seq_no. The _pending drain loop also never advances past the (now-missing) SequenceReset entry.

In practice this path requires the server to send a gap-fill SequenceReset before the resend it covers, which is an unusual ordering, but it's a logical hole in the recovery path. The parallel in _handle_inbound buffers out-of-order messages before requesting a resend; the same pattern should apply here.


2. from_env return type is concrete class, not Self

client.py:484 and kalshi/perps/fix/client.py:1023:

@classmethod
def from_env(cls, ...) -> FixClient:          # should be -> Self
def from_env(cls, ...) -> MarginFixClient:    # should be -> Self

_BaseFixClient.from_auth correctly uses -> Self. If a user subclasses FixClient and calls from_env, they get back a FixClient, not their subclass — inconsistent with the rest of the factory pattern. Change both to -> Self.


Low — Suggestions

3. MockAcceptor._connected event is never reset between reconnects

tests/fix/conftest.py:1090:

self._connected = asyncio.Event()  # set once, never cleared

wait_connected() will return immediately on any subsequent call within the same test, even before a new connection has arrived after drop_connection(). No current test calls wait_connected() after a drop, so this is latent, but it will silently pass if someone writes such a test in a follow-up. Consider either resetting the event in _handle on new connection or documenting that wait_connected() is only valid before the first connect.


4. _send_gap_fill silently bypasses outbound seq accounting — comment should say WHY

session.py:2335-2362: The gap-fill path correctly bypasses _send() so it doesn't consume an outbound seq number, and there's a test that asserts this. But the only comment says what it's doing, not why it can't call self._send(). A future reader refactoring _send could accidentally break this. A one-liner is enough:

# Does not call _send(): a gap-fill must use begin_seq_no as MsgSeqNum, not
# consume the next outbound slot — _send() always increments _out_seq.

5. KALSHI_FIX_ALLOW_UNKNOWN_HOST env var is undocumented

config.py:1057-1059: KALSHI_FIX_ALLOW_UNKNOWN_HOST=1 silently overrides the unknown-host guard. It's tested indirectly but never mentioned in the docstring for FixConfig. This is a useful escape hatch for CI/staging environments — worth a line in the class docstring so it's discoverable.


6. __all__ in kalshi/fix/__init__.py has category comments interleaved with unrelated items

Lines 107-156: Comments like # Clients / sessions appear before the FixClient entry, but FixConnection and FixEnvironment (which are not clients/sessions) follow without their own comments. Either sort __all__ alphabetically (simplest) or tighten the grouping so the comments accurately describe every entry that follows.


Correctness notes (no action needed, just confirmed correct)

  • Outbound seq reserved before send (_send): Correct. A write failure after bytes reach the peer produces a recoverable gap, not a silent seq collision.
  • _in_seq never rewinds: Both _handle_inbound and _handle_sequence_reset enforce forward-only advancement. The test_sequence_reset_never_rewinds test is comprehensive.
  • SOH injection blocked in non-DATA fields; length-prefixed DATA fields correctly bypass the SOH-scan path.
  • Credential redaction in RawMessage.__repr__ covers RAW_DATA, SIGNATURE, and PASSWORD.
  • TLS enforcement in FixConfig.__post_init__: plaintext to non-loopback raises at construction.
  • close() task ordering: heartbeat loop cancelled before Logout send, recv loop cancelled after — correct; a race between the heartbeat and the Logout is impossible.
  • assert self._connection is not None in _recv_loop: safe, start() sets _connection before spawning the task.

Test coverage

90 tests is solid for a foundation layer. Missing cases worth adding in a follow-up:


Verdict

Architecture and security posture are excellent. The two blocking-ish items (gap-fill SequenceReset buffering, Self return types) are both straightforward fixes. The rest are low-priority suggestions for clarity.

🤖 Generated with Claude Code

…turns

- _handle_sequence_reset now BUFFERS an out-of-order gap-fill SequenceReset
  (seq ahead of expected, RT/PT) and _drain_pending applies its NewSeqNo jump
  once the preceding gap is recovered (extracted _apply_sequence_reset_forward).
  Previously the reset was dropped and _in_seq never advanced.
- FixClient.from_env / MarginFixClient.from_env now return Self (subclass-correct,
  matching from_auth)
- _send_gap_fill: comment explains WHY it bypasses _send (begin_seq_no as
  MsgSeqNum; must not consume an outbound seq)
- document the KALSHI_FIX_ALLOW_UNKNOWN_HOST escape hatch in FixConfig's docstring
- MockAcceptor clears _connected on disconnect so wait_connected() is valid after
  a reconnect
- drop the misleading interleaved category comments in kalshi.fix.__all__

tests:
- out-of-order gap-fill SequenceReset buffering on RT (pins the bug-1 fix)
- new tests/fix/test_client.py: from_auth/from_env return the concrete subclass,
  prediction exposes all six sessions, margin lacks prediction-only helpers and
  rejects an RFQ session, product/config mismatch raises

ruff + mypy --strict clean; 97 FIX tests pass.

Part of #402.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@TexasCoding

Copy link
Copy Markdown
Owner Author

Thanks — second round addressed in 01ec0ad. ruff + mypy --strict clean; 97 FIX tests pass (+6).

Medium

  1. Out-of-order gap-fill SequenceReset dropped — fixed. _handle_sequence_reset now buffers the reset in _pending (RT/PT) and requests a resend; _drain_pending recognizes a buffered SequenceReset and applies its NewSeqNo jump once the gap fills (extracted _apply_sequence_reset_forward, used by both the in-order and drained paths). Added test_out_of_order_gapfill_sequence_reset_is_buffered_on_rt (gap-fill at seq 5 ahead of 2 → resend → fill 2,3,4 → _in_seq advances to the reset's NewSeqNo=7).
  2. from_env return type — both FixClient.from_env and MarginFixClient.from_env now return Self. Pinned with test_from_env_returns_concrete_subclass (+ from_auth equivalent) in the new tests/fix/test_client.py.

Low

  1. MockAcceptor._connected stale — cleared in _handle's finally, so wait_connected() is valid after a drop/reconnect.
  2. _send_gap_fill WHY comment — added: it can't use _send() because the gap-fill must carry begin_seq_no as its MsgSeqNum and must not consume an outbound seq.
  3. KALSHI_FIX_ALLOW_UNKNOWN_HOST — now documented in the FixConfig class docstring.
  4. __all__ comments — removed the misleading interleaved category comments (the list is ruff-sorted alphabetically; a single note now points readers to the grouped imports above).

Also added the follow-up test cases you flagged: out-of-order gap-fill (#1), from_env on a subclass (#2), and broader facade coverage (all six prediction sessions; margin lacking prediction-only helpers and rejecting an RFQ session; product/config mismatch). Thanks again — the correctness-notes confirmations were reassuring on the seq-handling invariants.

@claude

claude Bot commented Jun 6, 2026

Copy link
Copy Markdown

Code Review — FIX Foundation (#422)

This is a substantial, well-prepared PR. The multi-LLM adversarial pre-review is evident: the hardest edge cases (SOH injection, false-8= resync, no-rewind watermarks, gap-fill seq preservation, logon lower-seq fatal) are all correctly implemented and tested. Feedback below is tiered by severity.


One Medium Issue

_pending buffer has no size cap (retransmission sessions only)

session.py:137self._pending: dict[int, RawMessage] = {} grows without bound on RT/PT sessions. A misbehaving or adversarial server can send messages at seq numbers far ahead of _in_seq in a tight loop, filling the dict indefinitely. The codec already has MAX_BODY_LENGTH = 1_000_000 as a wire-layer DoS backstop; _pending needs an equivalent.

Suggested fix — fail fast when the buffer exceeds a reasonable cap (e.g. 10 k entries):

# in _handle_inbound, before self._pending[seq] = raw:
_PENDING_CAP = 10_000
if len(self._pending) >= _PENDING_CAP:
    raise FixSequenceError(
        f"pending message buffer cap {_PENDING_CAP} exceeded; closing session",
        expected=self._in_seq,
        received=seq,
    )
self._pending[seq] = raw

Same guard applies in _handle_sequence_reset where a gap-fill also lands in _pending. This follows the same "backstop, not a protocol limit" pattern already in the codec.


Low Issues

FixRejectError docstring mismatches current behavior

errors.py:98-122 — The class docstring says "Raised for an inbound session-level Reject (35=3) or BusinessMessageReject (35=j)", but session.py:605-613 handles MsgType.REJECT by logging only — it never raises FixRejectError. The class is exported and publicly documented, so a reader will reasonably expect the error to propagate to their on_message or application-layer caller; currently it doesn't. Either:

  • Update the docstring to say "Reserved for the order-entry phase; at the session layer, Rejects are currently logged via logger.warning", or
  • Open a tracking issue so the behavior matches the contract when order entry lands.

environment parameter is silently ignored when config is provided

client.py:43-47 — When a caller passes both an explicit config and a non-default environment, the environment is silently dropped (the pre-built config.environment wins). This is arguably correct, but it can surprise. A one-liner note in the __init__ docstring ("If config is provided, environment is ignored") would prevent head-scratching.

_BaseFixClient._PRODUCT is not ABC-enforced

client.py:21-29_PRODUCT: ClassVar[FixProduct] is declared but not assigned, so direct instantiation raises AttributeError at runtime rather than a clear TypeError. Since this is a private base class and both concrete subclasses set it, this is very low priority — but marking the class abc.ABC (or adding an __init_subclass__ check) makes the intent explicit.


Positive Observations

Codec correctness:

  • SOH-injection defense in encode() (line 86-87) correctly excludes DATA fields while blocking injection in all other fields.
  • _parse_fields enforces strict data-field adjacency (line 128-133), preventing tag smuggling within a declared byte length.
  • FixParser.get_message() correctly advances 2 bytes before re-raising on a corrupt frame (line 363-366), so a BodyLength-corrupted frame doesn't consume a following valid frame.
  • _MAX_BODYLEN_DIGITS bounds the pre-frame buffer before MAX_BODY_LENGTH is even reached.

Session state machine:

  • _apply_sequence_reset_forward() correctly never rewinds the watermark and drops buffered messages now behind it.
  • _send_gap_fill() correctly uses begin_seq_no as MsgSeqNum (not _out_seq) and acquires _send_lock directly without calling _send(), so the outbound sequence counter is not consumed — this is subtle and correct.
  • _out_seq is incremented before the write (line 252-253), not after — ensures a failed write does not silently reuse the sequence number on the next reconnect.
  • Lower-than-expected Logon seq is fatal (line 387-389), consistent with the spec and with the existing WS transport's "never accept a stale replay" invariant.

Security:

  • FixConfig.__post_init__ correctly blocks use_tls=False for any non-loopback host, protecting the RSA-PSS logon signature from plaintext exposure.
  • RawMessage.__repr__ redacts RAW_DATA, SIGNATURE, and PASSWORD tags — no credential leaks in logs or tracebacks.
  • The _FIX_SHA256 module-level descriptor reuse pattern (stateless algorithm object) matches kalshi.auth's pattern and is safe.

Tests:

  • The in-memory MockAcceptor is a clean, minimal FIX acceptor that actually runs the full state machine rather than mocking at the session layer. The until poller avoids asyncio.sleep-based timing brittleness.
  • _verify_logon_signature() in test_session.py independently reconstructs the pre-hash from wire fields and verifies with the public key — this is a genuinely useful end-to-end crypto test, not just an echo test.
  • test_parser_bounds_garbage_after_8equals asserts buffer length ≤ 1 after feeding 100,000 junk bytes — exactly the kind of adversarial test the codec needs.

Conventions:

  • builtins.list[T] not needed here (these are not resource classes) — bare list[T] in RawMessage.get_all and FixParser.messages is correct per CLAUDE.md.
  • New KalshiAuth.private_key property is minimal (5 lines) and well-justified — the key re-use avoids re-loading PEM for the FIX signer, and the docstring explains the single consumer.
  • FixRejectError.text field on the unused-at-this-phase error class correctly mirrors the text field on the REST KalshiError hierarchy.

Summary

The only actionable change before merge is capping _pending. The two low items (docstring and silent environment drop) are small enough to fix inline or defer to a follow-up. Everything else is solid. The codec + session foundations look ready to carry the order-entry and market-data phases.

🤖 Reviewed with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant