Two contracts on Ethereum Sepolia: UTL, a LayerZero OFT
reward token whose only local mint path is role-gated, and
CouponClaim, which mints it against K-of-N issuer
signatures within per-claim and per-epoch caps. Solidity ^0.8.24,
Foundry, OpenZeppelin + LayerZero oft-evm, all versions
pinned.
Layout & setup
src/ UTL.sol, CouponClaim.sol, interfaces/
script/ Deploy.s.sol + script/config/*.json
test/ unit, cross-check vectors, invariant/
git clone --recurse-submodules <repo> # forge-std lives in lib/ as a submodule
npm ci # OZ + LayerZero solidity sources into node_modules/
cp .env.example .env
forge build && forge test
Solidity libraries are pinned to exact versions in
package.json and resolved through remappings.txt;
lib/forge-std is pinned by submodule commit. Nothing floats.
UTL — the reward token
contract UTL is IUTL, OFT, AccessControlEnumerable {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
constructor(address admin, address lzEndpoint)
OFT("Utility Token", "UTL", lzEndpoint, admin) Ownable(admin) { … }
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
}
-
The only local mint path is
mint(), andMINTER_ROLEis held byCouponClaimalone — so the reward supply stays provably tied to claims. -
The OFT base is chosen now so the token never has to be migrated when
bridging lands. The deployment configures no peers,
which makes the
_lzReceivecross-chain path unreachable. -
adminisDEFAULT_ADMIN_ROLE, the Ownableowner, and the LayerZero delegate.
CouponClaim — K-of-N threshold claims
The on-chain boundary that bounds the blast radius of a backend
compromise. It inherits AccessControlEnumerable (not plain
AccessControl) because N is not stored — it is
getRoleMemberCount(ISSUER_ROLE), which the
K ≤ N invariant needs.
Storage
| Field | Meaning |
|---|---|
IUTL public immutable utl | the token this contract may mint |
mapping(bytes32 => bool) nullifierUsed | paymentRef → already paid out |
uint256 threshold | K — required distinct issuer signatures |
uint256 perClaimCap | max UTL per single claim |
uint256 epochCap | max UTL minted per epoch |
uint256 epochLength | epoch length in seconds |
mapping(uint256 => uint256) mintedInEpoch | epoch → UTL minted so far |
Interface
event Claimed(bytes32 indexed paymentRef, address indexed recipient, uint256 amount);
event ThresholdUpdated(uint256 threshold);
event CapsUpdated(uint256 perClaimCap, uint256 epochCap, uint256 epochLength);
error NotEnoughSignatures(); error UnsortedOrDuplicate(); error NotIssuer(address signer);
error AlreadyClaimed(); error Expired(); error ExceedsPerClaimCap();
error ExceedsEpochCap(); error InvalidThreshold(); error InvalidEpochLength();
error ZeroAddress(); error ThresholdWouldBeStranded(uint256 remainingIssuers, uint256 threshold);
The claim path
function claim(
address recipient,
uint256 amount,
bytes32 paymentRef,
uint256 deadline,
bytes[] calldata issuerSigs
) external onlyRole(RELAYER_ROLE) whenNotPaused
Checks in this order — cheap state checks first, ECDSA recovery (~3k gas each) last, so a replay or an over-cap claim reverts before paying for signature verification:
block.timestamp > deadline→Expired()nullifierUsed[paymentRef]→AlreadyClaimed()amount > perClaimCap→ExceedsPerClaimCap()mintedInEpoch[epoch] + amount > epochCap→ExceedsEpochCap()threshold == 0 || issuerSigs.length < threshold→NotEnoughSignatures()- effects: write the nullifier and the epoch total
-
for each signature:
ECDSA.recoverover the EIP-712 digest; requiresigner > lastSigner(UnsortedOrDuplicate()) andhasRole(ISSUER_ROLE, signer)(NotIssuer(signer)) - interaction:
utl.mint(recipient, amount), thenemit Claimed
Strict ascending order is what deduplicates the signer
set — one issuer cannot fill the threshold by signing K times.
lastSigner starts at address(0) deliberately:
ascending-from-zero also rejects a recovery that lands on the zero
address, on top of ordering and deduplication.
threshold == 0 is not a 0-of-N policy, it is an
unconfigured contract: without that explicit check an empty
issuerSigs would satisfy
length >= threshold and mint with no attestation at all.
Checks-effects-interactions throughout: the nullifier and the epoch total are written before the mint, so a token that called back could not be paid twice.
Roles & invariants
| Role | On | Can |
|---|---|---|
DEFAULT_ADMIN_ROLE (governor) | both | grant/revoke roles, setThreshold, setCaps |
ISSUER_ROLE | CouponClaim | be a valid signer of an Entitlement |
RELAYER_ROLE | CouponClaim | call claim() |
PAUSER_ROLE (guardian) | CouponClaim | pause() / unpause() |
MINTER_ROLE | UTL | mint() — held only by CouponClaim |
Invariant 1 ≤ threshold ≤ N holds through any
sequence, from both directions:
-
setThreshold(k)reverts onk == 0(an empty signature set would pass) and onk > getRoleMemberCount(ISSUER_ROLE)(every claim would be bricked). -
_revokeRoleis overridden: revoking an issuer down toN < thresholdreverts withThresholdWouldBeStranded. That is unrecoverable if missed — no signature set could ever reach K again, and the recovery action,setThreshold, is itself blocked by its ownk ≤ Ncheck. Overriding_revokeRolerather thanrevokeRolealso coversrenounceRole, the path that actually gets forgotten: an issuer could otherwise brick the contract by dropping its own role, with no admin involved. -
The constructor leaves
thresholdat 0 and it is set bysetThresholdafter the issuer set is granted, soK ≤ Nholds at every point of the deployment flow too.
Caps and epochs
perClaimCap bounds a single forged claim;
epochCap bounds a sustained stream of them, which
perClaimCap alone does not cover. Both are enforced on-chain
because both must hold even if every off-chain component lies.
currentEpoch() = block.timestamp / epochLength — fixed
windows, not a rolling one. Consequence: up to 2×
epochCap can mint across a window boundary. That is
a bounded, documented trade, not an oversight.
Demo values (Sepolia): perClaimCap = 100e18 ($100 of cashback
= a single $2,000 payment at 5 %), epochCap = 1000e18 ($1,000
per day across all users), epochLength = 86400.
Changing epochLength via setCaps re-keys
currentEpoch(), so the running epoch total is abandoned and
the new window starts empty — an admin can reset the cap this way.
Accepted, since the same admin could equally raise epochCap
outright; the real fix is the multisig + timelock on the governor, which
is roadmap.
EIP-712 — what the issuers sign
domain name "CouponClaim", version "1",
chainId + verifyingContract from the deployment
type Entitlement(address recipient,uint256 amount,bytes32 paymentRef,uint256 deadline)
typehash 0xdd065a0d40a532db6696301233dd7aa4b8c1d2add45574549d672c819b59c749
chainId and verifyingContract live in the
domain, so a signature is valid on exactly one contract on one chain and
nowhere else. They are not hardcoded on the TypeScript
side on purpose — read them from the deployed contract via
eip712Domain() (ERC-5267) and cross-check against
DOMAIN_SEPARATOR(). A domain copied by hand into backend
config is the drift that produces K signatures over K different digests,
none of which reach the threshold.
There is no couponId in the message — that is a number the
backend picks, and two coupon rows for one payment would give the contract
two ids it cannot tell apart. There is no nonce and no user signature
either: replay is carried by the paymentRef nullifier.
paymentRef — the nullifier is the payment
paymentRef = keccak256(abi.encode(srcChainId, txHash, outputIndex))
ABI types are exactly (uint256, bytes32, uint256).
One real payment, one nullifier. No batching, no sorted-set derivation —
CI greps for it. outputIndex is the log index on EVM and the
output index (vout) on Bitcoin and Spark. Non-EVM chains have
no EIP-155 id, so they get reserved ids above 2**32: Tron
2**32+1, Bitcoin 2**32+2, Spark
2**32+3.
For Bitcoin and Spark, txHash is the txid as
displayed by explorers and returned by RPC — used directly as
bytes32, not reversed into internal little-endian order. That
reversal is the likeliest way the two implementations drift, which is why
there is a committed vector for it.
The contract never derives this: CouponClaim receives
bytes32 and nullifies it. The Solidity helper lives in
test/PaymentRef.t.sol and exists only to cross-check the
TypeScript implementation.
Deployment
script/Deploy.s.sol performs the six wiring steps in order,
then re-reads the chain and asserts the whole wiring
before returning. A half-wired deployment is worse than a failed one — a
token with no minter, or a claim contract accepting claims below its
threshold, is invisible unless something checks.
1. UTL, with no peers configured — the cross-chain path stays unreachable
2. CouponClaim(utl, deployer, perClaimCap, epochCap, epochLength)
3. utl.grantRole(MINTER_ROLE, couponClaim) ← the single mint path
4. grant ISSUER_ROLE to each issuer ← issuers before threshold
5. couponClaim.setThreshold(K) ← so K ≤ N always holds
6. grant RELAYER_ROLE / PAUSER_ROLE
then, if governor ≠ deployer: grant admin to governor, transfer OFT
ownership, and only then renounce the deployer's admin
The deployer takes admin for the duration of the wiring and hands over at the end — otherwise a multisig governor would leave the script stuck at step 3 with two orphaned contracts already on chain. Grant first, renounce second: the reverse order would drop admin rights mid-way and leave both contracts permanently unadministrable.
Post-deploy assertions: sole minter is CouponClaim, supply is zero, K and the issuer set match the config, relayer and guardian are set, caps match, not paused, no LayerZero peers, exactly one admin.
# dry run — costs nothing, runs every check against live Sepolia state
forge script script/Deploy.s.sol:Deploy --rpc-url "$SEPOLIA_RPC_URL"
# broadcast + verify
forge script script/Deploy.s.sol:Deploy \
--rpc-url "$SEPOLIA_RPC_URL" --broadcast --verify -vvv
Funding: ~0.015 ETH at 2 gwei (~7.3M gas), measured from a dry run. Every
address in script/config/ethereum-sepolia.json starts as
0x0 and the script refuses to deploy until they are real —
except governor, where 0x0 means “use the
deployer”, the demo posture. A record is written to
deployments/<chainId>.json only on a real
broadcast: a dry run predicts addresses from the deployer nonce,
and writing those looks identical to a successful deployment.
Deployed addresses (Ethereum Sepolia, 11155111)
couponClaim | 0x5Dfc68FD44CCD83DD10cF5aA4B060AAe1602fb13 |
|---|---|
utl | 0x63dE56C3909825e1d83e69daDa3f1e9E379f71AD |
lzEndpoint | 0x6EDCE65403992e310A62460808c4b910D972f10f |
governor | 0x934d57BC117a762dDD544fCA2e56885bFd4F4365 |
issuers | 0xf4B48550B9D15d419f77727107fd3cAF0c160DEc (N = 1) |
relayer | 0x95FA3C48A38077e20b47c8Ef426597a7e1F112ab |
guardian | 0x5CBC57Ab603208eC26CDBB5cc54c99c7fb1C0c89 |
threshold | 1 |
perClaimCap / epochCap / epochLength | 100e18 / 1000e18 / 86400 |
domainSeparator | 0xb99e02fda448be8955396e89d99bf0e97b55d84d83efb75ddeef356bc6006237 |
entitlementTypehash | 0xdd065a0d40a532db6696301233dd7aa4b8c1d2add45574549d672c819b59c749 |
deployments/11155111.json is committed on purpose — it carries
domainSeparator and entitlementTypehash so the
issuer service can cross-check the EIP-712 domain rather than hardcoding a
copy, and the backend reads it for GET /config.
N = 1, K = 1 is the demo posture: forging a payout still needs two
independent keys, one issuer plus the relayer. Raising K to 2–3 is a config
change, not a code change — add issuers and redeploy, or grant the role and
call setThreshold on the live contract.
Security evidence
forge test # 119 tests
forge coverage --no-match-coverage 'test/|script/' # 100% lines / branches on src/
slither . --config-file slither.config.json # 0 findings
CI enforces all three, plus forge fmt --check and a 90 %
line-coverage floor on src/, with Slither at
fail-on: low.
Slither triage
| Finding | Resolution |
|---|---|
UTL should inherit IUTL | Fixed — UTL is IUTL, so the compiler keeps token and interface in sync |
lastSigner never initialized | Fixed — now explicit = address(0), which is a load-bearing sentinel, not a default |
block.timestamp comparison | Accepted — deadlines and epochs are time-based by design |
Claimed emitted after utl.mint | Accepted — utl is immutable and trusted, all effects precede the call |
DOMAIN_SEPARATOR not mixedCase | Accepted — the EIP-2612/EIP-712 name tooling looks for |
The three accepted ones are suppressed inline at the site with the reason, not globally, so a new instance of the same detector elsewhere still fails the build.
Coverage is not the bar. Every guard in
src/ was verified by deleting it and confirming a test
fails — line coverage was already 100 % while a real gap existed in the
_revokeRole scope test. The invariant suite was checked the
same way: all ten mutations are caught by the invariant_
functions alone, with the unit tests excluded.
Shared fixtures
Both files under test/fixtures/ are contracts between this
repo and the backend, read by the Solidity tests rather than duplicated
into them: entitlement.json pins one domain, one message and
the resulting digest; payment-refs.json pins the
srcChainId registry and one vector per chain, including the
Bitcoin vout and Tron cases.
Neither side may edit a fixture to make its own test pass. A mismatch is a real drift bug, and it is cheaper to catch as a red suite than as a claim that silently never reaches K signatures, or as two nullifiers for one payment.
Known gaps
- The governor is a single EOA in the demo. That key can rotate the issuer set, change caps and add a bridge peer. Multisig + timelock is the fix, and it is roadmap, not shipped.
- Fixed epoch windows allow up to 2×
epochCapacross a boundary. - The LayerZero bridge path exists in the base contract but is unreachable — no peers are configured, deliberately.