LightChallenge Protocol Specification
Version: 1.0 (Pre-Production)
Network: Lightchain testnet v2 (chain ID 8200)
Core contract: ChallengePay.sol (Solidity 0.8.24)
Active verifier: ChallengePayLightchainAttestor.sol
1. Overview
LightChallenge is a stake-weighted, permissionless challenge protocol on LightChain. Users create challenges backed by on-chain token stakes, submit real-world evidence (fitness activity, gaming results) off-chain, receive verification from a LightChain v2 worker (real LLM compute, signed JobCompleted event), and claim payouts based on verified outcomes.
The protocol enforces three invariants:
- ChallengePay holds zero funds. All deposits route to a bucketed Treasury contract. Claims are pull-based via Treasury allowances.
- Fees are snapshotted at creation time. Fee parameters are copied into each challenge at creation and cannot be changed retroactively.
- Verification is pluggable and immutable per challenge. The verifier address is set at creation and can only be tightened (not swapped) after participants join.
2. Protocol Actors
| Actor | On-chain identity | Role |
|---|---|---|
| Creator | challenge.creator | Creates a challenge, stakes tokens, receives creator fee share on finalization |
| Participant | Any address calling joinChallengeNative / joinChallengeERC20 | Joins a challenge by staking, submits evidence off-chain, claims rewards |
| Protocol | ChallengePay.protocol (immutable) | Receives protocol fee share; absorbs rounding dust and no-winner distributable |
| Dispatcher | Addresses in ChallengePay.dispatchers mapping | Authorized off-chain service that calls submitProofFor / submitProofForBatch on behalf of participants |
| Admin | ChallengePay.admin (2-step transfer) | Protocol governance: fee config, pause, dispatcher ACL, creator allowlist, verification config |
| LightChain worker | A staked node on LightChain v2 (assigned by gateway) | Runs the LLM judgment, emits signed JobCompleted on JobRegistry |
| Attestor | ChallengePayLightchainAttestor (off-chain key LCAI_WORKER_PK) | Records verdicts on chain via attest(...); verify() reads back per (challengeId, subject) |
| Treasury | Treasury.sol (AccessControl) | Holds all funds in buckets; OPERATOR_ROLE (granted to ChallengePay) manages grants; SWEEPER_ROLE recovers free funds |
3. Challenge Lifecycle
States
enum Status { Active, Finalized, Canceled }
enum Outcome { None, Success, Fail }State machine
createChallenge()
│
▼
Active
(Outcome.None)
/ │ \
/ │ \
cancelChallenge() │ finalize() [after endTime + proofDeadlineTs]
│ │ │
▼ │ ▼
Canceled │ Finalized
(Outcome.None) │ (Outcome.Success if winnersPool > 0,
│ Outcome.Fail otherwise)
│
submitProofFor / submitMyProof
[startTs..proofDeadlineTs window]
marks winners, grows winnersPoolTransition conditions
| Transition | Function | Conditions |
|---|---|---|
| — -> Active | createChallenge(CreateParams) | startTs > block.timestamp, duration > 0, lead time within bounds, valid verifier, proof deadline >= end time, stake deposited to Treasury |
| Active -> Active (join) | joinChallengeNative / joinChallengeERC20 / joinChallengePermit | status == Active, block.timestamp < joinClosesTs, participant cap not reached |
| Active -> Active (proof) | submitMyProof / submitProofFor / submitProofForBatch | status == Active, startTs <= block.timestamp <= proofDeadlineTs, participant has nonzero contribution, verifier returns true |
| Active -> Finalized | finalize(id) | status == Active, block.timestamp >= endTime, block.timestamp >= proofDeadlineTs, not already finalized |
| Active -> Canceled | cancelChallenge(id) | status == Active, caller is creator or admin, winnersCount == 0 |
Timing parameters
creation ──── joinClosesTs ──── startTs ──── endTime ──── proofDeadlineTs
│ │ │ │ │
│ join window │ proof window │ │ │
│ open │ closed │ open │ open │
│ │ │ │ (grace) │
└──────────────┘ └────────────┴───────────────┘
submitProof allowedjoinClosesTs: defaults tostartTsif zero; must be<= startTsendTime:startTs + durationproofDeadlineTs: must be>= endTime; defines the grace period for proof submission after challenge endfinalize()requiresblock.timestamp >= endTime && block.timestamp >= proofDeadlineTs
4. Staking and Pooling
All funds are deposited into Treasury buckets where bucketId = challengeId.
Creator stake
- Creator calls
createChallenge()withmsg.value(native) or ERC-20 approval - Funds route to
ITreasury.depositETH(id)orITreasury.depositERC20From(id, ...) - Creator’s stake is added to
challenge.poolandchallenge.contrib[creator] - Creator is marked as a participant
Participant stake
- Participant calls
joinChallengeNative(id)withmsg.value, orjoinChallengeERC20(id, amount), orjoinChallengePermit(id, amount, deadline, v, r, s) - Funds route to the same Treasury bucket
- Added to
challenge.poolandchallenge.contrib[participant] - Participant marked via
_enforceParticipantCap()
Pool accounting
challenge.pool: sum of all contributionschallenge.winnersPool: sum of contributions from addresses wherechallenge.winner[addr] == truelosersPool = pool - winnersPool(computed at finalization)
Constraints
minStake: global minimum enforced at creation (admin-configurable viasetMinStake())maxParticipants: per-challenge cap (0 = unlimited)- Contributions are additive: a participant can join multiple times, increasing their
contribandpool
5. Evidence and Verification Pipeline
The end-to-end path from user activity to on-chain finalization:
1. Evidence intake
User uploads activity data (Strava GPS, Garmin JSON, match history)
→ POST /api/aivm/intake (multipart/form-data)
→ Adapter normalizes → public.evidence
2. Evaluation
evidenceEvaluator worker polls public.evidence
→ fitnessEvaluator or gamingEvaluator per provider
→ Writes verdict (pass/fail + reasons) to public.verdicts
3. Per-participant dispatch
challengeDispatcher: one row per (challenge_id, passing-verdict participant)
→ public.aivm_jobs UNIQUE (challenge_id, subject), status = queued
4. Single-pass run (per participant)
challengeWorker claims by (challenge_id, subject)
→ runChallengePayLightchainJob(challengeId, subject):
a. judgeChallengeViaLightchain(...) — SIWE auth, gateway, ECDH-encrypted
relay channel, AES-GCM verdict decrypt
b. ChallengePayLightchainAttestor.attest(...) — record verdict on chain
c. ChallengePay.submitProofFor(id, subject, proof)
→ attestor.verify() reads attestation row → returns true
→ c.winner[subject] = true; winnersPool += contrib
5. Finalize + payout
After proofDeadlineTs, anyone calls ChallengePay.finalize(id)
→ outcome = (winnersPool > 0) ? Success : Fail
→ _snapshotAndBook() computes payouts and grants Treasury allowances
- winnersPool > 0: per-winner bonus = distributable / winnersPool
- winnersPool == 0: full distributable → protocol multi-sig (admin path)
autoDistributeWorker pushes Treasury allowances to recipient walletsProof verification
_submitProofInternal() calls challenge.verifier.verify(id, participant, proof) in a try/catch. If verification returns true:
challenge.winner[participant] = truechallenge.winnersPool += contribchallenge.winnersCount += 1- Emits
WinnerMarked
If verification returns false or reverts, the call completes without marking a winner (non-reverting failure).
6. Fee Model
Fees are configured globally via FeeConfig and snapshotted into each challenge at creation time. This prevents retroactive fee changes from affecting existing challenges.
Fee parameters (all in basis points, max 10000)
| Parameter | Field | Description |
|---|---|---|
forfeitFeeBps | fee_forfeitFeeBps | Total fee taken from losers’ forfeited pool (after cashback) |
protocolBps | fee_protocolBps | Protocol’s share of the forfeited pool (after cashback) |
creatorBps | fee_creatorBps | Creator’s share of the forfeited pool (after cashback) |
cashbackBps | fee_cashbackBps | Percentage returned to losers (taken before fees) |
Validation constraints
protocolBps + creatorBps <= forfeitFeeBps // shares cannot exceed total fee
forfeitFeeBps <= 10000 // max 100%
cashbackBps <= 10000 // max 100%
forfeitFeeBps <= feeCaps.forfeitFeeMaxBps // hard cap (if set)
cashbackBps <= feeCaps.cashbackMaxBps // hard cap (if set)Fee caps
FeeCaps provides an immutable upper bound. Once set, forfeitFeeBps and cashbackBps cannot exceed the cap values. This provides governance assurance that fees will not exceed published limits.
Rounding dust
Integer division in fee splits may produce remainders. Dust from feeGross - (protocolAmt + creatorAmt) is assigned to the protocol address. Per-claim bonus dust stays in the Treasury bucket and is recoverable via Treasury.sweep().
7. Payout Distribution
Payouts are computed atomically in _snapshotAndBook() during finalize(). All grants are issued as Treasury allowances (no direct transfers).
Computation
Given:
totalPool = challenge.poolwinnersPool = challenge.winnersPoollosersPool = totalPool - winnersPool
Step 1: cashback = losersPool * cashbackBps / 10000
Step 2: losersAfterCashback = losersPool - cashback
Step 3: feeGross = losersAfterCashback * forfeitFeeBps / 10000
Step 4: protocolAmt = losersAfterCashback * protocolBps / 10000
Step 5: creatorAmt = losersAfterCashback * creatorBps / 10000
Step 6: dust = feeGross - (protocolAmt + creatorAmt) → added to protocolAmt
Step 7: distributable = losersAfterCashback - feeGross
Step 8: perCommittedBonusX = distributable * 1e18 / winnersPool (if winnersPool > 0)
Step 9: perCashbackX = cashback * 1e18 / losersPool (if losersPool > 0)Claim functions
| Function | Eligible | Payout formula |
|---|---|---|
claimWinner(id) | winner[sender] == true, contrib > 0, not yet claimed | principal + principal * perCommittedBonusX / 1e18 |
claimLoser(id) | winner[sender] == false, contrib > 0, perCashbackX > 0, not yet claimed | principal * perCashbackX / 1e18 |
claimRefund(id) | status == Canceled, contrib > 0, not yet claimed | Full contrib (100% refund) |
Edge cases
- No winners (
winnersPool == 0): outcome isFail. The entire distributable amount is granted toprotocol. Losers still receive cashback. - All winners (
losersPool == 0): no fees, no distributable. Each winner claims exactly their principal. - Single participant who wins: claims their own principal (no bonus since losersPool is zero).
Immediate grants at finalization
_snapshotAndBook() immediately grants (via Treasury):
protocolAmttoprotocoladdresscreatorAmttochallenge.creatordistributabletoprotocolif no winners exist
Winner and loser claims are pull-based (recipients call claimWinner / claimLoser).
8. Treasury Model
Treasury.sol implements bucketed, claim-based custody using OpenZeppelin AccessControl.
Design properties
| Property | Mechanism |
|---|---|
| Bucketed isolation | Each challenge has its own bucket (bucketId = challengeId). Funds in one bucket cannot be used for another. |
| Operator grants | ChallengePay holds OPERATOR_ROLE. It calls grantETH(bucketId, to, amount) / grantERC20(...) to create allowances. |
| Pull-based claims | Recipients call claimETH(bucketId) / claimERC20(bucketId, token) on Treasury directly. Unstoppable: no admin can prevent a granted claim. |
| Sweep safety | SWEEPER_ROLE can only recover truly free funds: free = onchainBalance - outstandingAllowances - totalBucketBalances. Bucket balances and outstanding allowances are always protected. |
Accounting
bucketEthBalance[bucketId] — remaining allocatable ETH in bucket
totalBucketEthBalance — sum across all buckets
ethAllowanceOf[bucketId][addr] — granted but unclaimed amount
outstandingETH — sum of all outstanding allowancesDeposit: increases bucketBalance and totalBucketBalance.
Grant: decreases bucketBalance, increases allowanceOf and outstanding.
Claim: decreases allowanceOf and outstanding, transfers funds.
9. Achievement System
ChallengeAchievement.sol mints soulbound (non-transferable) ERC-721 tokens implementing ERC-5192.
Achievement types
| Type | Enum value | Eligibility |
|---|---|---|
| Completion | AchievementType.Completion (0) | Any address with contribOf(challengeId, user) > 0 in a Finalized challenge |
| Victory | AchievementType.Victory (1) | Any address where isWinner(challengeId, user) == true in a Finalized challenge with Outcome.Success |
On-chain verification
ChallengeAchievement reads ChallengePay state via the IChallengePay view interface:
getChallenge(id)— checksstatus == FinalizedandoutcomecontribOf(id, user)— confirms participationisWinner(id, user)— confirms winner status
ChallengePay has no knowledge of ChallengeAchievement. The dependency is strictly one-way (read-only).
Properties
- Soulbound: All transfers revert.
locked(tokenId)always returnstrue.Locked(tokenId)is emitted at mint. - Double-mint protection:
minted[challengeId][user][achievementType]mapping prevents duplicate mints. - Claim-based: Users call
mint(challengeId, achievementType)themselves; no admin action required.
10. Event Routing
EventChallengeRouter.sol maps multi-outcome events to individual ChallengePay challenges. This is an admin-only utility, not on the user-facing product path.
Model
An event (e.g., “Team A vs Team B”) is identified by a bytes32 eventId and contains N outcomes, each bound to a challengeId and a subject address.
struct Outcome {
string name;
uint256 challengeId;
address subject;
}Flow
- Register: Owner calls
registerEvent(eventId, title)to create the event. - Add outcomes: Owner calls
addOutcome(eventId, name, challengeId, subject)for each possible result. - Finalize: When the real-world outcome is known, owner calls
finalizeEvent(eventId, winnerIndex, proof):- Calls
challengePay.submitProofFor(winningChallengeId, winningSubject, proof)to mark the winner. - Calls
challengePay.finalize(winningChallengeId).
- Calls
- Finalize losers: Losing outcome challenges can be finalized separately (they expire with no winners, resulting in
Outcome.Fail).
Access control
All mutating functions (registerEvent, addOutcome, finalizeEvent, setEventURI) are restricted to the owner. Ownership uses a 2-step transfer pattern (transferOwnership + acceptOwnership).
11. LightChain v2 Integration
LightChallenge is a client of the LightChain v2 worker network. We submit jobs via the consumer gateway, decrypt responses via the relay, and bridge the verdict on chain through ChallengePayLightchainAttestor. We don’t operate workers or validators.
Contract + service dependencies
| Component | Role | Operated by |
|---|---|---|
ChallengePayLightchainAttestor | Records verdicts per (challengeId, subject); verify() returns true when the recorded verdict says passed=true | LightChallenge |
LightChain v2 JobRegistry | On-chain job anchoring + JobCompleted event audit trail | LightChain Foundation |
| LightChain v2 gateway | SIWE auth, session prep, dispatcher signing, blob upload | LightChain Foundation |
| LightChain v2 relay | Encrypted channel for worker response chunks | LightChain Foundation |
Request flow (per participant)
runChallengePayLightchainJob(challengeId, subject)
│
├── 1. judgeChallengeViaLightchain(...)
│ ├─ SIWE auth → JWT
│ ├─ /api/sessions/select → assigned worker + ECDH pubkeys
│ ├─ ECDH-P-256 wrap session key for worker + disputer
│ ├─ /api/sessions/prepare → 65-byte dispatcher signature
│ ├─ JobRegistry.createSession() [on chain]
│ ├─ /api/blobs upload → versioned prompt blob
│ ├─ JobRegistry.submitJob() payable 0.02 LCAI [on chain]
│ ├─ wss://relay → encrypted response chunks
│ └─ AES-GCM decrypt → verdict JSON
│
├── 2. ChallengePayLightchainAttestor.attest(...) [on chain]
│
└── 3. ChallengePay.submitProofFor(id, subject, proof) [on chain]
→ attestor.verify() reads the row → returns true
→ c.winner[subject] = true; winnersPool += contribVerification flow (ChallengePayLightchainAttestor.verify())
verify(challengeId, subject, proof) decodes the proof bundle and checks:
- The attestation row exists for
(challengeId, subject)withpassed = true - The proof’s
responseHashmatches what was attested - The proof’s
workermatches the attested worker - The proof’s
jobIdmatches the attested jobId
If any check fails, submitProofFor does NOT mark the participant as winner. The on-chain JobCompleted event from the LightChain worker is the public audit trail backing each attestation.
Trust model
| Trust unit | Stake | Failure mode |
|---|---|---|
| LightChain worker | 5,000 LCAI, slashable up to 50% | If the worker lies, LightChain’s disputer can decrypt the response and arbitrate |
Our attestor key (LCAI_WORKER_PK) | One signing key | Rotatable via attestor.setAttestor(addr, false) without redeploy |
| LightChain dispatcher | Single LightChain Foundation key | Off-line failure mode: jobs can’t be submitted; doesn’t affect already-attested verdicts |
There is no validator quorum — workers ARE the trust unit in v2.
12. Security Properties
Fund safety
- Zero balance in ChallengePay. All deposits route to
Treasury.depositETH/Treasury.depositERC20From. ChallengePay’saddress(this).balanceshould always be zero. - Bucketed isolation. Each challenge’s funds live in a separate Treasury bucket. A bug in one challenge’s payout cannot drain another’s bucket.
- Pull-based claims. Treasury allowances are unstoppable: once
grantETH(bucketId, to, amount)is called, the recipient can claim regardless of any admin action on ChallengePay. - Sweep safety.
Treasury.sweep()can only touch funds wherebalance > outstanding + bucketBalances. Active challenge funds and pending claims are always protected.
Fee safety
- Snapshot at creation.
fee_forfeitFeeBps,fee_protocolBps,fee_creatorBps,fee_cashbackBpsare copied into the challenge struct atcreateChallenge(). Admin fee changes do not affect existing challenges. - Underflow prevention.
protocolBps + creatorBps <= forfeitFeeBpsis enforced insetFeeConfig(). The computationlosersAfterCashback - feeGrosscannot underflow becausefeeGross = (losersAfterCashback * forfeitFeeBps) / 10000whereforfeitFeeBps <= 10000. - Fee caps.
FeeCapssets hard upper bounds onforfeitFeeBpsandcashbackBps. Once set, these cannot be exceeded bysetFeeConfig().
Access control
- 2-step admin transfer.
transferAdmin()setspendingAdmin;acceptAdmin()must be called by the pending admin. Prevents accidental admin loss. - Dispatcher ACL. Only addresses in the
dispatchersmapping (or admin) can callsubmitProofFor/submitProofForBatch. This prevents unauthorized proof submissions. - Creator allowlist. Optional gate (
useCreatorAllowlist) restricts who can create challenges. - Token allowlist. Optional gate (
useTokenAllowlist) restricts which ERC-20 tokens can be used.
Challenge integrity
- Verifier immutability.
setVerificationConfig()can update the verifier or proof deadline, but whenproofTightenOnlyis enabled, the deadline can only be reduced (not extended). This prevents bait-and-switch attacks where rules change after participants join. - Cancel blocked after winners.
cancelChallenge()reverts withAlreadyFinalizedifwinnersCount > 0. Once any participant has a verified proof, the challenge cannot be canceled. - Finalize requires deadline passage.
finalize()requires bothblock.timestamp >= endTimeandblock.timestamp >= proofDeadlineTs. No early finalization is possible. - Double-claim prevention.
winnerClaimed[sender],loserClaimed[sender], andrefundClaimed[id][sender]mappings prevent double claims.
Reentrancy
- All state-mutating public functions use OpenZeppelin’s
ReentrancyGuard(nonReentrantmodifier). - Treasury claims use the checks-effects-interactions pattern.
Global pause
admincan callpauseAll(true)to halt all challenge operations. ThenotPausedmodifier blockscreateChallenge,joinChallenge*,submitProof*,finalize,cancel, and all claim functions.
13. Dormant Infrastructure
TrustedForwarder
- Status: Deployed but inactive.
- Purpose: EIP-2771 gasless transaction relay.
- Current state:
ChallengePay.trustedForwarderis set to the forwarder address, but the forwarder’s target whitelist is empty. No relay is configured. The_msgSender2771()path is functional but never triggered in production because no external relayer is submitting meta-transactions. - Activation path: Admin calls
setTrustedForwarder(addr)on ChallengePay, and the forwarder must be configured with allowed target contracts and a funded relayer.
v1 AIVM path (archived 2026-04-28)
- Status: Archived in
.attic/v1-aivm-path/. Contracts (AIVMInferenceV2,LCAIValidatorRegistry,ChallengePayAivmPoiVerifier,ChallengeTaskRegistry) remain on chain but are unused. - Purpose: Commit-reveal-PoI ceremony for AI inference verification.
- Superseded by:
ChallengePayLightchainAttestor+ LightChain v2 worker network.
MetadataRegistry
- Status: Deployed and active for metadata writes, but not critical for protocol operation.
- Purpose: On-chain URI pointers for challenge metadata. Write-once by default (
ownerSet()reverts withAlreadySet). Admin corrections viaownerForceSet(). - Degradation: If metadata writes fail, challenges still function. The DB is the authoritative source for rendering. Failed writes are tracked and retried.
Appendix: Deployed Contracts (Testnet v2, chain ID 8200)
| Contract | Address |
|---|---|
| ChallengePay | 0xeC651C299E978667fCDeF706Ef5Dd285e56EFd0b |
| ChallengePayLightchainAttestor | 0xb400770550Db25Af86b1c3CC380e92BC777E3360 |
| Treasury | 0xF8E32344CC311A82f20112484F686b1038122FF3 |
| EventChallengeRouter | 0x08BA527C65FeD4653E8569fd26C582A72F4157d8 |
| MetadataRegistry | 0x21455872fc8529b3d91fB6d7Cb0E578c6817ef8D |
| ChallengeAchievement | 0xd4949186434C2F2b186A7E20Fcbe58ae1939a630 |
| TrustedForwarder | 0xc7C5F8f498158b2cdeaA9c91CBf79AE9d4251991 |
| MultiSigWallet (protocol) | 0xd9e56435290A2e8f93D6F8a0e329478D8E851469 |
Full address manifest: webapp/public/deployments/lightchain.json
Appendix: Event Reference
ChallengePay events
| Event | Emitted by |
|---|---|
ChallengeCreated(id, creator, kind, currency, token, startTs, externalId) | createChallenge() |
Joined(id, user, amount) | joinChallengeNative, joinChallengeERC20, joinChallengePermit |
ParticipantProofSubmitted(id, participant, verifier, ok) | _submitProofInternal() |
WinnerMarked(id, participant, contrib, winnersPool, winnersCount) | _submitProofInternal() (on success) |
Finalized(id, status, outcome) | finalize() |
Canceled(id) | cancelChallenge() |
FeesBooked(id, protocolAmt, creatorAmt, cashback) | _snapshotAndBook() |
SnapshotSet(id, success) | _snapshotAndBook() |
WinnerClaimed(id, user, amount) | claimWinner() |
LoserClaimed(id, user, amount) | claimLoser() |
RefundClaimed(id, user, amount) | claimRefund() |
Treasury events
| Event | Emitted by |
|---|---|
BucketCreditedETH(bucketId, from, amount) | depositETH() |
Received(from, amount) | receive() fallback |