Skip to content

feat: add <TokenCreate /> and <ChainSelector /> (slice of #73) - #151

Open
MayurK-cmd wants to merge 1 commit into
w3-kit:mainfrom
MayurK-cmd:feat/token-create-component
Open

feat: add <TokenCreate /> and <ChainSelector /> (slice of #73)#151
MayurK-cmd wants to merge 1 commit into
w3-kit:mainfrom
MayurK-cmd:feat/token-create-component

Conversation

@MayurK-cmd

Copy link
Copy Markdown

What

Implements the component — a guided flow for deploying a new ERC-20 (EVM) or SPL (Solana) token — plus the prerequisite . Ships via both the shadcn registry at registry/w3-kit/ and a new programmatic @w3-kit/ui package entry.

Why

Closes the assigned slice of #73 (Chain-agnostic components). The issue calls for a single UI that lets users create a token on EVM or Solana, with chain-agnostic field rendering, transaction preview, progress, and explorer-link result. This PR delivers that slice with factored out as a reusable component.

How

  • State machine (registry/w3-kit/token-create/token-create.tsx): 5 internal states — chain → configure → preview → submitting → result — collapsed to a 4-step visual stepper (Chain, Configure, Review, Done). Errors revert to preview with the form preserved.
  • EVM vs Solana branch: the consumer supplies a list of Chain objects; the component reads chain.ecosystem and renders or . EVM path exposes name/symbol/decimals (0–18)/initial supply/mintable/burnable. Solana exposes the same plus a "Keep authority / Renounce" radio (no per-holder burn in SPL; renounce is the equivalent).
  • Validation (utils.ts): pure functions validateEvmTokenForm and validateSolanaTokenForm — also exported so consumers can run them independently. Caps respect chain limits (EVM uint256, Solana u64).
  • Preview + Result: shows the deploy parameters read-only, shows the spinner phase with role="status", shows the deployed address with copy button and explorer links (etherscan.io, solscan.io, etc.) computed from chain.explorerHost.
  • Ecosystem detection in : chains are grouped by ecosystem field with EVM and Solana sections. Search and testnet toggle both supported. The list is a role="radiogroup" for accessibility.
  • is a sibling component (also ships via the registry) so TokenCreate doesn't duplicate its logic; both have .stories.tsx (CSF3) and .learn.md per ui Add .learn.md files to remaining UI components #70.
  • No contract coupling: onDeploy(req: DeployRequest) is a callback; consumers wire up viem/web3.js/Anchor/etc. req.family discriminates the path.

Out of scope (deliberate)

  • No automated unit tests — per issue discussion, smoke-test only. npm run smoke:a11y renders 6 component states into JSDOM and runs axe-core (6/6 pass, no critical/serious violations). Pure validators are exported and trivially testable later.
  • No Storybook install — CSF3 stories are ready in *.stories.tsx for a future npx storybook init. The repo currently has no Storybook.
  • Contract templates live in a separate contracts repo that's not in this working tree. Integration is delegated via the onDeploy callback contract rather than a hard dependency.

Checklist

  • Tests pass locally — npm run typecheck ✓, npm run lint ✓, npm run smoke:a11y ✓ (6/6 axe-core cases pass)
  • No unrelated changes included — only files for ChainSelector, TokenCreate, the @w3-kit/ui package, the smoke test, and repo-config updates needed by them
  • Documentation updated — .learn.md files added for both components per ui Add .learn.md files to remaining UI components #70 convention; new packages/w3-kit/README.md; registry.json entries for both components

@MayurK-cmd
MayurK-cmd requested a review from PetarStoev02 as a code owner July 30, 2026 15:23

const filtered = useMemo(() => {
let list = chains;
if (shouldShowToggle && !showTestnets) {

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.

Should-fix. shouldShowToggle gates both the toggle button (L133) and this testnet filter. So if a consumer passes showTestnetToggle={false} while the chain list contains testnets, the toggle is hidden AND this filter is skipped, so testnets are shown permanently with no way to hide them. The two concerns (render the toggle vs. filter the list) should be separated, e.g. always apply the !showTestnets filter when testnets exist and only let the prop control the button's visibility.

<button
key={chain.chainId.toString()}
onClick={() => handleSelect(chain.chainId)}
aria-pressed={isSelected}

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.

Should-fix (a11y). The container is role="radiogroup" (L121) but each option is a plain <button aria-pressed> rather than role="radio" / aria-checked, and there is no arrow-key roving-tabindex. A screen reader announces a radio group whose children are toggle buttons, and keyboard users get no arrow navigation. The axe smoke test will not catch this. Either give each row real radio semantics (role="radio", aria-checked, arrow-key handling) or drop the radiogroup role and expose them as a plain list of toggle buttons.

import { PendingOverlay, Progress } from "./progress";
import { ResultCard } from "./result-card";

const STEPS: StepDescriptor[] = [

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.

Should-fix. STEPS has no submitting entry, but step becomes "submitting" during deploy. In progress.tsx L16 currentIndex = steps.findIndex(s => s.id === current) then returns -1, so every step computes isComplete=false and isCurrent=false: the whole stepper renders as inert while the spinner shows (verified with a screenshot). Suggest mapping submitting onto the Review index so the stepper stays on step 3 during deployment.

const [result, setResult] = useState<DeployResult | null>(null);

const selectedChain = useMemo(
() => chains.find((c) => c.chainId === selectedChainId),

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.

Should-fix. chainId is typed number | string, but this lookup (and chain-selector.tsx L74/L79) compares with strict ===. A consumer passing defaultChainId={"1"} against numeric chainId: 1 (as in defaultEvmChains) silently never matches, so selectedChain is undefined and no row highlights. Normalize both sides (e.g. String(a) === String(b)) before comparing.

const whole = split[0] ?? "";
const frac = split[1] ?? "";
if (!whole) return "0";
const padded = (frac + "0".repeat(decimals)).slice(0, decimals);

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.

Should-fix. This silently truncates fractional digits beyond decimals, and the validators (^\d+(\.\d+)?$) never reject over-precision. So formatBaseUnits("1.9999999", 2) returns 199 (1.99) with no error, and that understated amount is what tx-preview.tsx shows as the raw value and what gets handed to onDeploy. Consider rejecting inputs with more fractional digits than decimals in the validator so the truncation is never silent.


export function ResultCard({ chain, result, className }: ResultCardProps) {
const addressUrl = explorerAddressUrl(chain, result.address);
const txUrl = explorerTxUrl(chain, result.txHash);

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.

Should-fix. explorerTxUrl / explorerAddressUrl only null-check explorerHost, so an empty txHash or address still yields a truthy URL like https://etherscan.io/tx/, and the txUrl && (...) guard renders a clickable link to a broken page (plus truncate("") renders an empty <code>). Worth guarding the value too, i.e. return null when the hash/address is empty.

} else {
// Reject values that would overflow an EVM uint256 when scaled.
const whole = BigInt(supply.split(".")[0]);
if (whole >= BigInt("115792089237316195423570985008687907853269984665640564039457")) {

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.

Nice-to-have. This cap is an unexplained magic bigint that is (2^256-1)/10^18, i.e. it hardcodes 18 decimals regardless of data.decimals. For a token with fewer decimals it over-rejects valid supplies. Deriving it from the actual decimals and naming the constant would read more clearly, e.g. (2n ** 256n - 1n) / 10n ** BigInt(data.decimals).

return errors;
}

export function validateSolanaTokenForm(data: SolanaTokenFormData): FieldError[] {

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.

Nice-to-have (DRY). validateSolanaTokenForm is a near-verbatim copy of validateEvmTokenForm; the name and symbol blocks are identical and only the decimals range and supply cap differ. Consider extracting a shared validateNameAndSymbol(data) plus a small per-chain { decimalsMax, supplyCap } config so only the chain-specific bits live in each path.

*/
export interface DeployRequest {
chain: Chain;
family: "evm" | "solana";

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.

Nice-to-have. DeployRequest.family duplicates chain.ecosystem (set verbatim in token-create.tsx L117), so consumers have two sources of truth that can disagree. Could drop family and have consumers branch on req.chain.ecosystem.

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.

2 participants