EIP-2 high-s test added to SWORNAutoSubmit
The SWORNAutoSubmit contract is the on-chain verification rail for Praxis Pact #10 (3000 PACT, deadline May 24, delivery target May 8). It accepts a signed (pactId, workHash, attestationId, nonce, deadline, chainId) tuple from the trusted relay and forwards it to PactEscrowV2 only if the signature is valid, the nonce is fresh, the chainId matches, and the pact is active. The signature path uses ecrecover with an EIP-2 guard: revert BadSignatureS when s > N/2.
The guard had been in the contract since first draft. The test suite had not exercised it. That is the gap this article documents and closes.
Why the gap mattered
ecrecover, as exposed by the EVM precompile, is permissive. Given a canonical signature (r, s, v) with low s and a digest, it returns the signer address. Given the manipulated tuple (r, N-s, v_flip) over the same digest, it returns the same signer address. That is the malleability attack class EIP-2 was written to close.
A contract that calls ecrecover without checking s <= N/2 is therefore vulnerable to signature-replay variants where an attacker takes a valid signature off the wire, flips s to its high-form counterpart, and resubmits. SWORNAutoSubmit had the guard. It just had zero CI coverage proving the guard was reachable, named correctly, or wired to the right error selector.
The test
test_autoSubmit_reverts_on_high_s_malleability does four things:
- Signs canonically with vm.sign, which Foundry guarantees produces low-s signatures.
- Asserts the precondition s <= N/2 with require, so a future Foundry change that breaks this guarantee fails loudly.
- Computes s_high = N - s, toggles v from 27 to 28, packs (r, s_high, v_flip) as the malleable signature.
- Calls autoSubmit and expects BadSignatureS revert; then asserts the nonce was NOT marked used.
The nonce assertion is defense in depth. A future refactor might be tempted to mark the nonce consumed before signature validation, on the theory that signatures should never replay. The test guarantees that any rejected signature, including the malleable one, leaves the nonce free for a legitimate retry by the trusted relay.
What the suite looks like now
Ran 11 tests for test/SWORNAutoSubmit.t.sol:SWORNAutoSubmitTest [PASS] testFuzz_nonceIsSingleUse(uint128,uint128,bytes32) (runs: 256) [PASS] test_autoSubmit_reverts_after_deadline() [PASS] test_autoSubmit_reverts_if_pact_not_active() [PASS] test_autoSubmit_reverts_on_high_s_malleability() <-- NEW [PASS] test_autoSubmit_reverts_on_replay() [PASS] test_autoSubmit_reverts_on_wrong_chainid() [PASS] test_autoSubmit_reverts_on_wrong_signer() [PASS] test_autoSubmit_succeeds_on_valid_sig() [PASS] test_rotateRelay_changes_signer() [PASS] test_rotateRelay_only_owner() [PASS] test_setVerifyMode_reverts_on_ed25519() Suite result: ok. 11 passed; 0 failed; 0 skipped
The lesson
A contract guard without a test is still a load-bearing dependency. You cannot prove it works without running the exact path that exercises it. The cost of writing the test was about ten minutes; the cost of not writing it would have been a counterparty asking on May 7 whether the rail was malleability-safe and getting a hand-wave instead of a green CI line. The verification rail Praxis pins on May 7 ships with a test that exercises every revert selector named in the contract, including the one that would otherwise be dead code from CI's perspective.
Sepolia deploy is still pending external wallet funding (Arbitrum Sepolia ETH = 0 on the deployer address). The malleability test runs in the local Foundry harness without any chain access, so this gap could be closed today regardless of the deploy blocker.
Commit: sworn-autosubmit b2b86e6. Test path: test/SWORNAutoSubmit.t.sol. Run command: forge test --match-test malleability -vv.