In 2023 and 2024 I worked on a blockchain at the Peking University Chongqing Big Data Research Institute. The chain was headed for government and carrier pilots, and the compliance requirements were explicit: SM2 for signatures, SM4 for symmetric encryption, SM9 for identity-based schemes, and SM3 as the hash underneath all of them. Its EVM, inherited from Ethereum, had precompiled contracts built on secp256k1, SHA-256 and the alt_bn128 pairing curve. My job was to rebuild the elliptic-curve precompiles on the SM curves using GmSSL, and to add a new set of precompiles for the SM algorithms themselves.

This looks like a mechanical substitution: swap the curve, swap the hash, keep the interfaces. In practice, part of it could be carried over that way, and part of it could not. This post is organized by that split.

Example — what was redone, what was added
Redone on SM curves   point add, scalar mul                 →  SM2's 256-bit prime-field curve
                      pairing check (is the product 1?)     →  SM9's BN curve
Added                 SM2 signature verify, SM3 hash, SM4 encrypt / decrypt,
                      SM9 signature verify, SM9 pairing (returns the pairing value itself; SM9 verify needs it)
Underneath            GmSSL (C): exactly one implementation of each primitive

1. Why precompiles

In principle all of this could be written in Solidity. Nobody does, for the same reason Ethereum did not: one elliptic-curve operation in EVM bytecode costs on the order of a million gas, and a pairing check is not feasible at all. A precompile is native code at a fixed address with its own gas formula; the EVM calls it like any other contract and validators run it at native speed. The second reason mattered more to me: a Solidity library would mean a new SM2 implementation written by us and checked by nobody else, while a precompile means every contract on the chain calls the same implementation — GmSSL's, which has been checked against the standard's published test vectors and used by many projects.

2. What carried over: curve arithmetic and encoding

Point addition and scalar multiplication were re-implemented on SM2's 256-bit prime-field curve; the pairing check moved to the curve SM9 uses, a 256-bit Barreto–Naehrig curve with the R-ate pairing. The input and output encoding follows Ethereum: coordinates are fixed-width 32-byte big-endian integers, the point at infinity is all zeros, and malformed input fails rather than being guessed at. Tooling that already encodes calls to 0x06 through 0x08 keeps working unchanged.

One more thing carried over: validate points before any arithmetic. Every point arriving in calldata is verified to be on the curve before it reaches GmSSL, and for the pairing's second group it is also verified to lie in the correct subgroup. Pairing on a point that is not on the curve is the classic invalid-curve attack, and a precompile cannot skip that check, because every contract on the chain calls it.

3. What did not carry over: ecrecover

Ethereum's first precompile, ecrecover, takes a hash and a signature and returns the signer's address; many of Solidity's identity patterns are built on it. I assumed I could swap secp256k1 for SM2 and keep the interface as it was. The problem is what SM2 hashes before it signs:

Example — the two hashes before an SM2 signature (GB/T 32918)
Z_A  =  SM3( ENTL_A ‖ ID_A ‖ a ‖ b ‖ x_G ‖ y_G ‖ x_A ‖ y_A )
e    =  SM3( Z_A ‖ M )

The signer's own public key (x_A, y_A) and identity ID_A both go into the digest. Recovering the public key from a signature is still mathematically possible: given e, derive x1 from r, then with t = r + s solve P_A = t⁻¹(R − [s]G), needing only a recovery id to pick among candidate points, exactly as on secp256k1. But a key recovered this way has no binding to the ID: whether the e the caller passed in was computed with this key and this ID, the chain has no way to check. Checking would mean passing M and the ID as well and recomputing e, which already costs more than a plain verification. So the precompile is verify, not recover: it takes a public key, a signer ID, a message and a signature, and returns true or false. Contracts that used recovery to derive identity had to be rewritten: pass the public key in, verify, then derive the address from the key.

The ID string was the second problem. Most implementations assume the standard's default, 1234567812345678, without saying so; this precompile takes the ID as an explicit input and documents the default, because a signature made under one ID fails verification under another, and that failure looks the same as a forged signature.

4. What stays off-chain: signing

SM2 and SM9 signing both draw a random number. If signing were a precompile, every validator would compute a different signature for the same transaction and consensus would fail. A deterministic nonce in the style of RFC 6979 does not solve this: signing needs the private key, and a private key placed in calldata stays on the ledger forever. So there is no signing precompile at all. Verification, hashing, point arithmetic and pairings run on-chain; signing stays wherever the private key is.

SM4 is the edge case. With the key and IV supplied by the caller, encryption and decryption under a given block mode are deterministic and can be a precompile, but the documentation has to state that anything placed in calldata stays on the ledger forever, the key included. On-chain SM4 is a building block for protocols that already manage keys off-chain. It is not a privacy feature, and a precompile that lets people believe it is one is a security risk in itself.

5. Gas, re-measured

An underpriced precompile is a denial-of-service primitive: a transaction spends little gas and consumes a lot of validator time. Ethereum priced its pairing precompile as a base fee plus a per-pair fee from the start (EIP-197); when implementations got faster, EIP-1108 lowered both numbers. I kept the same structure and the same method. Each operation was benchmarked with GmSSL on the validators' hardware and priced relative to the existing gas table: SM2 verification is a base fee plus a hashing fee proportional to message length, SM3 and SM4 are linear in input length, SM9 pairing is a base fee plus a per-pair fee. I do not remember the final numbers precisely, so I will not quote them here. The method was: measure first, price for the worst case, and measure again when the library or the hardware changes.

6. Checks at the C boundary

GmSSL is written in C and the VM is not, so every precompile call crosses a foreign-function boundary, and that boundary sits inside the consensus engine. The rules at it are simple. Inputs are length-checked, then copied into fixed-size buffers. No error inside the library may become a process crash. Every failure, such as a malformed point or a key of the wrong length, becomes a precompile failure: all gas consumed, nothing returned, consistent with what Ethereum specifies for its curve precompiles (EIP-196, EIP-197). Every decoder was fuzzed, because if calldata can crash a validator, anyone with a wallet can halt the chain.

Example — the tests every precompile has to pass
Known answer    the test vectors shipped in the GB/T standards for SM2, SM3, SM4, SM9
Differential    the same input through the GmSSL command line; output identical byte for byte
Negative        off-curve points, wrong-subgroup points, truncated input, wrong ID  →  fail, never a wrong "true"
Fuzz            random calldata into every decoder; no crash, no hang

What it cost

What I took away

A standard rarely maps one-to-one onto the API it replaces; the interface should be designed around the mathematics rather than the old function name, and ecrecover is an example. Anything that needs a private key or a random number should stay off-chain, however convenient it would be on-chain. A foreign-function boundary inside a consensus engine is a security boundary, and the checks placed there decide whether one bad transaction can stop a node.