Skip to content

fix: renew the ALTCHA proof when its challenge expires - #5

Merged
oxyc merged 1 commit into
mainfrom
fix/renew-expired-challenge
Jul 31, 2026
Merged

oxyc merged 1 commit into
mainfrom
fix/renew-expired-challenge

Conversation

@oxyc

@oxyc oxyc commented Jul 31, 2026

Copy link
Copy Markdown
Member

The problem

The widget solves its proof once, on load. The challenge the server issues is valid for Challenge::DEFAULT_EXPIRES_SECONDS (600s). When that window closes the widget moves to the expired state and clears the solution — it does not re-solve on its own.

On a long form, ten minutes is an ordinary amount of time. The Snellman consumer-service forms (43 fi / 45 sv) pair a 400+ option product dropdown with two date pickers, an address block and a file upload. Past the window, submitting fails validation with a form-level message that highlights no field, and the visitor loses everything they typed.

This surfaced as a bug report against the sv form that was initially attributed to the product field. It isn't — with ALTCHA bypassed, every branch of that form validates cleanly, including the one reported as broken.

The fix

Re-solve on the widget's expired event. verify() re-reads the challenge attribute (a URL), so this fetches a fresh challenge rather than re-solving the dead one.

  • Parked while the tab is hidden, resumed on visibilitychange — a backgrounded tab can't be submitting, so there's no reason to grind proof-of-work there.
  • MutationObserver binds widgets that appear later, since Gravity Forms replaces the form markup after AJAX submissions and on multi-page navigation.
  • The expired event doesn't bubble, so listeners are bound per element.

display="invisible" means none of this is visible to the visitor.

Verification

Against a local build with the window shortened to 25s, watching the real event and network timeline:

t what happened
0.7s challenge fetched, solved, verified (552-char payload)
25.8s expired — widget clears the payload ← the bug
131.5s tab visible, renewal fires, fresh challenge fetched
156.8s that one expires, renews again → verified, payload restored

It recovers repeatedly, not just once.

Test suite: 63/63 pass. pint --test clean.

Tradeoff

A form left open now re-solves every 10 minutes while it's on screen. At the standard cost (200k, ~4s) that's cheap, and nothing happens at all while the tab is hidden.

Note

Carries the 0.6.0 version bump. #2 (feat/classify-verification-failures) should ship in the same release.

🤖 Generated with Claude Code

The widget solves its proof once, on load, and the challenge the server
issues is only valid for DEFAULT_EXPIRES_SECONDS (600). When that window
closes the widget moves to the `expired` state and clears the solution --
it does not re-solve on its own.

On a long form that is easily reached before the visitor is done. The
Snellman consumer-service forms pair a 400+ option product dropdown with
two date pickers, an address block and a file upload; ten minutes is an
ordinary amount of time to spend there. Submitting past that point fails
validation with a form-level message that highlights no field, and the
visitor loses everything they typed.

Re-solve on `expired` so the payload stays fresh for as long as the form
is open. Renewal is parked while the tab is hidden and resumes on
visibilitychange, so backgrounded tabs don't grind proof-of-work for
nothing. A MutationObserver picks up widgets that appear later, since
Gravity Forms replaces the form markup after AJAX submissions and on
multi-page navigation.

Verified in a browser against a build with a shortened window: the widget
expires, renews, and returns to `verified` with a fresh payload -- and
keeps doing so on each subsequent expiry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@oxyc
oxyc merged commit c73d927 into main Jul 31, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant