Skip to content

fix(vault): compute share<>asset conversions in a 256-bit intermediate - #38

Merged
alejoamiras merged 11 commits into
mainfrom
stack/closeout-vault-overflow
Oct 13, 2026
Merged

alejoamiras merged 11 commits into
mainfrom
stack/closeout-vault-overflow

Conversation

@alejoamiras

@alejoamiras alejoamiras commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes F-004 from the audit. The vault's share<>asset conversions (a * b / denominator) overflowed u128 on large but valid inputs. Noir range-checks, so it reverts instead of wrapping - a vault that gets there can't deposit or withdraw anymore and everyone's funds are stuck.

  • New conversion.nr with a mul_div that widens to noir-bignum's U256 (same lib escrow already uses), divides there and narrows back, asserting the result fits. (2^128-1)^2 fits in 256 bits, so the product is always exact.
  • Rounding is unchanged: same floor(p/d) + (round_up && p%d != 0), so every input the old code accepted gives the same result. The 188 existing vault tests still pass, plus 9 new ones: products that used to overflow, max operands, rounding both ways, zero denominator, the exact-2^128 narrowing bound, and a 2^100-scale deposit and redeem through the contract, which runs mul_div in public as the vault does. That last one fails on the old math.
  • A zero denominator now reverts explicitly - bignum's unconstrained path would otherwise return garbage. Unreachable from the vault, but mul_div is safe on its own.
  • Not fixed here: the conversions still add vault_offset to the share supply (and 1 to total assets) in u128 before mul_div. A deposit can leave the supply above 2^128 - 1 - vault_offset, after which every conversion reverts, withdrawals included. At offset 1 that takes the asset's whole possible supply; at offset ≥ 2^127, one unit. Widening those additions overflows the 256-bit intermediate, so the vault README keeps it as a second known issue with the exact bounds, next to the still-open ARC-403 hook reentrancy (F-001/F-002).

The bigger Vault pushes its class publish to 63,776 DA gas, over the local network's per-tx cap (55,836 with the default 3s blocks; mainnet's 6s blocks admit ~117k). So CI now runs on mainnet geometry:

  • JS tests set SEQ_BLOCK_DURATION_MS=6000. The job is inlined because run-tests.yml has no knob for it, so the check is now JS Tests instead of checks / JS Tests.
  • Benchmarks pin aztec-benchmark v6.0.0-rc.1 (feat!: Aztec v6 (@aztec-labs), block-duration-ms input, SHA-pinned actions aztec-benchmark#3, which adds block-duration-ms), and the devDep moves to 6.0.0-rc.1 - same commit, @aztec-labs peers.
  • The bump-aztec-version skill now notes the benchmark peers, pins and DA cap, and points at yarn typecheck.

Rebased on v6 main. Also carries #47 (drop secrets: inherit) and #48 (typecheck), which were stacked on this branch.

🤖 Generated with Claude Code

@alejoamiras
alejoamiras force-pushed the stack/closeout-vault-overflow branch from 09aac15 to 5f6f39f Compare August 19, 2026 16:16
@alejoamiras
alejoamiras force-pushed the stack/closeout-nft-notehash branch from d17b64d to 252c352 Compare August 19, 2026 16:16
@alejoamiras
alejoamiras force-pushed the stack/closeout-vault-overflow branch from 5f6f39f to b22f58e Compare August 19, 2026 19:07
@alejoamiras alejoamiras changed the title fix(vault): compute share<>asset conversions in a 512-bit intermediate fix(vault): compute share<>asset conversions in a 256-bit intermediate Aug 19, 2026
Comment thread .github/workflows/main-tests.yml Fixed
@alejoamiras
alejoamiras force-pushed the stack/closeout-nft-notehash branch from 252c352 to c6a4513 Compare September 28, 2026 14:00
@alejoamiras
alejoamiras force-pushed the stack/closeout-vault-overflow branch from b22f58e to b96059e Compare September 28, 2026 14:00
@alejoamiras
alejoamiras removed this pull request from stack #39 September 28, 2026 14:01
@alejoamiras
alejoamiras force-pushed the stack/closeout-vault-overflow branch from b96059e to 3433a70 Compare September 29, 2026 15:19
@alejoamiras
alejoamiras changed the base branch from stack/closeout-nft-notehash to main September 29, 2026 15:19
alejoamiras and others added 2 commits September 30, 2026 13:04
The conversions compute `a * b / denominator`. Done natively in u128 the
intermediate product overflows for large-but-legitimate inputs; Noir
range-checks u128, so the transaction reverts rather than wrapping. That
is an availability bug (audit F-004): a vault whose totals reach the
range can no longer be deposited to or withdrawn from, permanently
locking every participant's funds. Both sites carried TODOs.

New `conversion.nr` module with a `mul_div` primitive that widens both
operands to noir-bignum's U256 (the same library the escrow's key
derivation already uses), multiplies and divides there, then narrows the
quotient back to u128, asserting it fits. U256 arithmetic is modulo
2^256 and the largest possible product, (2^128-1)^2, is 2^129-1 short of
that modulus, so the product is always exact — no modular wraparound.

Rounding is unchanged: both old and new return
floor(p/d) + (round_up && p%d != 0). For every input the old code
accepted the results are identical; the widening only extends the domain
that succeeds. This matters because the vault's economic safety depends
on rounding always favouring the vault — a shift in either direction
would leak value between the vault and its depositors.

`mul_div` also rejects a zero denominator explicitly: noir-bignum's
constrained udiv_mod fails on it, but its unconstrained path assumes
non-zero and would return a meaningless witness. Unreachable from the
vault (denominators are total_assets+1 and total_supply+vault_offset
with vault_offset >= 1) but the helper is now safe in isolation.

Extracting mul_div into its own module is what makes the overflow
boundary testable at all: a real vault cannot be driven to a 2^128
supply in a test, but the primitive can be called directly at its edges.

Also adds `ensureVaultContractClassPublished` to the JS test utils.
Publishing just the class is what the vault tests actually need and is
substantially cheaper in DA gas than deploying a throwaway Vault to get
the class published as a side effect. It is idempotent — publication
emits a nullifier keyed on the class id, so a second publish of the same
class is rejected with "Existing nullifier"; the helper checks
registration state first, the same way DeployMethod does.

Validated: vault_contract 195 Noir tests (188 pre-existing all still
green — the strongest evidence rounding did not shift — plus 7 new
covering limb round-trips, rounding direction both ways, products that
previously overflowed, max operands, and the two revert guards).
aztec compile OK. Codex adversarial review: correct, rounding invariance
proven algebraically, no value-leak path; its zero-denominator hardening
is applied.

Note: the vault README still describes the overflow as a known issue.
That warning block is rewritten in PR #24 (unmerged); leaving it there
avoids a three-way conflict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`aztec start --local-network` defaults to 3s blocks. With 72s slots that
packs 21 blocks into a checkpoint, and the per-tx DA admission limit is
`ceil(daBudget / blocks * 1.5)` = 55,882 DA gas. Mainnet runs 6s blocks
(10 blocks per checkpoint) and admits 117,668.

The protocol picks that 1.5 multiplier deliberately, and says so in
aztec stdlib `gas/tx_gas_limits.ts`: it is set "so the largest tx we want
to support — a maximal contract class registration (~97k DA gas) — fits
a single block under v5 mainnet geometry (72s slots, 6s blocks -> 10
blocks per checkpoint)". The default local geometry therefore advertises
a limit well below the ~97k the protocol guarantees for exactly this
kind of transaction, and rejects contracts that are valid on the network
we ship to.

Publishing the Vault contract class costs ~64k DA gas. That is fine on
mainnet and inside the protocol's stated envelope, but over the local
55,882 cap. Note this is not specific to any one change: main's Vault
already sits at ~54k, i.e. 97% of the local ceiling, so essentially any
growth in that contract trips it.

Reusable workflows do not inherit the caller's `env`, and
aztec-ci-actions' run-tests.yml exposes no knob for this, so the JS job
is inlined here (`run-js-tests: false` on the reusable call) purely to
own the environment. It still calls the same pinned `setup-aztec` and
`js-tests` composite actions, so behaviour is otherwise unchanged.

NOTE FOR REVIEWERS: this renames the check from "checks / JS Tests" to
"JS Tests". Any branch protection rule naming the old check needs
updating, or it will block merges waiting on a check that no longer runs.

The better long-term fix is upstream — either the local network should
default to mainnet geometry, or run-tests.yml should expose the knob.
Worth raising with the Aztec CI folks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Vault benchmark's setup publishes the Vault contract class (63,776 DA
gas). The local network's default 3s blocks cap a tx at 55,836, and
aztec-benchmark's reusable workflows had no way to change that.

Pin both workflows to AztecProtocol/aztec-benchmark#3, which adds a
block-duration-ms input, and pass 6000 (mainnet's 6s blocks: 117,624).
This is a pre-release commit; swap it for the v6.0.0-rc.1 tag commit
before merging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

This comment has been minimized.

879fa93 is the squash commit of aztec-benchmark#3 that v6.0.0-rc.1 tags.
Its pr-benchmark.yml and update-baseline.yml are byte-identical to the
d623993 head they were verified at; only publish.yml differs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

This comment has been minimized.

alejoamiras and others added 2 commits October 1, 2026 17:56
5.0.1 declared @aztec/aztec.js and @aztec/wallets peers (<6) and typed
its Benchmark base class against them, so against this repo's
@aztec-labs/* v6 packages the peers were unmet and the benchmark files
carried 33 type errors (unseen: tsx does not type-check). 6.0.0-rc.1 peers
@aztec-labs/* >=6.0.0-0 <7 and the benchmark files type-check clean.

Its gitHead and SLSA provenance name 879fa93, the commit both benchmark
workflows are pinned to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The benchmark gotcha described 5.0.1's v5 peers as current. 6.0.0-rc.1
is the first @aztec-labs release. A peer mismatch fails nothing (yarn
warns, tsconfig skips benchmarks/, tsx doesn't type-check), so document
the explicit typecheck. Record that the devDependency and both workflow
pins move together, and the local-network DA cap that the Vault class
publication hits without 6s blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

This comment has been minimized.

alejoamiras and others added 3 commits October 13, 2026 11:18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
None of the called workflows (aztec-ci-actions run-tests.yml, aztec-benchmark
pr-benchmark.yml and update-baseline.yml, at both the v5.0.1 and v6.0.0-rc.1
pins) declare `on.workflow_call.secrets` or read `secrets.*`, and the repo has
no repo, org or environment secrets. `inherit` passes nothing today, but it
would hand any secret added later to workflows that live in other repos, so a
future pin bump that starts reading one gets it without us ever opting in.

A called workflow always gets `github.token` / `secrets.GITHUB_TOKEN`, scoped by
the caller job's `permissions`, so the benchmark PR comment keeps working.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Vitest and tsx (which runs the benchmarks) only strip types, so a type error
in either never failed CI. Add `yarn typecheck` (`tsc --noEmit`) and run it in
both js-tests jobs, after the setup step's compile + codegen write src/artifacts.

tsconfig changes so the project type-checks clean:
- `target/**/*` matched no JSON: an include glob only picks up .json files
  when the pattern itself ends in `.json`. Every generated src/artifacts/*.ts
  imports its `target/*.json`, which `composite` then rejects with TS6307.
  Now `target/**/*.json`.
- include `benchmarks`, which nothing type-checked before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

This comment has been minimized.

alejoamiras and others added 2 commits October 13, 2026 12:11
…the contract path

The conversions still add `vault_offset` to the share supply and 1 to total
assets in u128 before `mul_div`. A deposit can leave the supply above
2^128 - 1 - vault_offset, after which every conversion reverts, withdrawals
included; with a large offset that takes few assets. The READMEs had dropped
the overflow warning entirely: restore it as a precise second known issue, and
correct comments that called the remaining overflow impossible.

`conversion.nr`'s tests run constrained; the vault runs it in public. Add a
2^100-scale deposit and redeem through the contract (fails on the old u128 math)
and a case that only the `hi < 256` narrowing bound rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It still described a temporary tsconfig and said tsconfig.json skips
benchmarks/, both stale since typecheck joined CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Benchmark Comparison

CPU Cores RAM Arch
AMD EPYC 9V74 80-Core Processor 4 16 GiB x64

Contract: escrow

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
⚪ (partial) withdraw 354,645 354,645 3,744 3,744 587,800 587,800 8,476 7,132 -1,344 (-15.9%)
⚪ withdraw 270,326 270,326 832 832 499,700 499,700 7,083 5,933 -1,150 (-16.2%)
⚪ withdraw_nft 266,680 266,680 1,440 1,440 527,400 527,400 7,354 6,153 -1,201 (-16.3%)

Contract: logic

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
⚪ get_escrow 298,866 298,866 192 192 456,000 456,000 7,799 6,568 -1,231 (-15.8%)
⚪ secret_key_to_public_keys 296,331 296,331 192 192 456,000 456,000 7,756 6,537 -1,219 (-15.7%)
⚪ share_escrow 245,424 245,424 1,952 1,952 520,700 520,700 6,858 5,760 -1,098 (-16.0%)

Contract: multitoken

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
⚪ burn_private 222,468 222,468 832 832 499,700 499,700 6,461 5,405 -1,056 (-16.3%)
⚪ burn_public 185,539 185,539 416 416 638,286 638,286 5,977 5,061 -916 (-15.3%)
⚪ initialize_transfer_commitment 188,484 188,484 768 768 474,500 474,500 6,006 5,106 -900 (-15.0%)
⚪ mint_to_private 239,217 239,217 1,408 1,408 511,400 511,400 6,808 5,709 -1,099 (-16.1%)
⚪ mint_to_public 185,539 185,539 416 416 637,794 637,794 6,085 5,079 -1,006 (-16.5%)
⚪ transfer_private_to_commitment 225,577 225,577 1,024 1,024 511,400 511,400 6,430 5,460 -970 (-15.1%)
⚪ transfer_private_to_private 278,917 278,917 2,048 2,048 555,100 555,100 7,212 6,042 -1,170 (-16.2%)
⚪ transfer_private_to_public 254,364 254,364 1,056 1,056 714,671 714,671 6,981 5,835 -1,146 (-16.4%)
⚪ transfer_public_to_commitment 185,539 185,539 640 640 663,507 663,507 6,052 5,096 -956 (-15.8%)
⚪ transfer_public_to_private 246,797 246,797 1,024 1,024 683,952 683,952 6,875 5,837 -1,038 (-15.1%)
⚪ transfer_public_to_public 185,539 185,539 480 480 673,499 673,499 5,996 5,077 -919 (-15.3%)

Contract: nft

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
⚪ burn_private 223,523 223,523 416 416 661,046 661,046 6,617 5,554 -1,063 (-16.1%)
⚪ burn_public 185,539 185,539 448 448 670,028 670,028 6,009 5,042 -967 (-16.1%)
⚪ mint_to_private 268,061 268,061 1,600 1,600 735,336 735,336 7,266 6,139 -1,127 (-15.5%)
⚪ mint_to_public 185,539 185,539 448 448 670,712 670,712 6,025 5,048 -977 (-16.2%)
⚪ transfer_private_to_private 211,981 211,981 832 832 499,700 499,700 6,287 5,281 -1,006 (-16.0%)
⚪ transfer_private_to_public 223,550 223,550 416 416 659,252 659,252 6,617 5,571 -1,046 (-15.8%)
⚪ transfer_public_to_private 240,913 240,913 992 992 683,262 683,262 6,812 5,725 -1,087 (-16.0%)
⚪ transfer_public_to_public 185,539 185,539 384 384 633,279 633,279 6,000 5,046 -954 (-15.9%)

Contract: token

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
⚪ burn_private 250,779 250,779 1,024 1,024 713,951 713,951 6,944 5,867 -1,077 (-15.5%)
⚪ burn_public 185,539 185,539 448 448 672,626 672,626 6,024 5,052 -972 (-16.1%)
⚪ initialize_transfer_commitment 188,484 188,484 768 768 474,500 474,500 6,006 5,053 -953 (-15.9%)
⚪ mint_to_private 281,953 281,953 2,144 2,144 738,064 738,064 7,446 6,234 -1,212 (-16.3%)
⚪ mint_to_public 185,539 185,539 448 448 672,221 672,221 6,042 5,080 -962 (-15.9%)
⚪ transfer_private_to_commitment 222,045 222,045 992 992 511,400 511,400 6,471 5,404 -1,067 (-16.5%)
⚪ transfer_private_to_private 286,325 286,325 2,592 2,592 557,600 557,600 7,279 6,126 -1,153 (-15.8%)
⚪ transfer_private_to_public 250,831 250,831 1,024 1,024 714,017 714,017 6,981 5,867 -1,114 (-16.0%)
⚪ transfer_private_to_public_with_commitment 254,753 254,753 1,600 1,600 747,317 747,317 7,011 5,859 -1,152 (-16.4%)
⚪ transfer_public_to_commitment 185,539 185,539 576 576 662,520 662,520 6,020 5,067 -953 (-15.8%)
⚪ transfer_public_to_private 244,762 244,762 992 992 683,298 683,298 6,863 5,773 -1,090 (-15.9%)
⚪ transfer_public_to_public 185,539 185,539 448 448 672,542 672,542 6,019 5,082 -937 (-15.6%)

Contract: vault

🚦 Function Gates DA Gas L2 Gas Proving Time (ms)
Base PR Diff Base PR Diff Base PR Diff Base PR Diff
🔴 deposit_private_to_private 362,767 362,767 1,312 1,312 878,227 1,080,775 +202,548 (+23.1%) 8,647 7,344 -1,303 (-15.1%)
🔴 deposit_private_to_private_exact 471,194 471,194 1,888 1,888 915,295 1,117,843 +202,548 (+22.1%) 10,811 9,188 -1,623 (-15.0%)
🔴 deposit_private_to_public 308,983 308,983 768 768 862,252 1,064,800 +202,548 (+23.5%) 7,847 6,619 -1,228 (-15.6%)
🔴 deposit_public_to_private 298,960 298,960 1,984 1,984 965,024 1,167,572 +202,548 (+21.0%) 7,747 6,504 -1,243 (-16.0%)
🔴 deposit_public_to_private_exact 301,691 301,691 1,952 1,952 949,448 1,151,996 +202,548 (+21.3%) 7,764 6,501 -1,263 (-16.3%)
🔴 deposit_public_to_public 185,539 185,539 832 832 897,542 1,100,090 +202,548 (+22.6%) 5,998 5,046 -952 (-15.9%)
🔴 issue_private_to_private_exact 471,194 471,194 1,888 1,888 915,955 1,118,119 +202,164 (+22.1%) 10,368 8,715 -1,653 (-15.9%)
🔴 issue_private_to_public_exact 339,595 339,595 1,344 1,344 899,977 1,102,141 +202,164 (+22.5%) 8,393 7,073 -1,320 (-15.7%)
🔴 issue_public_to_private 275,652 275,652 1,376 1,376 921,640 1,123,804 +202,164 (+21.9%) 7,434 6,203 -1,231 (-16.6%)
🔴 issue_public_to_public 185,539 185,539 832 832 898,232 1,100,396 +202,164 (+22.5%) 6,038 5,088 -950 (-15.7%)
🔴 redeem_private_to_private_exact 474,161 474,161 1,888 1,888 915,802 1,118,350 +202,548 (+22.1%) 10,403 8,768 -1,635 (-15.7%)
🔴 redeem_private_to_public 308,930 308,930 768 768 862,696 1,065,244 +202,548 (+23.5%) 7,862 6,571 -1,291 (-16.4%)
🔴 redeem_public_to_private_exact 304,711 304,711 1,952 1,952 949,769 1,152,317 +202,548 (+21.3%) 7,795 6,524 -1,271 (-16.3%)
🔴 redeem_public_to_public 185,539 185,539 832 832 898,064 1,100,612 +202,548 (+22.6%) 6,015 5,026 -989 (-16.4%)
🔴 withdraw_private_to_private 365,734 365,734 1,312 1,312 878,437 1,080,601 +202,164 (+23.0%) 8,712 7,330 -1,382 (-15.9%)
🔴 withdraw_private_to_private_exact 474,161 474,161 1,888 1,888 915,748 1,117,912 +202,164 (+22.1%) 10,413 8,892 -1,521 (-14.6%)
🔴 withdraw_private_to_public_exact 339,542 339,542 1,344 1,344 900,211 1,102,375 +202,164 (+22.5%) 8,416 7,093 -1,323 (-15.7%)
🔴 withdraw_public_to_private 314,923 314,923 2,528 2,528 967,335 1,169,499 +202,164 (+20.9%) 7,952 6,665 -1,287 (-16.2%)
🔴 withdraw_public_to_public 185,539 185,539 832 832 898,367 1,100,531 +202,164 (+22.5%) 6,008 5,032 -976 (-16.2%)

@alejoamiras
alejoamiras merged commit 45f9057 into main Oct 13, 2026
9 checks passed
@alejoamiras
alejoamiras deleted the stack/closeout-vault-overflow branch October 13, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants