test(clearsign): v2 static-schema coverage for a relay solver swap#291
Closed
BitHighlander wants to merge 102 commits into
Closed
test(clearsign): v2 static-schema coverage for a relay solver swap#291BitHighlander wants to merge 102 commits into
BitHighlander wants to merge 102 commits into
Conversation
chain_id was uint8_t, silently truncating chain IDs above 255. Base (8453), Arbitrum (42161), Avalanche (43114) and others all overflow a byte, causing tokenByChainAddress to match the wrong tokens or return UnknownToken for valid ERC-20 transfers. Widen chain_id to uint32_t in TokenType struct and both lookup functions (tokenByChainAddress, tokenByTicker).
Bug 1 (cancel ignored): confirmName() and confirmValue() cast review() return value to void, allowing signing to continue after user presses cancel. Added USER_CANCELLED error code and propagate cancellation up through the call chain. Bug 2 (strtoll overflow/garbage): strtoll() was called with NULL endptr, silently accepting trailing garbage and wrapping on overflow. Added endptr validation to reject non-numeric input, and reject negative values for uint types.
fsm_msgTonSignTx displayed to_address and amount from deprecated proto fields that are NOT included in the raw_tx bytes being signed. A malicious host could show one transfer on screen while getting a different transaction signed — identical CVE pattern to TronSignTx. Replace with a single blind-sign prompt showing only the raw_tx byte count.
fsm_msgTronSignTx displayed to_address and amount from deprecated proto fields that are NOT included in the raw_data bytes being signed. A malicious host could show one transfer on screen while getting a different transaction signed. Replace with a single blind-sign prompt that shows only the raw_data byte count, which is the actual data committed to by the signature.
TIP-712 signing sends only pre-computed hashes — the device cannot reconstruct or verify the original typed-data struct. Typed-data signatures can authorise token approvals and permits. Add an explicit acknowledgement screen before showing the raw hashes so users understand they are signing unverifiable data.
For SOL_INSTR_TOKEN_TRANSFER_CHECKED the instruction data encodes the authoritative decimal count in byte 9 (parsed into pi->extra_u8). The host-supplied SolanaTokenInfo.decimals is metadata only and could be forged to misrepresent the displayed amount. Split the fallthrough case so TRANSFER_CHECKED always uses pi->extra_u8 for decimal scaling while still displaying the host-supplied symbol as a label.
Previously the send path hardcoded "rune" as the coin denom in both the JSON signing payload and the on-device confirmation screen. This blocked signing of any other native THORChain L1 asset. Changes: - deps/device-protocol: add optional string denom = 11 to ThorchainMsgSend - messages-thorchain.options: add ThorchainMsgSend.denom max_size:69 - thorchain.h/c: add denom param to thorchain_signTxUpdateMsgSend; default to "rune" when absent (backward compat); split JSON write to avoid fixed 64-byte buffer overflow on long denoms - fsm_msg_thorchain.h: display actual denom in confirmation, pass through to signing, update final confirm text to "THORChain transaction" - thorchain.cpp: update existing call, add DefaultDenom/TCY/Rujira tests
- Add thorchain_isValidDenom() rejecting chars that need JSON escaping; valid set is [a-z0-9./\-], empty string rejected (caller uses "rune") - Use tendermint_sha256UpdateEscaped for the denom write in MsgSend as defense-in-depth (validation is the primary guard) - Register thorchain.cpp in CMakeLists.txt — it was never compiled, so all prior expected values were unvalidated placeholder text - Replace wrong address/signature expected values with vectors derived from the actual trezor-crypto library (standalone C verification tool) - Add ThorchainSignTxDefaultDenom: empty denom → same sig as explicit "rune" - Add ThorchainDenomValidation: unit-tests thorchain_isValidDenom directly - Add ThorchainSignTxInvalidDenom: quote-injection attempt returns false
…screens High: amount_str[32] could not hold amount + long denom suffix, causing bn_format_uint64 to silently return 0 and show a blank confirmation while the full denom was still signed. Fixed by passing NULL suffix so amount_str holds only the numeric part, then showing denom on its own "Asset" screen. Low: untrusted denom was formatted and shown before thorchain_isValidDenom ran in the signing layer. Moved explicit isValidDenom check to the top of the send path so invalid strings are rejected before any UI is touched.
Add optional memo field (proto field 7) to RippleSignTx. Firmware changes: - messages-ripple.options: add RippleSignTx.memo max_size:200 - ripple.c: serialize XRPL Memos array (STArray[9]/STObject[10]/MemoData VL[13]) after destination in canonical XRPL field order - fsm_msg_ripple.h: display memo on confirmation screen before signing THORChain swap memos (e.g. '=:ETH.ETH:0x...') are stored as raw UTF-8 bytes in the XRPL MemoData VL field, following the XRPL binary format spec.
1. Per-word rejection: invalid BIP-39 words rejected immediately during cipher entry with "Word not found in BIP39 wordlist" + OLED warning 2. Condition flip: !enforce_wordlist -> enforce_wordlist at finalization 3. Previous word display: shows "(N.word)" during cipher entry 4. layout_cipher: added prev_word_info parameter C31 test should now PASS with OLED screenshot.
INTENTIONALLY RED: upstream master lacks the proto field this fix needs (denom/memo land via keepkey/device-protocol#111). This PR is BLOCKED until #111 merges; re-pin to the updated master then → green.
INTENTIONALLY RED: upstream master lacks the proto field this fix needs (denom/memo land via keepkey/device-protocol#111). This PR is BLOCKED until #111 merges; re-pin to the updated master then → green.
…ocol EVM txs Adds MAYA_ROUTER constant and thor_isMayachainTx() / thor_confirmMayaTx() functions. Maya transactions (targeting the Maya ETH router) now show "Maya data" / "Maya router" on screen instead of "Thorchain data". The depositWithExpiry() ABI is shared between THORChain and Maya; the differentiator is the router contract address. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…error=discarded-qualifiers on ARM)
Derive child mnemonics per BIP-85 spec and display them on the device screen. Mnemonic is NEVER sent over USB — display only, with explicit user confirmation before showing. Supports 12/18/24 word outputs with configurable BIP-39 app index. All sensitive buffers are zeroed on every exit path.
…nly-zcash-privacy feat: BITCOIN_ONLY + ZCASH_PRIVACY build flags + bitcoin-only seed lock
Adds METADATA_VERSION_SCHEMA (0x02): a clear-sign metadata format that carries only the static decode schema (chainId, contract, selector, method, per-arg name + display format [+ static decimals/symbol]) with NO tx_hash and NO argument values. The device decodes the argument values from the exact calldata it is about to sign, so the displayed decode is bound to the signature structurally — no committed digest, no per-transaction signing, no online hot key. The catalog is signed once, offline, and can be served from a host CDN. v1 (per-tx, host-decoded values, tx_hash-bound) is unchanged and still selected by the version byte; v2 is added alongside. Scope: fixed single-word ABI head types (ADDRESS, AMOUNT, TOKEN_AMOUNT) at offset 4 + 32*i — covers approve/transfer/transferFrom and fixed-arg calls. Binding requires the entire calldata to be exactly selector + num_args*32 bytes, wholly present in data_initial_chunk (no hidden/streamed trailing data), else the tx falls to the normal blind-sign path. Dynamic types are out of scope (no v2 schema -> blind sign). - parse_v2_args / decode_v2_args, refactor parse into shared head/trailer + v1/v2 - signed_metadata_matches_tx: v2 branch decodes from the tx calldata - signed_metadata_enforce_schema_decision: structural (no tx_hash) enforce - 12 new unit tests (decode, rejection cases, enforce truth table); 71/71 pass Format: docs at docs/CLEARSIGN-V2-STATIC-SCHEMA.md (keepkey-clearsign). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt decode Two review findings on the v2 static-schema path: 1. v2 enforce relied on an implicit caller-ordering invariant (relied_on_metadata is only set after matches_tx()/decode). v2 has no tx_hash fallback, so make it explicit: add metadata_schema_decoded, set only on a successful decode_v2_args() in matches_tx(), and REQUIRE it in signed_metadata_enforce_schema_decision(). A v2 signature can now never be emitted unless the args were provably decoded from this tx's calldata — not merely inferred from call order. 2. decode_v2_args() was not idempotent for TOKEN_AMOUNT: it derived the append offset from value_len, which already included a prior call's 32-byte amount, so a second matches_tx() overflowed the prefix check. Derive the prefix from symlen (value[1]) instead — stable across repeated calls. Adds an idempotency test and a relied-but-not-decoded enforce case (73/73 pass). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pins the test-framework support for firmware v2 static-schema clearsign: serialize_schema_metadata() + schema_calldata(), TestClearSignV2SchemaOffline (offline byte-format + frozen-snapshot drift gate) and TestClearSignV2Device (on-device decode+sign+recover, requires_firmware 7.15.0 — v2 lands in the in-progress 7.15.0 line, so it runs with the v1 clear-sign device tests), plus VS1-VS5 in the PDF clear-signing report section. python-keepkey -> 5307888be7b6f39381e4eb9b77bf02d6ce289d9e (BitHighlander fork branch; NOTE .gitmodules declares the upstream url but this — like the previous pin — is a fork-only commit, so checkout must fetch python-keepkey from the fork). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oof) Review finding: metadata_schema_decoded could stay true after a LATER signed_metadata_matches_tx() that failed an early binding (unavailable metadata, wrong contract/selector/chain) before reaching the v2 decode branch — leaving a stale 'decoded' proof from a prior successful match. Since v2 enforce has no tx_hash fallback, that undermined the very independence the flag was added for. Fix: clear metadata_schema_decoded at the TOP of signed_metadata_matches_tx(), before any early return, so it reflects only the current call. Adds a read accessor (signed_metadata_schema_decoded, exported for tests) and a regression test that decodes a v2 blob, then feeds a mismatching tx and asserts the flag is cleared. 74/74 unit tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(clearsign): v2 static-schema blobs + on-device calldata decode
…_data The device signs sha256(raw_data) but displayed nothing about it — every TRON transaction was a 'TRON Blind Sign / Sign N-byte transaction?' screen. Pioneer-powered swaps from TRC-20 (USDT) were fully blind. Parse raw_data on-device (a minimal fail-closed protobuf reader over the exact signed bytes) and clear-sign the two payloads the Vault builds: - TransferContract: 'Send <amt> TRX to T...?' + memo (THORChain swap memos ride in raw_data.data) + fee_limit - TriggerSmartContract carrying transfer(address,uint256): token contract, recipient, amount in base units, fee_limit, memo Owner address must match the derived key (parsed from signed bytes). Anything not fully understood — extra contracts, unknown fields, Permission_id, TRC-10 value, non-transfer selectors, dirty ABI words — stays on the blind path, now gated behind AdvancedMode like Solana's opaque transactions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ting pb_read_varint accepted a 10-byte varint whose final byte's payload had bits set above bit 0 (shift=63 + 7 bits > 64), silently discarding the overflow via the left-shift. A malformed key, length, amount, or fee_limit varint using more precision than 64 bits allows could parse as if it were well-formed instead of falling to TRON_TX_UNVERIFIED. Reject outright when the 10th byte's payload exceeds 1. Added tests for overlong key, length-delimited length, amount, and fee_limit varints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…enom handling Swap-memo clear-signing (thorchain.c + mayachain.c parseConfirmMemo, deliberate near-copies kept textually parallel): - Replace the strtok tokenizer with a manual ':' split that PRESERVES empty fields. strtok collapses consecutive delimiters, so an empty field (e.g. the limit in "=:ETH.ETH:0xdest::kk:75") silently shifted later fields left and the affiliate would have been displayed as the limit on a security screen. - NEW 4th swap confirm screen "Affiliate fee <bps> bps to <affiliate>" whenever the affiliate field is non-empty (fee shows "0" when absent). Previously memo fields 5-6 (affiliate + fee bps) were silently dropped, so users never saw affiliate fee skimming. - SWAP/ADD/WITHDRAW intent detection, confirm titles/strings, "self"/"none" defaults, ADD's optional pool screen and WITHDRAW's required basis-points field keep their existing behavior. Memos whose second field has no chain.asset '.' pair now consistently fall back to raw-memo display. MAYA hardening to THOR parity: - Add mayachain_isValidDenom (copy of thorchain_isValidDenom) and validate the MsgSend denom (default "cacao" when absent/empty) in fsm_msgMayachainMsgAck BEFORE any display, failing with Failure_SyntaxError "Invalid denom". Previously arbitrary denom bytes flowed unvalidated into the UI and the signing JSON (snprintf'd into the amount object in mayachain.c). - Replace the raw sprintf into denom_str[71] with a bounded snprintf, and show the validated/defaulted denom on the final sign screen. - MsgDeposit display (both chains): widen asset_str 21 -> 64 and amount_str 32 -> 96. Long-form THOR assets (~50 chars, e.g. ETH.USDT-0XDAC17...) were truncated at 19 chars, and bn_format zeroes its output on overflow so the old 32-byte amount buffer would have rendered long-asset deposits as an empty amount. Tests (make xunit green: 200 firmware + 2 board + 4 crypto): - New parseConfirmMemo suites for both chains driven end-to-end through the real confirm() loop: tests preload ButtonAck + DebugLinkDecision tiny-message frames on the emulator UDP port and assert the exact number of confirm screens consumed, proving the affiliate screen exists, empty fields don't shift, and legacy ADD/WITHDRAW screen counts are unchanged. - Re-enable unittests/firmware/mayachain.cpp (stale 2-arg mayachain_signTxUpdateMsgSend call since 50164a2); pass "cacao" to match the historical JSON. The recorded signature vector turned out to be cryptographically invalid for the fixture's key/JSON (the file was never in the unit build, see 28c74a0, so it was never validated); replaced with a vector recomputed independently via python-ecdsa (RFC6979/secp256k1, low-s) that the firmware output matches exactly. - Add mayachain_isValidDenom validation vectors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… honest wording for unspecified affiliate fee thorchain_parseConfirmMemo/mayachain_parseConfirmMemo received size as a raw byte count from the BTC OP_RETURN caller (transaction.c passes op_return_data.size, no NUL), but copied the memo with strlcpy(dst, src, size), which writes only size-1 bytes — silently dropping the memo's final character. An affiliate fee of '75' bps displayed as '7'; a one-character affiliate vanished entirely. Copy the bytes exactly (bounded memcpy into the zeroed buffer) instead. Also: an empty fee field with an affiliate present displayed 'Affiliate fee 0 bps' — but THORName/MAYAName registration lets the network apply the affiliate's registered default bps when the memo field is empty, so '0' asserted a fact the device cannot know. Say 'unspecified' instead. Regression tests pass raw non-NUL-terminated bytes and assert the exact screen count (the dropped 1-char affiliate showed 3 screens, not 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-by-one at full buffer capacity mayachain_signTxUpdateMsgSend still wrote denom into the signed JSON with an unescaped %s — the FSM caller validates first, but the exported signer helper itself had no defense, unlike thorchain's hardened path. Add the same default+validate+escaped-write sequence directly in the signer function so it stays safe if called directly or reused later. Also: the memo copy fix (previous commit) still dropped the last byte when size == sizeof(memoBuf) exactly — the documented <=256 contract allows a full 256-byte raw memo, and copyLen was clamped to sizeof(memoBuf)-1 regardless. Widen memoBuf by one byte so the full 256-byte capacity can be copied verbatim while a terminator byte is still guaranteed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d AdvancedMode The old test_tron_sign_transfer_legacy_raw_data / test_tron_sign_deterministic / test_tron_sign_different_accounts used a hand-rolled, undecodable raw_data blob and expected unconditional blind-sign. This PR's raw_data clear-sign parser correctly routes undecodable contracts to the opaque path, which now requires AdvancedMode.
feat(tron): clear-sign TRX and TRC-20 transfers from raw_data
…haped messages
Pioneer-powered Solana swaps reach the device as versioned (v0)
serialized messages and were always blind-signed:
- solana_parseVersionedTx forced every v0 message OPAQUE. Now a v0
message parses like a legacy one; only instructions that actually
reference address-lookup-table accounts (indices beyond the static
list, unresolvable on-device) force the tx opaque. An attached but
unreferenced lookup table does not block verification.
- Memo instruction bodies (THORChain swap intents '=:ETH.ETH:...') were
detected but hidden ('Memo attached'). Printable memos are now shown.
- SolanaSignMessage payloads that parse as a fully-verified Solana tx
(from byte 0) are clear-signed per instruction with the SignTx rules
(signer check + per-instruction confirms) instead of hex-blob blind
signing — this is the exact path wallet integrations use for v0 swap
txs. Unverifiable payloads keep the AdvancedMode gate + hex preview.
- Over-limit instruction counts now walk the section structurally, so
trailing-data and lookup-section checks still apply (truncated bodies
are malformed rather than opaque).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ctations The old test asserted ALL versioned v0 txs require AdvancedMode; this PR's firmware change verifies v0 messages that only touch static accounts, same as legacy. Split the test into a static-verified case (no AdvancedMode needed) and a genuinely-opaque ALT-referencing case (AdvancedMode required).
feat(solana): clear-sign v0 transactions and memo bodies
Pins BitHighlander/python-keepkey feat/pdf-report-coverage-gaps (01fbd3b), which fixes 10 release-gate PDF test-report gaps found auditing CI run 28679110349 (post-#284 clearsign v2 static-schema) against the who/what/why standard for daily-driver EVM tx formats: - lowers 8 stale requires_firmware("7.15.1") gates to 7.15.0 (these behaviors already ship in this firmware line — the gate zeroed out the EIP-1559 signing-guard suite + blind-sign policy negative path on every 7.15.0 CI run) - surfaces 21 previously passing-but-invisible tests into SECTIONS: 6 malformed-metadata rejection tests (the WHY fail-closed story), 3 THORChain-router deposit tests (incl. the router-pin blind-sign gate), 3 Uniswap V2 liquidity tests (documented emulator-skip instead of silent), 6 signing-guard tests, EIP-712 typed-data (disclosed as a known display gap), + 2 new v2 static-schema device tests proving the decode-mismatch fail-closed fallback and malformed-schema rejection - fixes the ERC-4337 flow's overclaiming "innerCall decoded" text and the print_clearsign_flows() --flows dump crash (KeyError + missing arg) - fixes a stale-banner/self-contradicting PDF prose bug (V8 claimed "covered in 7.15.0+" while gated to 7.15.1) python-keepkey -> 01fbd3b6ac1f624bf745153e39261192e374ae50 (fork branch feat/pdf-report-coverage-gaps off the 5307888b pin; like the previous pin this is fork-only, checkout fetches from the fork). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(thorchain,maya): show affiliate fees in swap memos; harden MAYA denom handling
chore(deps): bump python-keepkey — 10 PDF report coverage fixes
KK_BITCOIN_ONLY and KK_ZCASH_PRIVACY have existed as CMake options since #282 (unit-test source gating included), but neither ci.yml nor release.yml ever passed -DKK_BITCOIN_ONLY=ON or -DKK_ZCASH_PRIVACY=ON — every build (PR, develop, release/**, and the tagged release pipeline) only ever produced the full/default variant. The flags were untested and unreleased. - build-arm-firmware / build-emulator / unit-tests (ci.yml) and build-firmware / test (release.yml) now run as a 3-way matrix: full, bitcoin-only, zcash-privacy. Each variant's coin/token unit-test gating (unittests/firmware/CMakeLists.txt) is now actually compiled and run against KK_BITCOIN_ONLY=ON / KK_ZCASH_PRIVACY=ON, not just the default. - Artifacts (firmware .bin/.elf, emulator image, unit-test results) are suffixed per variant so all three are downloadable/reviewable independently. The emulator Dockerfile already had an unused `ARG coinsupport=""` hook from an older (now-removed) COIN_SUPPORT build scheme — repurposed it to carry the new -D flags instead of adding a new mechanism. - generate-test-report and publish-emulator stay scoped to the full/default variant only (the PDF release-gate report and the DockerHub-published dev emulator don't need 3x copies). - release.yml's create-release job now downloads all 3 release-firmware-<variant> artifacts and attaches all of them (plus per-variant HASHES-<variant>.txt) to the draft GitHub Release. python-integration-tests (the full python-keepkey suite) is intentionally left un-matrixed — that requires docker-compose plumbing changes and is better scoped as its own follow-up.
…o longer overflows The new build-arm-firmware (zcash-privacy) matrix leg exposed that the variant does not link on real hardware: region 'rom' overflowed by 2,400 bytes. Bisect against the post-#282 baseline shows the variant had only 1,520 bytes of headroom (653,840 / 655,360) before the 7.15 clear-signing merges (#284 TRON #285 Solana #286 THOR/MAYA #287) added ~3,900 bytes of legitimate feature code. Rather than shaving feature code (which re-breaks on the next merge), reclaim real headroom from the largest discretionary tables: the five Gladman AES lookup tables (t_fn/t_fl/t_in/t_il/t_im, 4,096 bytes each). deps/crypto/trezor-firmware -> 56f404e45 adds an AES_SMALL_TABLES opt-in selecting the library's ONE_TABLE mode (1,024 bytes each); this variant defines it. AES here backs storage/session crypto only — no hot path. zcash-privacy ROM: 657,760 (overflow) -> 640,800 = 14,560 bytes headroom. full and bitcoin-only builds do not define the flag and stay byte-identical (crypto pin advances by one commit that is inert without the define).
…ifacts ci: build+test bitcoin-only and zcash-privacy variants (merge-back from release/7.15.0-rc4)
Adds V2SchemaDecodesRelaySolverArgs. Proves the device decodes a relay solver call (0x02d5f05f: token, amount, requestId) from a v2 static-schema blob and clear-signs it — the "add a new service via a signed payload" path for a contract NOT in ethereum_contractHandled. Calldata shape (selector + 3 fixed 32-byte words = 100 bytes, zero remainder) taken byte-for-byte from real relay traffic (22 live samples, all identical). firmware-unit 75/75 green locally.
Owner
Author
|
Superseded by #304 (clean cherry-pick of the one orphaned test onto current develop, merged into rc10). Closing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
V2SchemaDecodesRelaySolverArgsto the firmware v2 static-schema suite.Proves the device decodes a relay solver call (
0x02d5f05f:token,amount,requestId) from a v2 static-schema blob and clear-signs it — the "add a new service via a signed payload" path for a contract that is not inethereum_contractHandled.Why it matters
Relay is the hard case: no native firmware handler, and the online per-tx signer was removed by design. This locks in that a signed static schema alone teaches the device to clear-sign it.
Fidelity
The calldata shape (selector + 3 fixed 32-byte words = 100 bytes, zero remainder) is taken byte-for-byte from real relay traffic — 22 live samples, all identical length/shape. word0 = token (USDC), word1 = amount (TOKEN_AMOUNT), word2 = requestId (AMOUNT).
Verification
firmware-unit75/75 green locally (74 baseline + this). Purely additive — oneTEST_F, no production code touched.