Skip to content

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

Draft
alejoamiras wants to merge 2 commits into
stack/closeout-nft-notehashfrom
stack/closeout-vault-overflow
Draft

fix(vault): compute share<>asset conversions in a 256-bit intermediate#38
alejoamiras wants to merge 2 commits into
stack/closeout-nft-notehashfrom
stack/closeout-vault-overflow

Conversation

@alejoamiras

@alejoamiras alejoamiras commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Closes the last open item from the security audit: F-004, the share/asset conversion overflow.

The bug

_convert_to_shares / _convert_to_assets 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 — a vault whose totals reach that range can no longer be deposited to or withdrawn from, permanently locking every participant's funds. Both sites carried TODOs.

The fix

A 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, with 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; 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, but the helper is now safe in isolation.

Pulling 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.

Validation

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

Second commit: CI block geometry

While validating this, the JS suite failed to publish the Vault contract class. That turned out not to be a problem with this change:

aztec start --local-network defaults to 3s blocks. With 72s slots that is 21 blocks per 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 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 class costs ~64k DA gas: fine on mainnet, inside the protocol's stated envelope, over the local cap. This is not specific to this PR — main's Vault already sits at ~54k, i.e. 97% of the local ceiling, so essentially any growth in that contract trips it.

So the JS job now pins SEQ_BLOCK_DURATION_MS=6000, making CI reproduce mainnet geometry rather than a stricter one. Reusable workflows do not inherit the caller's env and run-tests.yml exposes no knob for it, so the JS job is inlined (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.

Important

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.

Note

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

alejoamiras and others added 2 commits August 19, 2026 19:06
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>
@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 on lines +25 to +45
name: JS Tests
runs-on: ubuntu-latest
timeout-minutes: 60
env:
# 6s blocks = mainnet geometry (10 blocks/checkpoint, 117,668 DA gas per tx). The local
# default of 3s blocks caps a tx at 55,882, below the ~97k the protocol guarantees for a
# maximal contract class registration, which the Vault class publication needs.
SEQ_BLOCK_DURATION_MS: '6000'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0

- name: Setup Aztec environment
uses: AztecProtocol/aztec-ci-actions/actions/setup-aztec@431859e477234b8690eb1f80d1305d2e34f10f1b # v0.1.1
with:
start-pxe: 'true'
run-codegen: 'true'

- name: Run JS tests
uses: AztecProtocol/aztec-ci-actions/actions/js-tests@431859e477234b8690eb1f80d1305d2e34f10f1b # v0.1.1
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