BLS12-381 For Motoko

Introducing bls12-381 — BLS12-381 pairings in pure Motoko

Hi everyone,

We just published bls12-381 to mops: a pure-Motoko implementation of the BLS12-381
curve — the field tower, G1 and G2 arithmetic, hash-to-curve, multi-scalar multiplication,
and pairing checks. No host functions, no system API, no Rust: it compiles with moc and
runs anywhere Motoko runs, including inside an ordinary IC canister.

The library was funded by ICDevs.org (a 501c3 non-profit). If you find it useful, please
consider chipping in at https://icdevs.org/donation.html.

mops add bls12-381

Repo: https://github.com/icdevsorg/bls12-381.mo (release v0.1.0)


Why we built it

BLS12-381 is everywhere the Internet Computer is: every IC certificate is a BLS signature
over it, vetKeys use it, and Ethereum’s Prague upgrade made it a precompile (EIP-2537) and
KZG commitments (EIP-4844) are built on it. We needed it first for
evm.mo, an EVM in Motoko, which has to execute the
EIP-2537 precompiles (G1ADD, G1MSM, G2ADD, G2MSM, PAIRING, MAP_FP_TO_G1, MAP_FP2_TO_G2)
exactly as Ethereum does. There was no Motoko implementation, so we wrote one, and then spent
most of the effort making it fast enough to live inside a canister’s instruction budget.

What’s in it

  • Fields: Fp, Fp2, Fp6, Fp12 with dedicated squaring, inversion, square roots and
    Frobenius maps.
  • Groups: G1 and G2 in projective coordinates — add, double, scalar multiply, affine
    conversion, on-curve checks, and subgroup checks (G2 via the ψ endomorphism).
  • Pairing: an optimal-ate Miller loop over the NAF-recoded curve parameter with
    sparse line multiplication, a multi-Miller loop that shares the squarings across
    pairs, and a final exponentiation split into an easy part and a hard part driven by
    cyclotomic squaring. pairing_check([(P1, Q1), (P2, Q2), …]) is the one you usually
    want: one Miller loop over all pairs, one final exponentiation.
  • Hash-to-curve helpers: the RFC 9380 simplified-SWU maps with the 11- and 3-isogenies
    (map_fp_to_g1, map_fp2_to_g2).
  • MSM for G1 and G2, and the EIP-2537 encodings (padded Fp/Fp2, G1/G2, scalars) plus
    compressed-G1 decoding.

Performance

Measured with mops bench (instruction counts on a replica):

Operation Instructions
one full pairing (Miller loop + final exponentiation) 1.20 B
final exponentiation 660 M
Miller loop, one pair 539 M
multi-Miller loop, two pairs (vs 1.08 B for two separate loops) 805 M
map_fp_to_g1 141 M
G2 scalar multiply 13.8 M
G1 scalar multiply 5.6 M
Fp multiply 22 K

A complete BLS signature verification (hash-to-curve, subgroup checks, and a two-pair
pairing_check) measures 1.44 billion instructions inside
mo:ic-agent, which uses it to verify IC certificates. The IC’s
per-message limit is 40 billion instructions for an update call and 5 billion for a query,
so a verification fits comfortably in either.

One result worth sharing because it surprised us: fixed-limb Montgomery multiplication is
much slower than plain Nat in Motoko.
We built a 6×64-bit-limb Montgomery field and
benchmarked it head to head. A Montgomery fp_mul took 1.86 million instructions, against
23 thousand for (a * b) % p on Nat: about 80× slower. Motoko’s arbitrary-precision
arithmetic runs in the runtime as native Wasm, and a limb loop written in Motoko cannot
compete with it. The
benchmark is in the repo (bench/montgomery_vs_nat.bench.mo) if you want to check it for
your own field code.

Correctness

  • 42 PocketIC tests: field and curve laws, subgroup checks, the pairing identities
    (e(P, Q) · e(-P, Q) = 1, bilinearity), MSM, encodings, and spot checks against
    @noble/curves for scalar multiplication,
    SWU and the isogeny maps.
  • 11 randomized fuzz tests comparing results against @noble/curves.
  • evm.mo’s suites: EIP-2537 known-answer vectors, and EIP-4844 KZG negative vectors.
  • Real IC certificates: mo:ic-agent uses it to verify
    certificates from PocketIC and from mainnet in its test gate. That is an end-to-end check
    that the pairing, subgroup checks and hash-to-curve compose correctly.

It has not had an independent cryptographic audit. Treat it accordingly before you
protect production assets with it, and please report anything that looks wrong. It is also
not constant-time — it is written for verifying public data (signatures, certificates,
proofs), not for handling secret keys.

Quick start

import BLS "mo:bls12-381";

// e(P, Q) · e(-P, Q) == 1
let p = BLS.G1_GEN;
let q = BLS.G2_GEN;
assert BLS.pairing_check([(p, q), (BLS.g1_neg(p), q)]);

// scalar multiplication and bilinearity: e(aP, Q) == e(P, aQ)
let a = 123456789;
assert BLS.pairing_check([(BLS.g1_mul(p, a), q), (BLS.g1_neg(p), BLS.g2_mul(q, a))]);

Feedback, issues and PRs are very welcome — especially benchmarks from your own workloads.

This is very useful and the approach taken differs from what we tested before. Thank you for sharing this

Quick feedback because one of our engineers who was working on this got a little excited:

  1. Allocation: build the Fp2/Fp6/Fp12 tower to work in place on preallocated [var] buffers instead of creating new tuples for every operation. That is how we got to 10 MB per
    verify, and it should work on top of your Nat base field, keeping your speed while cutting memory. It matters most when several pairings run in one message.
  2. Prepared G2: when the G2 side of a pairing is fixed (the IC root key in mo:ic-agent, or [τ]G2 in KZG point evaluation), the Miller-loop line coefficients can be computed once
    and reused. A prepare_g2 + pairing_check_prepared API would skip that G2 work on every call.

Minor: g1_from_affine and g2_from_affine accept coordinates ≥ P and silently reduce them. decode_fp already rejects these, so the EIP-2537 path is fine; only direct API callers
are affected. Congrats and thank you for sharing this

Ok small bug because we fell into it before: with more than 8 points, g1_msm and g2_msm take the Pippenger path, which uses numWindows = (256 + c - 1) / c. Any scalar bigger than the window range loses its high bits and the result is silently wrong. We reproduced it with 16 points and scalars ≥ 2^256, and with 9 points and scalars ≥ 2^300; the same inputs with 8 points (naive path) give the correct answer.

Thanks for the detailed feedback—we tested each suggestion separately.
We’ve implemented prepared G2 for 0.2.0. Reusing preparation reduced instructions by about 18% and temporary allocation by 17% in our representative two-pair check. Both affine constructors now reject components ≥ P. The G2 case was particularly useful to catch: oversized coordinates could survive construction and cause later arithmetic to trap.
We also prototyped reusable buffers while keeping Nat arithmetic. Beyond preparation, they saved another 2.8% allocation but increased instructions by 0.9%, so we’re leaving them out for now. We’d be interested in comparing benchmark setups with your 10 MB result—ours measures total allocation, not retained heap.
Thanks again—this led to a useful improvement and a concrete bug fix.

nice :slight_smile:

FYI: I recently worked on a PoC regarding seeding secrets into a backend canister via vetkeys to ensure that the plaintext secret is never leaked before set and decrypted in a backend canister deployed on a SEV-SNP enabled subnet.

repo: https://github.com/marc0olo/icp-seeding-secrets-poc

I wasn’t aware of your effort when starting on the poc and I also required a bls12-381 library for that use case.

I made some updates today on the repo. personally I don’t plan to maintain such a lib, so I might come up with a PR to your repo soon.

the SDK team might also come up with some standardized approach to make it as seamless as possible to seed secrets into a canister on a confidential subnet using icp-cli.