Smart contracts

UTL and CouponClaim on Sepolia

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);
    }
}

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

FieldMeaning
IUTL public immutable utlthe token this contract may mint
mapping(bytes32 => bool) nullifierUsedpaymentRef → already paid out
uint256 thresholdK — required distinct issuer signatures
uint256 perClaimCapmax UTL per single claim
uint256 epochCapmax UTL minted per epoch
uint256 epochLengthepoch length in seconds
mapping(uint256 => uint256) mintedInEpochepoch → 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:

  1. block.timestamp > deadlineExpired()
  2. nullifierUsed[paymentRef]AlreadyClaimed()
  3. amount > perClaimCapExceedsPerClaimCap()
  4. mintedInEpoch[epoch] + amount > epochCapExceedsEpochCap()
  5. threshold == 0 || issuerSigs.length < thresholdNotEnoughSignatures()
  6. effects: write the nullifier and the epoch total
  7. for each signature: ECDSA.recover over the EIP-712 digest; require signer > lastSigner (UnsortedOrDuplicate()) and hasRole(ISSUER_ROLE, signer) (NotIssuer(signer))
  8. interaction: utl.mint(recipient, amount), then emit 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

RoleOnCan
DEFAULT_ADMIN_ROLE (governor)bothgrant/revoke roles, setThreshold, setCaps
ISSUER_ROLECouponClaimbe a valid signer of an Entitlement
RELAYER_ROLECouponClaimcall claim()
PAUSER_ROLE (guardian)CouponClaimpause() / unpause()
MINTER_ROLEUTLmint() — held only by CouponClaim

Invariant 1 ≤ threshold ≤ N holds through any sequence, from both directions:

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 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)

couponClaim0x5Dfc68FD44CCD83DD10cF5aA4B060AAe1602fb13
utl0x63dE56C3909825e1d83e69daDa3f1e9E379f71AD
lzEndpoint0x6EDCE65403992e310A62460808c4b910D972f10f
governor0x934d57BC117a762dDD544fCA2e56885bFd4F4365
issuers0xf4B48550B9D15d419f77727107fd3cAF0c160DEc (N = 1)
relayer0x95FA3C48A38077e20b47c8Ef426597a7e1F112ab
guardian0x5CBC57Ab603208eC26CDBB5cc54c99c7fb1C0c89
threshold1
perClaimCap / epochCap / epochLength100e18 / 1000e18 / 86400
domainSeparator0xb99e02fda448be8955396e89d99bf0e97b55d84d83efb75ddeef356bc6006237
entitlementTypehash0xdd065a0d40a532db6696301233dd7aa4b8c1d2add45574549d672c819b59c749

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

FindingResolution
UTL should inherit IUTLFixed — UTL is IUTL, so the compiler keeps token and interface in sync
lastSigner never initializedFixed — now explicit = address(0), which is a load-bearing sentinel, not a default
block.timestamp comparisonAccepted — deadlines and epochs are time-based by design
Claimed emitted after utl.mintAccepted — utl is immutable and trusted, all effects precede the call
DOMAIN_SEPARATOR not mixedCaseAccepted — 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