diff --git a/.claude/skills/add-action-and-hook/SKILL.md b/.claude/skills/add-action-and-hook/SKILL.md deleted file mode 100644 index 316eb3f3e..000000000 --- a/.claude/skills/add-action-and-hook/SKILL.md +++ /dev/null @@ -1,313 +0,0 @@ ---- -name: add-action-and-hook -description: How to add a new action and hook to AppKit. Use when creating new getXxx/watchXxx actions and useXxx hooks. ---- - -# Adding a New Action and Hook to AppKit - -This skill describes the end-to-end process of adding a new action (`getXxx` / `watchXxx`) and a corresponding React hook (`useXxx`) to the AppKit library. Follow each step in order, consulting existing examples as reference. - ---- - -## Step 1: Create the Action in `appkit` - -There are two types of actions: -- **Get actions** — fetch data asynchronously (`getXxx`). Used for one-time reads. -- **Watch actions** — subscribe to state changes (`watchXxx`). Used when the value can change over time and the UI needs to react. - -### 1.1 Study existing actions -Before creating your own, look at similar actions in `packages/appkit/src/actions/` to understand the pattern. - -**Get action example:** -`packages/appkit/src/actions/network/get-networks.ts` - -**Watch action example:** -`packages/appkit/src/actions/network/watch-networks.ts` -```ts -export const watchNetworks = (appKit: AppKit, parameters: WatchNetworksParameters): WatchNetworksReturnType => { - const { onChange } = parameters; - - const unsubscribe = appKit.emitter.on(NETWORKS_EVENTS.UPDATED, () => { - onChange(getNetworks(appKit)); - }); - - return unsubscribe; // caller is responsible for cleanup -}; -``` - -### 1.2 Create the action file -Create `packages/appkit/src/actions//get-xxx.ts` (or `watch-xxx.ts`). - -**Get action structure:** -```ts -import type { AppKit } from '../../core/app-kit'; - -export interface GetXxxOptions { /* ... */ } -export type GetXxxReturnType = Promise; - -export const getXxx = async (appKit: AppKit, options: GetXxxOptions = {}): GetXxxReturnType => { - // All business logic goes here - return result; -}; -``` - -### 1.3 Export the action -Add exports to `packages/appkit/src/actions/index.ts`: -```ts -export { - getXxx, - type GetXxxOptions, - type GetXxxReturnType, -} from './/get-xxx'; -``` - ---- - -## Step 2: Create the Query or Mutation in `appkit` (for get actions only) - -Watch actions skip this step entirely — they go directly to the hook (Step 3). - -For **get** actions, create a `@tanstack/react-query` integration in `packages/appkit/src/queries/`. - -### Query — for reading data (`getXxx`) -Study `packages/appkit/src/queries/balances/get-balance-by-address.ts` as a reference. - -Create `packages/appkit/src/queries//get-xxx.ts`: -```ts -import type { AppKit } from '../../core/app-kit'; -import { getXxx } from '../../actions//get-xxx'; -import type { GetXxxOptions, GetXxxReturnType } from '../../actions//get-xxx'; -import type { QueryOptions, QueryParameter } from '../../types/query'; -import type { Compute, ExactPartial } from '../../types/utils'; -import { filterQueryOptions } from '../../utils'; - -export type GetXxxErrorType = Error; -export type GetXxxData = GetXxxQueryFnData; -export type GetXxxQueryConfig = Compute> & - QueryParameter; - -export const getXxxQueryOptions = ( - appKit: AppKit, - options: GetXxxQueryConfig = {}, -): GetXxxQueryOptions => { - return { - ...options.query, - queryFn: async (context) => { - const [, parameters] = context.queryKey as [string, GetXxxOptions]; - return getXxx(appKit, parameters); - }, - queryKey: getXxxQueryKey(options), - }; -}; - -export type GetXxxQueryFnData = Compute>; -export const getXxxQueryKey = (options: Compute> = {}): GetXxxQueryKey => - ['xxx', filterQueryOptions(options)] as const; -export type GetXxxQueryKey = readonly ['xxx', Compute>]; -export type GetXxxQueryOptions = QueryOptions< - GetXxxQueryFnData, GetXxxErrorType, selectData, GetXxxQueryKey ->; -``` - -### Mutation — for write/side-effect actions (`doXxx`) -Study `packages/appkit/src/queries/transaction/transfer-ton.ts` as a reference. - -Create `packages/appkit/src/queries//do-xxx.ts`: -```ts -import type { AppKit } from '../../core/app-kit'; -import { doXxx } from '../../actions//do-xxx'; -import type { DoXxxOptions, DoXxxReturnType } from '../../actions//do-xxx'; -import type { MutationOptions } from '../../types/query'; -import type { Compute } from '../../types/utils'; - -export type DoXxxErrorType = Error; -export type DoXxxData = Awaited; -export type DoXxxVariables = DoXxxOptions; -export type DoXxxMutationOptions = MutationOptions; - -export const doXxxMutationOptions = (appKit: AppKit): DoXxxMutationOptions => ({ - mutationFn: (variables) => doXxx(appKit, variables), -}); -``` - -### Export the query/mutation -Add to `packages/appkit/src/queries/index.ts` under the appropriate category comment: -```ts -// For a query: -export { - getXxxQueryOptions, - type GetXxxData, - type GetXxxErrorType, - type GetXxxQueryConfig, -} from './/get-xxx'; - -// For a mutation: -export { - doXxxMutationOptions, - type DoXxxData, - type DoXxxErrorType, - type DoXxxMutationOptions, - type DoXxxVariables, -} from './/do-xxx'; -``` - ---- - -## Step 3: Create the Hook in `appkit-react` - -The hook should be a **thin wrapper** — all business logic lives in `appkit`. This design makes it easy to later build `appkit-vue` or other framework adapters. - -### Hook from a query (`useXxx`) -Study `packages/appkit-react/src/features/nft/hooks/use-nft.ts` as the canonical reference. - -Create `packages/appkit-react/src/features//hooks/use-xxx.ts`: -```ts -import { getXxxQueryOptions } from '@ton/appkit/queries'; -import type { GetXxxData, GetXxxErrorType, GetXxxQueryConfig } from '@ton/appkit/queries'; - -import { useAppKit } from '../../../hooks/use-app-kit'; -import { useQuery } from '../../../libs/query'; -import type { UseQueryReturnType } from '../../../libs/query'; - -export type UseXxxParameters = GetXxxQueryConfig; -export type UseXxxReturnType = UseQueryReturnType; - -/** - * Hook to get ... - */ -export const useXxx = ( - parameters: UseXxxParameters = {}, -): UseXxxReturnType => { - const appKit = useAppKit(); - return useQuery(getXxxQueryOptions(appKit, parameters)); -}; -``` - -### Hook from a mutation (`useDoXxx`) -Study `packages/appkit-react/src/features/transaction/hooks/use-transfer-ton.ts` as the canonical reference. - -### Hook from a watch action (`useXxx`) -Watch-based hooks use `useSyncExternalStore` directly — no query/mutation involved. -Study `packages/appkit-react/src/features/network/hooks/use-networks.ts` as the canonical reference: - -```ts -import { useSyncExternalStore, useCallback } from 'react'; -import { getXxx, watchXxx } from '@ton/appkit'; -import type { GetXxxReturnType } from '@ton/appkit'; - -import { useAppKit } from '../../../hooks/use-app-kit'; - -export type UseXxxReturnType = GetXxxReturnType; - -export const useXxx = (): UseXxxReturnType => { - const appKit = useAppKit(); - - const subscribe = useCallback( - (onChange: () => void) => watchXxx(appKit, { onChange }), - [appKit], - ); - - const getSnapshot = useCallback(() => getXxx(appKit), [appKit]); - - return useSyncExternalStore(subscribe, getSnapshot, () => initialValue); -}; -``` - -### Export the hook -Add to `packages/appkit-react/src/features//index.ts`: -```ts -export { useXxx, type UseXxxParameters, type UseXxxReturnType } from './hooks/use-xxx'; -``` - ---- - -## Step 4: Add Examples and Tests - -### 4.1 Action example -Create `demo/examples/src/appkit/actions//get-xxx.ts`: -```ts -import type { AppKit } from '@ton/appkit'; -import { getXxx } from '@ton/appkit'; - -export const getXxxExample = async (appKit: AppKit) => { - // SAMPLE_START: GET_XXX - const result = await getXxx(appKit); - console.log('Result:', result); - // SAMPLE_END: GET_XXX -}; -``` - -Export it in `demo/examples/src/appkit/actions//index.ts`. - -### 4.2 Hook example -Create `demo/examples/src/appkit/hooks//use-xxx.tsx`: -```tsx -import { useXxx } from '@ton/appkit-react'; - -export const UseXxxExample = () => { - // SAMPLE_START: USE_XXX - const { data } = useXxx(); - return
Result: {data}
; - // SAMPLE_END: USE_XXX -}; -``` - -Export it in `demo/examples/src/appkit/hooks//index.ts`. - -### 4.3 Write tests -**Important:** Do NOT create a new test file per example. Add tests to the existing `.test.ts` / `.test.tsx` file in the same directory. - -**Action test:** -```ts -describe('getXxxExample', () => { - it('should log the result', async () => { - vi.spyOn(appKit.networkManager, 'getClient').mockReturnValue({ - getSomeData: vi.fn().mockResolvedValue({ value: 42 }), - } as any); - - await getXxxExample(appKit); - - expect(consoleSpy).toHaveBeenCalledWith('Result:', 42); - }); -}); -``` - -**Hook test** (add `describe` block inside the existing `describe('... Hooks Examples', ...)`) - -If the mock `appKit` in `beforeEach` doesn't have the required mocks (e.g., `getClient`), add them to the shared setup. - ---- - -## Step 5: Update Templates and Docs - -### 5.1 Update action template -Edit `template/appkit-actions.md`, add after the nearest related action: -```md -### `getXxx` - -Description of what the action does. - -%%demo/examples/src/appkit/actions/#GET_XXX%% -``` - -### 5.2 Update hooks template -Edit `template/appkit-hooks.md`, add after the nearest related hook: -```md -### `useXxx` - -Hook to ... - -%%demo/examples/src/appkit/hooks/#USE_XXX%% -``` - -### 5.3 Run quality check -```bash -pnpm quality -``` -All tests and type checks must pass before continuing. - -### 5.4 Regenerate documentation -```bash -pnpm docs:update -``` -Verify the relevant `.md` files in `packages/appkit/docs/` and `packages/appkit-react/docs/` were updated. diff --git a/.claude/skills/add-ui-component/SKILL.md b/.claude/skills/add-ui-component/SKILL.md deleted file mode 100644 index 99f9bc0f6..000000000 --- a/.claude/skills/add-ui-component/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: add-ui-component -description: How to add a new UI component to appkit-react. Use when creating new React components with styling and storybook. ---- - -# Adding a New UI Component to AppKit React - -This skill describes the rules and patterns for creating new UI components in the `appkit-react` package. Follow these guidelines to ensure consistency, theme support, and maintainability. - -## 1. Location and Directory Structure - -### 1.1 Placement -- **General Reusable Components**: Place in `packages/appkit-react/src/components/`. -- **Feature-Specific Components**: Place in `packages/appkit-react/src/features/{featureName}/components/`. - -### 1.2 Folder Structure -Every component must reside in its own subdirectory named after the component (kebab-case matching the component name). - -**Example for `Button`:** -```text -button/ -├── index.ts # Public API export -├── button.tsx # Main component logic -├── button.stories.tsx # Storybook documentation -└── button.module.css # Scoped styles -``` - ---- - -## 2. Component Implementation Guidelines - -### 2.1 Declaration and Exports -- Use `const`. -- Use `FC` from 'react. -- Use **named exports** only. Do not use default exports. -- Export both the component and its Props interface. - -**Example:** -```tsx -export interface ButtonProps extends ComponentProps<'button'> { - // custom props here -} - -export const Button: FC = (({ children, className, ...props }) => { - return ; -}); -``` - -### 2.2 TypeScript & Props -- Props should typically inherit from `ComponentProps<'tag'>` (e.g., `'div'`, `'button'`, `'input'`) to support standard HTML attributes. -- Use `clsx` for managing conditional class names. - ---- - -## 3. Styling and Design Tokens - -### 3.1 CSS Modules -Always use CSS Modules (`.module.css`) to prevent style leakage. - -### 3.2 Design Tokens (Variables) -Use variables defined in `packages/appkit-react/src/styles/index.css`. These ensure the component is theme-aware. - -Commonly used variables: -- Colors: `--ta-color-primary`, `--ta-color-text`, `--ta-color-background-secondary`. -- Border Radius: `--ta-border-radius-m`, `--ta-border-radius-xl`. - -### 3.3 Typography -To apply standardized font styles, use `composes` to import styles from `packages/appkit-react/src/styles/typography.module.css`. - -**Example:** -```css -/* button.module.css */ -.button { - composes: bodySemibold from "../../styles/typography.module.css"; - background-color: var(--ta-color-primary); - border-radius: var(--ta-border-radius-xl); - /* ... */ -} -``` -> [!IMPORTANT] -> Always use relative paths for `composes`. Adjust the number of `../` based on the component's depth. - ---- - -## 4. Reference Implementation - -Use `packages/appkit-react/src/components/block` as the canonical reference for: -- `index.ts` -- `block.tsx` -- `block.module.css` -- `block.stories.tsx` diff --git a/.claude/skills/kit-dev/SKILL.md b/.claude/skills/kit-dev/SKILL.md new file mode 100644 index 000000000..f6ec121b5 --- /dev/null +++ b/.claude/skills/kit-dev/SKILL.md @@ -0,0 +1,149 @@ +--- +name: kit-dev +description: Guides development across the @ton/kit monorepo (walletkit, appkit, appkit-react) — adding actions, queries, hooks, and components inside the SDK packages, fixing bugs in source, writing tests, creating examples and docs, and reviewing PRs. Covers SDK-internal concerns: cache invalidation patterns, query key naming, streaming provider plumbing, walletkit-package boundary, action/hook templates, and monorepo architecture. Activates when editing files under `packages/walletkit*`, `packages/appkit*`, or making changes to the SDK itself. +--- + +# Kit Development Guide + +Provides patterns, templates, and routing for the @ton/kit monorepo (walletkit, appkit, appkit-react). Assumes familiarity with TypeScript, React, and TanStack Query. + +## Architecture + +``` +@ton/appkit-react → @ton/appkit → @ton/walletkit +(hooks, UI, CSS) (actions, queries, connectors) (wallet ops, DeFi) +``` + +**appkit**: Actions (`src/actions//`), Queries (`src/queries//`), Core (`src/core/` — `AppKit`, `WalletsManager`, `AppKitNetworkManager`, `EventEmitter`, `AppKitCache`), Connectors (`src/connectors/`). +**appkit-react**: Hooks (`src/features//hooks/`), Components, Providers (`AppKitProvider` in `src/providers/`). + +Principles: business logic in `appkit` not React. Event-driven via `AppKit.emitter`. Named exports only. + +## Task Routing + +Do NOT glob directories listed below — the structure is documented. Do NOT read barrel index files to learn export patterns — add exports to the matching `// Domain` section comment. + +**Adding action/hook:** Read [skill-reference/recipes.md](skill-reference/recipes.md) for complete code templates. Read ONE reference file in the same domain (see table). Do NOT read `src/types/query.ts` or `src/utils/` — templates already include correct imports. + +**Adding staking/swap feature needing new walletkit types:** Walletkit manager methods follow `this.getProvider(providerId).methodName(userAddress, network)` with `this.createError(...)` wrapping. Staking barrel (`appkit/src/staking/index.ts`) re-exports: `StakingProvider`, `UnstakeMode`, `StakingError`, `StakingManager`, `StakeParams`, `StakingAPI`, `StakingQuote`, `StakingQuoteParams`, `StakingQuoteDirection`, `StakingBalance`, `StakingProviderInfo`, `StakingProviderInterface`, `StakingProviderMetadata`, `StakingTokenInfo`. Swap barrel (`appkit/src/swap/index.ts`) re-exports: `SwapProvider`, `SwapManager`, `SwapToken`, `TokenAmount`, `SwapParams`, `SwapAPI`, `SwapQuote`, `SwapQuoteParams`. Add new type re-exports to the matching barrel. + +**Writing tests:** Read the source file being tested + ONE existing test in the same domain for pattern. Do NOT read multiple test files — they all follow the same pattern. Use `createWrapper` from `demo/examples/src/__tests__/test-utils.tsx` and mocking patterns below. + +**Debugging balance/cache (e.g. balance doesn't update after send):** Walk through gotchas #7 (cache invalidation after mutations), #8 (streaming opt-in per network), #11 (network mismatch — app on one network while provider registered for another → balance silently stale), and #12 (provider wiring). Mention each as a possible cause when diagnosing. Key files: `appkit/src/queries/balances/get-balance-by-address.ts` (`handleBalanceUpdate`), `appkit-react/src/features/balances/hooks/use-watch-balance-by-address.ts`, `appkit/src/core/app-kit/services/app-kit.ts` (`registerProvider`). + +**Debugging disconnect/stale data:** Key file: `appkit-react/src/features/wallets/hooks/use-disconnect.ts`. Fix: add `removeQueries` for wallet-scoped key prefixes in `onSuccess`. **Preserve callback composition** — call the user's `parameters.mutation?.onSuccess?.(...args)` after the cleanup so consumers' callbacks still fire. **Tests live in `demo/examples/src/appkit/hooks/wallets/wallets.test.tsx`** (the shared wallets domain test file — do NOT create a colocated test file next to the hook). Add cases there asserting each wallet-scoped key is removed and the user's callback still fires. Query key prefixes are listed below — do NOT read query files to discover them. + +**Adding UI component:** Reference: `packages/appkit-react/src/components/ui/block`. Reusable primitives (button, input, modal, skeleton, dialog, etc.) → `src/components/ui//`. Composite shared components that mix business logic (e.g. button-with-connect, low-balance-modal) → `src/components/shared//`. Feature-specific → `src/features//components//`. Each gets `index.ts`, `.tsx`, `.module.css`, `.stories.tsx`. Use `FC`, named exports, `clsx`, CSS Modules. **Available `--ta-*` design tokens** (from `src/styles/index.css` — names are exact, do not guess): `--ta-color-{primary, primary-foreground, primary-light, error, success, text, text-secondary, text-tertiary, background, background-secondary, background-tertiary, background-bezeled, block, block-foreground, white, black, ton}`, `--ta-border-radius-{s,m,l,xl,2xl,full}`, `--ta-border-width-{s,m}`, typography (`--ta-font-family`, `--ta-body-*`, `--ta-headline-*`, `--ta-display-*`, etc.). For semantic errors/destructive states use `--ta-color-error` (there is **no** `--ta-color-negative` or `--ta-color-danger`). Use `var(--ta-foo)` WITHOUT fallback values (no `var(--ta-foo, 16px)`). **There are no `--ta-spacing-*` tokens** — for `padding`/`gap`/`margin` use literal `px` (consistent with existing components like button), or add a new token to `src/styles/index.css` first if the value will be reused across components. Component-internal dimensions (icon size, fixed primitive widths) also use literal `px` — e.g. button uses `width: 18px` for icon slots. Do NOT invent tokens that don't exist (e.g. `var(--ta-spacing-m)` will silently fall back to nothing). Typography via `composes` from `src/styles/typography.module.css`. **User-facing strings go through i18n**: `import { useI18n } from '../../../settings/hooks/use-i18n'` (adjust depth) and `const { t } = useI18n()`; reference keys via dot-notation, e.g. `t('nft.onSale')`. Add new keys to the dict in `src/locales/en.ts` (loaded by `src/libs/i18n.ts`). Do NOT hardcode user-visible English literals in JSX. For components that consume hooks (queries/mutations), destructure and render all four states explicitly: `isLoading`, `isError`/`error`, empty, and success. + +## Walletkit Boundary + +**For new actions, queries, hooks**: import walletkit types through the local appkit barrels rather than `@ton/walletkit` directly: +- staking → `../../staking` (barrel: `src/staking/index.ts`) +- swap → `../../swap` (barrel: `src/swap/index.ts`) +- streaming → `../../core/streaming` (barrel: `src/core/streaming/index.ts`) + +If you need a walletkit type that isn't re-exported yet, add the re-export to the matching domain barrel first, then import from there. + +**A new read-action requires all three layers** (action + query + hook) unless the user explicitly opts out — never deliver only the action file. After creating them, add exports to all three barrels: `packages/appkit/src/actions/index.ts`, `packages/appkit/src/queries/index.ts`, and `packages/appkit-react/src/features//index.ts`. Skipping the query/hook or the barrel updates leaves the feature unreachable from consumers. + +**Incremental tasks — trust the user's claim about pre-existing layers.** If the user says "I already added the action" (or query, or hook), treat that as ground truth: do NOT re-create or rewrite the file they say exists, and do NOT touch the barrel where it would already be exported. Build only the missing layers and add barrel exports only for those. Don't try to "simulate" the precondition by stubbing the pre-existing file — that defeats the test and creates a duplicate the user will have to merge. Same applies in reverse ("I have the query and hook, add tests" → don't recreate query/hook). + +**Existing exceptions**: low-level core/types/connectors files (`src/types/*.ts`, `src/core/app-kit/services/app-kit.ts`, `src/connectors/tonconnect/**`) still import `@ton/walletkit` directly — that's pre-existing and not in scope to change unless the user asks. The barrel rule is mandatory for new domain-level (actions/queries/hooks) code, not for these foundational layers. + +## Feature Domains + +| Domain | Actions dir | Hooks dir | Reference action → query → hook | +|---|---|---|---| +| balances | `actions/balances/` | `features/balances/` | `get-balance-by-address` → query `get-balance-by-address` → `use-balance(-by-address)` (the unparameterized `get-balance` is a thin wrapper that calls the `-by-address` action with the selected wallet) | +| staking | `actions/staking/` | `features/staking/` | `get-staked-balance` → query `get-staked-balance` → `use-staked-balance` | +| swap | `actions/swap/` | `features/swap/` | `get-swap-quote` → query `get-swap-quote` → `use-swap-quote` | +| jettons | `actions/jettons/` | `features/jettons/` | `get-jettons-by-address` → query `get-jettons-by-address` → `use-jettons-by-address` (`use-jettons` is a thin wrapper for the selected wallet) | +| nft | `actions/nft/` | `features/nft/` | `get-nfts-by-address` → query `get-nfts-by-address` → `use-nfts(-by-address)` | +| signing | `actions/signing/` | `features/signing/` | `sign-text` → mutation `sign-text` → `use-sign-text` | +| network | `actions/network/` | `features/network/` | `get-networks` (watch-based, no query) → `use-networks` | +| connectors | `actions/connectors/` | `features/wallets/` | `connect` → mutation `connect` → `use-connect` | +| transaction | `actions/transaction/` | `features/transaction/` | `transfer-ton` → mutation `transfer-ton` → `use-transfer-ton` | + +All actions dirs under `packages/appkit/src/actions/`. All hooks dirs under `packages/appkit-react/src/`. +Barrel exports: `packages/appkit/src/actions/index.ts`, `packages/appkit/src/queries/index.ts`, `packages/appkit-react/src/features//index.ts`. + +Action naming: `getXxx` (read), `watchXxx` (subscribe), `transferXxx`/`sendTransaction` (write), `setXxx` (local state), `buildXxxTransaction` (construct unsigned tx). + +## Query Key Prefixes + +For cache invalidation/removal. TanStack Query prefix-matches on first element: + +| Key prefix | Used by | +|---|---| +| `['balance', { address, network }]` | balance queries | +| `['nfts', { address, network }]` | NFT list | +| `['nft', { address, network? }]` | single NFT (`address` is the NFT item address — NOT `tokenAddress`) | +| `['jettons', { address, network }]` | jetton list | +| `['jetton-balance', ...]` | jetton balances | +| `['jetton-info', ...]` | jetton metadata | +| `['jetton-wallet-address', ...]` | jetton wallet address | +| `['stakedBalance', ...]` | staked balance | +| `['stakingProviderInfo', ...]` | staking provider info | +| `['stakingQuote', ...]` | staking quotes | +| `['swapQuote', { amount, from, to }]` | swap quotes | +| `['blockNumber', ...]` | block number | +| `['transactionStatus', ...]` | transaction status | + +## Layer Boundary + +| Concern | Layer | +|---|---| +| Business logic, fetch, transform | `appkit` actions | +| TanStack Query wrappers (queryKey, queryFn) | `appkit` queries | +| QueryClient ops (invalidate, remove) | `appkit-react` hooks | +| useSyncExternalStore, useQuery, useMutation | `appkit-react` hooks | + +## Testing + +Vitest. `node` env for appkit, `happy-dom` for demo/examples. Colocate tests — add to existing `.test.ts`. Do NOT create new test files per example. + +**Action test** — real `AppKit` + spy: +```ts +const appKit = new AppKit({ networks: { [Network.mainnet().chainId]: {} } }); +vi.spyOn(appKit.networkManager, 'getClient').mockReturnValue(mockClient); +``` + +**Hook test** — providers + `retry: false` + test loading/success/error: +```tsx +const createWrapper = (appKit: AppKit) => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +}; +``` + +**Mocking:** +- API client: `vi.spyOn(appKit.networkManager, 'getClient').mockReturnValue(mock)` +- SwapManager: `Object.defineProperty(appKit, 'swapManager', { value: { getQuote: vi.fn() } })` +- Emitter: `{ on: vi.fn().mockReturnValue(() => {}), off: vi.fn() }` + +**Test conflict:** `demo/examples/src/appkit/hooks/swap/swap.test.tsx` has module-level `vi.mock('@ton/appkit-react')` — test the real hook in a separate file. + +Existing helpers: `demo/examples/src/__tests__/test-utils.tsx` (`createWrapper`), `demo/examples/src/__mocks__/`. + +## Common Gotchas + +1. **Walletkit imports** — grep `packages/appkit` for `from '@ton/walletkit'`. Route through barrels. +2. **Thin hooks** — logic in actions, only QueryClient access in hooks. +3. **Two watch hook patterns** (codebase has both): + - **Snapshot pattern** — `useSyncExternalStore(subscribe, getSnapshot, getSnapshot)` when the action exposes a synchronous current value (`getNetworks()` returns now). Reference: `use-networks.ts`. No TanStack Query. + - **Cache-invalidation pattern** — `useEffect` that calls the watch action and writes updates into the TanStack Query cache (`handleBalanceUpdate`, or `queryClient.invalidateQueries`). Hook returns void; consumers use the paired query hook for the value. Reference: `use-watch-balance-by-address.ts`. Pick this when the value is async-fetched and you want a `useXxx` query hook to stay fresh. +4. **Do NOT create new test files** per example — add to existing `.test.ts`. +5. **`pnpm docs:update`** after example/template changes, then `pnpm quality`. +6. **Files under 200-250 lines.** +7. **Cache after mutations** — `invalidateQueries` after transfers, `removeQueries` after disconnect. React-layer concern. Use query key prefixes from table above. +8. **Streaming is opt-in, per network** — register a streaming provider for EACH network the app uses (mainnet AND testnet separately) via `AppKit.registerProvider()` (types: `swap`/`staking`/`streaming`) or `appKit.streamingManager.registerProvider({ network, ... })`. Without it `useWatchBalance` silently skips. One provider doesn't cover other networks. +9. **Serialize BigInt** — `.toString()` at boundaries. +10. **SSR** — `'use client'` for providers, gate wallet UI until mount (no `ssr` option on `AppKitConfig`). +11. **Network mismatch** — compare `defaultNetwork`, `useNetwork()`, tx network. Mainnet: `-239`, testnet: `-3`. +12. **AppKitProvider needs QueryClientProvider** — does not create its own. +13. **Transaction errors** — distinguish user rejection (`reject`/`cancel`/`abort`) from network errors. `isPending` disables submit. `reset()` for retry. +14. **Always include examples and tests** when adding public actions/hooks — `SAMPLE_START`/`SAMPLE_END` markers required. diff --git a/.claude/skills/kit-dev/evals.json b/.claude/skills/kit-dev/evals.json new file mode 100644 index 000000000..b232c47f6 --- /dev/null +++ b/.claude/skills/kit-dev/evals.json @@ -0,0 +1,167 @@ +{ + "skill_name": "kit-dev", + "evals": [ + { + "id": 1, + "name": "add-get-action-and-hook", + "prompt": "Add a new `getDelegations` action that returns staking delegations for the selected wallet, plus a `useDelegations` hook.", + "assertions": [ + {"text": "action_in_correct_dir", "description": "Creates action at packages/appkit/src/actions/staking/get-delegations.ts"}, + {"text": "query_in_correct_dir", "description": "Creates query at packages/appkit/src/queries/staking/get-delegations.ts"}, + {"text": "hook_in_correct_dir", "description": "Creates hook at packages/appkit-react/src/features/staking/hooks/use-delegations.ts"}, + {"text": "action_naming_get_prefix", "description": "Read action uses 'get' prefix (getDelegations, not fetchDelegations or loadDelegations)"}, + {"text": "query_options_factory_pattern", "description": "Query exports getDelegationsQueryOptions factory with queryKey and queryFn"}, + {"text": "hook_uses_useQuery", "description": "Hook calls useQuery with the query options factory, not duplicating logic"}, + {"text": "exports_in_barrels", "description": "Adds exports to packages/appkit/src/actions/index.ts, queries/index.ts, and features/staking/index.ts"} + ] + }, + { + "id": 2, + "name": "tests-for-hook", + "prompt": "Write tests for the `useSwapQuote` hook in the @ton/appkit-react package.", + "assertions": [ + {"text": "uses_createWrapper", "description": "Uses createWrapper from demo/examples/src/__tests__/test-utils.tsx for provider context"}, + {"text": "mocks_appkit_or_swap_manager", "description": "Mocks AppKit and swapManager (or its getQuote method) instead of hitting real DEX"}, + {"text": "covers_loading_state", "description": "Tests loading state (isLoading or pending state from useQuery)"}, + {"text": "covers_success_state", "description": "Tests success path with returned quote data"}, + {"text": "covers_error_state", "description": "Tests error path (mocked error from manager)"}, + {"text": "uses_vitest_and_testing_library", "description": "Uses vitest (describe/it/expect) and @testing-library/react renderHook"} + ] + }, + { + "id": 5, + "name": "tests-for-balance", + "prompt": "Write tests for the balance feature in the @ton/appkit package — covering the actions, queries, and the watch-balance cache update path.", + "assertions": [ + {"text": "finds_relevant_balance_files", "description": "Identifies balance-related files (use-balance, get-balance-by-address, etc.) before writing tests"}, + {"text": "mocks_networkManager_and_streamingManager", "description": "Mocks networkManager and streamingManager on AppKit"}, + {"text": "tests_handleBalanceUpdate_cache_writes", "description": "Tests how watch hooks write streaming updates into the useBalance cache"}, + {"text": "uses_existing_domain_test_file", "description": "Adds tests to the existing balances test file (not a new isolated one)"} + ] + }, + { + "id": 6, + "name": "orientation", + "prompt": "I just joined the project, how is everything organized here?", + "assertions": [ + {"text": "three_package_architecture", "description": "Names three packages: walletkit (lowest), appkit (middle), appkit-react (top)"}, + {"text": "feature_domains_concept", "description": "Explains action / query / hook layering OR domain folders (balances, staking, swap, jettons, nft, signing, network, connectors, transaction)"}, + {"text": "tooling_and_quality", "description": "Mentions `pnpm quality` (or equivalent: tests + lint + typecheck) as the verification command"}, + {"text": "named_exports_convention", "description": "Mentions named exports only (no default exports)"} + ] + }, + { + "id": 7, + "name": "debug-balance-stale-after-send", + "prompt": "I'm triaging a bug report on the AppKit SDK: a consumer says their `useBalance()` doesn't update after `useTransferTon` succeeds. Walk me through every cause I should check in the AppKit source and integration patterns, so I can tell the reporter what to fix or so I can fix it in the SDK.", + "assertions": [ + {"text": "mentions_cache_invalidation", "description": "Identifies missing invalidateQueries on transfer success as the most common cause"}, + {"text": "mentions_streaming_provider", "description": "Mentions registering a streaming provider for real-time updates"}, + {"text": "mentions_useWatchBalance", "description": "Mentions mounting useWatchBalance alongside useBalance"}, + {"text": "mentions_network_mismatch", "description": "Mentions network mismatch (mainnet/testnet) as a possible cause"} + ] + }, + { + "id": 9, + "name": "debug-disconnect-stale-data", + "prompt": "Bug report on the AppKit SDK: after a consumer disconnects a wallet and connects a different one, the previous wallet's balance and NFTs stay in the cache briefly. Fix the bug in the AppKit source (`use-disconnect` and friends) and update the matching tests.", + "assertions": [ + {"text": "identifies_use_disconnect_file", "description": "Identifies appkit-react/src/features/wallets/hooks/use-disconnect.ts as the file to edit"}, + {"text": "adds_removeQueries_in_onSuccess", "description": "Adds queryClient.removeQueries calls inside the onSuccess of useDisconnect"}, + {"text": "uses_removeQueries_not_invalidateQueries", "description": "Uses removeQueries (not invalidateQueries) because no wallet to refetch for"}, + {"text": "covers_multiple_query_key_prefixes", "description": "Removes multiple keys: ['balance'], ['nfts'], ['jettons'], ['jetton-balance'], ['stakedBalance'] (note: jetton keys are kebab-case)"}, + {"text": "preserves_callback_composition", "description": "Preserves any existing onSuccess callback the consumer may pass (calls user's onSuccess too)"}, + {"text": "mentions_tests", "description": "Mentions adding/updating tests in the existing wallets domain test file (demo/examples/src/appkit/hooks/wallets/wallets.test.tsx) — NOT a new colocated use-disconnect.test.ts"} + ] + }, + { + "id": 11, + "name": "watch-action-and-hook", + "prompt": "Add a `watchStakingQuote` action and a `useWatchStakingQuote` hook that subscribes to staking quote changes in real-time.", + "assertions": [ + {"text": "action_in_correct_dir", "description": "Creates watch action at packages/appkit/src/actions/staking/watch-staking-quote.ts"}, + {"text": "action_uses_emitter_pattern", "description": "Action uses appKit.emitter.on(EVENT, ...) and returns unsubscribe function"}, + {"text": "no_query_file_for_watch", "description": "Does NOT create a query file (watch actions skip the query layer)"}, + {"text": "hook_uses_non_query_watch_pattern", "description": "Hook uses one of the two valid watch patterns: (a) useSyncExternalStore with subscribe+getSnapshot (like use-networks), OR (b) useEffect that calls the watch action and keeps a TanStack Query cache fresh (like use-watch-balance-by-address). Does NOT use useQuery."}, + {"text": "action_naming_watch_prefix", "description": "Watch action uses 'watch' prefix (watchStakingQuote, not subscribeXxx or observeXxx)"}, + {"text": "exports_in_barrels", "description": "Adds exports to actions/index.ts and features/staking/index.ts"} + ] + }, + { + "id": 12, + "name": "add-mutation-and-hook", + "prompt": "Add a `revokeStake` mutation action that revokes a user's stake at a given provider, plus a `useRevokeStake` hook.", + "assertions": [ + {"text": "action_in_correct_dir", "description": "Creates action at packages/appkit/src/actions/staking/revoke-stake.ts"}, + {"text": "mutation_in_queries_dir", "description": "Creates mutation file at packages/appkit/src/queries/staking/revoke-stake.ts (mutation options, not query options)"}, + {"text": "hook_uses_useMutation", "description": "Hook uses useMutation, not useQuery"}, + {"text": "hook_returns_mutate_isPending", "description": "Hook returns the mutation result shape: mutate/mutateAsync, isPending, error, reset"}, + {"text": "action_naming_write_prefix", "description": "Write action uses an imperative verb (revokeStake, not getRevoke or watchRevoke)"}, + {"text": "exports_in_barrels", "description": "Adds exports to actions/index.ts, queries/index.ts, and features/staking/index.ts"} + ] + }, + { + "id": 13, + "name": "walletkit-barrel-boundary", + "prompt": "I'm adding a `getProtocolInfo` action that returns protocol metadata from the walletkit staking manager. The walletkit method returns a new `ProtocolMetadata` type that's not yet re-exported anywhere in appkit. Walk me through the changes.", + "assertions": [ + {"text": "adds_type_to_local_barrel", "description": "Re-exports ProtocolMetadata in the appkit staking barrel: packages/appkit/src/staking/index.ts"}, + {"text": "no_direct_walletkit_import_in_action", "description": "Action imports ProtocolMetadata from the local barrel (../../staking), NOT from @ton/walletkit directly"}, + {"text": "explains_boundary_rule", "description": "Explains the rule: never import @ton/walletkit directly in packages/appkit source"}, + {"text": "creates_three_layers", "description": "Creates action + query + hook (the standard 3-layer pattern)"}, + {"text": "exports_in_barrels", "description": "Adds exports to actions/index.ts, queries/index.ts, features/staking/index.ts"} + ] + }, + { + "id": 14, + "name": "incremental-action-exists", + "prompt": "I already added the `getStakingHistory` action at `packages/appkit/src/actions/staking/get-staking-history.ts`. Now create the query and the `useStakingHistory` hook for it.", + "assertions": [ + {"text": "does_not_recreate_action", "description": "Does NOT recreate or rewrite the existing action file"}, + {"text": "creates_query_file", "description": "Creates packages/appkit/src/queries/staking/get-staking-history.ts"}, + {"text": "creates_hook_file", "description": "Creates packages/appkit-react/src/features/staking/hooks/use-staking-history.ts"}, + {"text": "query_uses_options_factory", "description": "Query exports getStakingHistoryQueryOptions factory with queryKey & queryFn"}, + {"text": "hook_uses_query_options", "description": "Hook calls useQuery(getStakingHistoryQueryOptions(appKit, parameters))"}, + {"text": "exports_query_and_hook_only", "description": "Adds barrel exports for query and hook (not action — already exported)"} + ] + }, + { + "id": 15, + "name": "ui-reusable-component", + "prompt": "Add a reusable `` component to AppKit React.", + "assertions": [ + {"text": "location_in_components_dir", "description": "Component lives in packages/appkit-react/src/components/ui/spinner/ (or components/shared/ if it mixes business logic). Not feature-specific (features//components/), not at the top level of components/."}, + {"text": "four_files_present", "description": "Creates index.ts, spinner.tsx, spinner.module.css, spinner.stories.tsx (all four)"}, + {"text": "named_export_with_FC", "description": "export const Spinner: FC with named export (not default)"}, + {"text": "uses_clsx_for_size_variants", "description": "Uses clsx to switch CSS Module class based on size prop"}, + {"text": "uses_design_tokens", "description": "Uses --ta-* design tokens (without px fallbacks) for colors, border-radius, and border-width. Component-internal dimensions (e.g. width/height for the three sizes) MAY use literal px — same pattern as existing components like button (which uses `width: 18px` for icon slots)."}, + {"text": "has_storybook_story", "description": "Storybook story shows all size variants (sm, md, lg)"} + ] + }, + { + "id": 16, + "name": "ui-feature-component", + "prompt": "Create a `` component that renders one delegation in a list, used inside the staking feature.", + "assertions": [ + {"text": "location_in_features_dir", "description": "Lives at packages/appkit-react/src/features/staking/components/delegation-list-item/ (feature-specific, not shared components/)"}, + {"text": "four_files_present", "description": "Creates index.ts, delegation-list-item.tsx, delegation-list-item.module.css, delegation-list-item.stories.tsx"}, + {"text": "uses_design_tokens", "description": "Uses real --ta-* tokens (`--ta-color-*`, `--ta-border-radius-*`, `--ta-border-width-*`) without px fallbacks. Does NOT invent non-existent tokens (e.g. `--ta-spacing-*` does not exist in src/styles/index.css). Literal px is OK for padding/gap/margin and component-internal sizes."}, + {"text": "typography_via_composes", "description": "Typography classes use `composes` from src/styles/typography.module.css (not duplicated font styles)"}, + {"text": "i18n_for_user_facing_strings", "description": "User-facing text uses useI18n hook (const { t } = useI18n()), not hardcoded strings"} + ] + }, + { + "id": 17, + "name": "ui-component-using-hook", + "prompt": "Build a `` feature component that uses the `useStakingHistory` hook and displays a list of past staking operations.", + "assertions": [ + {"text": "location_in_features_dir", "description": "Lives at packages/appkit-react/src/features/staking/components/staking-history-list/"}, + {"text": "imports_useStakingHistory", "description": "Imports and uses useStakingHistory hook"}, + {"text": "handles_loading_state", "description": "Renders a loading state when isLoading or data is undefined"}, + {"text": "handles_error_state", "description": "Renders an error state when isError or error is present"}, + {"text": "handles_empty_state", "description": "Renders an empty state when list is empty"}, + {"text": "uses_design_tokens", "description": "Uses real --ta-* tokens (`--ta-color-*`, `--ta-border-radius-*`, `--ta-border-width-*`) without px fallbacks. Does NOT invent non-existent tokens (e.g. `--ta-spacing-*` does not exist). Literal px is OK for padding/gap/margin."} + ] + } + ] +} diff --git a/.claude/skills/kit-dev/skill-reference/recipes.md b/.claude/skills/kit-dev/skill-reference/recipes.md new file mode 100644 index 000000000..77396005f --- /dev/null +++ b/.claude/skills/kit-dev/skill-reference/recipes.md @@ -0,0 +1,248 @@ +# Action / Query / Hook Templates + +Use these templates directly. Do NOT read reference files to learn the pattern — these are accurate and complete. + +## Action template + +Create `packages/appkit/src/actions//get-xxx.ts`: + +```ts +import type { AppKit } from '../../core/app-kit'; + +export interface GetXxxOptions { /* input parameters */ } + +export type GetXxxReturnType = Promise<{ + /* shape of what your action returns */ +}>; + +export const getXxx = async (appKit: AppKit, options: GetXxxOptions = {}): GetXxxReturnType => { + // your business logic here + return {}; +}; +``` + +Export from `packages/appkit/src/actions/index.ts`. + +## Watch action template (subscriptions / live updates) + +Use this pattern for any "real-time" or "live" feature — never polling, never `setInterval`. The action subscribes to `appKit.emitter`, re-reads the value via the paired `getXxx`, and returns an unsubscribe function. + +Create `packages/appkit/src/actions//watch-xxx.ts`: + +```ts +import type { AppKit } from '../../core/app-kit'; + +import { getXxx } from './get-xxx'; +import type { GetXxxOptions, GetXxxReturnType } from './get-xxx'; + +export interface WatchXxxParameters extends GetXxxOptions { + onChange: (value: Awaited) => void; + onError?: (error: unknown) => void; +} + +export type WatchXxxReturnType = () => void; // unsubscribe + +export const watchXxx = (appKit: AppKit, parameters: WatchXxxParameters): WatchXxxReturnType => { + const { onChange, onError, ...options } = parameters; + let cancelled = false; + + const refresh = (): void => { + getXxx(appKit, options) + .then((value) => { + if (cancelled) return; + onChange(value); + }) + .catch((error: unknown) => { + if (cancelled) return; + onError?.(error); + }); + }; + + // Replace these AppKitEvents keys with the ones that signal a change in your domain. + const unsubscribeA = appKit.emitter.on('wallets:updated', refresh); + const unsubscribeB = appKit.emitter.on('networks:updated', refresh); + + return () => { + cancelled = true; + unsubscribeA(); + unsubscribeB(); + }; +}; +``` + +Watch actions skip the query layer entirely (no `*QueryOptions`, no `*QueryKey`). Resource inputs (`address`, `network`, `id`, …) come from `GetXxxOptions` and must flow through to `getXxx` so the watcher stays scoped — see `packages/appkit/src/actions/balances/watch-balance-by-address.ts`. + +## Query template (get-actions only, watch actions skip to Hook) + +Create `packages/appkit/src/queries//get-xxx.ts`: + +```ts +import { getXxx } from '../../actions//get-xxx'; +import type { GetXxxOptions, GetXxxReturnType } from '../../actions//get-xxx'; +import type { AppKit } from '../../core/app-kit'; +import type { QueryOptions, QueryParameter } from '../../types/query'; +import type { Compute, ExactPartial } from '../../types/utils'; +import { filterQueryOptions } from '../../utils'; + +export type GetXxxErrorType = Error; +export type GetXxxQueryFnData = Compute>; +export type GetXxxData = GetXxxQueryFnData; +export type GetXxxQueryKey = readonly ['xxx', Compute>]; + +export type GetXxxQueryConfig = + Compute> & + QueryParameter; + +export type GetXxxQueryOptions = QueryOptions< + GetXxxQueryFnData, + GetXxxErrorType, + selectData, + GetXxxQueryKey +>; + +export const getXxxQueryKey = (options: Compute> = {}): GetXxxQueryKey => + ['xxx', filterQueryOptions(options)] as const; + +export const getXxxQueryOptions = ( + appKit: AppKit, + options: GetXxxQueryConfig = {}, +): GetXxxQueryOptions => ({ + ...options.query, + queryFn: async (context: { queryKey: GetXxxQueryKey }) => { + const [, parameters] = context.queryKey; + return getXxx(appKit, parameters as GetXxxOptions); + }, + queryKey: getXxxQueryKey(options), +}); +``` + +For mutations, reference: `packages/appkit/src/queries/transaction/transfer-ton.ts`. +Export from `packages/appkit/src/queries/index.ts`. + +## Hook template + +Create `packages/appkit-react/src/features//hooks/use-xxx.ts`: + +```ts +import { getXxxQueryOptions } from '@ton/appkit/queries'; +import type { GetXxxData, GetXxxErrorType, GetXxxQueryConfig } from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseXxxParameters = GetXxxQueryConfig; +export type UseXxxReturnType = UseQueryReturnType; + +export const useXxx = ( + parameters: UseXxxParameters = {}, +): UseXxxReturnType => { + const appKit = useAppKit(); + return useQuery(getXxxQueryOptions(appKit, parameters)); +}; +``` + +Export from `packages/appkit-react/src/features//index.ts`. + +## Watch hook templates (paired with watch action) + +Two valid patterns — pick based on whether you want the watch hook to expose the value directly (Pattern A) or to keep a sibling TanStack query fresh while consumers read it via `useXxx` (Pattern B). + +### Pattern A — Snapshot via `useSyncExternalStore` + +The hook caches the most recent value emitted by `watchXxx` and exposes it directly through `useSyncExternalStore`. Returns `null` until the first update fires. + +```ts +import { useSyncExternalStore, useCallback, useRef } from 'react'; +import { watchXxx } from '@ton/appkit'; +import type { WatchXxxParameters } from '@ton/appkit'; + +import { useAppKit } from '../../settings'; + +type XxxValue = Parameters[0]; + +export type UseXxxReturnType = XxxValue | null; + +export const useXxx = (): UseXxxReturnType => { + const appKit = useAppKit(); + const cachedRef = useRef(null); + + const subscribe = useCallback( + (onChange: () => void) => + watchXxx(appKit, { + onChange: (value) => { + cachedRef.current = value; + onChange(); + }, + }), + [appKit], + ); + + const getSnapshot = useCallback(() => cachedRef.current, []); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; +``` + +Reference: `packages/appkit-react/src/features/network/hooks/use-networks.ts`. + +### Pattern B — Cache invalidation via `useEffect` + +Use when consumers already read the value via a paired `useXxx` (TanStack Query). The watch hook returns `void` and only nudges the query cache so the next read sees the latest value. + +```ts +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { watchXxx } from '@ton/appkit'; +import type { WatchXxxParameters } from '@ton/appkit'; +import { getXxxQueryKey } from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; + +export type UseWatchXxxParameters = Partial; + +export const useWatchXxx = (parameters: UseWatchXxxParameters = {}): void => { + const { onChange, onError, ...options } = parameters; + const appKit = useAppKit(); + const queryClient = useQueryClient(); + + useEffect(() => { + return watchXxx(appKit, { + ...options, + onError, + onChange: (value) => { + onChange?.(value); + // Invalidate the scoped key so the paired `useXxx(options)` re-reads the fresh value: + queryClient.invalidateQueries({ queryKey: getXxxQueryKey(options) }); + // Or write straight into cache via a hand-written `handleXxxUpdate(queryClient, options, value)` + // — see packages/appkit/src/queries/balances/get-balance-by-address.ts for an example. + }, + }); + // List each resource option (e.g. `address`, `network`) in the deps, not the `options` object itself. + }, [appKit, queryClient, onChange, onError]); +}; +``` + +Reference: `packages/appkit-react/src/features/balances/hooks/use-watch-balance-by-address.ts`. + +## Examples + +Action example in `demo/examples/src/appkit/actions//get-xxx.ts` with `SAMPLE_START: GET_XXX` / `SAMPLE_END: GET_XXX` markers. +Hook example in `demo/examples/src/appkit/hooks//use-xxx.tsx` with same pattern. +Export from domain `index.ts`. Add tests to existing `.test.ts`. + +## Docs + +1. `template/packages/appkit/docs/actions.md`: `%%demo/examples/src/appkit/actions/#GET_XXX%%` +2. `template/packages/appkit-react/docs/hooks.md` similarly. +3. `pnpm docs:update` then `pnpm quality`. + +## Completion Checklist + +- [ ] Action exported from `packages/appkit/src/actions/index.ts` +- [ ] Query/mutation exported from `packages/appkit/src/queries/index.ts` +- [ ] Hook exported from `packages/appkit-react/src/features//index.ts` +- [ ] Walletkit types through local barrel (never direct `@ton/walletkit` in appkit) +- [ ] Examples with `SAMPLE_START`/`SAMPLE_END` markers +- [ ] Tests added to existing domain test file +- [ ] `pnpm docs:update` + `pnpm quality` diff --git a/CLAUDE.md b/CLAUDE.md index 743d6d19b..50db0e98e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,11 @@ Monorepo for TON blockchain integration libraries: `@ton/walletkit` (core wallet - Prefer keeping files under 200–250 lines. Extract utility functions to a nearby `utils` file and types to a `types` file when it helps. - Use named exports only. No default exports. +## Agent skills + +- **Monorepo development** (actions, hooks, tests, UI): `.claude/skills/kit-dev/` +- **AppKit consumer integration** (setup, TonConnect, swaps, etc.): `packages/appkit/skills/ton-appkit/` + ## TypeScript - Avoid `any` and `unknown`. Use proper types whenever possible. diff --git a/packages/appkit-react/CLAUDE.md b/packages/appkit-react/CLAUDE.md index 1b58037ee..d1a9f5bcd 100644 --- a/packages/appkit-react/CLAUDE.md +++ b/packages/appkit-react/CLAUDE.md @@ -3,8 +3,7 @@ ## Code conventions - All file names must be in kebab-case. -- For creating new actions and hooks, use the `add-action-and-hook` skill. -- For creating new UI components, use the `add-ui-component` skill. +- For creating new actions, hooks, and UI components, use the `kit-dev` skill. - Every component must have a Storybook story (`.stories.tsx`). ## Hook ordering in components and custom hooks @@ -25,7 +24,7 @@ only to prepare input for a hook right below it can live next to that hook. - Always use CSS Modules (`.module.css`). - Use `clsx` for conditional class names. -- Use design tokens from `src/styles/index.css` — never hardcode colors or spacing. +- Use `--ta-*` design tokens from `src/styles/index.css` for colors, border-radius, and border-width. For padding/gap/margin use literal `px` — spacing tokens do not exist. - Use `composes` with relative paths for typography from `src/styles/typography.module.css`. ## i18n diff --git a/packages/appkit/CLAUDE.md b/packages/appkit/CLAUDE.md index 5e7107490..53ef5bfa4 100644 --- a/packages/appkit/CLAUDE.md +++ b/packages/appkit/CLAUDE.md @@ -3,7 +3,7 @@ ## Code conventions - All file names must be in kebab-case. -- For creating new actions and hooks, use the `add-action-and-hook` skill. +- For creating new actions and hooks, use the `kit-dev` skill. ## Imports from walletkit diff --git a/packages/appkit/skills/ton-appkit/SKILL.md b/packages/appkit/skills/ton-appkit/SKILL.md new file mode 100644 index 000000000..d5277f2a9 --- /dev/null +++ b/packages/appkit/skills/ton-appkit/SKILL.md @@ -0,0 +1,360 @@ +--- +name: ton-appkit +description: Use this skill whenever the user is building an app with @ton/appkit or @ton/appkit-react as a library consumer: setup, TonConnect wallet connection, balances, sending TON/jettons/NFTs, swaps, staking, signing, real-time WebSocket updates, cache invalidation, mainnet/testnet switching, SSR/Next.js hydration, Telegram Mini App returns, iOS deep links, or debugging stale wallet/network/transaction behavior. Do not use for contributing code to the @ton/kit monorepo itself; use kit-dev for repo-internal development tasks. +--- + +# AppKit Guide + +Guide developers consuming `@ton/appkit` + `@ton/appkit-react` in their TON apps. Assume React, TypeScript, and TanStack Query basics, but explain TON/AppKit-specific decisions clearly. + +For contributing to the @ton/kit monorepo, use `kit-dev` instead. This skill is for library-consumer answers: code the user's app would write, not package internals. + +## Response workflow + +1. **Classify the task first.** If the user wants a complete feature quickly, prefer drop-in components. If they describe custom UX, use hooks. If they report stale data, real-time, network, or SSR symptoms, start with the relevant gotcha before writing code. +2. **Give runnable integration shape.** Include provider setup when the bug could come from missing `QueryClientProvider`, `AppKitProvider`, connector config, CSS import, or streaming providers. Avoid isolated hook snippets that omit required context. +3. **Use AppKit semantics exactly.** Balances are already-formatted decimal strings (TON or jetton units — never raw nano), transfer amounts are human-readable strings, mutations do not automatically invalidate caches, streaming is opt-in, and wallet state is client-only for SSR. +4. **Name the tradeoff.** Drop-in components are fastest and safest; hooks are for custom UX and require explicit loading/error/cache handling. + +## What do you want to do? + +| Task | Path | +|---|---| +| Set up a new app | `pnpm create ton-appkit` (scaffolds React+Vite project) or Quick Setup below | +| Wallet connect/disconnect | `` or `useConnect`/`useDisconnect` | +| Show TON balance | `useBalance()` + optionally `useWatchBalance()` for live updates | +| Send TON | `` or `useTransferTon()` | +| Send jettons (USDT, etc.) | `` or `useTransferJetton()` | +| Show NFTs | `useNfts()` + `` | +| Swap tokens | `` or `useSwapQuote` + `useBuildSwapTransaction` + `useSendTransaction` | +| Stake TON | `` or staking hooks | +| Sign message | `useSignText` / `useSignBinary` / `useSignCell` | +| Mainnet/testnet support | Configure both networks + `useDefaultNetwork()` + explicit `network` params | +| Real-time updates | Register streaming provider + mount `useWatchBalance` / `useWatchTransactions` | +| Fix Next.js hydration | `'use client'` providers + mount gate or dynamic import | +| Refresh balance after send | `queryClient.invalidateQueries({ queryKey: ['balance'] })` | +| Clear stale data on disconnect | `queryClient.removeQueries` for wallet-scoped keys | + +Drop-in components (``, ``, ``, etc.) are the fastest path. Use hooks only for custom UX. + +For extended swap/staking/jetton recipes, see [skill-reference/recipes.md](skill-reference/recipes.md). + +## Packages + +- **`@ton/appkit`** — core SDK: `AppKit` class, actions, connectors (TonConnect), DeFi managers. +- **`@ton/appkit-react`** — React hooks, `AppKitProvider`, UI components. +- **`@tanstack/react-query`** — required peer dependency. + +## Quick Setup + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AppKit, Network, createTonConnectConnector } from '@ton/appkit'; +import { AppKitProvider } from '@ton/appkit-react'; +import '@ton/appkit-react/styles.css'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { refetchOnWindowFocus: false } }, +}); + +const appKit = new AppKit({ + networks: { + [Network.mainnet().chainId]: { + apiClient: { url: 'https://toncenter.com', key: 'YOUR_API_KEY' }, + }, + // Keep testnet too so users on testnet wallets aren't silently rejected. + // Drop it once you're production-only. + [Network.testnet().chainId]: { + apiClient: { url: 'https://testnet.toncenter.com', key: 'YOUR_API_KEY' }, + }, + }, + connectors: [ + createTonConnectConnector({ + tonConnectOptions: { manifestUrl: 'https://your-app.com/tonconnect-manifest.json' }, + }), + ], +}); + +// QueryClientProvider MUST wrap AppKitProvider +function App() { + return ( + + {/* your app */} + + ); +} +``` + +For Next.js: add `'use client'` to the providers file and gate wallet-dependent UI until mount, or use `dynamic(() => import(...), { ssr: false })`. **There is no `ssr` flag on `AppKitConfig`** — the SSR boundary is purely a React/Next concern, not an AppKit option. Wallet state (`useAddress`, balances, etc.) is client-only by construction; render a stable placeholder on the server and swap in wallet-dependent UI after `useEffect`/mount. + +### Browser polyfills (Vite / Webpack / Rspack) + +`@ton/core` (transitive dep) uses Node's `Buffer` and `process`. Browser bundlers don't include these by default — without polyfills you get a **white screen and `ReferenceError: Buffer is not defined`** at runtime. + +For Vite, install `vite-plugin-node-polyfills` and add it to `vite.config.ts`: +```ts +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; +export default defineConfig({ plugins: [react(), nodePolyfills()] }); +``` + +If the plugin clashes with your project (e.g. nested workspace, alias resolution warnings), use a manual polyfill instead — `pnpm add buffer process`, then create `src/polyfills.ts`: +```ts +import { Buffer } from 'buffer'; +import process from 'process'; +(globalThis as { Buffer?: typeof Buffer }).Buffer = Buffer; +(globalThis as { process?: typeof process }).process = process; +``` +Import `./polyfills` as the **first line of `main.tsx`** (before any AppKit code). + +Next.js: no polyfill needed — Next's Webpack config handles Node globals automatically. + +### First-touch send: self-transfer + +For a safe end-to-end test of the send flow, use `useAddress()` as the recipient — the user sends 0.01 TON to themselves. Validates wallet, network, signing, and on-chain confirmation without risk: +```tsx +const address = useAddress(); +{address && } +``` +For real recipients, paste a TON address from Tonkeeper (UQ… non-bounceable for wallets, EQ… bounceable for contracts). Random/made-up strings without valid checksums make Tonkeeper's simulator say "no changes to your account" and refuse to sign. + +## Hooks Reference + +Hooks come from `@ton/appkit-react`. Queries return TanStack `{ data, isLoading, isError, error }`. Mutations return `{ mutate, mutateAsync, isPending, error, data, reset }`. + +### Wallet +`useConnectors()` · `useConnect()` · `useDisconnect()` · `useSelectedWallet()` · `useAddress()` · `useConnectedWallets()` + +`useConnect()` returns a mutation; call it with `mutate({ connectorId })`, passing the connector's `id` from `useConnectors()` (not the connector object itself). On iOS, the deep link that opens the wallet only fires if `connect()` runs in the **same synchronous tick as the user's click** — any `await` before it kills the user-activation token and the wallet won't open. + +```tsx +const connectors = useConnectors(); +const { mutate: connect, isPending } = useConnect(); + +// ✅ Good: synchronous in click handler + + +// ❌ Bad on iOS: awaiting before connect() loses the user gesture + +``` +If you need side effects, fire-and-forget them (no `await`) or run them in `mutation: { onSuccess }`. + +### Balance & assets +- `useBalance()` / `useBalanceByAddress({ address, network? })` — returns a **decimal TON string already formatted with 9 decimals** (e.g. `"0.500000000"` for a wallet holding 0.5 TON). It is NOT raw nano and NOT a `bigint`. Render directly or format with `Intl.NumberFormat`; do **not** pass it through `formatUnits(_, 9)` again — that re-slices on the decimal point and yields garbage like `"0..500000000"`. Pass `network` explicitly for cross-network reads: `useBalanceByAddress({ address, network: Network.testnet() })`. +- `useWatchBalance()` / `useWatchBalanceByAddress(...)` — real-time updates (requires streaming provider) +- `useJettons()` — returns `{ jettons: Jetton[], addressBook }`. Use `data?.jettons`, not `data` directly. Each `Jetton.balance` is already formatted via the jetton's own `decimalsNumber` (e.g. `"1.5"` for 1.5 USDT) — same rule: don't re-format with `formatUnits`. Image URLs live under `info.image.smallUrl` / `info.image.url` (note the `Url` suffix). +- `useJettonBalanceByAddress({ jettonAddress, ownerAddress, jettonDecimals?, network? })` — note the param is `ownerAddress`, not `address`. Returns a **pre-formatted** decimal string in the jetton's own units (e.g. `"1.5"` for 1.5 USDT). If `jettonDecimals` isn't passed, AppKit looks it up from jetton metadata (one extra read). Same rule as `useBalance`: don't pass the result through `formatUnits` again. +- `useJettonInfo({ address, network? })` — `address` is the **jetton master** contract address. Returns `{ name, symbol, decimals?, image, … } | null`. +- `useJettonWalletAddress({ jettonAddress, ownerAddress, network? })` — resolves the owner's individual jetton-wallet contract address for a given jetton. +- `useWatchJettons()` / `useWatchJettonsByAddress(...)` — real-time jetton list updates (requires streaming provider). +- `useWatchBalanceByAddress({ address, network? })` — real-time balance for an arbitrary address. +- `useNfts()` — returns `{ nfts: NFT[], addressBook? }`. Use `data?.nfts`, not `data` directly. +- `useNft({ address })` (`address` is the NFT item's own contract address — NOT `tokenAddress`) + +### Sending +`useTransferTon()` · `useTransferJetton()` · `useTransferNft()` · `useSendTransaction()` + +Call with `mutate({ recipientAddress, amount: '0.5', comment? })`. Amount is a **human-readable string** — AppKit converts using token decimals. The mutation's `data` resolves to `SendTransactionResponse = { boc, normalizedBoc, normalizedHash }` (object, not a bare string). + +Param names differ per asset — easy to get wrong from intuition: +- **TON**: `mutate({ recipientAddress, amount, comment? })` +- **Jetton**: `mutate({ jettonAddress, recipientAddress, amount, comment? })` — `jettonAddress` is the jetton master contract. +- **NFT**: `mutate({ nftAddress, recipientAddress, amount?, comment? })` — **`nftAddress`** (NOT `tokenAddress`!) is the individual NFT item's contract address (`nft.address` from `useNfts()`). Optional `amount` is the forward TON (gas/notification), defaults are sensible. There is no `` drop-in — compose from the hook. + +### Signing +`useSignText()` · `useSignBinary()` · `useSignCell()` + +### Network +`useNetwork()` (selected wallet's network, `undefined` if not connected) · `useDefaultNetwork()` returns `[defaultNetwork, setDefaultNetwork]` · `useNetworks()` · `useBlockNumber()` + +For mainnet/testnet apps, configure both networks and make reads explicit when the UI lets users choose a network: +```tsx +const [network, setNetwork] = useDefaultNetwork(); +const { data: balance } = useBalanceByAddress({ address, network }); + + +``` + +### DeFi +- Swap: `useSwapQuote({ from, to, amount, slippageBps? })` · `useBuildSwapTransaction()` · `useSwapProviders()` · `useSwapProvider()` +- Staking: `useStakingProviders()` · `useStakingQuote({ amount, direction })` · `useBuildStakeTransaction()` · `useStakedBalance({ userAddress })` + +When you compose these into a custom UI (instead of `` / ``), you own loading and error state for every step: `useSwapQuote().isLoading` for the quote, `useBuildSwapTransaction().isPending` for the build, `useSendTransaction().isPending` for the send. Disable the submit button on the active step's pending flag, render the active step's `error.message`, and call `reset()` for retry. The widgets handle this for you; the hooks deliberately don't. + +## Drop-in Components + +Full-featured React components from `@ton/appkit-react`. They handle their own state, loading, and errors: + +| Component | Use for | +|---|---| +| `` | Wallet connect/disconnect UI | +| `` | Send TON | +| `` | Send jettons. ⚠ `jetton` is an **object** with `{ address, symbol, decimals }` — NOT a bare address string. `address` is the jetton master. `amount` is human-readable (e.g. `"5"` for 5 USDT); decimals come from the prop, not metadata. NFT transfers go via `useTransferNft` hook — no drop-in button. | +| `` | Full swap UI | +| `` | Full staking UI | +| `` | NFT card (image, name, collection, badge) | +| `` | Tracks tx until on-chain finalized | + +Render-prop pattern for custom UI with built-in state: +```tsx +{(ctx) => } +``` + +## Query Key Prefixes + +For `invalidateQueries` / `removeQueries`. TanStack prefix-matches on the **first array element** — passing `queryKey: ['balance']` invalidates every `['balance', { address: A, network: M }]`, `['balance', { address: B, network: T }]`, etc. You rarely need to enumerate addresses; matching by the bare prefix is enough. + +| Prefix | Full key shape | Caches | +|---|---|---| +| `['balance']` | `['balance', { address, network }]` | TON balance | +| `['nfts']` | `['nfts', { address, network }]` | NFT list | +| `['nft']` | `['nft', { address, network? }]` | Single NFT (`address` = NFT item's contract address) | +| `['jettons']` | `['jettons', { address, network }]` | Jetton list | +| `['jetton-balance']` / `['jetton-info']` / `['jetton-wallet-address']` | kebab-case keys with `{ jettonAddress, ownerAddress }` | Jetton state (kebab-case — camelCase will silently match nothing) | +| `['stakedBalance']` / `['stakingProviderInfo']` / `['stakingQuote']` | camelCase keys | Staking | +| `['swapQuote']` | `['swapQuote', { amount, from, to }]` | Swap quotes | + +## Four Critical Patterns + +### 1. Real-time updates (streaming) + +Streaming is opt-in. Two requirements: register a provider + mount the watch hook. + +```ts +import { createTonCenterStreamingProvider } from '@ton/appkit'; + +const appKit = new AppKit({ + networks: { /* ... */ }, + providers: [ + createTonCenterStreamingProvider({ network: Network.mainnet(), apiKey: 'KEY' }), + ], +}); +``` + +```tsx +function BalanceDisplay() { + const { data: balance } = useBalance(); // already-formatted decimal string like "0.5" + useWatchBalance(); // writes WS updates into useBalance cache + return

{balance ?? '—'} TON

; +} +``` + +Without **both**, `useBalance` is one-shot. + +**Streaming-provider auth quirks (keyless != work):** +- `createTonCenterStreamingProvider({ network, apiKey })` — `apiKey` is optional; keyless works but is heavily rate-limited. +- `createTonApiStreamingProvider({ network, apiKey })` — `apiKey` is **effectively required**. Keyless WebSocket connections to `wss://tonapi.io/streaming/v2/ws` are closed by the gateway with HTTP 401; the provider then reconnects in a loop and floods the console with `WebSocket error` lines. Get a token from or fall back to `createTonCenterStreamingProvider` if you want a keyless option. + +### 2. Cache after mutations + +Mutations don't auto-invalidate. Use `invalidateQueries` after transfers and `removeQueries` after disconnect: + +```tsx +const queryClient = useQueryClient(); + +// After transfer: invalidate so the next render refetches +useTransferTon({ + mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['balance'] }) }, +}); + +// After disconnect: remove entirely so old wallet's data can't flash +useDisconnect({ + mutation: { + onSuccess: () => { + ['balance', 'nfts', 'nft', 'jettons', 'jetton-balance', 'jetton-info', 'jetton-wallet-address', 'stakedBalance'].forEach(key => + queryClient.removeQueries({ queryKey: [key] }), + ); + }, + }, +}); +``` + +`removeQueries` is correct on disconnect because there's no wallet to refetch for — leaving stale entries would flash the old wallet's data before the new wallet loads. + +For a stronger guarantee when wallet switches happen mid-flight (pending tx, hooks holding stale data), wrap wallet-scoped UI with `key={address}` so React remounts the whole subtree on address change: +```tsx +const address = useAddress(); +return ; +``` +This kills any in-flight mutation state, useState, and refs tied to the previous wallet — cleanest fix for "spinner stuck forever after switching wallets". Combine with `removeQueries` on disconnect for the cache layer. + +### 3. Transaction confirmation is not instant + +`useTransferTon` / `useSendTransaction` resolve when the wallet **accepts** the tx — not when it's confirmed on-chain. Balances won't reflect the change immediately even after `onSuccess`. `data` is `SendTransactionResponse = { boc, normalizedBoc, normalizedHash }` — an **object**, not a bare BOC string. Two patterns: + +```tsx +// (a) Drop-in: tracks the tx until finalized +const { mutate, data } = useTransferTon(); +{data && /* now confirmed */} />} + +// (b) Hooks: invalidate on accept; the next refetch picks up the new state once on-chain +useTransferTon({ + mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['balance'] }) }, +}); +``` + +To track a tx by hash (e.g. backend webhook lookup), use `data.normalizedHash`. + +Pair with `useWatchBalance()` + streaming if you want the balance to refresh the instant confirmation lands without a manual refetch. + +### 4. Transaction error handling + +```tsx +const { mutate, isPending, error, reset } = useTransferTon(); +const isUserRejection = (err: Error) => /reject|cancel|abort/i.test(err.message); + + +{error && ( + <> +

{isUserRejection(error) ? 'Cancelled' : `Error: ${error.message}`}

+ + +)} +``` + +Disable only on `isPending`, not `isError` (button must re-enable so user can retry). Use `reset()` for retry. For swap/staking, `@ton/appkit` exports `DefiError` (with `DefiErrorCode`) for typed handling. + +## No-wallet fallback + +When the user has no TON wallet installed (no browser extension, no Tonkeeper on the device), `` still works — but the experience changes: + +- **On mobile** without Tonkeeper installed, the universal link opens the App Store / Google Play install flow. +- **On desktop** without a browser-extension wallet, TonConnect opens a modal with QR codes — the user scans from their mobile wallet to connect. This is intentional and works without any code from you. + +For a custom Connect button, you can detect emptiness via `useConnectors()` and render a fallback CTA: + +```tsx +const connectors = useConnectors(); +if (connectors.length === 0) { + return ( + + Install Tonkeeper to continue + + ); +} +``` + +But for most apps the right answer is **just use ``** and rely on TonConnect's built-in universal/QR/install-redirect flow — it covers all three cases (extension, mobile-installed, mobile-not-installed → install link, desktop → QR) without you wiring it. + +## Common Gotchas + +1. **`QueryClientProvider` wraps `AppKitProvider`** — not the other way around. `AppKitProvider` doesn't create its own QueryClient. +2. **`useBalance` returns a pre-formatted TON decimal string** (e.g. `"0.500000000"`), not raw nano and not `bigint`. Same for `useJettons().data.jettons[i].balance` (formatted using the jetton's own decimals). Render directly or format with `Intl.NumberFormat`; calling `formatUnits(balance, 9)` again will re-slice the string on the decimal point and produce `"0..500000000"`. +3. **Amounts are strings** (`'0.5'`, not `500000000n`). AppKit applies token decimals internally. +4. **Streaming is opt-in** — needs both a provider in config and `useWatchBalance` mounted. +5. **Network mismatch breaks transactions** — if the connected wallet is on testnet but your `defaultNetwork` is mainnet (or vice versa), TonConnect rejects the tx and your `useTransferTon` mutation lands in `error` with a network-mismatch message. Set `defaultNetwork`, expose switching with `useDefaultNetwork()`, and pass `network` into address-based reads. Check before sending: + ```tsx + const walletNetwork = useNetwork(); + const [defaultNetwork] = useDefaultNetwork(); + const networkMismatch = walletNetwork && walletNetwork.chainId !== defaultNetwork.chainId; + ``` + Mainnet chainId `-239`, testnet `-3`. +6. **iOS deep links need synchronous click** — call `connect()` directly in the handler, no `await` before it (see Wallet section above for the anti-pattern). +7. **Telegram Mini App return** — configure `tonConnectOptions.actionsConfiguration.returnStrategy`. +8. **React 19 / Next 15 hook errors** — check `@tonconnect/ui-react` version + run `pnpm why react` for duplicates. +9. **`` + keyless TonCenter = HTTP 429 cascade** — while the widget is mounted, its internal queries poll with `refetchInterval: 5000` (three queries: provider info, staked/token balances, quote), and each refresh can hit several TonCenter endpoints (`getPoolBalance` / `getPoolData` / APY). The Tonstakers provider itself has NO internal timer — registering `createTonstakersProvider()` alone causes no traffic; it only adds a 30s-TTL cache around on-demand reads. With a **keyless** `apiClient: { url: 'https://toncenter.com' }`, the widget's polling quickly exhausts the IP's anonymous TonCenter quota and cascades HTTP 429s into otherwise-unrelated reads (`useBalance`, `useJettons`, `useNfts`). Symptom: balance/jettons/nfts hang or fail, staking widget itself stays empty. Fix: set a real TonCenter `apiKey` on the apiClient, or don't mount `` until you have one. +10. **`` swallows build/send errors silently** — its `ctx.error` carries only client-side validation messages. Failures from `useBuildSwapTransaction` (e.g. Omniston returning `Internal server error: 5: [hash]`, an expired quote, no liquidity at execution time) and from `useSendTransaction` become **unhandled promise rejections**: the Continue button appears to do nothing, no toast, no in-widget error. Diagnose in DevTools Console (look for `Unhandled rejection: SwapError: …`). If you're composing your own UI via the render-prop, wrap `ctx.sendSwapTransaction()` in `try/catch` and surface the error yourself, or attach a `window.addEventListener('unhandledrejection', ...)` handler. For custom flows, the hooks (`useSwapQuote` → `useBuildSwapTransaction` → `useSendTransaction`) each expose their own `error` field — use them directly instead. diff --git a/packages/appkit/skills/ton-appkit/evals.json b/packages/appkit/skills/ton-appkit/evals.json new file mode 100644 index 000000000..557f38437 --- /dev/null +++ b/packages/appkit/skills/ton-appkit/evals.json @@ -0,0 +1,403 @@ +{ + "skill_name": "ton-appkit", + "evals": [ + { + "id": 1, + "name": "setup", + "prompt": "I'm starting a new React app with Vite. I want to integrate @ton/appkit so users can connect their TON wallet, see their balance, and send TON to another address. Show me the complete setup.", + "assertions": [ + {"text": "installs_required_packages", "description": "Mentions installing @ton/appkit, @ton/appkit-react, and @tanstack/react-query"}, + {"text": "imports_styles_css", "description": "Imports '@ton/appkit-react/styles.css' once near the app root"}, + {"text": "provider_order_correct", "description": "QueryClientProvider wraps AppKitProvider, not the other way around"}, + {"text": "appkit_singleton_outside_component", "description": "AppKit instance created at module level, not inside a component"}, + {"text": "uses_createTonConnectConnector", "description": "Sets up wallet connection via createTonConnectConnector with manifestUrl"}, + {"text": "useBalance_used", "description": "Uses useBalance hook for displaying balance"}, + {"text": "useTransferTon_used", "description": "Uses useTransferTon (directly or via ) for sending TON"}, + {"text": "amount_as_string", "description": "Passes amount as human-readable string (e.g., '0.5'), not bigint"}, + {"text": "vite_buffer_polyfill", "description": "Mentions polyfilling Node Buffer (and ideally process) for the browser — either vite-plugin-node-polyfills in vite.config.ts, or a manual import of buffer/process in main.tsx — because @ton/core needs Buffer at runtime"}, + {"text": "configures_both_networks_or_testnet_note", "description": "Either configures both mainnet and testnet in AppKit `networks`, or explicitly tells the user to add testnet if their wallet is on testnet (otherwise the tx gets silently rejected)"}, + {"text": "safe_test_recipient_pattern", "description": "Either uses `useAddress()` (or the connected wallet's address) as recipient for the first-touch send, or explicitly warns that the recipient must be a valid TON address (UQ.../EQ...) — not a made-up placeholder string"} + ] + }, + { + "id": 2, + "name": "streaming", + "prompt": "My app shows the balance using useBalance() but it doesn't update in real-time. How do I get WebSocket-based balance updates?", + "assertions": [ + {"text": "explains_streaming_optin", "description": "Explains streaming is opt-in, useBalance alone is one-shot"}, + {"text": "registers_streaming_provider", "description": "Shows registering a streaming provider (createTonCenterStreamingProvider or createTonApiStreamingProvider)"}, + {"text": "uses_useWatchBalance", "description": "Shows useWatchBalance must be mounted alongside useBalance"}, + {"text": "complete_setup_code", "description": "Provides complete working code from AppKit config to React component"} + ] + }, + { + "id": 3, + "name": "jetton-send", + "prompt": "How do I send 10 USDT (jetton) to another address in my app?", + "assertions": [ + {"text": "uses_useTransferJetton", "description": "Uses useTransferJetton hook"}, + {"text": "cache_invalidation", "description": "Mentions invalidateQueries for jetton cache after success"} + ] + }, + { + "id": 4, + "name": "disconnect-cleanup", + "prompt": "When a user disconnects their wallet and connects a different one, I see the old wallet's balance flash before the new one loads. How do I fix this?", + "assertions": [ + {"text": "uses_useDisconnect_onSuccess", "description": "Uses useDisconnect with onSuccess callback"}, + {"text": "explains_remove_vs_invalidate", "description": "Explains why removeQueries is correct (no wallet to refetch for)"}, + {"text": "covers_multiple_keys", "description": "Removes multiple keys: ['balance'], ['nfts'], ['jettons'], ['jetton-balance'], ['stakedBalance'] (jetton keys are kebab-case)"} + ] + }, + { + "id": 5, + "name": "swap-flow", + "prompt": "I want to add a token swap feature using AppKit. User selects two tokens, sees a quote, and confirms the swap. How?", + "assertions": [ + {"text": "uses_useSwapQuote", "description": "Uses useSwapQuote for getting quotes"}, + {"text": "uses_useBuildSwapTransaction", "description": "Uses useBuildSwapTransaction to build the tx"}, + {"text": "uses_useSendTransaction", "description": "Uses useSendTransaction to send the built tx"} + ] + }, + { + "id": 6, + "name": "ssr-nextjs", + "prompt": "I'm using Next.js 15 App Router. I keep getting hydration errors when I add AppKit. What do I do?", + "assertions": [ + {"text": "use_client_directive", "description": "Recommends adding 'use client' to the providers (wallet-using) file."}, + {"text": "mount_gate_or_dynamic_import", "description": "Recommends gating wallet-dependent UI on a useEffect-based mounted flag, or using next/dynamic(() => import(...), { ssr: false })."}, + {"text": "does_not_recommend_ssr_true", "description": "Does NOT recommend `ssr: true` on the AppKit constructor — there is no such field on `AppKitConfig`. The fix is purely a React/Next concern."} + ] + }, + { + "id": 7, + "name": "error-handling", + "prompt": "My users complain that sometimes the send button stays disabled forever after a failed transaction. The error doesn't show either. How should I handle transaction errors properly?", + "assertions": [ + {"text": "uses_error_isError", "description": "Uses error and isError from mutation result"}, + {"text": "user_rejection_detection", "description": "Distinguishes user rejection (reject/cancel/abort regex or similar) from other errors"}, + {"text": "uses_reset", "description": "Uses reset() function for retry flows"}, + {"text": "isPending_only_for_disable", "description": "isPending is used only for disabling during pending, not for error state"} + ] + }, + { + "id": 9, + "name": "orientation", + "prompt": "I just installed @ton/appkit and @ton/appkit-react in my project. Give me an overview of what these packages provide and how the parts fit together.", + "assertions": [ + {"text": "tanstack_query_peer_dep", "description": "Mentions @tanstack/react-query as required peer dependency"}, + {"text": "describes_dropin_vs_hooks", "description": "Distinguishes drop-in components (e.g., SwapWidget) from hooks (custom UX) as the two main paths"} + ] + }, + { + "id": 10, + "name": "wallet-picker", + "prompt": "My users have both Tonkeeper and MyTonWallet installed. How do I show a wallet picker so they can choose which one to connect?", + "assertions": [ + {"text": "uses_useConnectors", "description": "Uses useConnectors to list available connectors"}, + {"text": "uses_useConnect_with_id", "description": "Uses useConnect with connectorId parameter (not the connector object)"}, + {"text": "disabled_during_pending", "description": "Disables button with isPending during connection"} + ] + }, + { + "id": 11, + "name": "staking-flow", + "prompt": "I want to add a staking feature so users can stake TON and earn rewards. Show me how to fetch providers, get a quote, and execute the stake.", + "assertions": [ + {"text": "uses_useStakingProviders", "description": "Uses useStakingProviders to list options"}, + {"text": "uses_useStakingQuote", "description": "Uses useStakingQuote with amount and direction: 'stake'"}, + {"text": "uses_useBuildStakeTransaction", "description": "Uses useBuildStakeTransaction to build tx"}, + {"text": "mentions_unstake", "description": "Mentions direction: 'unstake' variant or unstaking flow"} + ] + }, + { + "id": 12, + "name": "watch-transactions", + "prompt": "I want to show a live feed of incoming transactions for the connected wallet — like a real-time transaction log. How?", + "assertions": [ + {"text": "uses_useWatchTransactions", "description": "Uses useWatchTransactions or useWatchTransactionsByAddress"}, + {"text": "streaming_provider_required", "description": "Mentions that a streaming provider must be registered"}, + {"text": "onchange_callback", "description": "Shows onChange callback to receive new transactions"} + ] + }, + { + "id": 13, + "name": "testnet-mainnet-switch", + "prompt": "My app needs to support both mainnet and testnet — users should be able to switch between them. How do I configure AppKit and handle the switch?", + "assertions": [ + {"text": "both_networks_in_config", "description": "Configures both mainnet and testnet in networks object"}, + {"text": "default_network_or_useDefaultNetwork", "description": "Sets defaultNetwork or uses useDefaultNetwork/useNetwork"}, + {"text": "chain_ids_correct", "description": "Mentions mainnet -239 and testnet -3 chain IDs"}, + {"text": "network_aware_hooks", "description": "Shows network parameter on hooks (e.g., useBalanceByAddress({ address, network }))"} + ] + }, + { + "id": 14, + "name": "consumer-vs-monorepo-routing", + "prompt": "I'm using @ton/appkit-react in my own Next.js app and want to fix stale NFT and balance data after switching wallets. Please don't talk about changing the AppKit repo internals; I just need app code.", + "assertions": [ + {"text": "consumer_app_focus", "description": "Keeps the answer focused on application integration code, not monorepo source changes"}, + {"text": "uses_removeQueries", "description": "Uses queryClient.removeQueries for wallet-scoped cache cleanup"}, + {"text": "covers_balance_and_nft_keys", "description": "Removes at least balance and NFT-related query keys"}, + {"text": "explains_no_wallet_refetch", "description": "Explains removeQueries is appropriate because there is no disconnected wallet to refetch for"} + ] + }, + { + "id": 15, + "name": "custom-vs-dropin-decision", + "prompt": "We need swaps in our Telegram Mini App. The default UI might be enough, but product may later want a custom quote screen. What should I use first, and what should I watch out for?", + "assertions": [ + {"text": "hook_path_for_custom_ux", "description": "Names useSwapQuote, useBuildSwapTransaction, and useSendTransaction as the custom UX path"}, + {"text": "loading_error_states", "description": "Calls out loading/error/pending state handling for the hook-based path"} + ] + }, + { + "id": 16, + "name": "ios-wallet-connect-click", + "prompt": "On iPhone, tapping my custom Connect Wallet button sometimes doesn't open Tonkeeper. I'm calling connect after an async analytics call. How should I wire this?", + "assertions": [ + {"text": "uses_useConnect", "description": "Uses useConnect or recommends TonConnectButton for simpler integration"}, + {"text": "pending_state", "description": "Disables the button only while the connection mutation is pending"} + ] + }, + { + "id": 17, + "name": "signing-flows", + "prompt": "I need users to prove they own their wallet by signing a plain message before they can join my app's leaderboard. How do I do this with AppKit?", + "assertions": [ + {"text": "uses_useSignText", "description": "Uses useSignText hook (not useSignCell or useSignBinary — plain message)"}, + {"text": "mutate_with_message", "description": "Calls mutate with the text/message to sign"}, + {"text": "handles_pending_error", "description": "Handles isPending and error states like other AppKit mutations"}, + {"text": "proof_or_signature_to_backend", "description": "Extracts signature/proof from the result for backend verification"} + ] + }, + { + "id": 18, + "name": "jetton-custom-decimals", + "prompt": "I need to send a jetton that has 18 decimals (not the standard 9). The user enters '1.5' tokens. Do I need to do anything special with the amount?", + "assertions": [ + {"text": "amount_stays_string", "description": "Passes '1.5' directly as a string, no manual decimal conversion"}, + {"text": "decimals_from_metadata", "description": "Explains AppKit reads decimals from jetton metadata (token info) automatically"}, + {"text": "no_manual_parseUnits", "description": "Explicitly says don't do parseUnits/toUnits/multiply-by-10^decimals yourself"}, + {"text": "uses_useTransferJetton", "description": "Uses useTransferJetton hook"} + ] + }, + { + "id": 19, + "name": "pending-tx-during-address-change", + "prompt": "A user starts a send TON transaction, then disconnects their wallet and connects a different one before the first tx confirms. My UI shows the first tx's spinner forever and then a confusing error from the new wallet. How do I handle this cleanly?", + "assertions": [ + {"text": "reset_or_cancel_on_disconnect", "description": "Calls mutation reset() or guards against state mismatch when wallet changes"}, + {"text": "watches_useAddress_for_change", "description": "Watches useAddress() to detect wallet/address change"}, + {"text": "removes_wallet_scoped_queries", "description": "Removes wallet-scoped TanStack caches (balance, nfts, etc.) on disconnect"}, + {"text": "key_by_address_or_remount", "description": "Suggests keying the wallet-scoped subtree by address (key={address}) or remounting"} + ] + }, + { + "id": 20, + "name": "manifest-troubleshoot", + "prompt": "When users tap Connect, Tonkeeper opens but shows 'Unknown app' instead of my app name and icon. What did I do wrong?", + "assertions": [ + {"text": "manifest_url_publicly_reachable", "description": "Says manifestUrl must be publicly reachable (not localhost in prod, no auth wall)"}, + {"text": "manifest_json_format", "description": "Shows the JSON shape: url, name, iconUrl (the required fields)"}, + {"text": "https_required", "description": "HTTPS required for the manifest URL"}, + {"text": "cors_or_content_type", "description": "Mentions correct content-type (application/json) or CORS so wallets can fetch it"} + ] + }, + { + "id": 21, + "name": "multi-step-orchestration", + "prompt": "I want a flow: when user clicks Send, send 1 TON, then once confirmed refresh the balance display and show a success toast. How do I wire this in AppKit?", + "assertions": [ + {"text": "uses_onSuccess_callback", "description": "Uses mutation onSuccess (or mutation: { onSuccess }) to chain after send"}, + {"text": "invalidates_balance_key", "description": "Calls queryClient.invalidateQueries({ queryKey: ['balance'] }) on success"}, + {"text": "tx_not_immediately_onchain", "description": "Explains tx isn't on-chain instantly — uses OR mentions wait/poll for confirmation"}, + {"text": "disables_during_isPending", "description": "Disables the send button while isPending"}, + {"text": "handles_error_branch", "description": "Covers onError or error state in the UI"} + ] + }, + { + "id": 22, + "name": "vite-buffer-polyfill", + "prompt": "I just integrated @ton/appkit into my Vite + React app following the docs. The page loads but shows a white screen, and the console says 'ReferenceError: Buffer is not defined'. What did I miss?", + "assertions": [ + {"text": "identifies_node_globals_in_browser", "description": "Explains the cause: @ton/core (transitive dep) uses Node's Buffer in the browser; Vite doesn't include Node polyfills by default"}, + {"text": "recommends_polyfill_solution", "description": "Recommends either vite-plugin-node-polyfills in vite.config.ts OR a manual `import 'buffer'; globalThis.Buffer = Buffer` at the top of main.tsx"}, + {"text": "mentions_process_too_or_full_polyfill", "description": "Mentions that `process` may also need polyfilling (covered by full nodePolyfills() or by adding `process` to the manual fallback)"}, + {"text": "polyfill_imported_first", "description": "If using the manual approach, says the polyfill import must come BEFORE any AppKit/TON code (first line of main.tsx)"} + ] + }, + { + "id": 23, + "name": "send-ton-recipient-test-pattern", + "prompt": "I integrated SendTonButton with a hardcoded recipient address. When I click Send, Tonkeeper opens but says 'Transaction canceled. There will be no changes to your account' before I even confirm. What's wrong with my setup?", + "assertions": [ + {"text": "identifies_invalid_address", "description": "Identifies that 'no changes to your account' indicates the simulator failed — most likely an invalid recipient TON address (bad checksum or made-up string)"}, + {"text": "explains_address_format", "description": "Mentions valid TON addresses are EQ.../UQ... base64 with checksums, not arbitrary strings"}, + {"text": "suggests_self_transfer_test", "description": "Suggests using `useAddress()` (the connected user's own address) as the recipient for a safe end-to-end test — sending to yourself avoids invalid-address bugs and risk"} + ] + }, + { + "id": 24, + "name": "nft-transfer", + "prompt": "How do I transfer one of the connected user's NFTs to another wallet address using AppKit? Show me the hook and a basic UI.", + "assertions": [ + {"text": "uses_useTransferNft", "description": "Uses the useTransferNft hook from @ton/appkit-react"}, + {"text": "passes_correct_nftAddress_param", "description": "Calls mutate with `nftAddress` (the actual API param — NOT `tokenAddress`, `nft`, `address`, or similar) AND `recipientAddress`. Hallucinating `tokenAddress` is the most common mistake and must FAIL this assertion."}, + {"text": "uses_useNfts_to_list", "description": "Uses useNfts() (or useNftsByAddress) to let the user pick which NFT to transfer"}, + {"text": "handles_pending_and_error", "description": "Disables submit during isPending and renders error state (same pattern as other mutations)"}, + {"text": "cache_invalidation_after_transfer", "description": "Invalidates the ['nfts'] (and optionally ['nft']) cache prefix after successful transfer so the list updates"} + ] + }, + { + "id": 25, + "name": "eq-vs-uq-address-format", + "prompt": "I have addresses in two formats: `EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c` and `UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJKZ`. They look almost identical. When I send TON to a fresh user wallet using the EQ form, Tonkeeper sometimes warns about an uninitialized contract. Which form should I use for what, and are they interchangeable?", + "assertions": [ + {"text": "explains_bounceable_vs_non_bounceable", "description": "Explains EQ-prefix is the bounceable form, UQ-prefix is the non-bounceable form"}, + {"text": "same_underlying_account", "description": "Notes both forms refer to the same on-chain account/hash — only the bounce-flag and checksum differ"}, + {"text": "uq_for_user_wallets", "description": "Recommends UQ (non-bounceable) when sending to a user's wallet address — avoids the 'uninitialized contract' bounce-back when the wallet hasn't been deployed yet"}, + {"text": "eq_for_known_contracts", "description": "Recommends EQ (bounceable) for known-initialized smart contracts where you want refund-on-failure semantics"}, + {"text": "appkit_accepts_either", "description": "Notes that AppKit's transfer hooks accept either format — but the form you pick controls bounce behavior, so it still matters which one you save/display"} + ] + }, + { + "id": 26, + "name": "telegram-mini-app-return", + "prompt": "I'm building a Telegram Mini App with @ton/appkit-react. When the user taps Connect, Tonkeeper opens correctly, but after the user signs the connection in Tonkeeper they have to manually switch back to my Mini App in Telegram — it doesn't auto-return. Same problem after signing transactions. How do I fix this so the Mini App is brought back to focus automatically?", + "assertions": [ + {"text": "configures_returnStrategy", "description": "Sets `tonConnectOptions.actionsConfiguration.returnStrategy` (typically `'back'` or a `tg://...` URL) on createTonConnectConnector"}, + {"text": "mentions_twaReturnUrl_or_telegram_url", "description": "Mentions `twaReturnUrl` (preferred for Mini Apps) or a `https://t.me//` deep link as the place to return to"}, + {"text": "config_at_appkit_setup", "description": "Configures the option on the connector at AppKit construction (not at every call site)"}, + {"text": "explains_why_it_works", "description": "Briefly explains that wallets read this config when handing control back, so without it they default to staying open"} + ] + }, + { + "id": 27, + "name": "no-wallets-installed-fallback", + "prompt": "What happens to my Connect button if a user opens my app with no TON wallet installed (no Tonkeeper, no MyTonWallet)? I want to show a fallback like 'Install Tonkeeper to continue' instead of an empty/broken state. How do I detect this and what's the right UX with AppKit?", + "assertions": [ + {"text": "checks_connectors_or_wallets_list", "description": "Checks the available connectors (or the TonConnect wallets list) for emptiness/availability"}, + {"text": "recommends_fallback_ui", "description": "Recommends rendering a fallback CTA when no wallets are detected — e.g., 'Install Tonkeeper'"}, + {"text": "deep_link_or_store_link", "description": "Mentions linking to wallet install (App Store / Google Play / browser extension / tonkeeper.com) as part of the fallback"}, + {"text": "mentions_TonConnect_handles_universal", "description": "Notes that TonConnect's universal/QR flow can also work for desktop users without a local wallet — useful nuance"} + ] + }, + { + "id": 28, + "name": "balance-return-type-no-double-format", + "prompt": "I want to render the connected wallet's TON balance with two decimals — e.g. '1.50 TON'. Here's my code: ```const { data: balance } = useBalance(); return

{formatUnits(balance, 9)} TON

;``` Is this right? What is `balance` actually?", + "assertions": [ + {"text": "useBalance_is_string_not_bigint", "description": "States that `useBalance().data` is a STRING (not a `bigint`, not raw nano). MUST FAIL if the answer says bigint or nano."}, + {"text": "already_formatted_as_ton", "description": "Explains the string is already in human-readable TON units (e.g. \"0.500000000\"), having been pre-formatted by `formatUnits(_, 9)` inside `getBalanceByAddress`."}, + {"text": "warns_against_double_formatUnits", "description": "Explicitly warns that calling `formatUnits(balance, 9)` again re-slices the string on the decimal point and produces garbage like \"0..500000000\" (two dots)."}, + {"text": "correct_render_pattern", "description": "Shows a correct render: either bare `{balance}` or `parseFloat(balance).toFixed(2)` / `Intl.NumberFormat(...).format(Number(balance))` — NOT a second formatUnits call."} + ] + }, + { + "id": 29, + "name": "jettons-hook-return-shape", + "prompt": "I'm rendering a list of the user's jettons: `const { data: jettons } = useJettons(); return jettons?.map(j => );` But `jettons.map is not a function` at runtime, and the symbol shows undefined. What's wrong?", + "assertions": [ + {"text": "data_is_object_not_array", "description": "Says `useJettons().data` is `{ jettons, addressBook }`, NOT an array. You need `data?.jettons.map(...)`."}, + {"text": "symbol_nested_under_info", "description": "Says jetton fields like symbol/name live under `j.info.symbol`/`j.info.name`, not on `j` directly."}, + {"text": "balance_already_formatted", "description": "Notes `j.balance` is already a formatted decimal string in the jetton's own units (via internal `formatUnits` using `j.decimalsNumber`) — don't reformat."}, + {"text": "image_url_field_names", "description": "Mentions image URLs live under `j.info.image.smallUrl` / `j.info.image.url` (with the `Url` suffix), not `j.info.image.small`/`medium`."} + ] + }, + { + "id": 30, + "name": "nfts-hook-return-shape", + "prompt": "Same idea but for NFTs: `const { data: nfts } = useNfts(); nfts?.map(n => );` — `nfts.map is not a function`. How do I read the list?", + "assertions": [ + {"text": "data_is_object_with_nfts", "description": "Says `useNfts().data` is `{ nfts, addressBook? }`, NOT an array. The list is at `data?.nfts`."}, + {"text": "uses_NftItem_or_destructures", "description": "Either uses `` drop-in or shows correct destructuring; doesn't pass the whole `data` object as if it were an array."} + ] + }, + { + "id": 31, + "name": "tonconnect-connector-factory", + "prompt": "I'm setting up AppKit and I see two ways to wire TonConnect online: `new TonConnectConnector(...)` (constructor) and `createTonConnectConnector(...)` (factory). Which is the current/correct one?", + "assertions": [ + {"text": "uses_factory_function", "description": "Recommends `createTonConnectConnector(...)` — the factory."}, + {"text": "rejects_constructor", "description": "Notes that `TonConnectConnector` is type-only in the current API and the `new TonConnectConnector(...)` form is no longer valid. MUST FAIL if the answer recommends `new TonConnectConnector`."}, + {"text": "options_shape_correct", "description": "Shows the factory takes `{ tonConnectOptions: { manifestUrl, ... } }`."} + ] + }, + { + "id": 32, + "name": "swap-widget-silent-continue", + "prompt": "I'm using `` and when the user clicks Continue, nothing happens — no modal, no Tonkeeper prompt, no error in the UI. The button is enabled and labeled 'Continue'. What's going on and how do I see what failed?", + "assertions": [ + {"text": "build_or_send_error_swallowed", "description": "Explains that `useBuildSwapTransaction` / `useSendTransaction` errors inside `SwapWidget` are NOT surfaced into `ctx.error` (which is validation-only) and become unhandled promise rejections."}, + {"text": "check_devtools_console", "description": "Tells the user to look at DevTools Console for `Unhandled rejection: SwapError: ...` or similar — this is the first diagnostic step."}, + {"text": "wrap_in_try_catch_or_hooks", "description": "Recommends either wrapping `ctx.sendSwapTransaction()` in try/catch via the render-prop, or composing the flow directly from `useSwapQuote` + `useBuildSwapTransaction` + `useSendTransaction` to get proper `error` fields per step."} + ] + }, + { + "id": 33, + "name": "tonstakers-rate-limit-cascade", + "prompt": "I added `createTonstakersProvider()` to my AppKit config alongside a keyless TonCenter API (`apiClient: { url: 'https://toncenter.com' }` with no key). Now `useBalance`, `useJettons`, and `useNfts` are all painfully slow or returning HTTP 429 errors. The staking widget itself shows nothing. What's happening?", + "assertions": [ + {"text": "explains_staking_widget_polling", "description": "Explains the traffic source: while `` is mounted, its internal queries poll with `refetchInterval: 5000` (several queries, each refresh hitting TonCenter endpoints like getPoolBalance/getPoolData/APY). MUST FAIL if the answer claims the Tonstakers provider polls on its own internal timer merely by being registered — the provider has no timer, only a 30s-TTL cache around on-demand reads."}, + {"text": "burns_keyless_quota", "description": "Says this quickly exhausts the IP's keyless TonCenter quota and cascades HTTP 429s into unrelated reads (useBalance, useJettons, useNfts)."}, + {"text": "fix_with_api_key_or_remove_provider", "description": "Recommends either adding a real TonCenter API key OR not mounting `` (or removing `createTonstakersProvider()` from `providers`) when no key is available."} + ] + }, + { + "id": 34, + "name": "tonapi-streaming-requires-key", + "prompt": "I'm using `createTonApiStreamingProvider({ network: Network.mainnet() })` (no apiKey) and I'm seeing repeated WebSocket errors in the console — the WS to `wss://tonapi.io/streaming/v2/ws` keeps closing. Why?", + "assertions": [ + {"text": "tonapi_ws_requires_token", "description": "Explains the TonAPI streaming WebSocket endpoint requires authentication — keyless connections are rejected with HTTP 401 during the WebSocket upgrade."}, + {"text": "pass_apiKey", "description": "Says to pass `apiKey` to `createTonApiStreamingProvider({ network, apiKey })` — get a token from tonconsole.com."}, + {"text": "alternative_toncenter_streaming", "description": "Notes that `createTonCenterStreamingProvider` is the alternative (TonCenter streaming also rate-limits but doesn't hard-fail without a key)."} + ] + }, + { + "id": 35, + "name": "next-js-no-ssr-flag", + "prompt": "How do I configure AppKit for Next.js App Router so server-rendered pages don't crash on wallet hooks? Does `new AppKit({ ssr: true, ... })` work?", + "assertions": [ + {"text": "no_ssr_field_on_AppKitConfig", "description": "Explicitly says there is NO `ssr` property on `AppKitConfig` — passing it does nothing. MUST FAIL if the answer recommends adding `ssr: true` to the AppKit constructor."}, + {"text": "use_client_directive", "description": "Recommends adding `'use client'` to the providers file."}, + {"text": "mount_gate_or_dynamic_import", "description": "Recommends either gating wallet-dependent UI behind a `useEffect`-based mount flag or using `dynamic(() => import(...), { ssr: false })`."} + ] + }, + { + "id": 36, + "name": "omniston-build-error-handling", + "prompt": "My SwapWidget gets a valid quote, but when the user clicks Continue I see in the console: `Unhandled rejection: SwapError: Failed to build Omniston transaction: Internal server error: 5: [hash]`. The UI says nothing. What's happening, what should I do, and is this my code's bug?", + "assertions": [ + {"text": "identifies_upstream_omniston", "description": "Identifies that this is an upstream STON.fi / Omniston server error (gRPC status 5 = NOT_FOUND on the build step), not a bug in the user's code or AppKit."}, + {"text": "suggests_retry_or_different_route", "description": "Suggests retrying after a short wait, using a larger amount (close to a resolver's minimum quotes fail more often), or trying a different pair / a different swap provider."}, + {"text": "fallback_provider", "description": "Mentions registering `createDeDustProvider()` as a fallback (it builds via the DeDust Router API, independent of Omniston's RFQ/resolver system — though the router itself aggregates several protocols including STON.fi pools) and notes the user can switch providers from the SwapWidget settings gear."}, + {"text": "widget_swallows_error", "description": "Notes that `` itself swallows this error (doesn't propagate it into `ctx.error`) — this is a kit-side UX bug, not the user's fault, and the diagnostic path is DevTools Console for unhandled rejections."} + ] + }, + { + "id": 37, + "name": "send-jetton-button-shape", + "prompt": "I want to drop in `` for sending 5 USDT to an address. What props does it need? Is `jetton` just the jetton address?", + "assertions": [ + {"text": "jetton_is_object_not_string", "description": "Says the `jetton` prop is an OBJECT, not a string. Required shape is `{ address, symbol, decimals }`. MUST FAIL if the answer passes a bare address string."}, + {"text": "amount_human_readable", "description": "Shows `amount` as a human-readable string (e.g. `'5'` for 5 USDT), and notes the button uses `jetton.decimals` to scale internally — don't pass raw units."}, + {"text": "recipient_address_prop", "description": "Includes the `recipientAddress` string prop."} + ] + }, + { + "id": 38, + "name": "html-balance-render", + "prompt": "Write a React component file BalanceDisplay.tsx (named export `BalanceDisplay`) that renders the connected wallet TON balance with 2 decimal places, e.g. '0.50 TON'. Use useBalance() from @ton/appkit-react. Component takes no props.", + "assertions": [ + {"text": "compiles_cleanly", "description": "tsc + vite build succeeds on the file"}, + {"text": "no_runtime_errors", "description": "page loads without console/runtime errors (favicon 404 ignored)"}, + {"text": "renders_balance_with_decimal", "description": "rendered DOM contains a number with at most 4 decimal places"}, + {"text": "no_double_dot_in_rendered_dom", "description": "CRITICAL: rendered text does NOT contain '..' sequence (double-formatUnits bug — would render '0..500000000')"}, + {"text": "no_NaN", "description": "rendered text does NOT contain NaN (signs of wrong type assumptions)"}, + {"text": "no_full_precision_leak", "description": "rendered text does NOT contain raw 9-decimal value '0.500000000' (formatter was bypassed entirely)"}, + {"text": "reasonable_magnitude", "description": "rendered text contains '0.5' — balance not over-divided. If executor assumes balance is nano-bigint and writes Number(balance)/1e9, the result becomes 5e-10 and toFixed(2) renders '0.00' — this assertion catches it."} + ] + } + ] +} diff --git a/packages/appkit/skills/ton-appkit/skill-reference/recipes.md b/packages/appkit/skills/ton-appkit/skill-reference/recipes.md new file mode 100644 index 000000000..ba74a1dea --- /dev/null +++ b/packages/appkit/skills/ton-appkit/skill-reference/recipes.md @@ -0,0 +1,299 @@ +# Extended AppKit Recipes + +Code recipes for DeFi flows (swap, staking), jetton/NFT transfers, live transactions, and multi-network apps. Read this when a basic hook reference isn't enough. + +**Before building from hooks, try the drop-in component first** (see SKILL.md "Drop-in Components"). E.g., for swap: ``. The hook-based recipes below are for cases where you need a fully custom UX. + +When adapting these recipes, preserve AppKit's consumer-facing invariants: transfer amounts are human-readable strings, wallet-scoped caches need explicit cleanup, streaming recipes need a registered streaming provider, and multi-network reads should pass `network` explicitly. + +## Swap Flow + +### Drop-in (recommended) +```tsx +import { SwapWidget, Network } from '@ton/appkit-react'; +import type { AppkitUIToken } from '@ton/appkit-react'; + +const tokens: AppkitUIToken[] = [ + { address: 'ton', symbol: 'TON', name: 'Toncoin', decimals: 9, network: Network.mainnet() }, + { address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', symbol: 'USDT', name: 'Tether USD', decimals: 6, network: Network.mainnet() }, +]; + +function SwapPage() { + return ; +} +``` +For custom UI within the widget's state: pass render-prop children. Register DEX providers in AppKit config — imported from sub-path entries, NOT from the main `@ton/appkit` package: + +```ts +import { createDeDustProvider } from '@ton/appkit/swap/dedust'; +import { createOmnistonProvider } from '@ton/appkit/swap/omniston'; + +const providers = [createDeDustProvider(), createOmnistonProvider()]; +``` + +### From hooks (custom UX) +```tsx +import { useState } from 'react'; +import { useSwapQuote, useBuildSwapTransaction, useSendTransaction, useAddress } from '@ton/appkit-react'; +import type { SwapToken } from '@ton/appkit-react'; + +function SwapForm({ fromToken, toToken }: { fromToken: SwapToken; toToken: SwapToken }) { + const userAddress = useAddress(); + const [amount, setAmount] = useState('1'); + + // 1. Get a quote + const { data: quote, isLoading: quoteLoading } = useSwapQuote({ + from: fromToken, + to: toToken, + amount, + slippageBps: 100, // 1% + }); + + // 2. Build and send transaction + const { mutateAsync: buildSwap } = useBuildSwapTransaction(); + const { mutate: sendTx, isPending: sending } = useSendTransaction(); + + const handleSwap = async () => { + if (!quote || !userAddress) return; + const tx = await buildSwap({ quote, userAddress }); + sendTx(tx); + }; + + return ( + <> + setAmount(e.target.value)} /> + {quoteLoading &&

Getting quote...

} + {quote && ( +

You'll receive: {quote.toAmount} {toToken.symbol} (price impact: {quote.priceImpact}%)

+ )} + + + ); +} +``` + +## Staking Flow + +```tsx +import { useState } from 'react'; +import { + useStakingProviders, + useStakingQuote, + useBuildStakeTransaction, + useStakedBalance, + useSendTransaction, +} from '@ton/appkit-react'; + +function StakeForm({ userAddress }: { userAddress: string }) { + const providers = useStakingProviders(); + const [providerId, setProviderId] = useState(providers[0]?.providerId); + const [amount, setAmount] = useState('10'); + + const { data: quote } = useStakingQuote({ + amount, + direction: 'stake', + providerId, + }); + + const { data: stakedBalance } = useStakedBalance({ userAddress, providerId }); + const { mutateAsync: buildStake } = useBuildStakeTransaction(); + const { mutate: sendTx } = useSendTransaction(); + + return ( + <> + + setAmount(e.target.value)} /> + {quote &&

You'll stake {quote.amountIn} → receive {quote.amountOut}

} +

Currently staked: {stakedBalance?.stakedBalance}

+ + + ); +} +``` + +For unstake, pass `direction: 'unstake'` to `useStakingQuote`. The returned quote may include an `unstakeMode` that differs by protocol — read it from the quote and forward via `providerOptions` if your protocol needs it. + +## Jetton Transfer + +```tsx +import { useState } from 'react'; +import { useJettons, useTransferJetton } from '@ton/appkit-react'; +import { useQueryClient } from '@tanstack/react-query'; + +function SendJetton() { + const { data } = useJettons(); // { jettons: Jetton[], addressBook } + const jettons = data?.jettons ?? []; + const [selectedJetton, setSelectedJetton] = useState(); + const [amount, setAmount] = useState(''); + const [recipient, setRecipient] = useState(''); + + const { mutate: transfer, isPending, error } = useTransferJetton(); + const queryClient = useQueryClient(); + + const handleSend = () => { + if (!selectedJetton) return; + transfer( + { jettonAddress: selectedJetton, recipientAddress: recipient, amount }, + { + onSuccess: () => { + // Refresh jetton balances + queryClient.invalidateQueries({ queryKey: ['jettons'] }); + queryClient.invalidateQueries({ queryKey: ['jetton-balance'] }); + }, + }, + ); + }; + + return ( + <> + + setRecipient(e.target.value)} /> + setAmount(e.target.value)} /> + + {error &&

Error: {error.message}

} + + ); +} +``` + +Notes: +- `amount` is a human-readable string with respect to jetton decimals. AppKit handles `parseUnits` internally. +- Jetton transfers are contract message flows — gas matters. AppKit's helper sets correct `forward_amount` and `response_destination`. + +## NFT Transfer + +```tsx +import { useState } from 'react'; +import { useNfts, useTransferNft } from '@ton/appkit-react'; + +function SendNft() { + const { data } = useNfts(); // { nfts: NFT[], addressBook? } + const nfts = data?.nfts ?? []; + const [selected, setSelected] = useState(); + const [recipient, setRecipient] = useState(''); + + const { mutate: transfer, isPending } = useTransferNft(); + + return ( + <> +
+ {nfts.map((nft) => ( + + ))} +
+ setRecipient(e.target.value)} /> + + + ); +} +``` + +## Watching Transactions + +```tsx +import { useState } from 'react'; +import { useWatchTransactions } from '@ton/appkit-react'; +import type { TransactionsUpdate } from '@ton/appkit-react'; + +function TransactionFeed() { + const [updates, setUpdates] = useState([]); + + useWatchTransactions({ + // onChange fires with a TransactionsUpdate that wraps one or more on-chain transactions. + onChange: (update) => setUpdates((prev) => [update, ...prev]), + }); + + return ( +
    + {updates.flatMap((u) => u.transactions).map((tx) => ( +
  • {tx.hash}: {tx.endStatus ?? '—'}
  • + ))} +
+ ); +} +``` + +Requires a streaming provider registered (see SKILL.md "Real-time balance updates"). + +## Multi-Network App + +```ts +import { AppKit, Network, createTonCenterStreamingProvider, createTonConnectConnector } from '@ton/appkit'; + +const appKit = new AppKit({ + defaultNetwork: Network.mainnet(), + networks: { + [Network.mainnet().chainId]: { + apiClient: { url: 'https://toncenter.com', key: 'MAINNET_KEY' }, + }, + [Network.testnet().chainId]: { + apiClient: { url: 'https://testnet.toncenter.com', key: 'TESTNET_KEY' }, + }, + }, + providers: [ + createTonCenterStreamingProvider({ network: Network.mainnet(), apiKey: 'MAINNET_KEY' }), + createTonCenterStreamingProvider({ network: Network.testnet(), apiKey: 'TESTNET_KEY' }), + ], + connectors: [createTonConnectConnector({ tonConnectOptions: { manifestUrl: '/manifest.json' } })], +}); +``` + +Hooks accept a `network` parameter to override: + +```tsx +import { useBalanceByAddress } from '@ton/appkit-react'; +import { Network } from '@ton/appkit'; + +function TestnetBalance({ address }: { address: string }) { + const { data: balance } = useBalanceByAddress({ address, network: Network.testnet() }); + return {balance ?? '—'}; +} +``` + +## Custom Mutation Cleanup + +If you have your own mutations that change wallet state, use the same cache patterns: + +```tsx +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +function useMyCustomTransfer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (params: { recipientAddress: string; amount: string }) => { + // ... your custom transfer implementation + return params; + }, + onSuccess: () => { + // Invalidate everything that might be affected + queryClient.invalidateQueries({ queryKey: ['balance'] }); + queryClient.invalidateQueries({ queryKey: ['jettons'] }); + queryClient.invalidateQueries({ queryKey: ['transactionStatus'] }); + }, + }); +} +```