Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
BulkRegistration Gas Optimizations
Summary
Reduced per-name gas in
BulkRegistration.multiRegisterby ~17.1k gas (~7.4%) across all batch sizes. Measured on mainnet fork attest/BulkRegistration.gas.t.solvia thevm.startSnapshotGas/vm.stopSnapshotGascheatcode pair, with results written tosnapshots/multiRegister.jsonfor branch-to-branch diffing.At a 36M block gas limit this raises the practical batch ceiling from ~149 to ~161 names (100% block fill) and from ~119 to ~128 at 80% fill.
Optimizations
1. Pre-computed prices passed in as a parameter (biggest win)
Change:
multiRegisternow takesuint256[] calldata pricesalongsidedurations. Callers fetch prices via the existing viewrentPrices()before submitting.Why it saves gas: Previously
multiRegistercalled_rentPrice(name, duration)per name to determine how much ETH to forward to the controller. The controller then re-queried its own price oracle insideregister()to validatemsg.value. That's two price-oracle round-trips per name for work whose result the controller discards. By accepting the price as an input, we eliminate the wrapper's query entirely; the controller still validates internally, and if the caller underpays the controller reverts (safe), while overpayment is refunded back (see #4 below).Tradeoff — breaking API change: The
pricesparameter is new. Callers/SDKs must pre-fetch prices. Price fetching is cheap off-chain (eth_call) and was already available viarentPrices(), so the integration cost is low.2. Cache
keccak256(bytes(label))once per iterationChange: The label hash is computed once at the top of each loop iteration and threaded into both
_enrichData(where it forms the namehash forsetAddr) and theNameRegisteredevent.Why it saves gas: Previously the label was hashed three times per name: once inside
_enrichDatato build the namehash, once in the event emit, and once again inside the controller'sregister(). We can't skip the controller's hash, but we can collapse our two into one.Applied identically in
makeCommitmentsso commitment hashes remain bit-for-bit identical to those produced at registration time. (The controller computescommitment = keccak256(abi.encode(registration))— any drift between commit-time and register-time inputs would triggerCommitmentNotFound.)3. Fast path in
_enrichDatafor empty caller dataChange: When
originalData.length == 0(the common case — a caller that just wantssetAddrprepended),_enrichDataallocates a length-1 array with thesetAddrentry and returns immediately, skipping the copy loop.Why it saves gas: The original code always ran a copy loop
for (i = 0; i < originalData.length; i++) enriched[i + 1] = originalData[i]even when there was nothing to copy. Skipping the loop saves ~200–500 gas per name in the empty-data case.4. Added
receive() external payableChange: Added an empty
receive()toBulkRegistration.Why it's necessary (correctness, not just gas): The underlying controller refunds over-payment mid-loop via
payable(msg.sender).transfer(msg.value - totalPrice). If a caller-suppliedprices[i]ever exceeds the controller's true rent (e.g. a stale oracle quote from a prioreth_call), that refund lands back onBulkRegistration. Withoutreceive()the refund reverts, and the whole batch with it.The contract sweeps its entire balance back to
msg.senderat the end ofmultiRegister, so both the caller's overpayment and any controller mid-loop refund are forwarded together in one call.5. Extracted
_registerOneinternal helperChange: The per-name work (keccak, struct construction,
registercall, event emit) lives in an internal helper called once per iteration.Why: Initially a compile-time fix —
multiRegisterhas 8 parameters, and adding two more loop locals (name,labelHash,cost) pushed the Yul IR pipeline into "stack too deep" territory. Extracting to a helper gives each iteration a fresh stack frame and makes the caller loop trivially readable as a side effect.Measured and rejected
Two candidate optimizations were tried and reverted based on measurement.
Manual
unchecked { ++i; }+.lengthcachingSwapping the idiomatic
for (uint256 i = 0; i < names.length; i++)for this form measured ~176 gas per name worse on the 100-name batch undersolc 0.8.28withvia_ir = true. The Yul optimizer already proves the loop counter cannot overflow and elides the checks; the "manual" form confuses it. Reverted — the idiomatic form is faster here.Refund via tracked
totalSpentinstead ofaddress(this).balanceThis was rejected for correctness, not gas. If the controller refunds mid-loop (see #4) because a
prices[i]exceeded the true rent, that refund lands on the contract after we've already trackedtotalSpentbased on what we sent, not what the controller kept. TrackingtotalSpentwould strand the controller's refund in the contract. Usingaddress(this).balanceis the correct final-state accounting. The gas cost of theSELFBALANCEopcode is ~5 gas — not worth trading correctness for.How to measure
The gas benchmark lives at
test/BulkRegistration.gas.t.sol. It runs against a mainnet fork (requiresMAINNET_RPC_URL) and capturesmultiRegistergas via two mechanisms in parallel:gasleft()bracket — feeds theconsole.logsummary for immediate feedback. This excludes cheatcode overhead and is the "truer" execution cost.vm.startSnapshotGas/vm.stopSnapshotGas— writessnapshots/multiRegister.jsonfor cross-branch diffing. Slightly higher than thegasleft()number because the cheatcode itself contributes to the measurement, but consistent across branches so deltas are clean.Run:
Files changed
src/BulkRegistration.sol— all five contract-level changes.test/BulkRegistration.t.sol— updated call sites to supply the newpricesargument; addedtest_multiRegister_overStatedPrice_refundsFullycovering the stale-oracle refund path.test/BulkRegistration.gas.t.sol— cheatcode-based gas capture intosnapshots/multiRegister.json.snapshots/multiRegister.json— current per-batchmultiRegistergas, tracked in git for branch comparison.