Protocol

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:

  1. ChallengePay holds zero funds. All deposits route to a bucketed Treasury contract. Claims are pull-based via Treasury allowances.
  2. Fees are snapshotted at creation time. Fee parameters are copied into each challenge at creation and cannot be changed retroactively.
  3. 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

ActorOn-chain identityRole
Creatorchallenge.creatorCreates a challenge, stakes tokens, receives creator fee share on finalization
ParticipantAny address calling joinChallengeNative / joinChallengeERC20Joins a challenge by staking, submits evidence off-chain, claims rewards
ProtocolChallengePay.protocol (immutable)Receives protocol fee share; absorbs rounding dust and no-winner distributable
DispatcherAddresses in ChallengePay.dispatchers mappingAuthorized off-chain service that calls submitProofFor / submitProofForBatch on behalf of participants
AdminChallengePay.admin (2-step transfer)Protocol governance: fee config, pause, dispatcher ACL, creator allowlist, verification config
LightChain workerA staked node on LightChain v2 (assigned by gateway)Runs the LLM judgment, emits signed JobCompleted on JobRegistry
AttestorChallengePayLightchainAttestor (off-chain key LCAI_WORKER_PK)Records verdicts on chain via attest(...); verify() reads back per (challengeId, subject)
TreasuryTreasury.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 winnersPool

Transition conditions

TransitionFunctionConditions
— -> ActivecreateChallenge(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 / joinChallengePermitstatus == Active, block.timestamp < joinClosesTs, participant cap not reached
Active -> Active (proof)submitMyProof / submitProofFor / submitProofForBatchstatus == Active, startTs <= block.timestamp <= proofDeadlineTs, participant has nonzero contribution, verifier returns true
Active -> Finalizedfinalize(id)status == Active, block.timestamp >= endTime, block.timestamp >= proofDeadlineTs, not already finalized
Active -> CanceledcancelChallenge(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 allowed
  • joinClosesTs: defaults to startTs if zero; must be <= startTs
  • endTime: startTs + duration
  • proofDeadlineTs: must be >= endTime; defines the grace period for proof submission after challenge end
  • finalize() requires block.timestamp >= endTime && block.timestamp >= proofDeadlineTs

4. Staking and Pooling

All funds are deposited into Treasury buckets where bucketId = challengeId.

Creator stake

  • Creator calls createChallenge() with msg.value (native) or ERC-20 approval
  • Funds route to ITreasury.depositETH(id) or ITreasury.depositERC20From(id, ...)
  • Creator’s stake is added to challenge.pool and challenge.contrib[creator]
  • Creator is marked as a participant

Participant stake

  • Participant calls joinChallengeNative(id) with msg.value, or joinChallengeERC20(id, amount), or joinChallengePermit(id, amount, deadline, v, r, s)
  • Funds route to the same Treasury bucket
  • Added to challenge.pool and challenge.contrib[participant]
  • Participant marked via _enforceParticipantCap()

Pool accounting

  • challenge.pool: sum of all contributions
  • challenge.winnersPool: sum of contributions from addresses where challenge.winner[addr] == true
  • losersPool = pool - winnersPool (computed at finalization)

Constraints

  • minStake: global minimum enforced at creation (admin-configurable via setMinStake())
  • maxParticipants: per-challenge cap (0 = unlimited)
  • Contributions are additive: a participant can join multiple times, increasing their contrib and pool

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 wallets

Proof verification

_submitProofInternal() calls challenge.verifier.verify(id, participant, proof) in a try/catch. If verification returns true:

  1. challenge.winner[participant] = true
  2. challenge.winnersPool += contrib
  3. challenge.winnersCount += 1
  4. 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)

ParameterFieldDescription
forfeitFeeBpsfee_forfeitFeeBpsTotal fee taken from losers’ forfeited pool (after cashback)
protocolBpsfee_protocolBpsProtocol’s share of the forfeited pool (after cashback)
creatorBpsfee_creatorBpsCreator’s share of the forfeited pool (after cashback)
cashbackBpsfee_cashbackBpsPercentage 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.pool
  • winnersPool = challenge.winnersPool
  • losersPool = 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

FunctionEligiblePayout formula
claimWinner(id)winner[sender] == true, contrib > 0, not yet claimedprincipal + principal * perCommittedBonusX / 1e18
claimLoser(id)winner[sender] == false, contrib > 0, perCashbackX > 0, not yet claimedprincipal * perCashbackX / 1e18
claimRefund(id)status == Canceled, contrib > 0, not yet claimedFull contrib (100% refund)

Edge cases

  • No winners (winnersPool == 0): outcome is Fail. The entire distributable amount is granted to protocol. 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):

  • protocolAmt to protocol address
  • creatorAmt to challenge.creator
  • distributable to protocol if 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

PropertyMechanism
Bucketed isolationEach challenge has its own bucket (bucketId = challengeId). Funds in one bucket cannot be used for another.
Operator grantsChallengePay holds OPERATOR_ROLE. It calls grantETH(bucketId, to, amount) / grantERC20(...) to create allowances.
Pull-based claimsRecipients call claimETH(bucketId) / claimERC20(bucketId, token) on Treasury directly. Unstoppable: no admin can prevent a granted claim.
Sweep safetySWEEPER_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 allowances

Deposit: 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

TypeEnum valueEligibility
CompletionAchievementType.Completion (0)Any address with contribOf(challengeId, user) > 0 in a Finalized challenge
VictoryAchievementType.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) — checks status == Finalized and outcome
  • contribOf(id, user) — confirms participation
  • isWinner(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 returns true. 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

  1. Register: Owner calls registerEvent(eventId, title) to create the event.
  2. Add outcomes: Owner calls addOutcome(eventId, name, challengeId, subject) for each possible result.
  3. 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).
  4. 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

ComponentRoleOperated by
ChallengePayLightchainAttestorRecords verdicts per (challengeId, subject); verify() returns true when the recorded verdict says passed=trueLightChallenge
LightChain v2 JobRegistryOn-chain job anchoring + JobCompleted event audit trailLightChain Foundation
LightChain v2 gatewaySIWE auth, session prep, dispatcher signing, blob uploadLightChain Foundation
LightChain v2 relayEncrypted channel for worker response chunksLightChain 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 += contrib

Verification flow (ChallengePayLightchainAttestor.verify())

verify(challengeId, subject, proof) decodes the proof bundle and checks:

  1. The attestation row exists for (challengeId, subject) with passed = true
  2. The proof’s responseHash matches what was attested
  3. The proof’s worker matches the attested worker
  4. The proof’s jobId matches 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 unitStakeFailure mode
LightChain worker5,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 keyRotatable via attestor.setAttestor(addr, false) without redeploy
LightChain dispatcherSingle LightChain Foundation keyOff-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’s address(this).balance should 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 where balance > outstanding + bucketBalances. Active challenge funds and pending claims are always protected.

Fee safety

  • Snapshot at creation. fee_forfeitFeeBps, fee_protocolBps, fee_creatorBps, fee_cashbackBps are copied into the challenge struct at createChallenge(). Admin fee changes do not affect existing challenges.
  • Underflow prevention. protocolBps + creatorBps <= forfeitFeeBps is enforced in setFeeConfig(). The computation losersAfterCashback - feeGross cannot underflow because feeGross = (losersAfterCashback * forfeitFeeBps) / 10000 where forfeitFeeBps <= 10000.
  • Fee caps. FeeCaps sets hard upper bounds on forfeitFeeBps and cashbackBps. Once set, these cannot be exceeded by setFeeConfig().

Access control

  • 2-step admin transfer. transferAdmin() sets pendingAdmin; acceptAdmin() must be called by the pending admin. Prevents accidental admin loss.
  • Dispatcher ACL. Only addresses in the dispatchers mapping (or admin) can call submitProofFor / 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 when proofTightenOnly is 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 with AlreadyFinalized if winnersCount > 0. Once any participant has a verified proof, the challenge cannot be canceled.
  • Finalize requires deadline passage. finalize() requires both block.timestamp >= endTime and block.timestamp >= proofDeadlineTs. No early finalization is possible.
  • Double-claim prevention. winnerClaimed[sender], loserClaimed[sender], and refundClaimed[id][sender] mappings prevent double claims.

Reentrancy

  • All state-mutating public functions use OpenZeppelin’s ReentrancyGuard (nonReentrant modifier).
  • Treasury claims use the checks-effects-interactions pattern.

Global pause

  • admin can call pauseAll(true) to halt all challenge operations. The notPaused modifier blocks createChallenge, 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.trustedForwarder is 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 with AlreadySet). Admin corrections via ownerForceSet().
  • 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)

ContractAddress
ChallengePay0xeC651C299E978667fCDeF706Ef5Dd285e56EFd0b
ChallengePayLightchainAttestor0xb400770550Db25Af86b1c3CC380e92BC777E3360
Treasury0xF8E32344CC311A82f20112484F686b1038122FF3
EventChallengeRouter0x08BA527C65FeD4653E8569fd26C582A72F4157d8
MetadataRegistry0x21455872fc8529b3d91fB6d7Cb0E578c6817ef8D
ChallengeAchievement0xd4949186434C2F2b186A7E20Fcbe58ae1939a630
TrustedForwarder0xc7C5F8f498158b2cdeaA9c91CBf79AE9d4251991
MultiSigWallet (protocol)0xd9e56435290A2e8f93D6F8a0e329478D8E851469

Full address manifest: webapp/public/deployments/lightchain.json


Appendix: Event Reference

ChallengePay events

EventEmitted 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

EventEmitted by
BucketCreditedETH(bucketId, from, amount)depositETH()
Received(from, amount)receive() fallback