Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Informational
4 findings
4 fixed
0 acknowledged
Informational7 findings
Suggestions to improve the test coverage for the Transfer-executor policy
State
- Fixed
PR #5464
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Valerian Callens
Description
The V3 transfer core checks policies in this order and compares the stored, raw IDs:
check_policy(policies.executor, caller)?;if caller != from || policies.executor != policies.sender { check_policy(policies.sender, from)?;}check_policy(policies.receiver, to)?;The following aspects are not covered by the tests:
- Raw-ID comparison in self-delegated
transferFrom:
The scenario should set
executor = baseIdandsender = inverted(baseId), make the caller/from account authorized bybaseId, and calltransferFromwithcaller == from. Because the IDs differ, the sender check must still run and reject the inverted sender policy. Repeat for Asset and Stablecoin, and assert unchanged balances, allowance, and logs. This would catch a future implementation that compares normalized IDs and skips the sender check.- Executor/sender/receiver precedence:
Existing tests cover direct-transfer sender denial and isolated executor denial, while the unit test combines sender and receiver denial but does not assert which error wins. No test configures all three policies to deny simultaneously and asserts the first failure, and no test explicitly proves receiver precedence after executor and sender pass. The scenario should add one direct-transfer and one
transferFromcase per token variant with distinct denying policies, expectPolicyForbids(TransferExecutor)first, then add the missing sender-only/receiver-only follow-ups where needed. FortransferFrom, it should also assert the allowance is unchanged on every policy failure.Recommendation
Add the two suggested test groups above for both Asset and Stablecoin.
Base
Fixed in PR 5464.
Cantina Managed
Fixed in the PR above by improving the test coverage.
Suggestions to improve the test coverage for the current-token recipient checks
State
- Fixed
PR #5464
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Valerian Callens
Description
The shared guard rejects the zero address and the current token address before allowance or policy evaluation:
B20CreditRecipient::new(to, token.token_address()) .map_err(|_| InvalidReceiver { receiver: to })?;The following aspects are not covered by the tests:
- Memo selector coverage:
The three transfer/mint memo selectors currently have successful-path tests but no invalid-recipient tests. For both Asset and Stablecoin, a test scenario should add invalid current-token recipient tests for
transferWithMemo,transferFromWithMemo, andmintWithMemo, but also add the equivalent zero-amount case to the already-coveredseizeWithMemorejection. A test scenario should also use a nonzero amount and at least one zero-amount call, assertInvalidReceiver, and no emission ofTransfer,Memo, orSeizedevent.- Transfer policy precedence:
The existing tests use privileged dispatch or do not install the competing policy/allowance, so they do not cover the following combinations. For both token variants, a test scenario should call unprivileged
transfer(to = TOKEN)with a denying executor or receiver policy and expectInvalidReceiver, notPolicyForbids. It should also have the same check fortransferFrom(to = TOKEN)with a finite, insufficient allowance and a denying executor, expectInvalidReceiver, and assert that the allowance remains unchanged.- Mint and batch-mint policy precedence:
For both token variants, a test scenario should call privileged
mint(to = TOKEN)andmintWithMemo(to = TOKEN)whileMintReceiverdenies the token, expectInvalidReceiverbefore policy evaluation and no state/events. For Assets, a test scenario should also add abatchMintwhose late recipient isTOKENwhileMintReceiverdenies it and assertInvalidReceiver, rollback of the earlier mint, unchanged supply, and noTransferevents.- Pause and role precedence:
A test scenario should add one representative test per token variant showing the documented ordering: a paused transfer/memo call to
TOKENreturnsContractPausedbecause pause is checked before recipient validation, as well as an unprivileged mint or seizure toTOKENwithout its role returns the role error because role checks precede recipient validation.Recommendation
Add the suggested test groups above.
Base
Fixed in PR 5464.
Cantina Managed
Fixed in the PR above by improving the test coverage.
Unknown policy IDs have inconsistent documented and actual behavior
State
- Fixed
PR #5457
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Valerian Callens
Description
The specification states that an unknown or malformed policy ID must return
false, for instance inbase-std/changelog/03_Denim_PolicyRegistry_not_policy.md:B20 stores a `uint64` policy ID per scope (`TRANSFER_FROM`, `TRANSFER_TO`, `MINT_RECEIVER`, `SEIZE_EXEMPT`, and other scopes) and calls `isAuthorized(policyId, account)` before gated operations. Invert is a query-time flip of that result, so it inherits the existing contract: `isAuthorized` never reverts, and a malformed or unknown ID returns `false` (deny).Instead, the ABI interface documents empty-member semantics for unknown
ALLOWLISTandBLOCKLIST IDs, without defining composite behavior, for instance inbase-std/src/interfaces/IPolicyRegistry.sol:/// @notice Returns whether `account` is authorized under `policyId`. Never reverts; unknown /// or malformed IDs collapse to empty-member-set semantics (ALLOWLIST -> false, /// BLOCKLIST -> true). /// /// @dev Callers that store policy IDs MUST validate `policyExists(policyId)` at write time. /// @dev Invert: `isAuthorized(invertedPolicyId(id), account)` returns the negated /// result of the base. Applies to every policy type. /// /// @param policyId Policy to query. /// @param account Account to check. /// /// @return Whether `account` is authorized. function isAuthorized(uint64 policyId, address account) external view returns (bool);The implementation of
is_authorized()incommon/precompiles/src/policy/logic/v3.rs:// Malformed IDs (type byte > INTERSECT) are treated as unauthorized rather than reverting. if !Self::is_well_formed(policy_id) { return Ok(false); } match Self::policy_id_type(policy_id) { Self::UNION_TYPE => self.is_authorized_union(storage, policy_id, account), Self::INTERSECT_TYPE => self.is_authorized_intersect(storage, policy_id, account), Self::ALLOWLIST_TYPE => storage.read_member(policy_id, account), Self::BLOCKLIST_TYPE => Ok(!storage.read_member(policy_id, account)?), _ => unreachable!("is_well_formed rejects type bytes > INTERSECT"), }behaves as follows:
malformed ID -> falsemissing ALLOWLIST -> falsemissing BLOCKLIST -> truemissing UNION -> falsemissing INTERSECT -> truemissing inverted base -> falseThe concrete fail-open case is a missing but well-formed
INTERSECTID:missingIntersect = (INTERSECT << 56) | 999 // well-formed ID: dispatch by type, without policy_exists()INTERSECT_TYPE => is_authorized_intersect(storage, policy_id, account) // no child record: empty loop returns the identity value, truefor child in read_children(policy_id)? { if !is_authorized(child, account)? { return Ok(false); }}Ok(true)Therefore:
policyExists(missingIntersect) == falseisAuthorized(missingIntersect, account) == trueCode of
is_authorized_intersect():/// Evaluates an INTERSECT (AND) composite over its live child set: authorized only if every /// child authorizes. An empty set is authorized. fn is_authorized_intersect<S: PolicyAccounting>( &self, storage: &S, policy_id: u64, account: Address, ) -> Result<bool> { for child in storage.read_children(policy_id)? { if !self.is_authorized(storage, child, account)? { return Ok(false); } } Ok(true) }This is a public Policy Registry view inconsistency, not currently a B20 token configuration bypass: in Asset and Stablecoin,
updatePolicy()reject the same ID becausepolicyExistsis false. An integration that treatsisAuthorized == trueas sufficient could nevertheless accept authorization from a nonexistent policy.The mismatch is explicit: the specification's blanket fail-closed wording implies
falsefor every unknown ID, while the non-inverted BLOCKLIST and INTERSECT paths returntrue.Recommendation
Add a characterization test for the full matrix above, especially
policyExists(missingIntersect) == falsepaired withisAuthorized(missingIntersect, account) == true. Then either document the matrix explicitly in both specifications and ABI comments, or change both V2 and V3 through an intentional versioned behavior change so every missing policy fails closed.Base
Fixed in PR 234 (
base-stdrepository) and in PR 5457 (baserepository).Cantina Managed
Behavior confirmed as intended, clarified by updating the specs in
base-stdand covered by tests in thebaserepository, fileb20_policy_v3_golden.rs.B20 precompiles are missing from the Base documentation describing precompiles
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Valerian Callens
Description
The B20 precompiles are not included in the Base documentation describing precompiles: https://docs.base.org/specifications/base-protocol/execution/precompiles.
Recommendation
Consider adding the B20 precompiles to the Base precompile documentation.
Base
Fixed here
Cantina Managed
Fixed, the B20 precompiles are now included in the Base documentation describing precompiles
Code Overview
State
- New
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Valerian Callens
Base is an Ethereum Layer-2 rollup whose B-20 tokens and Policy Registry are implemented as native Rust EVM precompiles. This review covers the Denim (V3) execution paths and their fork/version routing from Beryl through Denim, with the frozen V1/V2 implementations used as compatibility baselines.
The reviewed system comprises the Policy Registry, the B20 Asset and Stablecoin precompiles, their shared dispatch, ABI, storage, policy, token-accounting, and version-resolution layers.
Denim introduces three behavioral changes:
- inverted Policy Registry IDs using bit 63 as a query-time NOT flag;
- enforcement of
TRANSFER_EXECUTOR_POLICYon all transfer variants; - rejection of balance credits to either the zero address or the current token address.
These changes apply across transfer, mint, batch-mint, and seize flows, while preserving the existing policy, allowance, role, pause, balance, event, and revert-order semantics.
The review also covers shared credit-recipient validation, policy-ID storage and evaluation, composite-policy behavior, ABI and selector changes, fork-gated dispatch, cross-feature interactions, and the associated colocated and golden tests. V1 and V2 production behavior is treated as frozen, the primary security scope is V3 logic and its correct activation only on Denim and later forks.
Trust Assumptions
State
- New
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Valerian Callens
This review assumes that the underlying Base L2, EVM/revm execution engine, transaction admission, block production, consensus, and state-root verification operate according to their documented protocols. Their general correctness and availability are outside the scope of this review.
The review assumes that the configured hardfork is authoritative for precompile installation and version selection: the Policy Registry, Asset, and Stablecoin precompiles are unavailable before Beryl, use V1 at Beryl, V2 at Cobalt, and V3 at Denim and later. Version routing, ABI selection, and preservation of frozen V1/V2 behavior are nevertheless reviewed rather than trusted.
The Policy Registry singleton address and the B20 Factory, Asset, and Stablecoin precompile addresses are assumed to be installed according to the chain configuration. No trust is placed in callers of these precompiles: public EOAs and contracts may invoke all externally accessible paths, including factory initialization and view functions, without roles unless the implementation explicitly requires them.
The review assumes that the base-std specifications and the supplied Denim change guide define the intended external behavior, while the final merged Rust implementation is authoritative where the materials conflict. Frozen V1/V2 implementations and their golden tests are treated as compatibility baselines; no migration of existing token or Policy Registry storage is assumed.
Security Review Statement
State
- New
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Valerian Callens
Base engaged Cantina to conduct a security review of its Denim B-20 V3 precompiles. We would like to thank the Base team for their responsiveness and constructive engagement throughout the review process. No significant issues were identified during the assessment, and the system is expected to operate as intended.