Findings
Low Risk
2 findings
1 fixed
1 acknowledged
Informational
12 findings
10 fixed
2 acknowledged
Low Risk2 findings
SecurityToken omits the standard IB20 pausedFeatures() selector
State
Severity
- Severity: Low
Submitted by
Sujith S
Description
IB20declarespausedFeatures() external view returns (PausableFeature[] memory), butSecurityTokenimplements onlypause,unpause, andisPausedand there is nopausedFeatures(). A caller casting the token toIB20/IB20asset and invokingpausedFeatures()hits no matching function and reverts.Unlike the policy-surface omissions (policyId/updatePolicy), which the contract explicitly documents as intentional divergences, this omission is undocumented and reads as an oversight.
Recommendation
Implement
pausedFeatures()by expanding the storedpausedMaskbits into aPausableFeature[]. If the omission is instead deliberate, document it alongside the existing policy-surface divergence note rather than leaving it silent.Allowlist transfer policies can disable minting and burning
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
0xhuy0512
Description
PolicyRegistrycontract rejectsaddress(0)in_batchSetMembers(), so zero can never be added as a policy member.SecurityTokencontract runs the configuredtransferPolicyIdpolicy insideSecurityToken._update()againstfrom,to, andmsg.sender. This includes minting, wherefrom == address(0), and burning, whereto == address(0).transferPolicyIdis only documented as conventionally aBLOCKLIST; it is not required to be one.setTransferPolicyId()only checks that the new policy exists and does not enforce that it is aBLOCKLIST. As a result, an admin can configure anALLOWLISTastransferPolicyIdafter deployment.With an
ALLOWLISTtransfer policy,address(0)is not authorized because it is not a member and cannot be added. This makes minting and burning revert wheneverSecurityToken._update()checks the zero address. The highest impact is that a valid policy update can unintentionally disable token issuance and redemption flows. Note that the policy can be changed post-deploy viasetTransferPolicyId().Recommendation
Choose one invariant and enforce it consistently:
- Require
transferPolicyIdto be aBLOCKLISTin both documentation and on-chain validation ininitialize()andsetTransferPolicyId(). - Permit
address(0)in policy membership, so allowlisting zero unblocks mint and burn, while blocklisting zero can intentionally pause mint and burn. - Skip policy checks for
address(0)inSecurityToken._update(), so mint and burn do not depend on zero-address policy membership.
Coinbase: We understand the issue, we are going to put off-chain checks to ensure this isn't possible
Informational12 findings
burnBlocked natspec claims to and origin checks that do not exist
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
@devnatspec onburnBlockedsays the path preserves the "to/sender/origin checks." But_burnSkippingFromSanctionshardcodesto = address(0), and the contract never checkstx.origin. Only the sender check is enforced, alongside the BURN pause vector.Recommendation
Reword to name only the checks that run - the
msg.sendersanctions check and the BURN pause vector:- /// design) while preserving the to/sender/origin checks and the+ /// design) while preserving the `msg.sender` sanctions check and theInit-time ALWAYS_ALLOW_ID guard does not extend to per-deploy extraData overrides
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
Function
initialize()rejectsALWAYS_ALLOW_ID(0) as the factory default to force sanctions gating onto every token's transfer slots.But
_buildInitCalls()seeds those four slots first and then appends the caller'sextraDatainit-calls, so aTOKEN_DEPLOYER_ROLEcaller can passupdatePolicy(slot, 0)to overwrite all four with the open-allow sentinel and the token still auto-registers with theTokenSupplyManager. The guard therefore constrains only the stored default, not the deployed outcome it implies.Recommendation
Document on
initialize/deployTokenthat theInvalidPolicyIdcheck is init-only and does not prevent a trustedTOKEN_DEPLOYER_ROLEdeployer from re-pointing the slots to 0 via extraData i.e. the open-allow protection rests on the trusted-role assumption plus off-chain review, not on an on-chain end-to-end guarantee.Optionally, for defense-in-depth, scan the decoded
extraDataforupdatePolicy(<transfer slot>, ALWAYS_ALLOW_ID)and reject it, mirroring the init-time guard.Coinbase: Understood, but we won't be allowing this via offchain checks, the documentation is updated in b3b6af9
setTransferPolicyId accepts ALWAYS_ALLOW_ID (0) that initialize rejects (undocumented asymmetry)
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
Function
initialize()rejectstransferPolicyId == ALWAYS_ALLOW_ID(0), butsetTransferPolicyId()checks only if the policyExists. So aDEFAULT_ADMIN_ROLEadmin can set it post-deploy, making_isSanctioned()returnfalsefor everyone and silently disabling_checkSanctions()on everytransfer/mint/burn. By design: a trusted admin can reach the same fail-open state (or brick the token via an all-blocking policy / ALWAYS_BLOCK_ID) through any user-created policy, so a literal-0 check at the update path adds no real protection. The asymmetry is simply undocumented.Recommendation
Add an inline comment on
setTransferPolicyId()so readers understand why 0 is rejected at init but not here, and extend the existing admin-trust natspec to name the fail-open / brick (centralization) risk.redeemWithMemo natspec misattributes the Memo event to the redeemer
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
redeemWithMemo()natspec claims the memo is emitted "associating it with the redeemer not the manager." ButtransferFromWithMemo()emitsMemo(msg.sender, memo)(SecurityToken.sol:637), whose first field is caller and when the TSM calls it, that caller is the manager (TSM), not the redeemer.The redeemer is actually captured by
Transfer.fromandRedeemed.from, which do the real tracking; the memo path exists only so a redeemer can tag their redemption.Recommendation
Simplify the comment to drop the redeemer-attribution claim and state that
Memo.calleris the manager, that redeemer attribution comes fromRedeemed(andTransfer.from), and that the memo just lets a redeemer tag their redemption.Single UPGRADER_ROLE controls both the shared token beacon and the factory's UUPS upgrade
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
SecurityTokenFactorydefines one upgrade credential,UPGRADER_ROLE(keccak256("UPGRADER_ROLE")), and uses it to gate two distinct and independently-scoped upgrade surfaces:upgradeBeacon(address newImplementation)(SecurityTokenFactory.sol:317) moves the shared UpgradeableBeacon pointer, atomically replacing the logic of every deployed SecurityToken in a single call._authorizeUpgrade(address newImplementation)(SecurityTokenFactory.sol:368) authorizes the factory's own UUPS implementation upgrade.
Concentrating both upgrade surfaces into a single
UPGRADER_ROLEreduces granularity of control and increases the trust placed in that one credential, so that a single compromised key is sufficient to subvert both the factory and every deployed token at once.Recommendation
Separate the two upgrade surfaces into distinct roles: for example
BEACON_UPGRADER_ROLE(token implementation) andFACTORY_UPGRADER_ROLE(factory UUPS), so they can be assigned to different holders and governed independently (if required).PolicyRegistry._create silently wraps the policy counter on overflow, diverging from the IPolicyRegistry specification
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The IPolicyRegistry interface documents an explicit overflow contract for both creation entry points:
@dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value.However, the implementation does the opposite.
_createreads the uint56nextCounterand increments it inside an unchecked block:uint56 counter = $.nextCounter;// No overflow guard: at one policy per 2-second block, exhausting// the 56-bit counter space takes ~4.6 billion years.unchecked { $.nextCounter = counter + 1;}unchecked suppresses the over/underflow check for all integer widths, uint56 included, so at the ceiling
type(uint56).max+ 1 wraps to 0 with no Panic(0x11), directly contradicting the interface.Beyond the documentation mismatch, the wrap is actively corrupting rather than merely non-conformant. The condition is unreachable in practice as exhausting the 56-bit space at one policy per ~2s block takes ~4.6 billion years. So there is no exploitable risk.
Recommendation
Make the implementation comply to the standard interface definition by removing the unchecked wrapper so the increment reverts with Panic(0x11) at the ceiling.
Or consider documenting this inconsistency.
Redundant burn pause checks
State
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
0xhuy0512
Description
In
SecurityTokencontract,burn()andburnWithMemo()call_requireUnpaused()forIB20.PausableFeature.BURNbefore calling_burn(). However,_burn()routes through_update(), which already checks the same burn pause state whento == address(0). This leads to successful burns paying unnecessary gas, and the duplicated check adds maintenance noise without changing behavior.Recommendation
Remove the redundant
_requireUnpaused()calls and rely on_update().- _requireUnpaused(IB20.PausableFeature.BURN); _burn(msg.sender, amount);Missing memo variants for batch minting and blocked burning
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xhuy0512
Description
In
SecurityTokenandTokenSupplyManagercontracts, several state-changing operations lack variants that accept a memo. WhilemintWithMemo()andburnWithMemo()exist, there are no equivalent functions forbatchMint(),burnBlocked(), orcreate()inTokenSupplyManager.Recommendation
Consider adding
batchMintWithMemo(),burnBlockedWithMemo(), andcreateWithMemo()to provide a consistent API surface for memoized operations across all token flows.Coinbase: This is a B20 Parity point, there is no withMemo in those ABIs, and create is an admin OP, so we don't expect to need withMemo.
Cantina: Acknowledged.
Privileged roles can configure state for unregistered tokens
State
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
0xhuy0512
Description
In the
TokenSupplyManagercontract, several administrative functions omit a check for whether the target token is registered (_storage().state[token].registered). These functions includesetMinimumRedeemableShares(),grantRedeemPauser(),revokeRedeemPauser(),pauseRedeem(),unpauseRedeem(),removeCaller(), andreplenishAllowance().This can lead to an admin accidentally configuring state, such as pausers or redeem settings, for a token that has been deregistered or does not exist. While this does not pose a direct security risk, it can leave confusing stale configuration for an inactive token. Moreover, if the same token address is later re-registered, those previously applied settings will become active and affect the new registration unexpectedly.
Recommendation
Consider adding the
TokenNotRegisteredcheck to these administrative functions to ensure state is only modified for active tokens.SecurityToken omits the IB20-mandated supply cap
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
SecurityTokenclaimsIB20Assetconformance (which isIB20), but implements none of the IB20 supply-cap surface: there is no supplyCap storage field, nosupplyCap()view, noupdateSupplyCap()setter, and noSupplyCapExceededcheck on any mint path.IB20mandates that all the mint functions revertSupplyCapExceededwhentotalSupply + amount > supplyCap.Recommendation
Implement the full IB20 supply-cap surface. Alternatively, if the omission is intentional, document it explicitly as a deliberate divergence and remove
updateSupplyCapreferences from the B20AssetFactory contract.SecurityToken.announce bubbles inner revert reasons, diverging from the IB20Asset InternalCallFailed contract
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Description
IB20Assetspecifies that when an inner call dispatched by announce reverts, the contract reverts withInternalCallFailed(call)and "the inner revert reason is not bubbled".SecurityToken.announceinverts this: on a failed innerdelegatecallit bubbles the inner revert data verbatim when returndata is present, and only falls back toInternalCallFailed(internalCalls[i])when the inner call reverts with empty returndata.Recommendation
Decide which behavior is canonical and align the two.
Coinbase: Understood, we want to allow the revert to bubble up for off-chain access. We can't fix B20 ourselves.
Paused token irrecoverably burns users' funds on HyperCore to HyperEVM bridge-in
State
Severity
- Severity: Informational
Submitted by
Sujith S
Description
On HyperLiquid, bridging a linked token from HyperCore into HyperEVM is not atomic and has no retry or idempotency.
The sequence is:
- HyperCore debits (burns) the Core side first, then
- HyperCore calls transfer(recipient, amount) on the HyperEVM ERC-20 as the token's system address to credit the recipient.
If step 2 reverts, HyperCore does not retry or roll back step 1. Since the Core-side debit has already happened, so the bridged tokens are irrecoverably destroyed.
The HyperEVM SecurityToken is pausable per-operation (TRANSFER / MINT / BURN) via the base
_update()gate, and it starts fully paused at initialization (operators must explicitly unpause each feature).The inbound bridge credit is an ordinary transfer, so it routes through
_updateto_requireUnpaused(TRANSFER). Whenever TRANSFER is paused - at launch before unpausing, or any time an operator pauses transfers (e.g. during an incident) every Core to EVM bridge-in reverts on the credit leg and the user's bridged tokens are permanently lost, even though the user did nothing wrong.There is no on-chain recourse: the revert does not block the move, it burns the funds. This makes a routine, intended administrative action (pausing transfers) silently destructive to honest users who bridge in during the pause window.
The issue was identified by the Coinbase team during the course of the review.
Recommendation
Exempt the inbound system-address credit leg from the pause gate so the credit settles instead of reverting, while keeping all compliance (sanctions/policy) checks intact.