The 5 security invariants that protect Key0's payment flow. Load this to know what must never be broken — and therefore what must always be tested and reviewed.
These are the 5 rules that must hold at all times. Violating any one of them means someone can get paid access without paying, pay once and receive multiple tokens, or corrupt the payment state.
transition()All challenge state changes MUST use IChallengeStore.transition(id, fromState, toState, updates). Direct writes that bypass the compare-and-swap guard are forbidden.
Why: Without CAS, two concurrent submitProof calls both read PENDING, both pass verification, and both issue a token — one payment, two tokens.
The rule: transition() only writes if the current state matches fromState. If another caller already changed the state, this call returns false and must abort.
The full happy-path state machine is: PENDING → PAID → DELIVERED. DELIVERED is the terminal success state — it is set after succeeds and the is stored on the record. EXPIRED and CANCELLED are terminal failure states. All transitions go through .
fetchResourceCredentialsaccessGranttransition()Forbidden patterns:
store.set(), store.update(), or direct Map/Redis writes to change statetransition()markUsed() Return Value Is Checked, with Rollback GuardISeenTxStore.markUsed(txHash, challengeId) is an atomic SET NX. It returns false if that txHash was already used — abort immediately.
Why: Without this, the same on-chain transaction can be submitted as proof for two different challenges. One real payment → two different resources unlocked.
Three things must all be present:
transition(PENDING → PAID) succeeds before markUsed() is calledmarkUsed() is called before issuing the token; a false return aborts the flowmarkUsed() returns false, the state MUST be rolled back via transition(PAID → PENDING) — otherwise the challenge is stuck in PAID with no token issued and the honest client can never retry with the same txverifyTransfer() must verify ALL of the following. Skipping any one opens a specific attack:
| # | Check | Attack if skipped |
|---|---|---|
| 1 | receipt.status === "success" | Reverted tx accepted — no USDC moved |
| 2 | ERC-20 Transfer event in logs | Any contract interaction accepted, not just transfers |
| 3 | Transfer.to === challenge.destination | Payment to attacker's own address accepted |
| 4 | Transfer.value >= challenge.amountRaw | Underpayment accepted |
| 5 | chainId matches challenge.chainId | Free testnet USDC satisfies a mainnet challenge |
| 6 | block.timestamp <= challenge.expiresAt | Post-expiry payment accepted |
All six checks are required. Partial verification is not sufficient.
JWTs issued after payment must:
jti = challengeId — links the token to the specific challenge for replay detectionexp — tokens without expiry are valid forever, even after access should be revokedForbidden:
jti claimexp claimalg: "none" or algorithm confusion (e.g. switching HS256 to RS256 using a public key as the secret)onPaymentReceived and fetchResourceCredentials are user-supplied callbacks that run after the critical payment path.
onPaymentReceived — MUST be fire-and-forget:
.catch(noop) and not awaited in the main flowfetchResourceCredentials — errors leave the challenge stuck in PAID:
accessGrant and act on them