Skip to content

docs(faucet): inline the testnet faucet widget as a Mintlify snippet - #71

Merged
alexander-sei merged 6 commits into
mainfrom
docs/inline-faucet-widget
Sep 5, 2026
Merged

docs(faucet): inline the testnet faucet widget as a Mintlify snippet#71
alexander-sei merged 6 commits into
mainfrom
docs/inline-faucet-widget

Conversation

@alexander-sei

@alexander-sei alexander-sei commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Closes PLT-1141

Summary

The faucet was the last page still iframing sei-widgets.vercel.app. This replaces that embed with an in-repo Mintlify snippet, finishing the widget migration started in 24fb87e.

The widgets repo kept /faucet as an iframe because it imported npm packages that Mintlify snippets cannot bundle (@hcaptcha/react-hcaptcha, viem, sonner). hCaptcha itself was never the blocker — none of those packages are actually required:

Dependency Replacement
@hcaptcha/react-hcaptcha js.hcaptcha.com/1/api.js loaded on mount, driven via window.hcaptcha.render / .execute
viem isAddress 0x + 40-hex shape test — not a like-for-like swap, see below
sonner toasts inline status banners

Mintlify's default CSP already allowlists hcaptcha.com and *.hcaptcha.com for script-src, frame-src, style-src and connect-src, so the invisible challenge renders on the docs origin without any CSP or sitekey work.

On address validation: viem's isAddress also verifies the EIP-55 checksum, which needs keccak256 and is not reachable from a snippet (no npm, and SubtleCrypto has no keccak). So a mixed-case address with a typo now reaches the faucet instead of being caught client-side, and the faucet is the authority on whether an address is real. Acceptable for valueless testnet SEI.

Changes

  • snippets/faucet.jsx (new) — the faucet UI: address input, invisible hCaptcha, request button, rate-limit notice, tx polling, and an explorer link. Uses only browser globals and the --sei-* design tokens, matching the other snippets.
  • learn/faucet.mdx — imports <Faucet /> instead of the iframe.
  • iframe-resizer.js (deleted) — the postMessage height bridge existed only for the sei-widgets iframes.
  • style.css — drops the aspect-ratio: auto override that unpicked Mintlify's 16:9 iframe wrapper for those same embeds.

The faucet backend is untouched: requests still POST to faucet-v3.seinetwork.io/atlantic-2 and poll /message/:id until a tx hash comes back.

Behavior notes

  • A sei1… paste gets a specific hint rather than a generic invalid-address error.
  • Failures that were toasts are rendered inline, so they persist. Where the faucet returns a message, that is shown instead of generic copy.
  • The rate-limit notice counts down and clears itself once the window passes.
  • Enter in the address field opens the captcha, or submits when already verified.
  • Polling keeps the 3s interval and 5min timeout, with an in-flight guard and a generation check so a late response cannot write stale state.

Testing

Manually exercised against the preview build: captcha completed, tokens requested, tx hash and explorer link resolved. This also confirms the invisible-challenge overlay escapes the 0×0 container clip on a Mintlify page.

Follow-up

Once this lands, sei-docs-widgets has no remaining routes and can be archived.

The faucet was the last page still iframing sei-widgets.vercel.app. The
widget repo kept it there because it imported npm packages that Mintlify
snippets cannot bundle (@hcaptcha/react-hcaptcha, viem, sonner), but none
of them are actually required:

- hCaptcha loads from js.hcaptcha.com and runs through
  window.hcaptcha.render / .execute. hcaptcha.com and *.hcaptcha.com are
  already on Mintlify's default CSP for script-src, frame-src, style-src
  and connect-src.
- address validation is a 0x + 40-hex regex instead of viem's isAddress
- sonner toasts become inline status banners

The faucet backend is unchanged: requests still POST to
faucet-v3.seinetwork.io/atlantic-2 and poll /message/:id for the tx hash.

Also drops iframe-resizer.js and the aspect-ratio override in style.css,
which existed only to size the sei-widgets iframes.

Note: the hCaptcha sitekey needs docs.sei.io (plus localhost for
`mint dev`) added to its allowed hostnames, since the captcha now runs on
the docs origin rather than inside the widget iframe.

Co-authored-by: Cursor <cursoragent@cursor.com>
@mintlify

mintlify Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
sei-docs 🟢 Ready View Preview Sep 4, 2026, 9:27 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

The sitekey is not hostname-restricted, so moving the challenge from the
widget iframe to the docs origin needs no dashboard change. Removes the
note from the snippet header and rewrites the load-failure message, which
told readers to edit a sitekey they have no access to.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Adds a large client-side faucet flow (hCaptcha, cross-origin API calls, polling) on a user-facing docs page; backend and token economics are unchanged and limited to testnet.

Overview
Replaces the last sei-widgets iframe on the faucet docs page with an in-repo Faucet Mintlify snippet, completing the widget migration.

The new snippet talks to the same faucet-v3.seinetwork.io backend (POST /atlantic-2, poll /message/:id), loads hCaptcha from js.hcaptcha.com without npm deps, and uses inline status UI instead of toasts. Copy now asks for an EVM 0x address (with hints when users paste sei1…). Client validation is a hex shape check only—no EIP-55 checksum like viem.

iframe-resizer.js is removed and style.css drops the Mintlify iframe aspect-ratio override, since those existed only for the embedded widgets.

Reviewed by Cursor Bugbot for commit 3f7e07c. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, self-contained replacement of the sei-widgets faucet iframe with an in-repo Mintlify snippet: the effect/interval lifecycle is cleaned up correctly, the deleted iframe-resizer.js and CSS override have no remaining consumers, and the snippet matches the repo's existing hook/token conventions. No blockers found — remaining notes are accessibility, error-surfacing, and polish items, plus the fact that both second-opinion passes produced no output.

Findings: 0 blocking | 12 non-blocking | 7 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Both second-opinion review files are empty — codex-review.md and cursor-review.md contain no content, so this review is from a single pass with no Codex/Cursor findings merged in. REVIEW_GUIDELINES.md is also empty; I applied AGENTS.md conventions instead.
  • Verified the cleanup is safe: no sei-widgets.vercel.app or localhost:3100 iframes remain anywhere in the repo, and neither iframe-resizer.js nor the deleted style.css block was referenced from docs.json (Mintlify auto-injects root-level assets), so removing both is a no-op for the remaining Symphony/YouTube iframes.
  • There is no automated coverage for snippets — validation is entirely the manual preview check in the PR description. Worth having a reviewer actually complete a captcha + request on the preview build before merge, since the failure modes here (hCaptcha overlay rendering, CSP on the docs origin) are invisible to CI and the production domain differs from the preview domain.
  • learn/faucet.mdx:14 still says "Enter your wallet address below" while the widget now accepts only 0x EVM addresses. The placeholder and the bech32 hint carry that, but the prose could say "EVM (0x) address" so readers know before they paste a sei1… address.
  • The polling pollOnce can overlap itself if a /message/:id request takes longer than the 3s interval. Harmless for a read-only status poll, but an in-flight guard would avoid piling up requests on a slow faucet.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread snippets/faucet.jsx Outdated
<div className='not-prose w-full flex flex-col gap-4 my-4' style={{ position: 'relative' }}>
<div
ref={captchaNode}
aria-hidden='true'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] aria-hidden='true' on the element that hCaptcha renders into means the invisible challenge — which is injected as a child of this container when execute() runs — sits inside an aria-hidden subtree, so screen readers won't see the image/audio challenge and can't complete it. The container needs to be hidden visually but not from assistive tech; consider dropping aria-hidden (the container is empty until the challenge opens) rather than hiding the whole subtree.

Separately, worth confirming on the preview build that width: 0; height: 0; overflow: hidden doesn't clip the challenge. hCaptcha positions the overlay fixed, so it should escape the clip as long as no Mintlify ancestor sets transform/filter/contain — but that's exactly the kind of thing only a real render will tell you.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 15a366a — dropped aria-hidden from the container. It stays out of the layout (position: absolute; width: 0; height: 0) but is now in the accessibility tree, so the image/audio challenge is reachable.

On the clipping question: a manual run against the preview build completed the captcha and a token request successfully, so the fixed-positioned overlay does escape the 0×0 clip on the Mintlify page — no ancestor transform/filter/contain in the way.

Comment thread snippets/faucet.jsx Outdated
} else if (responseJson && responseJson.data && responseJson.data.nextAllowedUseDate) {
setNextUseTime(responseJson.data.nextAllowedUseDate);
} else {
setErrorMsg('Error requesting tokens. Please try again later.');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Any non-success response that isn't a rate limit collapses into the same generic "Error requesting tokens." — including the ones a user could act on, e.g. a rejected captcha token or a rejected address. If the faucet returns a message/error field, surfacing it here (falling back to the generic string) would make failures self-service instead of sending people to Discord. This is a step back from the previous behavior in one respect: the PR moves toast copy inline precisely so it persists, but the persisted copy no longer says what went wrong.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 15a366a. Added a faucetError(payload) helper that reads the first non-empty string among message, error, data.message, data.error, data.reason and falls back to the generic copy only when the response says nothing useful. You are right that this was the one place the inline-banner change regressed against the toasts it replaced.

Comment thread snippets/faucet.jsx Outdated
const formatNextUseTime = (iso) => {
const date = new Date(iso);
const diffMs = date.getTime() - Date.now();
if (!isFinite(diffMs) || diffMs <= 0) return 'now';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] If nextAllowedUseDate is missing or unparseable, getTime() is NaN, !isFinite is true, and the banner reads "You can request tokens again in now" — which contradicts the button being disabled. A distinct fallback ("later" / "soon") would read correctly. Also note this string is computed at render time and never re-renders, so a "23h 59m" notice stays frozen while the user sits on the page.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 15a366a, both halves.

The NaN case is now separated from the expiry case and returns "a little while", so it no longer contradicts the disabled button. And the notice is no longer frozen: a 60s interval (only while nextUseTime is set) drives a nowMs state, and once the deadline passes it clears nextUseTime, which re-enables the button rather than stranding the reader on an expired countdown.

Comment thread snippets/faucet.jsx
})
.catch(() => {
if (cancelled) return;
window.__seiHCaptchaPromise = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Clearing window.__seiHCaptchaPromise on failure allows a retry, but the script[data-sei-hcaptcha] tag is left in the DOM — so the next attempt takes the document.querySelector branch and polls waitForApi() for the full 15s before failing again, rather than re-requesting the script. Removing the script element here would make the retry meaningful.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 15a366a — the script[data-sei-hcaptcha] tag is now removed alongside the cached promise, so a retry actually re-fetches instead of polling out the full 15s against a script that already failed.

Comment thread snippets/faucet.jsx
}
};

const isSubmitDisabled = !!nextUseTime || !isValidAddress || !captchaToken || isPolling || sendingRequest;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Because isSubmitDisabled already covers !captchaToken and !isValidAddress, the two matching guards at the top of handleSubmit (lines 251-258) are unreachable — the button can't be clicked in those states. The flip side is that a user with a valid address and no captcha sees a greyed-out button with no explanation of why. Consider either dropping the dead guards, or keeping the button enabled so the guards can do their job of explaining the blocker.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 15a366a. Took both halves of the suggestion: the unreachable guards are gone, and the card now names the missing precondition ("Complete the captcha verification to enable the request.", or the address hint) so the disabled button is explained rather than silent. Kept the button disabled since that reads better than one that is enabled only to reject you.

Comment thread snippets/faucet.jsx
className='flex-1 min-w-0 px-5 py-5 outline-none bg-transparent text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500'
placeholder='Enter your EVM (0x...) address'
value={destAddress}
onChange={handleAddressChange}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The input isn't wrapped in a <form> and has no onKeyDown handler, so pressing Enter after pasting an address does nothing — the user has to mouse over to the buttons. An onKeyDown that fires handleCaptchaVerification (or handleSubmit when already verified) would match the muscle memory for a single-field form.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 15a366a — added an onKeyDown on the input. Enter opens the captcha when the address is valid but unverified, and submits when already verified. No-ops on an invalid address so it cannot fire a request that would just bounce.

Comment thread snippets/faucet.jsx
const [requestHover, setRequestHover] = useState(false);

const trimmed = destAddress.trim();
const isValidAddress = /^0x[0-9a-fA-F]{40}$/.test(trimmed);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Worth flagging that this is a slight relaxation, not a like-for-like swap: viem's isAddress validates the EIP-55 checksum by default, so mixed-case addresses with a typo were previously rejected client-side and now pass through to the faucet. Fine if you're happy to let the backend be the authority (and the tokens are worthless testnet SEI), but the PR description presents the regex as an equivalent replacement.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, and the PR description overstated it — corrected there and in the snippet header, which now says this is a shape test rather than an equivalent swap. EIP-55 verification needs keccak256, which is not reachable from a Mintlify snippet (no npm, and SubtleCrypto has no keccak), so the faucet stays the authority on the address. Happy to accept that for valueless testnet SEI.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread snippets/faucet.jsx
Bugbot (medium): a /message/:id response could land after polling had
already stopped, so a stale confirmation could appear next to the timeout
error, or after the reader had changed the address. stopPolling now bumps
a generation counter that pollOnce checks before it writes any state.

seidroid:
- the hCaptcha container was aria-hidden, which put the image and audio
  challenge inside a hidden subtree and out of reach of screen readers.
  It is still visually hidden, but now stays in the accessibility tree.
- non-rate-limit failures all collapsed into one generic string, so a
  rejected captcha or address said nothing actionable. The faucet's own
  message field is surfaced when it sends one.
- an unparseable nextAllowedUseDate rendered "request again in now" while
  the button stayed disabled; it now reads "a little while". The notice
  also ticks, and clears itself once the window has passed, instead of
  freezing at whatever it said on first render.
- a failed hCaptcha load left its script tag behind, so the next attempt
  waited out the full 15s timeout rather than re-fetching. The tag is
  removed with the cached promise.
- the two guards at the top of handleSubmit were unreachable, because the
  button is disabled in exactly those states. Dropped them, and the card
  now names the missing precondition instead of showing an unexplained
  greyed-out button.
- Enter in the address field does nothing; it now advances to the captcha,
  or submits when already verified.
- a poll slower than the 3s interval could stack up behind it. Guarded.

Also notes in the header comment that the regex is a shape test, not a
like-for-like swap for viem's isAddress: EIP-55 needs keccak256, which is
not available here, so the faucet stays the authority.

learn/faucet.mdx said "wallet address" while the field takes 0x only.

Co-authored-by: Cursor <cursoragent@cursor.com>
@alexander-sei

Copy link
Copy Markdown
Collaborator Author

Thanks both — all 8 inline items plus the two summary-level ones are addressed in 15a366a, and I have replied in each thread.

Bugbot, medium — stale poll updates UI. stopPolling now bumps a generation counter; pollOnce captures it at start and re-checks after the await before writing any state. A response landing after an address change or after the 5-minute timeout is discarded. Covers all three flagged locations.

seidroid inline

Item Resolution
aria-hidden hides the challenge from screen readers Dropped. Still visually hidden, now in the a11y tree
Generic error swallows actionable failures faucetError() surfaces the faucet's own message/error, generic only as fallback
NaN renders "request again in now"; notice frozen Distinct "a little while"; 60s tick; auto-clears at the deadline
Failed hCaptcha load leaves its script tag Tag removed with the cached promise, so retry re-fetches
Unreachable guards in handleSubmit Removed; the card now names the missing precondition
Enter does nothing onKeyDown opens the captcha, or submits when verified
Regex is not a like-for-like isAddress Correct — PR description and snippet header now say shape test. EIP-55 needs keccak256, unavailable in a snippet

seidroid summary

  • learn/faucet.mdx prose now reads "Enter your EVM (0x) address" instead of "wallet address".
  • pollOnce overlap: in-flight guard added.
  • No automated coverage: agreed, and it was worth flagging. A manual pass against the preview build has now been done end to end — captcha completed, tokens requested, tx hash and explorer link resolved. That also settles your open question on whether width: 0; height: 0; overflow: hidden clips the challenge: it does not, the fixed overlay escapes it.

Snippet verified to compile with esbuild --loader:.jsx=jsx; mint dev still will not start locally (uv_interface_addresses in detect-port), so the preview build remains the real check.

crate-ci/typos rejects "unparseable" in favour of "unparsable".

Co-authored-by: Cursor <cursoragent@cursor.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-commented migration of the faucet from a third-party iframe to an in-repo Mintlify snippet; the stale-response guards, cleanup paths, and CSP/dependency reasoning are all sound, and I confirmed the iframe-resizer.js and style.css deletions leave no dangling references. Four non-blocking issues, mostly around the rate-limit countdown clock and accessibility of the status banners.

Findings: 0 blocking | 8 non-blocking | 4 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Both configured second-opinion passes produced no output — codex-review.md and cursor-review.md are empty files, and REVIEW_GUIDELINES.md is also empty, so no repo-specific standards or external findings could be merged into this review.
  • The snippet carries non-trivial async logic (poll generation tracking, in-flight guard, 5min timeout, hCaptcha lifecycle) but has no automated coverage — verification is manual against a preview build. That matches existing practice for snippets/*.jsx in this repo, so it's a note rather than a request, but the polling/captcha state machine is the most test-worthy code added to snippets/ so far.
  • llms-full.txt:2905 still contains the old sei-widgets.vercel.app/faucet iframe markup. No action needed — .github/workflows/regenerate-llms.yml rebuilds it — just flagging so the stale entry isn't mistaken for a missed reference.
  • The viem isAddress → shape-test tradeoff is correctly reasoned and documented in both the PR body and the file header. Agreed it's acceptable for valueless testnet SEI with the faucet as the authority; no change requested.
  • 4 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread snippets/faucet.jsx Outdated
const deadline = new Date(nextUseTime).getTime();
const id = setInterval(() => {
const now = Date.now();
setNowMs(now);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] nowMs is seeded once at mount (useState(() => Date.now())) and only refreshed inside this interval, whose first tick lands 60s after nextUseTime is set. So the rate-limit banner's first render — and its entire first minute on screen — computes diffMs against a clock that is as old as the page session.

Concretely: a reader opens /learn/faucet, reads for 20 minutes, requests tokens, and gets rate-limited with a 2h window. formatNextUseTime returns deadline - nowMs = 2h20m, so the banner claims "2h 20m". Docs pages are long-lived tabs, so the skew can be much larger than that. It always over-states the wait, never under-states it, so it's cosmetic rather than functional — but it's the one number in the widget the reader is asked to act on.

Seeding the clock at the top of the effect body fixes it:

useEffect(() => {
	if (!nextUseTime) return;
	const deadline = new Date(nextUseTime).getTime();
	setNowMs(Date.now());   // don't render the first frame against a mount-time clock
	const id = setInterval(() => {
		...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and my bug — fixed in 8386020. setNowMs(Date.now()) now happens in the same update as setNextUseTime(deadline), so the first paint of the banner is already on a fresh clock and the interval only maintains it from there. Your 20-minute-tab example is exactly right: it would have read "2h 20m" for the first minute.

Comment thread snippets/faucet.jsx Outdated
setNowMs(now);
// Clear the notice once the window has passed so the button comes
// back, instead of stranding the reader on an expired countdown.
if (isFinite(deadline) && now >= deadline) setNextUseTime(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] If the faucet returns a nextAllowedUseDate that new Date() can't parse, deadline is NaN, this guard is permanently false, and nextUseTime never self-clears. Since isSubmitDisabled includes !!nextUseTime, the request button stays disabled indefinitely under a banner reading "You can request tokens again in a little while" — the reader's only escape is editing the address field, which isn't discoverable.

The !isFinite(diffMs) branch in formatNextUseTime already anticipates an unparseable date; this path should too. Clearing rather than latching would be consistent:

if (!isFinite(deadline) || now >= deadline) setNextUseTime(null);

Or reject the value at the source in handleSubmit before calling setNextUseTime.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also mine, also fixed in 8386020 — and I took the stronger version of your suggestion.

Rather than clearing on a NaN deadline, the unreadable date never reaches state at all: nextUseTime is now a parsed timestamp via a parseDeadline() helper, and if the faucet sends something new Date() cannot read we show a plain "Rate limited by the faucet. Try again later." instead. That removes the latch, and it also removes the !isFinite(diffMs) branch you had me add earlier, since the invariant is now enforced where the value enters state rather than re-checked at render.

Comment thread snippets/faucet.jsx Outdated
if (pollGenRef.current !== generation) return;
setPollingMessage('Checking transaction status...');
} finally {
pollInFlightRef.current = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The finally clears pollInFlightRef unconditionally, including for a poll whose generation is already stale — which can hand a newer polling run an incorrectly-cleared flag.

Sequence: poll A is awaiting fetch, pollInFlightRef === true. The reader edits the address → stopPolling() sets the flag false and bumps the generation. A resubmit calls startPolling, and poll B sets the flag true and awaits. A's fetch now resolves, hits the generation check at line 232, returns — and this finally sets the flag to false while B is still in flight. The next 3s tick then issues a second concurrent /message/:id for the same run, which is exactly what the guard at line 224 exists to prevent.

Self-limiting (one extra request, and stale results are already filtered by generation), but the fix is a one-liner:

} finally {
	if (pollGenRef.current === generation) pollInFlightRef.current = false;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and a genuine hole in the fix I pushed for the Bugbot finding — the generation check protected state writes but not the flag itself. Fixed in 8386020:

} finally {
    if (pollGenRef.current === generation) pollInFlightRef.current = false;
}

A superseded run now leaves the flag to whoever owns the current generation, so your A/B sequence can no longer let the interval issue a second request while B is still awaiting.

Comment thread snippets/faucet.jsx Outdated

{blockedReason ? <p className='text-sm text-neutral-600 dark:text-neutral-400'>{blockedReason}</p> : null}

{errorMsg ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] None of the four status regions — blockedReason (478), errorMsg (here), the rate-limit notice (486), and the polling/confirmed banner (496) — are announced to assistive tech. A screen-reader user presses "Request SEI", and the only feedback is the button label flipping to "Processing..."; the failure reason, the rate-limit countdown, and the eventual "Transaction confirmed" plus explorer link all appear silently below the focused element.

The captcha-container comment at line 424 shows a11y was deliberately considered here, so this reads as an oversight rather than a decision. Wrapping the banner stack in a live region covers all four at once:

<div role='status' aria-live='polite' className='flex flex-col gap-4'>
	{/* blockedReason, errorMsg, nextUseTime, polling/txHash banners */}
</div>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and it was an oversight rather than a decision — fixed in 8386020.

All four regions are now inside persistent live regions: role="alert" for errorMsg, role="status" for the blocker hint, the rate-limit notice, and the polling/confirmed banner. They are wrapped rather than annotated in place, because the blocks mount and unmount and a live region that appears together with its own text is not reliably announced — the region has to already exist for the subtree change to be picked up. The wrappers are display: contents so they hold the roles without becoming flex children, leaving the gap-4 layout exactly as it was.

…ixes

Three of these were introduced by the previous commit.

- nowMs was seeded at mount and only refreshed by the 60s interval, whose
  first tick lands a minute after the banner appears. A docs page is a
  long-lived tab, so the first minute of the countdown was off by however
  long the reader had been on the page. It is now set in the same update
  as the deadline.
- an unreadable nextAllowedUseDate left deadline as NaN, so the expiry
  guard was never true and the notice never cleared — the button stayed
  disabled with no way back except editing the address. nextUseTime is now
  a parsed timestamp, and a date we cannot read never enters state; the
  reader gets a plain rate-limit message instead.
- the finally block released the in-flight flag even for a superseded
  poll, so a stale run could clear the flag while a newer run was still
  awaiting, letting the interval fire a second overlapping request. Only
  the run that still owns the generation releases it now.

Also: none of the four status regions were announced to assistive tech, so
a screen-reader user got no feedback beyond the button label. Each is now
inside a persistent live region — display: contents, so the flex layout is
untouched — with role=alert for errors and role=status for the rest.

Co-authored-by: Cursor <cursoragent@cursor.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-documented migration of the faucet from a third-party iframe to an in-repo Mintlify snippet; the cleanup of iframe-resizer.js and the style.css aspect-ratio override is complete (no dangling references remain). No blocking correctness or security issues — the remaining findings are edge-case UX/a11y polish in snippets/faucet.jsx.

Findings: 0 blocking | 10 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Both second-opinion passes produced no output: codex-review.md and cursor-review.md are empty, so nothing was merged from Codex or Cursor. REVIEW_GUIDELINES.md is also empty, so no repo-specific standards were applied beyond AGENTS.md/STYLE_GUIDE.md.
  • Dropping viem's EIP-55 checksum check is called out in the PR description and is a reasonable trade for a valueless testnet faucet — the shape test plus server-side authority is fine. No objection, just confirming the trade-off was deliberate and documented.
  • Verified the removals are safe: grep finds no remaining references to iframe-resizer or sei-widgets.vercel.app in MDX/CSS/JS/JSON, so deleting the resizer and the aspect-ratio: auto override strands nothing. Explorer path testnet.seiscan.io/tx/<hash> matches the convention used elsewhere in the repo, and the --sei-* tokens referenced all exist in style.css.
  • llms-full.txt:2905 still contains the old sei-widgets.vercel.app/faucet iframe markup. Per AGENTS.md that file is regenerated by the weekly regenerate-llms.yml action and must not be hand-edited — flagging only so someone can trigger the workflow manually if the stale snippet matters before the next run.
  • This snippet is now first-party code with a nontrivial state machine (poll generations, in-flight guard, captcha lifecycle) and the repo has no JS test or lint job in CI — verification is manual against the preview build. Consistent with existing practice for snippets/*.jsx, but worth noting as the snippet directory grows.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread snippets/faucet.jsx
<div className='flex items-stretch'>
<button
type='button'
onClick={handleCaptchaVerification}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The Verify Captcha button is never disabled. Two consequences: (1) clicking it while sendingRequest/isPolling opens a fresh challenge mid-request, and the resulting token is immediately discarded by the resetCaptcha() in handleSubmit; (2) after "Verified" it still calls hcaptcha.execute(id), re-running the challenge on a token that was already good. handleCaptchaVerification guards !captchaReady with an error banner, but a disabled={!captchaReady || !!captchaToken || sendingRequest || isPolling} would make the state visible instead of only reporting it after the click.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3f7e07c. Added isVerifyDisabled = !captchaReady || !!captchaToken || sendingRequest || isPolling || !!nextUseTime, which covers both cases you list plus the rate-limited one. The !captchaReady error banner stays as the keyboard-reachable explanation for the one state a reader might hit early; the rest are now simply not clickable.

Comment thread snippets/faucet.jsx Outdated
setCaptchaReady(true);
})
.catch(() => {
if (cancelled) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The if (cancelled) return; guard runs before the window.__seiHCaptchaPromise = null reset, so if the component unmounts while the script load is failing (Mintlify client-side nav away during the 15s wait), the rejected promise stays cached on window for the life of the tab. Every later mount then short-circuits to that rejection at loadHCaptcha's if (window.__seiHCaptchaPromise) check and can never re-fetch the script. Moving the promise/tag cleanup above the cancelled check — and keeping only setErrorMsg behind it — makes the retry path survive an unmount.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real bug, fixed in 3f7e07c — the cache reset and tag removal moved above the cancelled check, so an unmount during a failing load no longer poisons window.__seiHCaptchaPromise for the life of the tab. Only the setErrorMsg stays behind the guard, which is the one thing that actually needs the component to still be mounted.

Comment thread snippets/faucet.jsx
className='flex-1 min-w-0 px-5 py-5 outline-none bg-transparent text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500'
placeholder='Enter your EVM (0x...) address'
value={destAddress}
onChange={handleAddressChange}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The address input stays editable while sendingRequest is true. trimmed is captured in the closure at click time, so a keystroke during the in-flight POST clears txHash/errorMsg and calls stopPolling(), but the response then calls startPolling() with a fresh generation and eventually renders "Transaction confirmed" for an address the field no longer shows. Disabling the input (or the field's reset side effects) while sendingRequest || isPolling would keep the banner and the field in agreement.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3f7e07c — the input is now disabled while sendingRequest || isPolling. That also removes the reset side effects you point at, since handleAddressChange cannot fire, so the confirmation banner can no longer describe an address the field has stopped showing.

Comment thread snippets/faucet.jsx Outdated
// Clear the notice once the window has passed so the button comes
// back, instead of stranding the reader on an expired countdown.
if (now >= nextUseTime) setNextUseTime(null);
}, 60000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The countdown ticks every 60s, which matches the minute-granularity display, but it also gates the setNextUseTime(null) that re-enables the request button. A deadline that expires just after a tick leaves the button disabled and the notice on screen for up to a full extra minute. A shorter interval (or a setTimeout scheduled for the exact deadline alongside the 60s display tick) clears it on time.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3f7e07c, taking the setTimeout option: the 60s interval now only drives the display, and expiry is a separate timeout scheduled for nextUseTime - Date.now(). The button comes back on the deadline rather than on the next minute boundary.

Comment thread snippets/faucet.jsx Outdated
its own text — the region has to be there already. These wrappers
are display: contents, so they hold the roles without taking part
in the flex layout. */}
<div style={{ display: 'contents' }} role='status'>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Keeping the live regions permanently mounted is the right call, but display: contents is a risky carrier for an ARIA role — browsers historically dropped display: contents elements from the accessibility tree, and support for preserving roles on them is still uneven. Since the announcements are the whole point of these wrappers, a zero-height wrapper (or hoisting role='status'/role='alert' onto persistent siblings that already participate in the flex layout) would be more dependable than relying on display: contents to keep the region addressable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — that was the weak point of the previous commit and you are right that display: contents has a poor history in the accessibility tree. Changed in 3f7e07c to ordinary always-mounted <div role="status"> / <div role="alert"> wrappers.

The reason for contents was to avoid the parents gap-4adding spacing around empty wrappers. That is solved by moving the spacing off the parent: the parent is now plainflex flex-coland each visible status block carriesmt-4`. An empty wrapper is a zero-height flex item contributing no margin, so the rendered layout is unchanged, and the roles now sit on elements that browsers reliably expose.

- the Verify Captcha button was never disabled, so it could re-run a
  solved challenge (discarding a good token) or open one mid-request that
  handleSubmit's resetCaptcha would immediately throw away.
- the hCaptcha catch checked for unmount before clearing the cached
  promise, so navigating away during a failing load left the rejection on
  window for the life of the tab and no later mount could re-fetch. The
  cache and script tag are cleared first now.
- the address field stayed editable during an in-flight request, so a
  keystroke could reset the banners while the POST resolved against the
  address captured at click time, confirming an address no longer shown.
  Disabled while sending or polling.
- re-enabling the request button hung off the 60s display tick, leaving it
  dead for up to a minute past the deadline. Expiry is its own timeout,
  scheduled for the deadline itself.
- display: contents is a poor carrier for an ARIA role — browsers have
  historically dropped such elements from the accessibility tree. The live
  regions are now ordinary always-mounted wrappers, with spacing moved
  from a parent gap onto the blocks themselves so an empty wrapper still
  occupies no height.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3f7e07c. Configure here.

Comment thread snippets/faucet.jsx
// Separate from the display tick: hanging the re-enable off a 60s
// interval would leave the button dead for up to a minute past the
// deadline. This fires on the deadline itself.
const expiry = setTimeout(() => setNextUseTime(null), Math.max(0, nextUseTime - Date.now()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate-limit release ignores wall clock

Medium Severity

The rate-limit unlock is now a one-shot relative setTimeout, while the countdown still compares nextUseTime to wall-clock Date.now(). After system sleep, browsers often freeze that timer, so the notice can read expired while nextUseTime stays set and both request and verify remain disabled.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3f7e07c. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-reasoned migration of the last sei-widgets iframe into an in-repo Mintlify snippet: the lifecycle handling (poll generation guard, in-flight lock, deadline timer, captcha teardown) is unusually careful, and both deletions are safe — I confirmed no remaining references to iframe-resizer.js or to sei-widgets.vercel.app/localhost:3100 iframes. No blockers; a handful of minor polling/UX/a11y nits below.

Findings: 0 blocking | 12 non-blocking | 4 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Both second-opinion passes produced no output — codex-review.md and cursor-review.md are empty files. REVIEW_GUIDELINES.md is empty too, so no repo-specific standards were applied beyond AGENTS.md. All findings here come from this pass alone.
  • Verified the two deletions are safe: no file references iframe-resizer.js, and no sei-widgets.vercel.app / localhost:3100 iframe remains. The removed CSS selector was scoped to those hosts, so the surviving iframes (YouTube, symph.ag, seistream) are unaffected.
  • Design tokens used by the snippet (--sei-maroon-50/100/200, --sei-gold-100, --sei-live, --sei-font-mono) all exist in style.css, the explorer host matches the repo's testnet.seiscan.io convention, and the hooks-without-import + inline-icon-component style matches the other snippets.
  • handleSubmit never consults response.ok — an HTTP-level failure is only distinguishable if the body happens to be JSON with a message field. The faucetError fallback keeps this from being wrong, but folding the status code in (e.g. a distinct message for 429/5xx) would make faucet outages read as outages rather than as a generic retry prompt.
  • Dropping viem's EIP-55 checksum check is a real (if small) validation regression, but it's documented in the PR, the faucet remains authoritative, and the describeBlocker hint catches the common sei1… paste — reasonable for valueless testnet SEI.
  • No automated coverage: CI only lints prose, links and spelling, so the whole flow rests on the author's manual preview run. Fine for this repo's conventions — noting it because 565 lines of stateful async code now ship untested.
  • learn/faucet.mdx:20 still states the rate limit as "once per day / wait 24 hours" (pre-existing, outside the diff). Now that the UI surfaces a live countdown from nextAllowedUseDate, it'd be worth confirming the prose still matches the backend's actual window.
  • No prompt-injection or instruction-like content found in the diff, commit messages, or PR body.
  • 4 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread snippets/faucet.jsx
// Polling stopped while this was in flight — the address
// changed, or the timeout fired. Its result is stale.
if (pollGenRef.current !== generation) return;
if (responseJson && responseJson.status === 'success') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] A non-success top-level envelope isn't handled. If /message/:id returns e.g. { status: 'error', message: 'unknown message id' }, this if is skipped and control falls to the generic setPollingMessage('Checking transaction status...') on line 256, so the widget keeps polling every 3s for the full 5-minute timeout before telling the reader anything went wrong.

Worth adding an else if (responseJson && responseJson.status === 'error') that calls stopPolling() and surfaces faucetError(responseJson) — you already have that helper, and it would turn a 5-minute silent spinner into an immediate, specific message.

Comment thread snippets/faucet.jsx
// The request button is disabled until every precondition is met, so say
// which one is missing rather than leaving a greyed-out button unexplained.
const describeBlocker = () => {
if (nextUseTime || isPolling || sendingRequest || txHash) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] txHash in the early-return means that after a successful request the Request button goes back to disabled (captcha was reset on line 343) with no accompanying hint, since describeBlocker bails here. The enabled Verify Captcha button makes it recoverable, but dropping txHash from this condition — or returning the "Complete the captcha verification" hint when txHash && !captchaToken — would keep the stated goal of "never leave a greyed-out button unexplained" true for the repeat-request case too.

Comment thread snippets/faucet.jsx
if (pollInFlightRef.current) return;
pollInFlightRef.current = true;
try {
const response = await fetch(`${FAUCET_API_URL}/message/${messageId}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] messageId comes straight from the faucet response and is interpolated into the path unencoded; same for txHash in the explorer href on line 549. Not exploitable here (the https:// prefix is fixed, so neither can become a javascript: URL), but encodeURIComponent(...) on both would make a stray ?, # or / in an API value fail loudly instead of silently hitting a different path.

Comment thread snippets/faucet.jsx
autoCapitalize='off'
autoCorrect='off'
spellCheck={false}
aria-label='Sei EVM address'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The validation hint rendered at line 512 is announced via role="status", but it isn't programmatically tied to the field it describes. Giving that <p> an id and adding aria-describedby here would let a screen reader surface "Enter a valid EVM address…" when focus lands on the input, not only at the moment the text appears.

@alexander-sei
alexander-sei merged commit 53d8596 into main Sep 5, 2026
31 of 32 checks passed
@alexander-sei
alexander-sei deleted the docs/inline-faucet-widget branch September 5, 2026 00:27
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