Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
High Risk
1 findings
1 fixed
0 acknowledged
Low Risk
11 findings
10 fixed
1 acknowledged
Informational
17 findings
10 fixed
7 acknowledged
High Risk1 finding
Oversized crypto precompile inputs abort EVM
Severity
- Severity: High
Submitted by
slowfi
Description
The Base size guards for BN254 pairing and BLS12 381 precompiles return
PrecompileError::Fatalwhen calldata is one byte above the configured limit. Revm defines fatal precompile errors as unrecoverable provider errors, while invalid input length is supposed to be reported as a normal precompile halt.A user can submit a transaction that calls one of these precompile addresses with oversized calldata. Instead of producing a deterministic failed call result that spends gas, the precompile provider returns an EVM custom error before a call outcome is created. In execution pipelines that propagate fatal precompile errors as provider failures, an ordinary transaction input can fail block execution instead of behaving like a normal failed call.
Proof Of Concept
use base_common_precompiles::{ GRANITE_MAX_INPUT_SIZE, ISTHMUS_G1_MSM_MAX_INPUT_SIZE, ISTHMUS_G2_MSM_MAX_INPUT_SIZE, ISTHMUS_PAIRING_MAX_INPUT_SIZE, JOVIAN_G1_MSM_MAX_INPUT_SIZE, JOVIAN_G2_MSM_MAX_INPUT_SIZE, JOVIAN_MAX_INPUT_SIZE, JOVIAN_PAIRING_MAX_INPUT_SIZE, run_isthmus_g1_msm, run_pair_granite,}; const BN254_PAIR_ELEMENT_LEN: usize = 192; // G1(64) + G2(128) // PROOF 1: every Base cap matches op-geth params/protocol_params.go exactly, and the BLS caps// are whole-element multiples (160/288/384). Verified vs op-geth (optimism branch).#[test]fn poc_crypto_constants_match_opgeth() { assert_eq!(GRANITE_MAX_INPUT_SIZE, 112687); // Bn256PairingMaxInputSizeGranite assert_eq!(JOVIAN_MAX_INPUT_SIZE, 81984); // Bn256PairingMaxInputSizeJovian assert_eq!(ISTHMUS_G1_MSM_MAX_INPUT_SIZE, 513760); // Bls12381G1MulMaxInputSizeIsthmus assert_eq!(JOVIAN_G1_MSM_MAX_INPUT_SIZE, 288960); // Bls12381G1MulMaxInputSizeJovian assert_eq!(ISTHMUS_G2_MSM_MAX_INPUT_SIZE, 488448); // Bls12381G2MulMaxInputSizeIsthmus assert_eq!(JOVIAN_G2_MSM_MAX_INPUT_SIZE, 278784); // Bls12381G2MulMaxInputSizeJovian assert_eq!(ISTHMUS_PAIRING_MAX_INPUT_SIZE, 235008); // Bls12381PairingMaxInputSizeIsthmus assert_eq!(JOVIAN_PAIRING_MAX_INPUT_SIZE, 156672); // Bls12381PairingMaxInputSizeJovian // whole-element multiples assert_eq!(ISTHMUS_G1_MSM_MAX_INPUT_SIZE % 160, 0); assert_eq!(JOVIAN_G1_MSM_MAX_INPUT_SIZE % 160, 0); assert_eq!(ISTHMUS_G2_MSM_MAX_INPUT_SIZE % 288, 0); assert_eq!(ISTHMUS_PAIRING_MAX_INPUT_SIZE % 384, 0); assert_eq!(JOVIAN_MAX_INPUT_SIZE % BN254_PAIR_ELEMENT_LEN, 0); // 427 pairs println!("all 8 caps match op-geth; off-by-one boundary is `>` (== cap allowed), matching op-geth `len(input) > max`");} // PROOF 2 (the finding): oversize input takes the FATAL path (aborts the whole tx) whereas// op-geth returns a standard precompile error (`return nil, errBadPairingInputSize`) that only// fails the CALL (gas consumed, tx still valid). Demonstrated by contrast: 17 bytes flip a// graceful Ok(halt) into an Err(fatal).#[test]fn poc_bn254_oversize_is_fatal_vs_graceful_halt_below_cap() { // 587 pairs = 112704 bytes > 112687 cap, and a *valid* pair length (multiple of 192). let over = vec![0u8; 587 * BN254_PAIR_ELEMENT_LEN]; let r_over = run_pair_granite(&over, u64::MAX, 0); assert!( matches!(&r_over, Err(e) if e.is_fatal()), "EXPECTED divergence: oversize -> Err(PrecompileError::Fatal) which revm propagates as \ EVMError::Custom (aborts the whole transaction). Got: {r_over:?}" ); // 112687 bytes == cap (passes the length guard), but not a multiple of 192, so revm's // run_pair returns a GRACEFUL halt wrapped in Ok -> the call fails, the tx still commits. let at = vec![0u8; GRANITE_MAX_INPUT_SIZE]; let r_at = run_pair_granite(&at, u64::MAX, 0); assert!( matches!(&r_at, Ok(o) if o.is_halt()), "at-cap malformed input should fail GRACEFULLY (Ok halt, tx continues). Got: {r_at:?}" ); println!( "bn254 Granite: {} bytes -> Ok(halt) [graceful call-failure, op-geth-equivalent]; \ {} bytes -> Err(fatal) [aborts whole tx, DIVERGES from op-geth]", GRANITE_MAX_INPUT_SIZE, 587 * BN254_PAIR_ELEMENT_LEN );} // PROOF 3: the same fatal-on-oversize mechanism applies to the BLS precompiles.#[test]fn poc_bls_g1msm_oversize_is_fatal() { let over = vec![0u8; ISTHMUS_G1_MSM_MAX_INPUT_SIZE + 1]; let r = run_isthmus_g1_msm(&over, u64::MAX, 0); assert!( matches!(&r, Err(e) if e.is_fatal()), "BLS G1MSM oversize -> Err(PrecompileError::Fatal) (aborts tx) where op-geth returns a \ standard precompile error that only fails the call. Got: {r:?}" ); println!("BLS G1MSM: {} bytes -> Err(fatal)", ISTHMUS_G1_MSM_MAX_INPUT_SIZE + 1);}all 8 caps match op-geth; boundary `>` matches op-geth `len(input) > max`bn254 Granite: 112687 bytes -> Ok(halt) [graceful call-failure, op-geth/op-revm-equivalent] 112704 bytes -> Err(fatal) [aborts whole tx, DIVERGES from op-geth/op-revm]BLS G1MSM: 513761 bytes -> Err(fatal)The bn254 case is the sharpest demonstration: a 112,687-byte input (≤ cap, not a multiple of 192) fails gracefully as
Ok(halt), while a 112,704-byte input — 17 bytes larger —becomes Err(fatal)and aborts the entire transaction. Both reference clients treat both inputs as ordinary call-failures.Recommendation
Return a normal precompile halt for input size violations, such as
PrecompileOutput::halt(PrecompileHalt::other_static(...), reservoir)or a dedicated input length halt, and reservePrecompileError::Fatalfor internal errors that cannot be caused by calldata.References
- op-revm (canonical upstream, Base's fork source):
ethereum-optimism/optimism— https://github.com/ethereum-optimism/optimism/blob/develop/rust/op-revm/src/precompiles.rs#L209-L211 - op-geth:
ethereum-optimism/op-geth— https://github.com/ethereum-optimism/op-geth/blob/optimism/core/vm/contracts.go#L940-L942 (bn256PairingGranite.Run,bls12381*Isthmus/Jovian.Run),core/vm/evm.go(precompile-error call-site),params/protocol_params.go. - revm:
revm-precompile 34.0.0/src/interface.rs:26,378-381,529-548,bn254.rs:191-192,bls12_381/{g1_msm,g2_msm,pairing}.rs;revm-handler 18.1.0/src/frame.rs:203;MIGRATION_GUIDE.md(PrecompileError restructure, PR #3496/#3502).
Low Risk11 findings
Successful precompile results drop gas refunds
Description
The shared
IntoPrecompileResultsuccess path buildsPrecompileOutputwithPrecompileOutput::newand never copies the refund counter intoout.gas_refunded. Dispatchers for activation, policy, factory, and B20 token calls use this helper after executing storage operations, so successful calls that clear storage can lose their EIP 3529 refund information.The activation registry shows the issue directly.
ActivationRegistry.dispatchcallsdeactivate(bytes32), which deletes the feature mapping slot when the feature is active. That delete reaches the EVM backedsstorepath, which records the storage refund throughrefund_gas(...). The refund remains visible throughStorageCtx::gas_refunded(), and the crate already hasStorageCtx::success_output(...)to copy it intoPrecompileOutput::gas_refunded. However, the dispatch path returns throughIntoPrecompileResult, whose success branch ignores the refund value.The policy registry has the same dispatcher level issue.
PolicyRegistryStorage::dispatchreturns successful calls throughIntoPrecompileResult, while policy admin and membership operations can delete storage slots.finalizeUpdateAdmin,renounceAdmin,updateAllowlist(false, accounts), andupdateBlocklist(false, accounts)can all reachdelete()paths that record storage refunds in the provider, but the returnedPrecompileOutputstill leavesgas_refundedat zero.Users are overcharged for successful refund eligible precompile operations, and native precompile gas accounting diverges from normal EVM
SSTOREaccounting. This does not change state correctness, but it drops refunds that the EVM frame handler expects to receive throughPrecompileOutput::gas_refunded.Recommendation
Change the conversion helper to accept the refund amount and populate
PrecompileOutput::gas_refunded, or replace successful dispatch conversions withStorageCtx::success_outputorStorageCtx::abi_success. Add regression tests for successful refund eligible storage clears through the activation registry and the other storage backed dispatchers.Known selector decode failures are misclassified
Description
The shared
decode_precompile_call!macro extracts the first four bytes, then callsSolInterface::abi_decode(calldata). If the selector belongs to the interface but the calldata body is malformed, the ABI decoder fails, but the macro maps every decode error toBasePrecompileError::UnknownFunctionSelector(selector).The error type already has a distinct
AbiDecodeFailed { selector, error }variant for the case where the selector is known but its arguments fail ABI decoding. Activation registry dispatch exposes the shared behavior throughinner, so malformed calls to known activation selectors are reported as unknown selectors instead of ABI decode failures.Malformed calldata with a valid activation registry selector is reported with misleading revert diagnostics. Clients, tests, and monitoring that distinguish unknown functions from malformed arguments cannot rely on the advertised
AbiDecodeFailedpath for activation registry calls, and the same shared macro behavior can affect the other precompile dispatchers that use it.Recommendation
Change
decode_precompile_call!to first determine whether the selector belongs to the target interface, then returnAbiDecodeFailed { selector, error }when the selector is known but full calldata decoding fails. KeepUnknownFunctionSelectorfor short calldata and selectors that are not part of the interface. Add ABI dispatch tests with a known selector and malformed body for the activation registry and one shared macro caller.Activation admin zero address allows zero sender deposits
Description
ActivationRegistryStorage::adminreturnsAddress::ZEROfor bothNoneandSome(Address::ZERO), but mutations treat those two states differently.Nonerejects every caller because there is no configured admin, whileSome(Address::ZERO)configures the zero address as the admin and authorizes any call that reaches the precompile withmsg.sender == Address::ZERO.That ambiguity means
admin() == address(0)does not tell callers whether activation mutations are disabled or whether the zero address is the configured admin. The activation registry acceptsAddress::ZEROas a configured admin, and the activation PoC suite shows that zero sender deposit transactions can reach the activation precompile withmsg.sender == Address::ZEROand successfully toggle activation state.The practical effect is that a misconfigured chain with
activation_admin_address == Some(Address::ZERO)does not merely brick activation for ordinary users. It creates a backdoor for any execution path that carries a zero recovered sender, including the deposit transaction path exercised by the repo's own PoC. In that state, an attacker can activate or deactivate the registry at will.Recommendation
Reject
Address::ZEROfor the activation admin everywhere it is configured or loaded. If zero sender deposit transactions must remain supported, add an explicit guard in the activation precompile that rejects zero callers regardless of admin configuration. Add a regression test for the zero sender deposit case.B20 factory address hashing is unmetered
Description
The B20 factory computes deterministic token addresses by hashing
(creator, salt)withalloy_primitives::keccak256directly. This helper has no access toStorageCtx, so it cannot charge the configured keccak gas even when called from the precompile dispatch path.Both address helpers have the same issue.
createB20reachesB20Variant::compute_addresswhile creating the token, andgetB20Addressreaches the same helper while serving the public address prediction view.compute_address_for_discriminantalso performs the same direct hash for callers that compute an address from a raw variant byte.Each factory address computation undercharges one small keccak operation. The effect is minor per call, but it makes native precompile gas accounting diverge from the EVM cost model and gives repeated factory address prediction or creation calls a tiny unmetered CPU cost.
Recommendation
Route factory address hashing through a gas aware helper that receives
StorageCtx, or deduct the keccak cost in the factory dispatch and creation paths before computing the address. Keep pure helpers only for tests or offchain utilities where no gas accounting is expected. Add a regression test that compares gas used bygetB20AddressorcreateB20before and after the address hash is charged.B20 factory prefunded address creation is undercharged
Description
createB20only rejects a predicted token address when the account already has non empty code. An account that already holds ETH but has no code passes this collision check, so the factory can still deploy the B20 marker bytecode to that predicted address.The later
set_codemetering path uses a different test. It charges the CREATE equivalent gas only whenAccountInfo::is_empty()is true. A prefunded no code account is not empty because it has a balance, soset_codestill writes the marker bytecode but skips the new account branch that chargescreate_cost(), the stored code hash keccak cost, and state creation gas.A caller can prefund a predicted B20 token address, then call
createB20and deploy the token marker bytecode with less gas than an empty account creation would charge. With the current one byte marker code, the concrete execution gas undercharge is about32036gas, before considering any state gas pricing. This does not create a large economic saving today, but it makes factory creation metering depend on whether the address was prefunded.Recommendation
Use the same account emptiness rule for the factory collision check and the
set_codecreation charge, or explicitly charge the CREATE equivalent costs when writing code to an account that has balance but no code. Add a regression test that prefunds a predicted B20 address, creates the token, and asserts creation gas is charged the same way as deployment to a fully empty account.nonpayable ABI is not enforced
Description
The B20 factory ABI declares
createB20as a nonpayable function, and its view selectors are also not payable. The native precompile wrapper does not enforce that ABI property. It only rejects delegate style calls by checkingis_direct_call(), then constructs the storage provider and dispatches calldata.PrecompileInputcarries the call value, butEvmPrecompileStorageProvider::newdiscards it with..and exposes no value check to factory dispatch. As a result, a low levelCALLwith non zero value to any successful factory selector can complete successfully even though the ABI says the call is not payable.Because EVM call value is transferred before successful precompile execution, the sent ETH can end up at
B20FactoryStorage::ADDRESS. The factory has no withdrawal path, so that value is stuck.Callers can accidentally or deliberately strand ETH at the B20 factory precompile address by sending value with a successful
createB20,getB20Address,isB20, orisB20Initializedcall. This does not let an attacker steal funds from other users, but it violates ABI expectations and can permanently lock funds sent with these calls.Recommendation
Reject non zero call value for every nonpayable native precompile selector before dispatch succeeds. The most robust fix is to add a value guard in the shared
base_precompile!wrapper or provider boundary, while still allowing future explicitly payable precompiles to opt in. Add regression tests forCALL{value: X}tocreateB20and one factory view selector, asserting the call reverts and no value remains at the factory address.Announce wraps system errors as internal call failures
Description
B20AssetToken::announceexecutes eachinternalCallsentry by redispatching it throughinner_with_privilege. If the inner call returns any error, the current code discards the original error and wraps it asIB20Asset::InternalCallFailed.That wrapping is appropriate for ordinary inner reverts, but it also catches system errors such as
OutOfGas,Fatal,Panic, andSlotOverflow. Those error variants have special semantics when converted to aPrecompileResult:OutOfGasbecomes an out of gas halt, andFatalorSlotOverflowbecomes a fatal precompile error. By replacing them with a normal ABI revert,announcecan undercharge gas and hide host or storage failures from the EVM.The factory init call mapper already handles this distinction by preserving
err.is_system_error()and only wrapping non system failures.announceshould follow the same pattern unless it is changed to implement a real inner frame for the documented self dispatch model.An out of gas or fatal storage error inside an announcement internal call can be reported as a normal
InternalCallFailed(bytes)revert. This changes error semantics, can avoid the gas behavior expected for out of gas, and can obscure unrecoverable host failures that should abort the precompile instead of appearing as user level call failures.Recommendation
Only wrap ordinary inner reverts as
InternalCallFailed. Propagateerr.is_system_error()unchanged, and preserve meaningful inner revert data where appropriate. Longer term, implement a real inner frame for the documented self dispatch model so gas, revert, and fatal error semantics match EVM call behavior. Add regression tests that inject an innerOutOfGasand an innerFataland assert they are not converted toInternalCallFailed.Native precompile calldata fee uses a misleading constant name
Description
Every native B-20 precompile dispatch charges a per-word fee on its calldata via
deduct_calldata_cost!, which is invoked at the top of each variant's dispatch (e.g.b20_asset,b20_stablecointoken dispatch). The fee is:const G_SHA3WORD: u64 = 6;let calldata_cost = calldata_len.div_ceil(32).saturating_mul(G_SHA3WORD as usize) as u64; // ceil(len/32) * 6The native precompiles emulate the cost a Solidity predeploy would incur to ingest its calldata. The closest EVM-equivalent for that is the copy/load family, not keccak. Per the evm-opcodes gas reference (https://github.com/wolflo/evm-opcodes/blob/main/gas.md#a3-copy-operations),
CALLDATACOPYcostsG_verylow (3) + G_copy (3) * ceil(len/32)plus memory expansion, i.e. a marginal 3 gas per word, andCALLDATALOADis a flatG_verylow = 3. The 6-per-word figure used here is therefore roughly 2x the natural EVM per-word cost, and it is borrowed from the keccak256 word constant (G_SHA3WORD), which is unrelated to reading calldata since nothing is hashed at this point. The only construction that naturally lands on 6/word is treating it as a flat simplification of copy + memory,G_copy (3) + G_memory (3) = 6.Two concerns:
- Pricing. If faithful EVM-equivalence is the goal, the per-word charge looks like it should be
G_copy = 3per word (plus memory expansion for dynamic types), so the current charge over-prices input by about 2x. If the 6 is intentional (e.g. a bundled copy + memory simplification, a deliberately conservative margin, or alignment with a base-std spec), that intent is not recoverable from the code. - Clarity / drift. The constant name
G_SHA3WORDis misleading for a calldata fee (nothing is being hashed), and there is no test pinning the value and no comment explaining the derivation. This matters for more than readability: the charged amount feedsgas_used, which determines the transaction'sgasUsedand the block's receipts/cumulative-gas commitment, so the per-word value must be identical across every Base execution client. A schedule that lives only as an unexplained6with no pinned test can silently drift or diverge from another client's implementation and become a consensus mismatch.
Recommendation
- Document the intent: add a comment stating exactly what the 6/word is meant to represent and citing its source (EVM-equivalent derivation, base-std spec, or a deliberate margin).
- Decide the value against that intent. For EVM-equivalence, use
G_copy = 3per word (and account for memory expansion on dynamic ABI data); if the intent is a bundledcopy (3) + memory (3)simplification, keep 6 but say so explicitly in the comment. - Rename the constant from
G_SHA3WORDto something accurate for a calldata fee (e.g.CALLDATA_WORD_GASorPRECOMPILE_CALLDATA_WORD_GAS). - Add a unit test that pins the exact charged value for a known calldata length, so the per-word schedule cannot drift unnoticed (it is part of the gas-used / receipts commitment and must match across all Base clients).
- Pricing. If faithful EVM-equivalence is the goal, the per-word charge looks like it should be
announce internal calls omit the per-call EVM execution overhead
Severity
- Severity: Low
Submitted by
Cryptara
Description
In the base-std reference,
announceruns each entry ofinternalCallsas a real EVM message call back into the contract:// MockB20Asset.sol:131for (uint256 i = 0; i < internalCalls.length; i++) { _checkSelector(internalCalls[i]); (bool success,) = address(this).delegatecall(internalCalls[i]); // ...}Each
delegatecallcarries real EVM cost: the DELEGATECALL opcode itself (warm address access, ~100 gas under EIP-2929), memory expansion for the input/output regions, and the callee frame's own opcode execution (including itsCALLDATALOAD/CALLDATACOPYreads of the sub-call arguments).The native precompile instead replays each internal call through a plain in-process function call,
inner_with_privilege, which performs no EVM call at all:// b20_asset/dispatch.rs:423self.inner_with_privilege(ctx, call_bytes, privileged) // no DELEGATECALL cost, no per-call overheadSo an
announcewith N internal calls under-charges relative to the equivalent Solidity flow by approximatelyN × (DELEGATECALL overhead + callee read opcodes). The only gas the native version charges for the batch is the single top-leveldeduct_calldata_cost!(taken once on the outerannouncecalldata) plus the storage work each inner operation performs.Recommendation
Decide and document the intended gas model for
announceinternal calls, then pin it:- If the goal is approximate EVM/Solidity-equivalent gas, add a per-internal-call overhead charge that mirrors the EVM
DELEGATECALLcost (a flat per-call constant, plus the callee execution that is already metered), soannounce(N)is not materially cheaper than performing the operations directly. Do not add a per-word calldata re-charge, which would double-count. - If the cheaper batched cost is intentional, state that explicitly at
dispatch.rs:412and in the B-20 Asset docs, so the asymmetry between batched and direct execution is a documented property rather than an accident. - Either way, add a test that pins the gas charged by
announcefor a fixed N and fixed inner operations, since the value is part of the receipts/gas-used commitment and must be identical across all Base execution clients. Confirm the chosen model matches any other client's native B20announceaccounting.
Inactive policy registry masks dispatch errors
Description
PolicyRegistryStorage::dispatchdecides whether to enforce activation by checking only the first four calldata bytes. If the selector matches one of the view functions, the call bypasses the activation gate and enters ABI dispatch. Every other selector goes throughActivationRegistryStorage::ensure_activatedbefore the dispatcher decodes the full calldata.When the policy registry feature is inactive, this ordering masks selector and ABI errors for non view calls. An unknown selector, short calldata, or a known write selector with malformed arguments returns
FeatureNotActivatedbeforeinnercan classify it asUnknownFunctionSelectoror an ABI decode failure. The same malformed write selector can therefore produce a different error depending only on whether the feature is active.View selectors are treated differently because the selector only check sends them to
innereven while inactive. A malformedpolicyExistsorisAuthorizedcall can reach decode classification while a malformedupdateAllowlistcall is hidden behind the activation error.Policy registry error behavior becomes activation dependent. Clients, tests, and monitoring cannot reliably distinguish an inactive feature from malformed calldata or an unknown selector for non view calls. This does not change policy state, but it weakens ABI diagnostics and can make cross environment simulations disagree near activation boundaries.
Recommendation
Decode and classify calldata before enforcing the activation gate, or at least determine whether the selector belongs to a known write function before returning
FeatureNotActivated. Keep view calls callable while inactive, but preserveUnknownFunctionSelectorand ABI decode errors for invalid calldata. Add regression tests for inactive unknown selectors, malformed view selectors, and malformed write selectors.Policy registry decodes account batches before enforcing the size cap
State
- Acknowledged
Severity
- Severity: Low
Submitted by
slowfi
Description
The policy registry defines a 64 account batch cap for
createPolicyWithAccounts,updateAllowlist, andupdateBlocklist, but that cap is enforced only after ABI dispatch has decoded the full dynamicaddress[]argument into aVec<Address>.PolicyRegistryStorage::innerfirst decodes calldata through the shared ABI macro. The generated call structs own theiraccountsfield as a vector, so the dynamic array has already been allocated and decoded by the time execution reachesrequire_account_batch_size.Alloy's dynamic sequence decoder reads the declared array length and reserves a vector with that capacity before decoding the elements. A valid oversized array is therefore fully allocated and decoded before the policy cap returns
BatchSizeTooLarge. A malformed calldata body can also declare a very large array length with little payload, causing a large allocation attempt before the decoder later discovers that the elements are missing.The 64 account cap does not protect the ABI decoding path. Callers can force policy registry dispatch to spend memory and CPU on oversized or malformed account arrays before the intended policy level size check rejects the call.
This does not bypass authorization or mutate state by itself, but it weakens resource accounting for the native precompile. The most concerning path is a short malformed call with a very large declared array length, which can reach
Vec::try_reservebefore failing ABI decoding.Recommendation
Reject oversized policy account batches before full vector allocation. For these selectors, pre-scan the calldata head and dynamic array length, validate that the length is at most
MAX_ACCOUNTS_PER_BATCH, and only then run the full ABI decoder. Alternatively, implement a capped decoder for the policy account arrays that fails as soon as the declared length exceeds the policy limit.Add regression tests for
createPolicyWithAccounts,updateAllowlist, andupdateBlocklistwith an array length of 65, and for malformed calldata that declares a very large account array length with insufficient element data. The malformed case should fail without attempting to allocate according to the declared length.
Informational17 findings
Repeated deactivation returns wrong ABI error
Severity
- Severity: Informational
Submitted by
slowfi
Description
The activation registry ABI declares two different errors for inactive features:
AlreadyDeactivated(bytes32)for a repeated deactivation andFeatureNotActivated(bytes32)for checks that require an active feature. The implementation does not make that distinction.ActivationRegistryStorage::set_activateduses the requested target state as the only discriminator, sodeactivate(feature)on an already inactive feature returnsFeatureNotActivated(bytes32)instead of the ABI advertisedAlreadyDeactivated(bytes32).This makes
AlreadyDeactivatedunreachable on the deactivation path. Clients generated from the ABI and automation that handles activation idempotency cannot reliably distinguish "this feature is already deactivated" from "this feature failed an activation check or was never activated".Repeated deactivation failures are reported with the wrong selector. Integrators that depend on the ABI cannot observe the advertised
AlreadyDeactivated(bytes32)error and may misclassify an idempotent admin operation as a missing activation state failure.Recommendation
Return
IActivationRegistry::AlreadyDeactivatedwhencurrent == activatedandactivatedisfalse. KeepFeatureNotActivatedforcheckActivated(bytes32)and other paths that require an active feature. Add ABI regression tests that assert repeated activation returnsAlreadyActivated(bytes32)and repeated deactivation returnsAlreadyDeactivated(bytes32)by selector.Activation admin is not consensus anchored
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
The activation registry admin controls a precompile that can change state, but the address is carried as chain spec metadata outside consensus instead of being anchored in consensus state or fork identity.
BaseChainSpecstores the admin separately from the innerChainSpec, validates only that some admin is present when Beryl is scheduled, accepts arbitrary admin metadata when wrapping an existingChainSpec, and then passes the option into EVM construction. The genesis header is built frommake_genesis_header(&genesis, &hardforks); the activation admin is not an input to that header construction, and theHardforksfork ID implementation delegates toinnerwithout including the admin.Two nodes can therefore have the same chain ID, genesis state, genesis hash, and fork schedule while disagreeing about which caller is authorized to execute
ActivationRegistry.activate(bytes32)ordeactivate(bytes32). Once a block contains an activation mutation from either configured admin, one node applies the storage update and emits the event while the other reverts the call. That is a consensus split on the activation registry state and any gated B20 or policy precompile behavior that follows.This also affects fault proof execution: proof boot information derives the activation admin from a built in
ChainConfigbased only on the committed chain ID, then threads it into the EVM factory. If an execution node was configured with a different admin for the same chain ID and fork schedule, native execution and proof execution can disagree on precompile results. The succinct publicBootInfoStructcommits the rollup config hash but not the activation admin itself.This issue is scoped to the new Beryl activation registry functionality. The activation registry is not part of the already live Base mainnet or Sepolia execution rules while Beryl remains unscheduled there. It becomes consensus critical once Beryl is active, or on custom chains where operators can supply Beryl active genesis or chain spec data.
Recommendation
Make the activation admin a value committed by consensus. Prefer storing it in activation registry state at genesis and reading it from storage during precompile execution, or otherwise include it in a genesis or fork identity that consensus checks so peers and provers cannot silently disagree on it. Reject
Address::ZEROas a configured admin, and add tests proving that changing the admin either changes the committed chain identity or is rejected before block execution.//Store admin in genesis state and read it from storage // Add admin storage to the activation registry: #[contract(addr = Self::ADDRESS)] #[namespace("base.activation_registry")] pub struct ActivationRegistryStorage { pub features: Mapping<B256, bool>, pub admin: Address, } impl ActivationRegistryStorage<'_> { pub fn admin(&self) -> Result<Address> { self.admin.read() } fn require_admin(&self) -> Result<()> { let caller = self.storage.caller(); let admin = self.admin()?; if admin.is_zero() || caller != admin { return Err(BasePrecompileError::revert(IActivationRegistry::Unauthorized { caller })); } Ok(()) } pub fn set_activated(&mut self, feature: B256, activated: bool) -> Result<()> { if self.storage.is_static() { return Err(BasePrecompileError::revert(IActivationRegistry::StaticCallNotAllowed {})); } self.require_admin()?; // existing feature mutation logic... Ok(()) } }Then genesis construction must write
activation_admin_addressintoActivationRegistryStorage::admin storage. After that, changing admin changes genesis state root.Coinbase: The network has a single centralized sequencer node. Thus this would not need consensus for the config at the moment.
Cantina Managed: Acknowledged by Coinbase team.
Bool storage decodes any non zero word as true
Description
The activation registry stores feature flags as
Mapping<B256, bool>, andis_activatedreads the mapped value directly through the storage library. The storage library writes booleans canonically as0or1, but itsFromWord for booldecoder accepts any non zero storage word astrue.That means a feature slot containing
2,0xff, or any other non zero value is treated as activated even though it is not a canonical boolean encoding. The same primitive decoder is shared by the other boolean mappings in the native precompiles, including policy membership flags, B20 role membership flags, and consumed announcement ids.If raw state, genesis state, state import, migration code, or a future writer leaves a non canonical value in one of these boolean slots, the precompile logic treats it as
true. For the activation registry specifically, any non zero value for a feature key enables the feature gate. For policy and role mappings, any non zero value grants membership semantics for the relevant mapping entry.Recommendation
Make boolean decoding canonical by accepting only
0and1. Return a storage decoding error for any other value, or normalize all boolean storage values before they can be observed by precompile logic. Add regression tests that write raw values such as2andU256::MAXinto activation, policy, and role boolean slots and assert the intended behavior.isB20Initialized accepts any prefix account with code
Description
isB20Initialized(address)is documented as returning whethercreateB20initialized the token, but the implementation only checks two structural conditions: the address must have the B20 prefix and the account must have non empty code. It does not verify that the factory predicted or created the address, that the marker bytecode is the factory marker, or that the token storage was initialized bycreateB20.As a result, any B20 prefix account with arbitrary code returns
truefromisB20Initialized, even if the factory never created it. Normal transactions cannot realistically deploy arbitrary code to a chosen B20 prefix address because the prefix fixes the first byte and the next nine bytes to zero, but genesis state, state import, migration code, or other privileged state setup can create such an account.Factory clients can receive a semantic false positive from
isB20Initialized. An address can be reported as an initialized B20 token even though it was not created by the factory and may not have valid B20 storage. This is mostly an integration and migration safety issue rather than a practical user transaction exploit.Recommendation
Track factory initialized tokens explicitly, or verify a stronger factory marker than generic code presence. For example, store a factory created flag keyed by token address during
createB20, or require the exact marker code hash and any required initialization storage sentinel before returningtrue. Add a regression test that injects arbitrary code at a B20 prefix address without callingcreateB20and assertsisB20Initializedreturns the intended value.set_code ignores static call context
Description
createB20installs the B20 marker bytecode before token initialization by callingStorageCtx::set_code. The EVM backedset_codeprovider path does not check whether the current precompile execution is static. It deducts code deposit gas, may deduct create gas and state gas, and then writes code through the revm internals before any later token storage write reachessstore.Other state mutation operations reject static execution at the provider boundary.
sstore,tstore, and log emission all checkself.is_staticbefore charging gas or mutating state.set_codeis the exception, so a staticcreateB20call observes the first attempted mutation incorrectly: marker code installation runs and charges gas first, then a later initialization storage write rejects withStaticCallViolation.The factory checkpoint should roll back the marker bytecode in production, so this does not appear to leave a persistent token from a static call. The observable issue is wrong static call semantics and extra gas charged before the call eventually reverts.
Static
createB20calls are overcharged and do not fail at the first state mutation. The final state should be rolled back by the factory checkpoint, but gas accounting and mutation ordering differ from normal EVM static call behavior.Recommendation
Add a static call guard before any gas deduction or mutation in
set_code, preferably at the provider implementation orStorageCtx::set_codeboundary so all callers get the same behavior. Add a regression test that executescreateB20through a static call and asserts it reverts before marker code gas is charged or code is written.Activation bytecode marker persists after deactivation
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
The activation registry writes marker bytecode to its own account when a feature is first activated. The deactivation path only deletes the feature flag mapping entry and emits
FeatureDeactivated; it never clears the marker bytecode fromActivationRegistryStorage::ADDRESS.As a result, after an activate then deactivate sequence,
isActivated(feature)correctly returnsfalse, butEXTCODESIZE(ActivationRegistryStorage::ADDRESS)andEXTCODEHASH(ActivationRegistryStorage::ADDRESS)remain non zero. Any integration or onchain guard that treats code presence at the activation registry address as a proxy for feature enablement receives a permanent false positive after the first successful activation.This is not a bypass of the registry's own
isActivatedorcheckActivatedcalls. The documented live state signal is the feature flag API, and the README does not document the bytecode marker as an activation signal. The issue is an externally visible state inconsistency that can mislead integrations that use code presence as a shortcut.Integrations can incorrectly conclude that activation is still enabled after deactivation if they inspect code presence instead of calling the registry. This can cause stale onchain guards, monitoring, or migration checks to behave as though a feature remains live even after the admin disabled it.
Recommendation
Either clear the marker bytecode when the activation registry has no active features, or explicitly document that activation registry code presence is only an initialization marker and not a live feature state signal. Add a regression test that verifies the intended post deactivation model for both
isActivatedand account code presence.B20 variant decimals helper is misleading
Description
B20Variant::decimals()returns6for both asset and stablecoin variants. That is accurate for stablecoin creation, but asset decimals are not a variant constant. They are supplied during asset token creation, validated against the allowed range, and stored per token.The current factory implementation does not use this helper for asset creation events.
init_asset_tokencopiesinit.decimalsbefore initialization and emits that value inB20Created, whileinit_stablecoinusesB20Variant::Stablecoin.decimals()for the fixed stablecoin event value. There is no current asset decimals event bug, but the helper name and asset arm make future callers likely to assume it returns the decimals for any variant.Recommendation
Remove the asset arm from
B20Variant::decimals(), rename the helper to clarify that it is only a stablecoin default, or return an optional fixed decimals value where assets returnNone. Add a regression test or compile time check around asset creation events so they continue to emit the token specificinit.decimalsvalue.B20 factory accepts dirty ABI words
Description
Factory dispatch decodes calldata with
SolInterface::abi_decodethrough the shareddecode_precompile_call!macro. NestedcreateB20.paramsbytes are also decoded withabi_decode. These decode paths do not enforce canonical Solidity ABI words for narrow integer, enum, and address fields, so dirty high bits can be masked or truncated instead of rejected.This affects the outer factory arguments and the inner creation parameter structs. A dirty enum word with a low byte matching
STABLECOINcan decode asB20Variant::STABLECOIN. A dirty address word with non zero high 12 bytes can decode as its low 20 byte address. Dirtyuint8words can similarly decode to their low byte. Affected fields includevariant,sender,token,version,initialAdmin, and assetdecimals.Solidity's canonical ABI decoder rejects these dirty words. The native factory therefore accepts some calldata that a Solidity implementation of the same ABI would reject.
Callers and integrations can observe permissive factory behavior for non canonical calldata. This can make offchain simulation, Solidity wrappers, and native precompile execution disagree about whether a call is valid. The most direct effects are semantic ambiguity around variant selection, address arguments, version checks, and asset decimals validation.
Recommendation
Use the validating ABI decoder for factory dispatch and nested creation params. Replace
abi_decodewithabi_decode_validatewhere available, or add explicit canonical word checks for enum, address, and narrow integer fields before accepting decoded values. Add regression tests with dirty high bits forvariant,sender,token,version,initialAdmin, anddecimals.B20 asset zero multiplier normalizes to WAD
Description
updateMultiplier(0)stores and emits a zero multiplier, but the public read path normalizes a stored zero back to WAD. The factory uses zero as the initial storage sentinel so newly created asset tokens can read as1e18without writing WAD. That sentinel behavior is reasonable for uninitialized storage, butupdateMultiplierexposes the same raw storage slot as an operator controlled value without rejecting zero or normalizing it before storage and event emission.An operator can therefore call
updateMultiplier(0)and emitMultiplierUpdated(0). After the transaction,multiplier()returns1e18, andtoScaledBalance,toRawBalance, andscaledBalanceOfall behave as if the multiplier is WAD. EmittingMultiplierUpdated(0)and emittingMultiplierUpdated(WAD)produce different logs but the same onchain accounting result. Offchain systems that trust the emitted multiplier event will observe a different value than onchain reads and conversions.The issue does not change raw balances or total supply, because the multiplier is only a derived view. It does create an event and read inconsistency on the asset accounting surface.
Indexers and integrations can record a multiplier of zero from
MultiplierUpdated(0)while onchain reads return WAD and conversions use WAD. This can desynchronize displayed balances and audit records for a token until the next nonzero multiplier update.Recommendation
Reject
newMultiplier == 0inupdateMultiplier, or normalize zero to WAD before both storage and event emission. If zero is meant to remain a storage sentinel only, the public mutation path should not be able to write it as an intentional multiplier update.Dead beryl() constructor and missing cobalt(): fork dispatch collapses Azul / Beryl / Cobalt into azul()
Description
BasePrecompiles::new_with_specselects the static precompile table per fork via a match. The Azul, Beryl, and Cobalt upgrades are collapsed into a single arm that callsSelf::azul():BaseUpgrade::Azul | BaseUpgrade::Beryl | BaseUpgrade::Cobalt => Self::azul(),A dedicated
beryl()constructor exists but is never reached — it only forwards toazul():/// Static precompiles are the same as Azul; Beryl adds dynamic precompiles at install time.pub fn beryl() -> &'static Precompiles { Self::azul()}and there is no
cobalt()constructor at all. This is functionally correct today — Beryl/Cobalt deliberately share Azul's static table, and the Beryl+ dynamic precompiles (B-20 factory, dynamic token lookup, policy registry, activation registry) are installed separately ininstall_with_observer, gated onself.spec.upgrade() >= BaseUpgrade::Beryl(provider.rs:185). So no behavior is wrong.The issue is consistency and a future-divergence footgun. The naming is asymmetric: every other supported fork,
fjord(),granite(),isthmus(),jovian(),azul(), has its own constructor that the match uses, butberyl()is dead andcobalt()is absent. If a later change ever needs Beryl or Cobalt to carry a different static table, a developer would naturally editberyl()(or add tocobalt()) and expect it to take effect — but the match ignoresberyl()entirely, so the change would silently have no effect. That is exactly the kind of subtle, hard-to-spot mistake that can produce a fork/consensus discrepancy.Recommendation
Pick one of two consistent shapes:
-
Per-spec constructors (preferred for future divergence): give each fork its own arm and constructor, with
beryl()/cobalt()forwarding toazul()for now:BaseUpgrade::Azul => Self::azul(),BaseUpgrade::Beryl => Self::beryl(),BaseUpgrade::Cobalt => Self::cobalt(),pub fn beryl() -> &'static Precompiles { Self::azul() }pub fn cobalt() -> &'static Precompiles { Self::azul() } // add thisThis keeps the per-fork extension point live so a future divergence is a one-line edit in the obvious place.
-
Drop the dead code: remove
beryl()and keep the sharedAzul | Beryl | Cobalt => Self::azul()arm, with a comment stating these forks intentionally share Azul's static table and that any divergence must be added here.
-
isAnnouncementActive() is not implemented by the B-20 Asset precompile
Description
The B-20 Asset variant declares
isAnnouncementIdUsed(string)in its RustIB20Assetinterface but does not declare or handleisAnnouncementActive(). As a result a standards-conformant call toisAnnouncementActive()(selector0xf152925d) is not decoded by any arm and falls through toUnknownFunctionSelector, i.e. the call reverts instead of returning abool. For the same calldata the base-std reference returns a value, so this is an observable behavioral divergence from the spec for valid input.The underlying state does exist:
B20AssetTokencarries anin_announcement: booland an internalis_announcement_active()accessor (token.rs:44), but it is used only for the in-bracket recursion guard and is never exposed as a precompile function. Two gaps follow:- The selector is simply absent from the ABI surface and dispatch, so the documented read is unavailable to integrators.
- The backing flag is process-local to a single
B20AssetTokeninstance, and a fresh instance is constructed per precompile invocation (b20_asset/precompile.rs). The natspec promises the value is observable to inner-call contracts dispatched inside the bracket, which re-enter the token through a new precompile call; those calls would see a freshly-constructedin_announcement = false. So even adding the selector but backing it with the in-memory bool would not satisfy the cross-call, reset-per-transaction semantics the spec requires; that requires EIP-1153 transient storage.
Provenance / traceability: the function was added on base-std and then removed in the port, per:
- commit: https://github.com/base/base-std/pull/140/commits/2979199b7d6e53471dc021f680e200f43feb3d3b
- PR: https://github.com/base/base-std/pull/140
This is raised for traceability within the requested audit scope; it appears to be reconciled in parallel. The team should confirm the intended end state (implement in the precompile vs. remove from the base-std interface) and keep the Rust ABI and the base-std interface in sync.
Recommendation
No code change is required. The discrepancy is an artifact of the spec snapshot used for this review, not a defect in the precompile: the audited base-std commit (
ea1b5b0) still declaredisAnnouncementActive(), but the function was subsequently removed from base-std (PR #140, commit2979199), so the precompile's omission of it is correct and the implementation and the current spec now agree.Action items are administrative only:
- Confirm the canonical base-std commit the precompiles are expected to track, and re-pin the audit/scope baseline to a post-removal commit so the interface diff is clean.
- Treat this as closed (no remediation), recorded here for traceability of the scope/spec mismatch that existed at the start of the review.
Factory bootstrap bypasses transfer policies but still enforces the MintReceiver policy
Description
privileged=trueis set only during the factory bootstrap window, when the factory dispatches the creationinitCallson the new token (b20_factory/storage.rs:139,181, viainner_with_privilege(.., true)). In that window the policy checks are applied inconsistently across operations:transfer/transfer_fromskip the transfer-side policies when privileged:// transferable.rs:45if !privileged { B20Guards::ensure_policy_type::<Self>(self, B20PolicyType::TransferSender, from)?; B20Guards::ensure_policy_type::<Self>(self, B20PolicyType::TransferReceiver, to)?;}// transferable.rs:99 (delegated transfer)if !privileged && spender != from { B20Guards::ensure_policy_type::<Self>(self, B20PolicyType::TransferExecutor, spender)?;}mintenforces the MintReceiver policy unconditionally; only the role gate is behind the privilege check:// mintable.rs:15if !privileged { B20Guards::ensure_token_role::<Self>(self, caller, B20TokenRole::Mint)?;}// ...// mintable.rs:21 (runs even when privileged == true)B20Guards::ensure_policy_type::<Self>(self, B20PolicyType::MintReceiver, to)?;
So during bootstrap,
TransferSender/TransferReceiver/TransferExecutorare bypassed, butMintReceiveris not. The behavior is defensible (a reasonable invariant is "never mint to a policy-denied address, even at bootstrap"), and it is not reachable by untrusted callers (privileged is factory-only, and fresh tokens default every scope toALWAYS_ALLOW). The problem is that the asymmetry is implicit: nothing in the code comments, theIB20/IB20Factorynatspec, or the B-20 docs states that policy bypass during the bootstrap window applies to transfers but deliberately excludes mint. An integrator whoseinitCallsset a restrictive MintReceiver policy and then mint in the same bootstrap bundle would hit aPolicyForbidsrevert that the "init bypasses policy gates" mental model does not predict.Recommendation
Make the intent explicit and pin it:
- Document the asymmetry where the bootstrap window is described (
IB20Factorynatspec / B-20 Factory docs) and at the two call sites (transferable.rs:45,mintable.rs:21): during factoryinitCalls, transfer-side policies are bypassed but the MintReceiver policy is always enforced, with the rationale (never issue new supply to a policy-denied recipient, even at creation). - Add a unit test that pins both behaviors under
privileged=true(privileged transfer to a denied receiver succeeds; privileged mint to a denied MintReceiver reverts), so the asymmetry is intentional and protected against accidental change. - Confirm this matches the base-std reference's stated bootstrap-policy semantics; if base-std documents a blanket "policy gates bypassed during init", reconcile the two so the spec and implementation agree.
EIP-2612 permit accepts non-canonical (high-s) signatures
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Cryptara
Description
recover_signerapplies no low-s guard. It builds the signature withSignature::from_scalars_and_parity(r, s, parity)and callsrecover_address_from_prehash, which normalizes a high-s value back to low-s (and flips parity) before recovery. As a result the malleated twin(r, N - s, v xor 1)of a valid signature recovers the sameownerand is accepted, i.e.permittreats both the canonical and the non-canonical encoding of a signature as valid.This is standard ECDSA signature malleability. As implemented it is not exploitable:
- Replay is prevented by the per-owner nonce, which is part of the signed digest and is incremented on each successful
permit(permittable.rs:157-162). Once either encoding is used, the nonce advances and the other no longer matches the recomputed digest, so only one can ever execute. - No state is keyed by the signature bytes. Malleability is dangerous when a system stores or dedups by
keccak(r, s, v); the twin hashes differently and slips past such a check. This code keys replay off the nonce only, so the alternate encoding has nothing to bypass. owner,spender,value, anddeadlineare all inside the signed digest, so the twin cannot redirect or alter the approval. The only possible effect is that the owner's own one-time approval executes via an equivalent encoding of their signature, with an identicalApprovalresult.
Why harden anyway: accepting low-s only (canonical signatures) is the convention enforced by OpenZeppelin's
ECDSAlibrary and is what many wallets and signing tools assume an EIP-2612 endpoint enforces. Rejecting high-s removes this class of malleability outright and provides a cheap layer of defense-in-depth against any future code path that might key off a signature instead of the nonce.Proof of Concept
Integration test of
base-common-precompiles(--features test-utils): sign a valid permit (k256 emits low-s), construct the high-s twins' = N - swith flipped parity, submit only the twin, and observepermitaccepts it and sets the allowance.use alloy_primitives::{Address, B256, U256, keccak256};use base_common_precompiles::{ InMemoryPolicy, InMemoryTokenAccounting, PermitArgs, Permittable, TestToken, Token, TokenAccounting,};use k256::ecdsa::SigningKey; // secp256k1 group order Nconst SECP256K1_N: U256 = U256::from_be_bytes([ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41,]);const PRIVATE_KEY: [u8; 32] = alloy_primitives::hex!("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");const SPENDER: Address = Address::repeat_byte(0xbb);const TOKEN_ADDR: Address = Address::repeat_byte(1);const CHAIN_ID: u64 = 1; fn owner_address() -> Address { let key = SigningKey::from_slice(&PRIVATE_KEY).unwrap(); let point = key.verifying_key().to_encoded_point(false); Address::from_slice(&keccak256(&point.as_bytes()[1..])[12..])} #[test]fn poc_permit_accepts_high_s() { let owner = owner_address(); let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); accounting.name = "TestToken".to_string(); let mut token: TestToken = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); let value = U256::from(500u64); // Sign a valid canonical (low-s) permit. let domain_sep = token.domain_separator(CHAIN_ID).unwrap(); let nonce = token.accounting().nonce(owner).unwrap(); let mut args = PermitArgs { owner, spender: SPENDER, value, deadline: U256::MAX, v: 0, r: B256::ZERO, s: B256::ZERO, }; let (sig, recid) = SigningKey::from_slice(&PRIVATE_KEY) .unwrap() .sign_prehash_recoverable(args.signing_hash(domain_sep, nonce).as_slice()) .unwrap(); let sig_bytes = sig.to_bytes(); args.r = B256::from_slice(&sig_bytes[..32]); args.s = B256::from_slice(&sig_bytes[32..]); args.v = if recid.is_y_odd() { 28 } else { 27 }; let low_s = U256::from_be_bytes(args.s.0); assert!(low_s <= SECP256K1_N / U256::from(2u64), "k256 emits low-s"); // Malleate to the non-canonical high-s twin: s' = N - s, flip parity. let high_args = PermitArgs { s: B256::from((SECP256K1_N - low_s).to_be_bytes()), v: if args.v == 27 { 28 } else { 27 }, ..args }; // The high-s twin is accepted: no low-s guard in recover_signer. assert!(token.permit(CHAIN_ID, U256::ZERO, high_args).is_ok()); assert_eq!(token.accounting().allowance(owner, SPENDER).unwrap(), value);}Output:
low-s = 0x4fdc8b597feb140608a1a4d2bb6df7543fcde85c041849a125e6354300ee9783high-s = 0xb02374a68014ebf9f75e5b2d449208aa7ae0f48aab30569a99ec2949cf47a9be (= N - s, non-canonical)test poc_crypto001_high_s_permit_accepted ... okRecommendation
Add a low-s check to
recover_signer: reject signatures withs > N/2(the secp256k1 half-order) and return the existingInvalidSignerrevert, before treating the signature as valid. This makespermitaccept canonical signatures only, matching the OpenZeppelinECDSAconvention and eliminating the malleable second encoding. The change has no effect on the current (already nonce-safe) flow; it only rejects the non-canonical twin. Add a unit test asserting that a high-s signature reverts withInvalidSignerwhile its canonical low-s counterpart succeeds.B20 lookup exposes uninitialized token addresses
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
BerylLookupreturns a dynamic B20 token precompile for any address that structurally matches a supported B20 prefix and variant byte. The lookup does not check whether the factory actually created the token or whether the token account has the marker bytecode.The token dispatchers do perform an initialization check before executing ABI handlers. If the address has not been initialized by the factory,
dispatch_with_observerreturns an empty revert. This prevents fake token operations from succeeding, but it meansPrecompilesMap::getandPrecompilesMap::containscan still report a B20 precompile for an address that was never created.This does not let callers operate an uninitialized token. Calls to never created B20 prefix addresses revert before reaching token logic.
The observable issue is address classification. Integrations that treat precompile map lookup as evidence that a token exists can get false positives for reserved B20 prefix addresses. Calls to those addresses also behave like failed precompile calls instead of ordinary calls to empty accounts.
Recommendation
Document that B20 dynamic lookup is a structural route, not a token existence check, and require integrations to use
isB20Initializedor the token dispatch initialization check for existence. If lookup is intended to expose only created tokens, move the bytecode or initialization check into a state aware lookup path and add a regression test for a structurally valid but never created B20 address.Coinbase: Acknowledged. Calls to structurally valid but never-created B20 addresses still revert before any token logic executes, so no fake token operation can succeed. The lookup is intentionally a structural route rather than an existence check. We will add documentation clarifying that integrations should use
isB20Initializedrather than the precompile map for token existence checks.Cantina Managed: Acknowledged by Coinbase team.
Beryl lookup install overwrites existing dynamic lookup
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
BerylLookup::install_with_observerinstalls the Beryl dynamic precompile lookup by callingPrecompilesMap::set_precompile_lookup. The map stores a single dynamic lookup, so installing Beryl lookup on a map that already has a dynamic lookup silently replaces the previous lookup.The normal
BasePrecompiles::install_with_observerpath starts fromPrecompilesMap::from_static(self.precompiles()), then installs the Beryl lookup. That default path is not affected because there is no prior dynamic lookup to preserve. The issue is at the public helper boundary for embedders or tests that compose Base precompiles with another dynamic lookup.Custom embedders can accidentally lose an existing dynamic precompile resolver when they call
BerylLookup::installorinstall_with_observer. Addresses that were previously resolved by the older lookup then stop behaving as precompiles.This is not a normal Base execution issue, because the default install path builds a fresh static map before adding Beryl dynamic precompiles.
Recommendation
Either document that Beryl lookup installation replaces any existing dynamic lookup, or provide a composable install helper that chains an existing lookup with Beryl lookup. If replacement is intended, consider asserting or exposing a return value so callers can detect that a previous lookup was overwritten.
Installed precompiles map can go stale after spec change
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
BasePrecompiles::install()builds aPrecompilesMaponce from the current Base spec. For Beryl and later, that construction also installs the B20 factory, dynamic B20 lookup, policy registry, and activation registry.After the map is installed, it is no longer tied to the EVM config spec. The
PrecompileProviderimplementation for Alloy'sPrecompilesMapreturnsfalsefromset_spec()and does not rebuild or replace the installed table. If an embedder reuses an EVM and mutatescfg.specacross a Base fork boundary, revm's spec update path can leave the old precompile map in place.This differs from the normal Base factory path, which creates a fresh
PrecompilesMapfrom theEvmEnvspec when constructing a new EVM. The issue is reachable for direct embedders or tests that reuse an existing EVM instance across spec changes instead of rebuilding it.An EVM reused across a spec change can execute with stale precompile behavior. For example, a Jovian map reused after switching to Azul can keep old MODEXP or P256 rules. An Azul map reused after switching to Beryl can miss the Beryl native precompiles. A Beryl map reused after switching back to Azul can leave Beryl native precompiles available before they should exist.
This is not a normal Base block or proof execution issue if those paths construct a fresh EVM and precompile map from each block's
EvmEnv. It is an API correctness hazard for custom embedders that mutatectx.cfg.specor otherwise reuse an installedPrecompilesMapacross fork boundaries.Recommendation
Document that installed
PrecompilesMapvalues are spec-specific and must be rebuilt whenever the Base spec changes, or wrap the map in a Base-aware provider whoseset_spec()rebuilds the installed map fromBasePrecompiles::new_with_spec(spec).install(). Add a regression test that constructs an EVM at Azul, changes the config spec to Beryl, and verifies that Beryl native precompiles are not silently missing or stale.Coinbase: Acknowledged. Normal block and proof execution paths construct a fresh EVM from each block's environment and are not affected. This is an API hazard for custom embedders that mutate the spec on an existing EVM instead of rebuilding it. We will add documentation to install making clear that the returned map is spec-specific and must be rebuilt on fork boundary changes.
Cantina Managed: Acknowledged by Coinbase team.
Stablecoin currency validation runs after token existence check
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
B20FactoryStorage::create_b20appears to validate creation parameters before computing the deterministic token address and checking whether a token already exists there. That ordering is true for asset decimals, but not for stablecoin currency:TokenCreateParams::validate_stablecoinis a no-op, and the actual empty/non-uppercase currency validation is deferred untilB20StablecoinStorage::initialize.As a result, when the target stablecoin address already exists, a second
createB20call with the same(caller, variant, salt)and an invalidcurrencyreturnsTokenAlreadyExistsbefore reaching the stablecoin currency checks. A fresh-address call with the same invalidcurrencyreturnsMissingRequiredField("currency")orInvalidCurrency(code).The
base-stdfactory interface documents stablecoin currency validation errors as part ofcreateB20, and the local factory already hoists asset-decimals validation into the pre-address-check validation phase. Stablecoin currency should follow the same pattern so field-level validation is not masked by deterministic-address collisions.The same invalid stablecoin creation parameters can produce different custom errors depending only on whether the deterministic token address has already been deployed. Contracts, tests, or tooling that expect malformed creation parameters to be rejected with
MissingRequiredFieldorInvalidCurrencycan instead observeTokenAlreadyExistswhen the caller reuses a salt.This does not corrupt state or bypass authorization, but it creates an observable revert-data mismatch and makes factory validation order inconsistent across stablecoin and asset variants.
Recommendation
Move the stablecoin currency checks into
TokenCreateParams::validate_stablecoin, beforecompute_addressand theTokenAlreadyExistscheck. KeepB20StablecoinStorage::initializedefensive if desired, but the factory-level validation should reject empty and non-uppercase currency values during the same pre-address-check phase as asset decimals.Add a regression test that first creates a stablecoin, then repeats
createB20with the same caller/salt and an empty or lowercasecurrency, asserting that the field-level currency error takes precedence overTokenAlreadyExists.