Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Disable expensive diffs for large/generated fixtures

test/fixtures/fcl_vectors.json -diff
test/fixtures/*.csv -diff

20 changes: 19 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,22 @@ docs/
.env

# Python files
__pycache__
__pycache__
*.pyc
.pytest_cache/
.mypy_cache/

# Local Python virtual environments
.venv/
test/helpers/.venv/

# OS/editor cruft
.DS_Store
.idea/
.vscode/

# Temp artifacts from earlier collection steps
test/fixtures/.cases.tmp
**/.venv/
venv/
**/venv/
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@
[submodule "lib/openzeppelin-contracts"]
path = lib/openzeppelin-contracts
url = https://github.com/openzeppelin/openzeppelin-contracts
[submodule "lib/ozp256"]
path = lib/ozp256
url = https://github.com/OpenZeppelin/openzeppelin-contracts
90 changes: 89 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,92 @@ WebAuthn.WebAuthnAuth memory auth = WebAuthn.WebAuthnAuth({
After cloning the repo, run the tests using Forge, from [Foundry](https://github.com/foundry-rs/foundry?tab=readme-ov-file)
```bash
forge test
```
```

## Verification Gas Limit (VGL) estimation for passkeys

Problem: During ERC‑4337 gas estimation, bundlers simulate validation without a real passkey signature. On RIP‑7212 chains the simulation therefore always falls back to the software (FCL) verifier instead of the cheap precompile, overestimating gas. On non‑7212 chains, both valid and invalid inputs execute the software (FCL) verifier whose gas usage varies by input. Without a stable *valid* signature in simulation, estimates are noisy. We profiled many valid signatures and selected a high‑gas (worst‑case) vector to hardcode for accurate, conservative simulation.

Approach: Provide a simulation‑only entry (`verifySim`) that ignores the caller’s `(r,s,x,y)` at the final step and instead verifies a hardcoded known‑valid P‑256 vector. Simulation then follows the same path as real execution:
- On RIP‑7212 chains: precompile succeeds (cheap, matches onchain).
- Without RIP‑7212: fallback (FCL) runs with a worst‑case valid vector (conservative).

> [!WARNING]
> `verifySim` is for simulation‑only bytecode overrides. Do not deploy it in production contracts or expose it in on‑chain execution paths.

Why we needed a statistical sweep: The FCL (software) verifier’s gas usage is input‑dependent; different valid signatures can consume different amounts of gas. To avoid under‑budgeting on non‑7212 chains, we generated many valid vectors, measured their FCL gas, and selected a high‑gas (worst‑case) valid vector to hardcode for simulation. This yields a stable and slightly conservative VGL.

What’s included here:
- `src/WebAuthn.sol`:
- `verifySim(...)` for simulation‑only overrides.
- Tooling to generate, profile, and visualize FCL gas usage:
- `test/helpers/generate_p256_vectors.py`: produces P‑256 WebAuthn assertion vectors.
- `test/ProfileOne.t.sol`: profiles a single vector index, measuring gas and printing fields.
- `scripts/profile_fcl_gas.sh`: loops all vectors, writes `test/fixtures/fcl_gas_profile.csv`, and reports the max‑gas row.
- `scripts/plot_fcl_gas_html.py`: builds `test/fixtures/fcl_gas_hist.html`, an interactive histogram with a “Download PNG” button.

### Why these vectors are representative of FCL gas variance

**What FCL actually sees**
- FCL only receives five values: `message` (32‑byte hash), `r`, `s`, `Qx`, `Qy`. WebAuthn fields upstream are only used to compute `message = sha256(authenticatorData || sha256(clientDataJSON))`.

**Where the gas variance comes from**
- The heavy work is a combined scalar multiplication whose control flow depends on the bit patterns of `u = message * s^-1 mod n` and `v = r * s^-1 mod n`. Different bit patterns lead to slightly different arithmetic paths and gas.

**Why our sampling exercises that variance**
- We vary the challenge and thus the hash of `clientDataJSON`, making the final `message` effectively random across samples.
- ECDSA signing with low‑s normalization (as used in production) still yields `r,s` with the usual distribution; low‑s does not collapse the variability that FCL sees through `u`/`v`.
- We also vary public keys. Even if a real wallet reused one key, the dominant driver of variance is the `u`/`v` bit patterns, not which valid key is used.

**What doesn’t influence FCL gas**
- RP ID hash, flags, counters, or JSON layout do not flow into FCL directly; they only affect the 32‑byte `message`. We’re not fixing the challenge in a way that would reduce `message` diversity.

**Practical takeaway**
- The histogram you see reflects the true variability driver inside FCL. Choosing the max‑gas valid vector from our sweep is a sound, conservative bound for non‑7212 chains and aligns simulations with worst‑case reality.

### Generating P‑256 vectors (Python)
We commit the vector inputs under `test/fixtures`, but you can reproduce or regenerate them locally.

Prereqs:
- Python 3.10+ (macOS: `python3 --version`)

Set up a virtual environment and install deps:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r test/helpers/requirements.txt
```

Generate vectors (defaults to `test/fixtures/fcl_vectors.json`):
```bash
python3 test/helpers/generate_p256_vectors.py \
--count 300 \
--seed 42 \
--out test/fixtures/fcl_vectors.json
```

Notes:
- `--seed` is optional; provide it for reproducible output.
- The script will create the output directory if it does not exist.

How to reproduce our profiling:
```bash
bash scripts/profile_fcl_gas.sh
python3 scripts/plot_fcl_gas_html.py --csv test/fixtures/fcl_gas_profile.csv --out test/fixtures/fcl_gas_hist.html
open test/fixtures/fcl_gas_hist.html
```

How to use in bundler simulation (conceptual):
- Deploy a “fake implementation” for the wallet where its call site uses `WebAuthn.verifySim(challenge, requireUV, auth, x, y)` instead of `verify`.
- In `eth_estimateUserOperationGas`, supply overrides that point the wallet’s ERC‑1967 implementation to the fake implementation during simulation only. Continue passing dummy signature calldata for shape parity; `verifySim` matches `verify`’s signature but ignores signature/public key at the final step and uses an internal fixed valid vector.

Testing with/without RIP‑7212 locally:
- Foundry doesn’t expose native precompiles. Tests that measure the FCL path etch a tiny stub at `address(0x100)` that returns empty data so the code cleanly falls back to FCL without reverting.

Artifacts you may care about:
- `test/fixtures/fcl_gas_profile.csv`: `index,gas,msgHash,r,s,x,y` for valid vectors.
- `test/fixtures/fcl_gas_hist.html`: interactive histogram of gas counts.

Notes:
- `verifySim` is for simulation‑only bytecode overrides; do not use in production deployments.
- Some generated vectors are invalid due to strict byte‑level checks (clientDataJSON indices, base64url encoding, flags). The pipeline skips invalids automatically and profiles only valid ones.
20 changes: 20 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"lib/FreshCryptoLib": {
"rev": "76f3f135b7b27d2aa519f265b56bfc49a2573ab5"
},
"lib/forge-std": {
"rev": "ae570fec082bfe1c1f45b0acca4a2b4f84d345ce"
},
"lib/openzeppelin-contracts": {
"rev": "5705e8208bc92cd82c7bcdfeac8dbc7377767d96"
},
"lib/ozp256": {
"tag": {
"name": "v5.5.0",
"rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565"
}
},
"lib/solady": {
"rev": "e7024bee47b1623f436ee491ca9458a6dc8abce9"
}
}
1 change: 1 addition & 0 deletions lib/ozp256
Submodule ozp256 added at fcbae5
97 changes: 97 additions & 0 deletions scripts/plot_fcl_gas_html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import csv
import os
import argparse
import json

DEF_CSV = os.path.join(os.path.dirname(__file__), "../test/fixtures/fcl_gas_profile.csv")


def bin_data(values, bins):
lo = min(values)
hi = max(values)
if lo == hi:
return [lo], [len(values)]
width = (hi - lo) / bins
edges = [int(lo + i * width) for i in range(bins)] + [hi]
counts = [0] * bins
for v in values:
idx = int((v - lo) / width)
if idx >= bins:
idx = bins - 1
counts[idx] += 1
labels = [f"{edges[i]}-{edges[i+1]}" for i in range(bins)]
return labels, counts


def main():
ap = argparse.ArgumentParser()
ap.add_argument("--csv", default=DEF_CSV, help="Path to fcl_gas_profile.csv")
ap.add_argument("--bins", type=int, default=30, help="Number of histogram bins")
ap.add_argument("--out", default="fcl_gas_hist.html", help="Output HTML filename")
args = ap.parse_args()

gas_values = []
with open(args.csv, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
try:
gas_values.append(int(row["gas"]))
except Exception:
pass

if not gas_values:
print("No gas values found in CSV", args.csv)
return

labels, counts = bin_data(gas_values, args.bins)

html = f"""
<!doctype html>
<html>
<head>
<meta charset=\"utf-8\" />
<title>FCL verify gas distribution</title>
<script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script>
<style>body{{font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; margin:20px}}</style>
</head>
<body>
<h3>FCL verify gas distribution</h3>
<p><button id=\"save\">Download PNG</button></p>
<canvas id=\"chart\" width=\"1000\" height=\"500\"></canvas>
<script>
const labels = {json.dumps(labels)};
const data = {json.dumps(counts)};
const ctx = document.getElementById('chart').getContext('2d');
const chart = new Chart(ctx, {{
type: 'bar',
data: {{ labels, datasets: [{{ label: 'Count', data, backgroundColor: 'rgba(54, 162, 235, 0.5)' }}] }},
options: {{
responsive: false,
scales: {{
x: {{ title: {{ display: true, text: 'Gas range' }}, ticks: {{ maxRotation: 45, minRotation: 45, autoSkip: true, maxTicksLimit: 20 }} }},
y: {{ title: {{ display: true, text: 'Count' }}, beginAtZero: true }}
}}
}}
}});
document.getElementById('save').addEventListener('click', () => {{
const a = document.createElement('a');
a.href = chart.toBase64Image('image/png', 1.0);
a.download = 'fcl_gas_hist.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}});
</script>
</body>
</html>
"""

out_path = args.out
with open(out_path, "w") as f:
f.write(html)
print("Saved", out_path)


if __name__ == "__main__":
main()
65 changes: 65 additions & 0 deletions scripts/profile_fcl_gas.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Profile FCL gas for WebAuthn verification across generated vectors and
# emit a CSV with per-index gas plus a summary of the max-gas case.
#
# Inputs:
# - test/fixtures/fcl_vectors.json (produced by generate_p256_vectors.py)
# - Optional env: START, LIMIT to bound the index range
# Behavior:
# - Forces FCL path in tests; parses forge output; records only valid cases
# - Uses --via-ir to avoid "stack too deep" during compilation
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)"
REPO_ROOT="${SCRIPT_DIR}/.."
cd "${REPO_ROOT}"

# Resolve a usable forge binary (PATH first, then default Foundry install).
FORGE_BIN="$(command -v forge || true)"
if [ -z "$FORGE_BIN" ] && [ -x "$HOME/.foundry/bin/forge" ]; then
FORGE_BIN="$HOME/.foundry/bin/forge"
fi
if [ -z "$FORGE_BIN" ]; then
echo "forge not found" >&2; exit 1
fi

IN_JSON="test/fixtures/fcl_vectors.json"
OUT_CSV="test/fixtures/fcl_gas_profile.csv"

# Determine the sweep range from the input JSON and optional bounds.
COUNT=$(jq -r .count "$IN_JSON")
START="${START:-0}"
LIMIT="${LIMIT:-$COUNT}"
END=$(( START + LIMIT ))
if [ $END -gt $COUNT ]; then END=$COUNT; fi

echo "index,gas,msgHash,r,s,x,y" > "$OUT_CSV"
# Track the highest measured gas and its corresponding CSV line for reporting.
MAX_GAS=0; MAX_IDX=-1; MAX_LINE=

for ((i=START; i<END; i++)); do
# Run the single-index profiler test. It logs structured lines we parse below.
OUT=$(NO_COLOR=1 INDEX=$i "$FORGE_BIN" test --match-test test_profileIndex --via-ir -vvv 2>&1 || true)

# Extract the validity flag and gas/fields from the test output.
OK=$(echo "$OUT" | awk '/^ OK:/{getline;print $1}')
GAS=$(echo "$OUT" | awk '/^ GAS:/{getline;print $1}')
MH=$(echo "$OUT" | awk '/^ MSGHASH:/{getline;print $1}')
R=$(echo "$OUT" | awk '/^ R:/{getline;print $1}')
S=$(echo "$OUT" | awk '/^ S:/{getline;print $1}')
X=$(echo "$OUT" | awk '/^ X:/{getline;print $1}')
Y=$(echo "$OUT" | awk '/^ Y:/{getline;print $1}')

# Only record valid cases (OK=1) with a parsed gas value.
if [ "${OK:-0}" = "1" ] && [ -n "${GAS:-}" ]; then
echo "$i,$GAS,$MH,$R,$S,$X,$Y" >> "$OUT_CSV"
if [ "$GAS" -gt "$MAX_GAS" ]; then MAX_GAS=$GAS; MAX_IDX=$i; MAX_LINE="$i,$GAS,$MH,$R,$S,$X,$Y"; fi
fi

# Progress heartbeat every 50 iterations.
if (( (i-START+1) % 50 == 0 )); then echo "Processed $((i-START+1))/$((END-START))"; fi
done

echo "CSV written: $OUT_CSV"
echo "Max gas: $MAX_GAS at index $MAX_IDX"
echo "Max line: $MAX_LINE"
Loading
Loading