This PR introduces the CLAWNFA contract#23
This PR introduces the CLAWNFA contract#23tomzlabs wants to merge 7 commits intoChatAndBuild:mainfrom
CLAWNFA contract#23Conversation
|
update |
|
Thank you @tomzlabs, we will review quickly. |
contracts/claw/CLAWNFA.sol
Outdated
Check warning
Code scanning / Slither
Assembly usage Warning
contracts/claw/CLAWNFA.sol
Outdated
Check warning
Code scanning / Slither
Low-level calls Warning
contracts/claw/CLAWNFA.sol
Outdated
Check warning
Code scanning / Slither
Low-level calls Warning
contracts/claw/CLAWNFA.sol
Outdated
Check warning
Code scanning / Slither
Assembly usage Warning
contracts/claw/CLAWNFA.sol
Outdated
Check warning
Code scanning / Slither
Assembly usage Warning
contracts/claw/CLAWNFA.sol
Outdated
Check notice
Code scanning / Slither
Local variable shadowing Low
contracts/claw/CLAWNFA.sol
Outdated
Check notice
Code scanning / Slither
Block timestamp Low
contracts/claw/CLAWNFA.sol
Outdated
Check notice
Code scanning / Slither
Reentrancy vulnerabilities Low
contracts/claw/CLAWNFA.sol
Outdated
Check notice
Code scanning / Slither
Block timestamp Low
contracts/claw/CLAWNFA.sol
Outdated
Check notice
Code scanning / Slither
Missing zero address validation Low
highsoft-85
left a comment
There was a problem hiding this comment.
@tomzlabs , please resolve the failed actions
- convert contracts/claw/CLAWNFA.sol line endings from CRLF to LF - wrap MINT_REQUEST_TYPEHASH declaration to satisfy max line length lint rule - upgrade github/codeql-action/upload-sarif from v2 to v3 in CI workflows"
contracts/claw/CLAWNFA.sol
Outdated
Check warning
Code scanning / Slither
State variables that could be declared immutable Warning
contracts/claw/CLAWNFA.sol
Outdated
Check warning
Code scanning / Slither
Conformance to Solidity naming conventions Warning
|
Hi @tomzlabs, great work on the CLAWNFA contract! I took a closer look at the code and wanted to share some security observations that might help strengthen the implementation before it gets merged. Hope this is helpful! Security Review —
|
| Severity | Count |
|---|---|
| Critical | 2 |
| High | 3 |
| Medium | 4 |
| Low | 6 |
| Info | 5 |
Critical
C-1: delegatecall in executeAction() allows storage corruption and fund theft
(bool ok, bytes memory result) = s.logicAddress.delegatecall(data);delegatecall executes the logic contract's code in the NFA contract's storage context. This means any whitelisted logic contract has full read/write access to ALL storage variables (_owner, _owners, _balances, signerAddress, etc.) and the contract's ETH balance. Even a well-intentioned logic contract with a subtle bug could accidentally overwrite critical storage slots.
Suggestion: Replace with a regular call, or use ERC-6551 token-bound accounts to isolate each agent's storage:
(bool ok, bytes memory result) = s.logicAddress.call(data);C-2: ecrecover signature malleability — missing s value check
The _recover() function uses raw ecrecover without checking the s value range. For every valid ECDSA signature (v, r, s), there exists a second valid signature (v', r, n-s). Combined with the fact that ecrecover returns address(0) on invalid inputs — if signerAddress were ever set to address(0), any invalid signature would pass verification.
Suggestion: Add OpenZeppelin's standard s value check:
require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0,
"Invalid signature 's' value");
require(v == 27 || v == 28, "Invalid signature 'v' value");
address recovered = ecrecover(digest, v, r, s);
require(recovered != address(0), "Invalid signature");Or consider using OpenZeppelin's ECDSA.recover() which handles all of this.
High
H-1: Funded ETH is permanently locked for token holders
fundAgent() tracks per-token balances via _states[tokenId].balance, but there is no withdrawFromAgent() function for token owners to withdraw their funds. The only withdrawal mechanism is withdraw() which is onlyOwner and sweeps the entire contract balance regardless of per-token accounting. This means:
- Users fund their agents believing they can withdraw later
- Only the contract owner can ever withdraw, and it takes everything
Suggestion: Add a per-token withdrawal function:
function withdrawFromAgent(uint256 tokenId, uint256 amount) external nonReentrant {
require(ownerOf(tokenId) == msg.sender, "NFA: not owner");
require(_states[tokenId].balance >= amount, "NFA: insufficient");
_states[tokenId].balance -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "NFA: withdraw failed");
}H-2: setSignerAddress allows zero address
function setSignerAddress(address newSigner) external onlyOwner {
signerAddress = newSigner; // No zero-address check, no event
}Setting signerAddress = address(0) + C-2's ecrecover behavior = anyone can mint with invalid signatures. Also missing an event for off-chain monitoring.
H-3: Terminated agents can be revived via unpause()
function unpause(uint256 tokenId) external override {
require(ownerOf(tokenId) == msg.sender, "NFA: not owner");
_states[tokenId].status = Status.Active; // No check for Terminated!
}A terminated agent should never be reactivatable. Also, fundAgent() has no check against terminated agents — ETH sent to terminated agents is locked forever.
Suggestion: Add status validation:
function unpause(uint256 tokenId) external override {
require(ownerOf(tokenId) == msg.sender, "NFA: not owner");
require(_states[tokenId].status == Status.Paused, "NFA: not paused");
_states[tokenId].status = Status.Active;
}Medium
M-1: Events emit address(this) instead of tokenId
All events use address(this) as the agent identifier:
emit AgentFunded(address(this), msg.sender, msg.value);
emit StatusChanged(address(this), Status.Paused);Since all tokens share the same contract, address(this) is identical for every event — making it impossible for indexers/subgraphs to track which specific agent was affected.
M-2: _transfer() doesn't update _states[tokenId].owner for terminated agents
When transferring a terminated agent NFT, _owners[tokenId] updates but _states[tokenId].owner does not, creating an owner inconsistency.
M-3: balanceOf(address(0)) should revert per ERC-721 spec
Current implementation returns 0 for zero address instead of reverting.
M-4: No totalSupply() function
_nextTokenId is private with no getter. Many marketplace integrations expect totalSupply().
Low
- L-1:
setAllowedLogicContract()emits no event (critical governance action givendelegatecall) - L-2:
setBaseURI()emits no event - L-3: No
renounceOwnership()in customOwnable - L-4: Single-step ownership transfer (consider
Ownable2Step) - L-5:
nameandsymbolare mutable storage (could be corrupted viadelegatecall) - L-6:
DOMAIN_SEPARATORcomputed once at deploy — no chain fork protection
Informational
- I-1:
receive()accepts ETH without tracking (not attributed to any agent) - I-2: Consider custom errors instead of revert strings for gas savings
- I-3: Token IDs start at 0 (some integrations expect 1)
- I-4: Consider exact pragma
solidity 0.8.20for production - I-5: No reentrancy guard on
withdraw()function
Overall
The most impactful change would be replacing delegatecall with call in executeAction() — this single change eliminates C-1, reduces L-5 risk, and is a straightforward fix. The second priority would be adding OpenZeppelin's ECDSA library for signature verification (fixes C-2).
Happy to discuss any of these further. Nice foundation — looking forward to seeing this evolve! 🤝
- replace executeAction delegatecall with call and bubble revert data - harden signature recovery with signer/nonzero, s-range and v checks - add per-agent withdrawals and isolate owner-withdrawable balance - enforce lifecycle rules for pause/unpause/terminate and funding - add governance/ops events plus balanceOf zero-address and totalSupply
|
Hi @tomzlabs, great response on the security fixes! The critical and high-severity items are well addressed. A few remaining observations: Remaining Security Items1. Single-step ownership transfer (Low) function transferOwnership(address newOwner) external onlyOwner {
require(newOwner != address(0), "Owner: zero address");
emit OwnershipTransferred(_owner, newOwner);
_owner = newOwner;
}If 2.
3. The current // BAP578.sol
receive() external payable {
revert("Use fundAgent() instead");
}This is a design choice, but worth aligning with the standard if full compliance is the goal. Structural Deviations from BAP578.solFor the maintainers' reference — the following are not bugs, but architectural differences from the official
These are design choices — CLAWNFA adds features like EIP-712 minting, logic contract governance, and three-state lifecycle that BAP578.sol doesn't have. However, certain deviations (especially event signatures and missing Overall the security posture is much stronger after the latest commit. The above items are lower priority but worth considering for long-term maintainability. 👍 |
- switch Ownable to two-step transfer (transfer + accept) - make DOMAIN_SEPARATOR fork-aware with dynamic chainid handling - reject direct ETH transfers in receive() to enforce fundAgent flow
|
Thanks @saiboyizhan for jumping in much appreciated |
- add BAP578-style indexed events while keeping existing events for compatibility - add contract-level paused state and setPaused admin control - enforce whenNotPaused on mint and fundAgent entrypoints
Key Changes
IBAP578interface for full agent lifecycle management (state, metadata, execution).Ownableimplementation.REQUIRED_TOKENandsignerAddress).REQUIRED_TOKEN,MAX_SUPPLY) configurable via the constructor.nonces,signerAddress) to streamline the contract.Verification