A small inheritable Solidity mixin for tracking on-chain endorsements.
Endorsable is a request-then-endorse handshake: the owner (or, for state-level items, the state owner) asks a specific address for an endorsement; that address can grant or later revoke it. Comments travel in events only — they are not stored on-chain.
This is a utility, not a reputation protocol. It does not enumerate endorsers, weight them, expire them, or attach structured metadata that other contracts can read.
Two complementary contracts:
| Contract | Purpose | Who controls requests / removals |
|---|---|---|
Endorsable.sol |
One endorsement status per address, for the whole contract | Contract owner (Ownable) |
EndorsableState.sol |
Independent statuses per (owner, identifier) plus the contract-level mapping |
The state owner (msg.sender), not the contract owner |
Endorsable implements IEndorsable. EndorsableState implements IEndorsableState and inherits Endorsable. The shared State enum lives on IEndorsable.
Both layers use the same five states:
UNASSIGNED (0) → REQUESTED (1) → ENDORSED (2)
↓ ↓
REMOVED (4) REVOKED (3)
UNASSIGNED: No interaction yet.REQUESTED: A request is outstanding.ENDORSED: The requested address granted the endorsement.REVOKED: The endorser withdrew it.REMOVED: The owner (contract owner, or state owner) cancelled a request or an active endorsement.
Rules that apply at both layers:
- Endorse only from
REQUESTED. - Revoke only from
ENDORSED. - Remove only from
REQUESTEDorENDORSED(not fromREVOKED/UNASSIGNED). - A new request is allowed from
UNASSIGNED,REVOKED, orREMOVED. It is rejected if the status is alreadyREQUESTEDorENDORSED.
Useful when a contract wants a few known addresses (auditors, partners, clients) to opt in to an on-chain endorsement, and a parent contract or an indexer will decide what those statuses mean.
Not a substitute for Ethereum Attestation Service, soulbound tokens, or a scoring system. There is no on-chain list of endorsers: any “count” or “score” has to be given a candidate address list from off-chain.
Foundry:
forge install https://github.com/brucedonovan/endorsable.gitThere is no npm package.
pragma solidity ^0.8.13;
import "endorsable/src/Endorsable.sol";
contract MyContract is Endorsable {
constructor(address[] memory _initialRequests) Endorsable(_initialRequests) {}
}Only the owner can requestEndorsement / removeEndorsement. The requested address calls endorse / revokeEndorsement.
pragma solidity ^0.8.13;
import "endorsable/src/EndorsableState.sol";
contract MyProjectPortfolio is EndorsableState {
constructor(address[] memory _initialRequests) EndorsableState(_initialRequests) {}
function createProject(string calldata projectId, address[] calldata endorsers) external {
for (uint256 i = 0; i < endorsers.length; i++) {
// Keys the state to msg.sender, not to the contract owner
requestStateEndorsement(projectId, endorsers[i], "Please review this project");
}
}
}State functions are not onlyOwner. Each address owns the identifiers it requests under: stateId = keccak256(abi.encodePacked(msg.sender, identifier)). Calling removeStateEndorsement as someone else removes a status on their identifier, not yours.
function requestEndorsement(address addr, string calldata comment) external; // onlyOwner
function endorse(string calldata comment) external;
function revokeEndorsement(string calldata comment) external;
function removeEndorsement(address addr, string calldata comment) external; // onlyOwner
function getEndorsementStatus(address addr) public view returns (State);addr must be non-zero. Constructor initial requests are capped at 100 and also reject address(0).
function requestStateEndorsement(string calldata identifier, address addr, string calldata comment) public;
function endorseState(address stateOwner, string calldata identifier, string calldata comment) public;
function revokeStateEndorsement(address stateOwner, string calldata identifier, string calldata comment) public;
function removeStateEndorsement(string calldata identifier, address addr, string calldata comment) external;
function getStateEndorsementStatus(address owner, string calldata identifier, address addr) public view returns (State);
function getStateId(address owner, string calldata identifier) public pure returns (bytes32);identifier must be non-empty. addr / stateOwner must be non-zero. A state owner cannot request an endorsement from themselves.
Comments are included in events only (Endorsed, EndorsementRequested, StateEndorsed, …). They are not written to storage.
- Access control is OpenZeppelin
Ownable(not two-step). Ownership transfer is a singletransferOwnershipcall. ConsiderOwnable2Stepin the parent contract if that matters. - The contract owner can
removeEndorsementany requested or active contract-level endorsement. - There is no endorser enumeration, expiry, or on-chain comment/data hash. Do not treat
getEndorsementStatus == ENDORSEDas a complete audit or KYC signal. - There are no external calls in these contracts, so there is no reentrancy surface in the mixin itself. Parent contracts that add callbacks should still reason about reentrancy on their own code.
examples/ has two illustrative contracts (ReputableBusiness, ProjectPortfolio) and tests. Reputation scores in those examples are demonstrations — they take a caller-supplied address list and apply arbitrary weights. See examples/README.md.
git clone https://github.com/brucedonovan/endorsable.git
cd endorsable
forge install
forge test
forge test -vvv
forge buildExample tests:
cd examples && forge testEndorsableimplementsIEndorsable;EndorsableStateimplementsIEndorsableState.Stateis defined once on the interface; bothgetEndorsementStatusandgetStateEndorsementStatusreturnState.- State-level removal now writes
REMOVED(notUNASSIGNED), and only fromREQUESTEDorENDORSED. - State-level re-request is allowed from
REVOKEDandREMOVED, matching contract-level behaviour. - Tests updated for zero-address and empty-identifier validation.
- README rewritten to match the implementation (access control, return types, no batch API, Foundry-only install).
EndorsableState.solfor identifier-scoped endorsements alongside contract-level ones.- Examples:
ReputableBusiness.solandProjectPortfolio.sol. - Comment strings on all mutating calls, emitted in events.
- Comment parameter on endorsement actions.
- Basic request / endorse / revoke / remove lifecycle on
Ownable.
MIT. See LICENSE.