Summary
The PoW rate limiter has two different definitions of "expired", and they disagree by exactly one second. In that second an already solved challenge can be redeemed a second time, without doing any new proof of work.
Challenge::is_expired treats a challenge as valid while current_time - timestamp <= challenge_lifetime:
https://github.com/0xMiden/faucet/blob/next/crates/pow/src/challenge.rs#L117-L120
The challenge cache, meanwhile, stops rate limiting a solver as soon as challenge_lifetime seconds have elapsed since their last submission, because next_challenge_delay saturates to 0 at exactly that point:
https://github.com/0xMiden/faucet/blob/next/crates/pow/src/challenge_cache.rs#L94-L98
ChallengeCache::drop_expired_challenges uses that same >= lifetime boundary. So at t = submission + challenge_lifetime the solver is free again, while the challenge they already used is still considered valid.
Steps to reproduce
With the default --pow-challenge-lifetime of 30s, a single (account_id, api_key) solver:
GET /pow for their account, solve the challenge once (issued at t0).
GET /get_tokens with that challenge and nonce at t0 — accepted, tokens minted.
GET /get_tokens again with the same challenge and the same nonce at t0 + 30 — accepted again, tokens minted again.
No API key is needed, since domain defaults when none is supplied.
As a unit test against next (crates/pow/src/tests.rs), which passes today:
let challenge = Challenge::new(target, issued_at, request_complexity, requestor, domain, secret);
let nonce = find_pow_solution(&challenge, 10000).unwrap();
// Redeemed once, as intended.
assert!(pow.submit_challenge(requestor, domain, &challenge, nonce, issued_at, request_complexity).is_ok());
// Redeemed a second time with the same solution, one lifetime later.
let t = issued_at + pow.config.challenge_lifetime.as_secs();
assert!(pow.submit_challenge(requestor, domain, &challenge, nonce, t, request_complexity).is_ok());
Expected behaviour
The second submission is rejected. validate_get_tokens_params already documents this as an error condition — "the challenge has already been used" — but nothing enforces it:
https://github.com/0xMiden/faucet/blob/next/bin/faucet/src/api/get_tokens.rs#L188-L195
Actual behaviour
The second submission succeeds, so one proof of work yields two mints.
Impact
The PoW cost per mint is halved for anyone who redeems on the boundary second. The window is only one second wide, but it is a whole second of wall clock at a predictable offset from a timestamp the server itself hands out in the /pow response, so hitting it from a script is not difficult. This is a faucet, so the direct cost is testnet tokens and the node capacity spent minting them, rather than anything of value.
I want to be clear about what this is not: it does not require a contrived clock or an unusual configuration, and it reproduces on the default 30s lifetime.
Notes
The two boundaries need to agree, and the cache's >= lifetime is the one that keeps the rate limit meaningful: a challenge has to stop being valid no later than the moment its solver stops being rate limited. Making is_expired use >= does that, and gives challenge_lifetime its natural reading of "valid for N seconds" (diffs 0..=N-1) rather than N+1 distinct seconds.
I have a fix ready with regression tests and can open a PR. One thing worth flagging in advance: requestor_is_rate_limited_after_challenge_expires currently depends on the boundary second still being valid, so it needs updating. It submits 2.9s into a 3s lifetime, which under whole-second timestamps can land on either side of the boundary, so it is also flaky today independently of this change; I made it deterministic by driving it with explicit timestamps instead of sleeps.
Summary
The PoW rate limiter has two different definitions of "expired", and they disagree by exactly one second. In that second an already solved challenge can be redeemed a second time, without doing any new proof of work.
Challenge::is_expiredtreats a challenge as valid whilecurrent_time - timestamp <= challenge_lifetime:https://github.com/0xMiden/faucet/blob/next/crates/pow/src/challenge.rs#L117-L120
The challenge cache, meanwhile, stops rate limiting a solver as soon as
challenge_lifetimeseconds have elapsed since their last submission, becausenext_challenge_delaysaturates to0at exactly that point:https://github.com/0xMiden/faucet/blob/next/crates/pow/src/challenge_cache.rs#L94-L98
ChallengeCache::drop_expired_challengesuses that same>= lifetimeboundary. So att = submission + challenge_lifetimethe solver is free again, while the challenge they already used is still considered valid.Steps to reproduce
With the default
--pow-challenge-lifetimeof 30s, a single(account_id, api_key)solver:GET /powfor their account, solve the challenge once (issued att0).GET /get_tokenswith that challenge and nonce att0— accepted, tokens minted.GET /get_tokensagain with the same challenge and the same nonce att0 + 30— accepted again, tokens minted again.No API key is needed, since
domaindefaults when none is supplied.As a unit test against
next(crates/pow/src/tests.rs), which passes today:Expected behaviour
The second submission is rejected.
validate_get_tokens_paramsalready documents this as an error condition — "the challenge has already been used" — but nothing enforces it:https://github.com/0xMiden/faucet/blob/next/bin/faucet/src/api/get_tokens.rs#L188-L195
Actual behaviour
The second submission succeeds, so one proof of work yields two mints.
Impact
The PoW cost per mint is halved for anyone who redeems on the boundary second. The window is only one second wide, but it is a whole second of wall clock at a predictable offset from a timestamp the server itself hands out in the
/powresponse, so hitting it from a script is not difficult. This is a faucet, so the direct cost is testnet tokens and the node capacity spent minting them, rather than anything of value.I want to be clear about what this is not: it does not require a contrived clock or an unusual configuration, and it reproduces on the default 30s lifetime.
Notes
The two boundaries need to agree, and the cache's
>= lifetimeis the one that keeps the rate limit meaningful: a challenge has to stop being valid no later than the moment its solver stops being rate limited. Makingis_expireduse>=does that, and giveschallenge_lifetimeits natural reading of "valid for N seconds" (diffs0..=N-1) rather than N+1 distinct seconds.I have a fix ready with regression tests and can open a PR. One thing worth flagging in advance:
requestor_is_rate_limited_after_challenge_expirescurrently depends on the boundary second still being valid, so it needs updating. It submits 2.9s into a 3s lifetime, which under whole-second timestamps can land on either side of the boundary, so it is also flaky today independently of this change; I made it deterministic by driving it with explicit timestamps instead of sleeps.