diff --git a/.changeset/crypto-onramp-async-metadata.md b/.changeset/crypto-onramp-async-metadata.md new file mode 100644 index 000000000..90f0408c8 --- /dev/null +++ b/.changeset/crypto-onramp-async-metadata.md @@ -0,0 +1,18 @@ +--- +'@ton/walletkit': major +'@ton/appkit': major +'@ton/appkit-react': major +--- + +Make crypto-onramp provider `getMetadata()` async. + +- `@ton/walletkit`: + - `CryptoOnrampProvider.getMetadata()` and `CryptoOnrampProviderInterface.getMetadata()` now return `Promise`. Concrete `DecentCryptoOnrampProvider` and `LayerswapCryptoOnrampProvider` updated. + - `CryptoOnrampManager` gains `getMetadata(providerId?)` proxy with the existing `try/catch + log.debug` pattern. + - `getSupportedNetworks()` left synchronous — the shared `DefiProvider` base method is unchanged in this branch. +- `@ton/appkit`: + - New action `getCryptoOnrampProviderMetadata` and matching TanStack query (`getCryptoOnrampProviderMetadataQueryOptions`). +- `@ton/appkit-react`: + - New hook `useCryptoOnrampProviderMetadata` (wagmi-style `UseQueryResult`). + - `CryptoOnrampContext` exposes `providersMetadata` (`Record`) and `isProvidersMetadataLoading`. The settings modal renders providers with per-row `providerId` fallback while metadata resolves — one slow/broken provider does not block the others. The selected provider's name shows a skeleton in the info row until its metadata arrives. + - `OptionSwitcher` gains a `loading` prop that replaces the trigger content with a `Skeleton`. diff --git a/.gitignore b/.gitignore index f93ad03a1..bfd498a22 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ release-artifacts *.tgz apps/appkit-minter/Caddyfile Caddyfile +.superset diff --git a/.superset/config.json b/.superset/config.json deleted file mode 100644 index f806b5255..000000000 --- a/.superset/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "setup": [], - "teardown": [], - "run": [] -} diff --git a/apps/appkit-minter/src/core/components/layout/app-router/app-router.tsx b/apps/appkit-minter/src/core/components/layout/app-router/app-router.tsx index b9e578436..ace97a09f 100644 --- a/apps/appkit-minter/src/core/components/layout/app-router/app-router.tsx +++ b/apps/appkit-minter/src/core/components/layout/app-router/app-router.tsx @@ -12,7 +12,7 @@ import { useWatchBalance, useWatchTransactions, useWatchJettons, useBalance } fr import { middleEllipsis } from '@ton/appkit'; import { toast } from 'sonner'; -import { JettonsPage, MinterPage, NftsPage, StakingPage, SwapPage } from '@/pages'; +import { CryptoOnrampPage, JettonsPage, MinterPage, NftsPage, StakingPage, SwapPage } from '@/pages'; export const AppRouter: React.FC = () => { // Set balance refetch interval to 20 seconds @@ -58,6 +58,7 @@ export const AppRouter: React.FC = () => { } /> } /> } /> + } /> } /> diff --git a/apps/appkit-minter/src/core/components/layout/layout/layout.tsx b/apps/appkit-minter/src/core/components/layout/layout/layout.tsx index 7bfb7c0f4..d08063403 100644 --- a/apps/appkit-minter/src/core/components/layout/layout/layout.tsx +++ b/apps/appkit-minter/src/core/components/layout/layout/layout.tsx @@ -7,7 +7,17 @@ */ import { TonConnectButton, useAddress } from '@ton/appkit-react'; -import { ArrowLeftRight, BookOpen, Coins, ExternalLink, Github, ImageIcon, Sparkles, Wallet } from 'lucide-react'; +import { + ArrowLeftRight, + BookOpen, + Coins, + Bitcoin, + ExternalLink, + Github, + ImageIcon, + Sparkles, + Wallet, +} from 'lucide-react'; import { Link, NavLink } from 'react-router-dom'; import type { ComponentType, FC, ReactNode } from 'react'; @@ -56,6 +66,7 @@ const NAV_GROUPS: readonly { label?: string; links: readonly NavGroupLink[] }[] links: [ { to: '/swap', label: 'Swap', icon: ArrowLeftRight }, { to: '/staking', label: 'Staking', icon: Coins }, + { to: '/crypto-onramp', label: 'Crypto Onramp', icon: Bitcoin }, ], }, ]; diff --git a/apps/appkit-minter/src/core/configs/app-kit.ts b/apps/appkit-minter/src/core/configs/app-kit.ts index 9bffe4a33..fe58469b1 100644 --- a/apps/appkit-minter/src/core/configs/app-kit.ts +++ b/apps/appkit-minter/src/core/configs/app-kit.ts @@ -17,9 +17,16 @@ import { import { createDeDustProvider } from '@ton/appkit/swap/dedust'; import { createOmnistonProvider } from '@ton/appkit/swap/omniston'; import { createTonstakersProvider } from '@ton/appkit/staking/tonstakers'; +import { createLayerswapProvider } from '@ton/appkit/crypto-onramp/layerswap'; +import { createDecentProvider } from '@ton/appkit/crypto-onramp/decent'; import { createTonApiGaslessProvider } from '@ton/appkit/gasless/tonapi'; -import { ENV_TON_API_KEY_TESTNET, ENV_TON_API_KEY_MAINNET, ENV_TONCONNECT_MANIFEST_URL } from '@/core/configs/env'; +import { + ENV_TON_API_KEY_TESTNET, + ENV_TON_API_KEY_MAINNET, + ENV_DECENT_API_KEY, + ENV_TONCONNECT_MANIFEST_URL, +} from '@/core/configs/env'; const mainnetApiClient = new ApiClientToncenter({ network: Network.mainnet(), @@ -53,6 +60,8 @@ export const appKit = new AppKit({ createOmnistonProvider(), createDeDustProvider(), createTonstakersProvider(), + createLayerswapProvider(), + createDecentProvider({ apiKey: ENV_DECENT_API_KEY }), createTonCenterStreamingProvider({ network: Network.mainnet(), apiKey: ENV_TON_API_KEY_MAINNET }), createTonCenterStreamingProvider({ network: Network.testnet(), apiKey: ENV_TON_API_KEY_TESTNET }), createTonApiGaslessProvider(), diff --git a/apps/appkit-minter/src/core/configs/env.ts b/apps/appkit-minter/src/core/configs/env.ts index a30f93e31..7bcb1bee8 100644 --- a/apps/appkit-minter/src/core/configs/env.ts +++ b/apps/appkit-minter/src/core/configs/env.ts @@ -10,6 +10,7 @@ export const ENV_TON_API_KEY_MAINNET = import.meta.env.VITE_TON_API_KEY ?? '25a9b2326a34b39a5fa4b264fb78fb4709e1bd576fc5e6b176639f5b71e94b0d'; export const ENV_TON_API_KEY_TESTNET = import.meta.env.VITE_TON_API_TESTNET_KEY ?? 'd852b54d062f631565761042cccea87fa6337c41eb19b075e6c7fb88898a3992'; +export const ENV_DECENT_API_KEY = import.meta.env.VITE_DECENT_API_KEY ?? '1be323b5c83198191ba640f07f8815b0'; const DEV_TONCONNECT_MANIFEST_URL = 'https://tonconnect-sdk-demo-dapp.vercel.app/tonconnect-manifest.json'; const PROD_TONCONNECT_MANIFEST_URL = import.meta.env.VITE_TONCONNECT_MANIFEST_URL ?? DEV_TONCONNECT_MANIFEST_URL; diff --git a/apps/appkit-minter/src/pages/index.ts b/apps/appkit-minter/src/pages/index.ts index 09a13a93d..c92843d0d 100644 --- a/apps/appkit-minter/src/pages/index.ts +++ b/apps/appkit-minter/src/pages/index.ts @@ -11,3 +11,4 @@ export { JettonsPage } from './jettons-page'; export { NftsPage } from './nfts-page'; export { SwapPage } from './swap-page'; export { StakingPage } from './staking-page'; +export { CryptoOnrampPage } from './onramp-page'; diff --git a/apps/appkit-minter/src/pages/onramp-page.tsx b/apps/appkit-minter/src/pages/onramp-page.tsx new file mode 100644 index 000000000..37e5ed942 --- /dev/null +++ b/apps/appkit-minter/src/pages/onramp-page.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type React from 'react'; +import { CryptoOnrampWidget } from '@ton/appkit-react'; +import type { CryptoOnrampDestinationRef, CryptoOnrampSourceRef } from '@ton/appkit-react'; +import { Caip2ByNetwork } from '@ton/appkit-react'; + +import { Layout } from '@/core/components'; + +const DEFAULT_DESTINATION: CryptoOnrampDestinationRef = { + address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', +}; + +const DEFAULT_SOURCE: CryptoOnrampSourceRef = { + chain: Caip2ByNetwork.ArbitrumMainnet, + address: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', +}; + +export const CryptoOnrampPage: React.FC = () => { + return ( + +
+ +
+
+ ); +}; diff --git a/apps/appkit-minter/src/pages/swap-page.tsx b/apps/appkit-minter/src/pages/swap-page.tsx index 191d80110..6fdb47146 100644 --- a/apps/appkit-minter/src/pages/swap-page.tsx +++ b/apps/appkit-minter/src/pages/swap-page.tsx @@ -16,6 +16,7 @@ import { USDT_MASTER_MAINNET } from '@/core/constants/tokens'; const TOKENS: AppkitUIToken[] = [ { + id: 'ton', symbol: 'TON', name: 'Toncoin', decimals: 9, @@ -24,6 +25,7 @@ const TOKENS: AppkitUIToken[] = [ logo: './tokens/ton.png', }, { + id: 'usdt', symbol: 'USD₮', name: 'Tether USD', decimals: 6, @@ -33,6 +35,7 @@ const TOKENS: AppkitUIToken[] = [ logo: `./tokens/usdt.png`, }, { + id: 'ston', symbol: 'STON', name: 'STON', decimals: 9, @@ -41,6 +44,7 @@ const TOKENS: AppkitUIToken[] = [ logo: './tokens/ston.png', }, { + id: 'xaut', symbol: 'XAUt0', name: 'Tether Gold', decimals: 6, @@ -49,6 +53,7 @@ const TOKENS: AppkitUIToken[] = [ logo: './tokens/xaut0.png', }, { + id: 'usde', symbol: 'USDe', name: 'Ethena USDe', decimals: 6, @@ -58,6 +63,7 @@ const TOKENS: AppkitUIToken[] = [ logo: './tokens/usde.png', }, { + id: 'tston', symbol: 'tsTON', name: 'Tonstakers TON', decimals: 9, @@ -66,6 +72,7 @@ const TOKENS: AppkitUIToken[] = [ logo: './tokens/tston.svg', }, { + id: 'gemston', symbol: 'GEMSTON', name: 'GEMSTON', decimals: 9, @@ -74,6 +81,7 @@ const TOKENS: AppkitUIToken[] = [ logo: './tokens/gemston.png', }, { + id: 'utya', symbol: 'UTYA', name: 'Utya', decimals: 9, @@ -82,6 +90,7 @@ const TOKENS: AppkitUIToken[] = [ logo: './tokens/utya.png', }, { + id: 'weth', symbol: 'WETH', name: 'Wrapped Ether', decimals: 18, @@ -100,7 +109,7 @@ export const SwapPage: React.FC = () => { network={Network.mainnet()} fiatSymbol="$" defaultFromSymbol="TON" - defaultToSymbol="USD₮" + defaultToSymbol="USDT" /> diff --git a/demo/examples/src/appkit/actions/onramp/get-crypto-onramp-provider-metadata.ts b/demo/examples/src/appkit/actions/onramp/get-crypto-onramp-provider-metadata.ts new file mode 100644 index 000000000..6d0918f14 --- /dev/null +++ b/demo/examples/src/appkit/actions/onramp/get-crypto-onramp-provider-metadata.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { AppKit } from '@ton/appkit'; +import { getCryptoOnrampProviderMetadata } from '@ton/appkit'; + +export const getCryptoOnrampProviderMetadataExample = async (appKit: AppKit) => { + // SAMPLE_START: GET_CRYPTO_ONRAMP_PROVIDER_METADATA + const metadata = await getCryptoOnrampProviderMetadata(appKit, { providerId: 'layerswap' }); + console.log('Crypto onramp provider metadata:', metadata); + // SAMPLE_END: GET_CRYPTO_ONRAMP_PROVIDER_METADATA +}; diff --git a/demo/examples/src/appkit/actions/onramp/get-crypto-onramp-provider.ts b/demo/examples/src/appkit/actions/onramp/get-crypto-onramp-provider.ts new file mode 100644 index 000000000..f40789b4e --- /dev/null +++ b/demo/examples/src/appkit/actions/onramp/get-crypto-onramp-provider.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { AppKit } from '@ton/appkit'; +import { getCryptoOnrampProvider } from '@ton/appkit'; + +export const getCryptoOnrampProviderExample = (appKit: AppKit) => { + // SAMPLE_START: GET_CRYPTO_ONRAMP_PROVIDER + const provider = getCryptoOnrampProvider(appKit, { id: 'layerswap' }); + console.log('Crypto onramp provider:', provider.providerId); + // SAMPLE_END: GET_CRYPTO_ONRAMP_PROVIDER +}; diff --git a/demo/examples/src/appkit/actions/onramp/get-crypto-onramp-providers.ts b/demo/examples/src/appkit/actions/onramp/get-crypto-onramp-providers.ts new file mode 100644 index 000000000..308939650 --- /dev/null +++ b/demo/examples/src/appkit/actions/onramp/get-crypto-onramp-providers.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { AppKit } from '@ton/appkit'; +import { getCryptoOnrampProviders } from '@ton/appkit'; + +export const getCryptoOnrampProvidersExample = (appKit: AppKit) => { + // SAMPLE_START: GET_CRYPTO_ONRAMP_PROVIDERS + const providers = getCryptoOnrampProviders(appKit); + console.log( + 'Registered crypto onramp providers:', + providers.map((p) => p.providerId), + ); + // SAMPLE_END: GET_CRYPTO_ONRAMP_PROVIDERS +}; diff --git a/demo/examples/src/appkit/hooks/jettons/jettons.test.tsx b/demo/examples/src/appkit/hooks/jettons/jettons.test.tsx index 1483bfc7f..ba8d151a3 100644 --- a/demo/examples/src/appkit/hooks/jettons/jettons.test.tsx +++ b/demo/examples/src/appkit/hooks/jettons/jettons.test.tsx @@ -8,7 +8,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import * as AppKitReact from '@ton/appkit-react'; +import { + useJettonInfo, + useJettonWalletAddress, + useJettonBalanceByAddress, + useJettonsByAddress, + useJettons, + useTransferJetton, + useWatchJettons, + useWatchJettonsByAddress, +} from '@ton/appkit-react'; import { UseJettonInfoExample } from './use-jetton-info'; import { UseJettonBalanceByAddressExample } from './use-jetton-balance-by-address'; @@ -47,7 +56,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonInfoExample', () => { it('should render loading state', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonInfo).mockReturnValue({ + vi.mocked(useJettonInfo).mockReturnValue({ isLoading: true, data: undefined, error: null, @@ -59,7 +68,7 @@ describe('Jetton Hooks Examples', () => { it('should render error state', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonInfo).mockReturnValue({ + vi.mocked(useJettonInfo).mockReturnValue({ isLoading: false, data: undefined, error: new Error('Failed to fetch'), @@ -71,7 +80,7 @@ describe('Jetton Hooks Examples', () => { it('should render jetton info', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonInfo).mockReturnValue({ + vi.mocked(useJettonInfo).mockReturnValue({ isLoading: false, data: { name: 'Test Jetton', @@ -91,7 +100,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonBalanceByAddressExample', () => { it('should render balance', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonBalanceByAddress).mockReturnValue({ + vi.mocked(useJettonBalanceByAddress).mockReturnValue({ isLoading: false, data: '1000000', error: null, @@ -105,7 +114,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonWalletAddressExample', () => { it('should render wallet address', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonWalletAddress).mockReturnValue({ + vi.mocked(useJettonWalletAddress).mockReturnValue({ isLoading: false, data: 'EQB-mock-address', error: null, @@ -119,7 +128,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonsByAddressExample', () => { it('should render list of jettons', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonsByAddress).mockReturnValue({ + vi.mocked(useJettonsByAddress).mockReturnValue({ isLoading: false, data: { jettons: [ @@ -139,7 +148,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonsExample', () => { it('should render list of jettons for current wallet', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettons).mockReturnValue({ + vi.mocked(useJettons).mockReturnValue({ isLoading: false, data: { jettons: [{ walletAddress: 'addr1', info: { name: 'My Jetton' }, balance: '100' }], @@ -156,7 +165,7 @@ describe('Jetton Hooks Examples', () => { it('should call transfer mutation on button click', () => { const mockMutate = vi.fn(); // @ts-expect-error - mock - vi.mocked(AppKitReact.useTransferJetton).mockReturnValue({ + vi.mocked(useTransferJetton).mockReturnValue({ mutate: mockMutate, isPending: false, error: null, @@ -175,7 +184,7 @@ describe('Jetton Hooks Examples', () => { it('should disable button when loading', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useTransferJetton).mockReturnValue({ + vi.mocked(useTransferJetton).mockReturnValue({ mutate: vi.fn(), isPending: true, error: null, @@ -190,7 +199,7 @@ describe('Jetton Hooks Examples', () => { describe('UseWatchJettonsExample', () => { it('should render jetton list', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettons).mockReturnValue({ + vi.mocked(useJettons).mockReturnValue({ isLoading: false, data: { jettons: [{ walletAddress: 'addr1', info: { name: 'My Jetton' }, balance: '100' }], @@ -200,14 +209,14 @@ describe('Jetton Hooks Examples', () => { render(); expect(screen.getByText('My Jetton: 100')).toBeDefined(); - expect(AppKitReact.useWatchJettons).toHaveBeenCalled(); + expect(useWatchJettons).toHaveBeenCalled(); }); }); describe('UseWatchJettonsByAddressExample', () => { it('should render jetton list for address', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonsByAddress).mockReturnValue({ + vi.mocked(useJettonsByAddress).mockReturnValue({ isLoading: false, data: { jettons: [{ walletAddress: 'addr2', info: { name: 'Other Jetton' }, balance: '50' }], @@ -217,7 +226,7 @@ describe('Jetton Hooks Examples', () => { render(); expect(screen.getByText('Other Jetton: 50')).toBeDefined(); - expect(AppKitReact.useWatchJettonsByAddress).toHaveBeenCalledWith({ + expect(useWatchJettonsByAddress).toHaveBeenCalledWith({ address: 'UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJKZ', }); }); diff --git a/demo/examples/src/appkit/hooks/onramp/use-crypto-onramp-provider-metadata.tsx b/demo/examples/src/appkit/hooks/onramp/use-crypto-onramp-provider-metadata.tsx new file mode 100644 index 000000000..b3ca3972e --- /dev/null +++ b/demo/examples/src/appkit/hooks/onramp/use-crypto-onramp-provider-metadata.tsx @@ -0,0 +1,16 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCryptoOnrampProviderMetadata } from '@ton/appkit-react'; + +export const UseCryptoOnrampProviderMetadataExample = () => { + // SAMPLE_START: USE_CRYPTO_ONRAMP_PROVIDER_METADATA + const { data: metadata } = useCryptoOnrampProviderMetadata({ providerId: 'layerswap' }); + return
Provider name: {metadata?.name}
; + // SAMPLE_END: USE_CRYPTO_ONRAMP_PROVIDER_METADATA +}; diff --git a/demo/examples/src/appkit/hooks/onramp/use-crypto-onramp-provider.tsx b/demo/examples/src/appkit/hooks/onramp/use-crypto-onramp-provider.tsx new file mode 100644 index 000000000..e22d71796 --- /dev/null +++ b/demo/examples/src/appkit/hooks/onramp/use-crypto-onramp-provider.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCryptoOnrampProviderById } from '@ton/appkit-react'; + +export const UseCryptoOnrampProviderExample = () => { + // SAMPLE_START: USE_CRYPTO_ONRAMP_PROVIDER + const provider = useCryptoOnrampProviderById({ id: 'layerswap' }); + + return
Provider: {provider?.providerId}
; + // SAMPLE_END: USE_CRYPTO_ONRAMP_PROVIDER +}; diff --git a/demo/examples/src/appkit/hooks/onramp/use-crypto-onramp-providers.tsx b/demo/examples/src/appkit/hooks/onramp/use-crypto-onramp-providers.tsx new file mode 100644 index 000000000..9ac7a26d2 --- /dev/null +++ b/demo/examples/src/appkit/hooks/onramp/use-crypto-onramp-providers.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCryptoOnrampProviders } from '@ton/appkit-react'; + +export const UseCryptoOnrampProvidersExample = () => { + // SAMPLE_START: USE_CRYPTO_ONRAMP_PROVIDERS + const providers = useCryptoOnrampProviders(); + + return ( +
    + {providers.map((p) => ( +
  • {p.providerId}
  • + ))} +
+ ); + // SAMPLE_END: USE_CRYPTO_ONRAMP_PROVIDERS +}; diff --git a/demo/examples/src/appkit/swap/swap-widget.tsx b/demo/examples/src/appkit/swap/swap-widget.tsx index 05a896eab..8c30f322a 100644 --- a/demo/examples/src/appkit/swap/swap-widget.tsx +++ b/demo/examples/src/appkit/swap/swap-widget.tsx @@ -11,6 +11,7 @@ import { SwapWidget } from '@ton/appkit-react'; const tokens = [ { + id: 'ton', address: 'ton', symbol: 'TON', name: 'Toncoin', @@ -19,6 +20,7 @@ const tokens = [ logo: 'https://ton.org/symbol.png', }, { + id: 'usdt', address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', symbol: 'USDT', name: 'Tether', diff --git a/packages/appkit-react/.storybook/app-kit.ts b/packages/appkit-react/.storybook/app-kit.ts index a9928f281..9b83a3994 100644 --- a/packages/appkit-react/.storybook/app-kit.ts +++ b/packages/appkit-react/.storybook/app-kit.ts @@ -6,11 +6,11 @@ * */ -import { AppKit, Network } from '@ton/appkit'; -import { createTonConnectConnector } from '@ton/appkit'; +import { AppKit, Network, createTonConnectConnector } from '@ton/appkit'; import { createOmnistonProvider } from '@ton/appkit/swap/omniston'; import { createDeDustProvider } from '@ton/appkit/swap/dedust'; import { createTonstakersProvider } from '@ton/appkit/staking/tonstakers'; +import { createLayerswapProvider } from '@ton/appkit/crypto-onramp/layerswap'; import { createTonApiGaslessProvider } from '@ton/appkit/gasless/tonapi'; export const appKit = new AppKit({ @@ -40,6 +40,7 @@ export const appKit = new AppKit({ createOmnistonProvider(), createDeDustProvider(), createTonstakersProvider(), + createLayerswapProvider(), createTonApiGaslessProvider(), ], }); diff --git a/packages/appkit-react/.storybook/main.ts b/packages/appkit-react/.storybook/main.ts index ae60e331a..e6d7d9d91 100644 --- a/packages/appkit-react/.storybook/main.ts +++ b/packages/appkit-react/.storybook/main.ts @@ -18,6 +18,10 @@ import { dirname } from 'node:path'; import type { StorybookConfig } from '@storybook/react-vite'; +const getAbsolutePath = (value: string): string => { + return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))); +}; + const config: StorybookConfig = { stories: ['../src/**/*.stories.@(ts|tsx)'], addons: [getAbsolutePath('@storybook/addon-docs')], @@ -64,7 +68,3 @@ const config: StorybookConfig = { }; export default config; - -function getAbsolutePath(value: string) { - return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))); -} diff --git a/packages/appkit-react/docs/hooks.md b/packages/appkit-react/docs/hooks.md index d817c64a7..cb523bfb7 100644 --- a/packages/appkit-react/docs/hooks.md +++ b/packages/appkit-react/docs/hooks.md @@ -717,6 +717,45 @@ return ( ); ``` +## Crypto Onramp + +### `useCryptoOnrampProvider` + +Hook to get a registered crypto-onramp provider by id, or the default one when no id is given. + +```tsx +const provider = useCryptoOnrampProviderById({ id: 'layerswap' }); + +return
Provider: {provider?.providerId}
; +``` + +### `useCryptoOnrampProviders` + +Hook to get all registered crypto-onramp providers. + +```tsx +const providers = useCryptoOnrampProviders(); + +return ( +
    + {providers.map((p) => ( +
  • {p.providerId}
  • + ))} +
+); +``` + +### `useCryptoOnrampProviderMetadata` + +Hook to get static metadata for a crypto-onramp provider (display name, logo, url). + +```tsx +const { data: metadata } = useCryptoOnrampProviderMetadata({ + providerId: 'layerswap', +}); +return
Provider name: {metadata?.name}
; +``` + ## Staking ### `useStakingProviders` diff --git a/packages/appkit-react/package.json b/packages/appkit-react/package.json index 35d4be223..da5490523 100644 --- a/packages/appkit-react/package.json +++ b/packages/appkit-react/package.json @@ -45,6 +45,7 @@ "dependencies": { "@ton/appkit": "workspace:*", "clsx": "^2.1.1", + "qrcode.react": "^4.2.0", "radix-ui": "^1.4.3", "rosetta": "1.1.0" }, diff --git a/packages/appkit-react/src/components/shared/amount-presets/amount-presets.module.css b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.module.css index 1e635f7b2..e1e0344d9 100644 --- a/packages/appkit-react/src/components/shared/amount-presets/amount-presets.module.css +++ b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.module.css @@ -4,7 +4,6 @@ gap: 8px; justify-content: center; grid-template-columns: 1fr 1fr 1fr 1fr; - margin: 0 auto; } .preset { diff --git a/packages/appkit-react/src/components/shared/amount-presets/amount-presets.tsx b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.tsx index 9fa85baff..d932e553b 100644 --- a/packages/appkit-react/src/components/shared/amount-presets/amount-presets.tsx +++ b/packages/appkit-react/src/components/shared/amount-presets/amount-presets.tsx @@ -22,12 +22,14 @@ export interface AmountPresetsProps extends ComponentProps<'div'> { presets: AmountPreset[]; currencySymbol?: string; onPresetSelect: (value: string) => void; + disabled?: boolean; } export const AmountPresets: FC = ({ presets, currencySymbol, onPresetSelect, + disabled, className, ...props }) => { @@ -38,6 +40,7 @@ export const AmountPresets: FC = ({ key={preset.label} size="s" variant="secondary" + disabled={disabled} className={styles.preset} onClick={() => (preset.onSelect ? preset.onSelect() : onPresetSelect(preset.amount))} > diff --git a/packages/appkit-react/src/components/shared/amount-preview/amount-preview.stories.tsx b/packages/appkit-react/src/components/shared/amount-preview/amount-preview.stories.tsx index 55378495d..c2b37692e 100644 --- a/packages/appkit-react/src/components/shared/amount-preview/amount-preview.stories.tsx +++ b/packages/appkit-react/src/components/shared/amount-preview/amount-preview.stories.tsx @@ -13,6 +13,7 @@ import { AmountPreview } from './amount-preview'; import type { AppkitUIToken } from '../../../types/appkit-ui-token'; const tonToken: AppkitUIToken = { + id: 'ton', symbol: 'TON', name: 'Toncoin', decimals: 9, @@ -22,6 +23,7 @@ const tonToken: AppkitUIToken = { }; const usdtToken: AppkitUIToken = { + id: 'usdt', symbol: 'USDT', name: 'Tether USD', decimals: 6, diff --git a/packages/appkit-react/src/components/shared/button-with-connect/button-with-connect.stories.tsx b/packages/appkit-react/src/components/shared/button-with-connect/button-with-connect.stories.tsx new file mode 100644 index 000000000..399448c19 --- /dev/null +++ b/packages/appkit-react/src/components/shared/button-with-connect/button-with-connect.stories.tsx @@ -0,0 +1,43 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; + +import { ButtonWithConnect } from './button-with-connect'; + +const meta: Meta = { + title: 'Components/Shared/ButtonWithConnect', + component: ButtonWithConnect, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Continue', + variant: 'fill', + size: 'l', + fullWidth: true, + onClick: fn(), + }, +}; + +export const Disabled: Story = { + args: { + children: 'Continue', + variant: 'fill', + size: 'l', + fullWidth: true, + disabled: true, + onClick: fn(), + }, +}; diff --git a/packages/appkit-react/src/components/shared/copy-button/copy-button.module.css b/packages/appkit-react/src/components/shared/copy-button/copy-button.module.css new file mode 100644 index 000000000..bf9d35f3e --- /dev/null +++ b/packages/appkit-react/src/components/shared/copy-button/copy-button.module.css @@ -0,0 +1,19 @@ +.button { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: none; + cursor: pointer; + color: var(--ta-color-text-secondary); + border-radius: var(--ta-border-radius-s); + transition: color 0.15s, background-color 0.15s; +} + +.button:hover { + color: var(--ta-color-text); + background-color: var(--ta-color-background-tertiary); +} diff --git a/packages/appkit-react/src/components/shared/copy-button/copy-button.stories.tsx b/packages/appkit-react/src/components/shared/copy-button/copy-button.stories.tsx new file mode 100644 index 000000000..fc5c5dcde --- /dev/null +++ b/packages/appkit-react/src/components/shared/copy-button/copy-button.stories.tsx @@ -0,0 +1,26 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { CopyButton } from './copy-button'; + +const meta: Meta = { + title: 'Components/Shared/CopyButton', + component: CopyButton, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', + 'aria-label': 'Copy address', + }, +}; diff --git a/packages/appkit-react/src/components/shared/copy-button/copy-button.tsx b/packages/appkit-react/src/components/shared/copy-button/copy-button.tsx new file mode 100644 index 000000000..400e0857b --- /dev/null +++ b/packages/appkit-react/src/components/shared/copy-button/copy-button.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { ComponentProps, FC } from 'react'; +import clsx from 'clsx'; + +import { CheckIcon, CopyIcon } from '../../ui/icons'; +import { useCopy } from '../../../hooks/use-copy'; +import styles from './copy-button.module.css'; + +export interface CopyButtonProps extends Omit, 'value' | 'children' | 'onClick'> { + /** The text written to the clipboard when the button is clicked. */ + value: string; + /** Accessible label for screen readers. */ + 'aria-label': string; +} + +export const CopyButton: FC = ({ value, className, type = 'button', ...props }) => { + const [copied, copy] = useCopy(value); + + return ( + + ); +}; diff --git a/packages/appkit-react/src/components/shared/copy-button/index.ts b/packages/appkit-react/src/components/shared/copy-button/index.ts new file mode 100644 index 000000000..6f660b49e --- /dev/null +++ b/packages/appkit-react/src/components/shared/copy-button/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CopyButton } from './copy-button'; +export type { CopyButtonProps } from './copy-button'; diff --git a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.module.css b/packages/appkit-react/src/components/shared/currency-item/currency-item.module.css similarity index 72% rename from packages/appkit-react/src/features/balances/components/currency-item/currency-item.module.css rename to packages/appkit-react/src/components/shared/currency-item/currency-item.module.css index ac05553b9..f8527c130 100644 --- a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.module.css +++ b/packages/appkit-react/src/components/shared/currency-item/currency-item.module.css @@ -1,4 +1,4 @@ -.currencyItem { +.container { box-sizing: border-box; width: 100%; display: flex; @@ -39,7 +39,7 @@ } .name { - composes: bodySemibold from "../../../../styles/typography.module.css"; + composes: bodySemibold from "../../../styles/typography.module.css"; color: var(--ta-color-text); white-space: nowrap; @@ -56,19 +56,26 @@ } .ticker { - composes: labelMedium from "../../../../styles/typography.module.css"; + composes: labelMedium from "../../../styles/typography.module.css"; color: var(--ta-color-text-secondary); margin: 0; } -.details { +.rightSide { text-align: right; } -.balance { - composes: bodyMedium from "../../../../styles/typography.module.css"; +.mainBalance { + composes: bodyMedium from "../../../styles/typography.module.css"; color: var(--ta-color-text); margin: 0; } + +.underBalance { + composes: labelMedium from "../../../styles/typography.module.css"; + + color: var(--ta-color-text-secondary); + margin: 0; +} diff --git a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.stories.tsx b/packages/appkit-react/src/components/shared/currency-item/currency-item.stories.tsx similarity index 87% rename from packages/appkit-react/src/features/balances/components/currency-item/currency-item.stories.tsx rename to packages/appkit-react/src/components/shared/currency-item/currency-item.stories.tsx index 03ed16e75..2a37d2588 100644 --- a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.stories.tsx +++ b/packages/appkit-react/src/components/shared/currency-item/currency-item.stories.tsx @@ -12,7 +12,7 @@ import { fn } from 'storybook/test'; import { CurrencyItem } from './currency-item'; const meta: Meta = { - title: 'Features/Balances/CurrencyItem', + title: 'Components/Shared/CurrencyItem', component: CurrencyItem, tags: ['autodocs'], args: { @@ -71,6 +71,17 @@ export const NoBalance: Story = { }, }; +export const WithUnderBalance: Story = { + args: { + ticker: 'TON', + name: 'Toncoin', + balance: '55', + underBalance: '$385.00', + icon: 'https://ton.org/download/ton_symbol.png', + isVerified: true, + }, +}; + export const CurrencyList: Story = { render: () => (
diff --git a/packages/appkit-react/src/components/shared/currency-item/currency-item.tsx b/packages/appkit-react/src/components/shared/currency-item/currency-item.tsx new file mode 100644 index 000000000..87ceef566 --- /dev/null +++ b/packages/appkit-react/src/components/shared/currency-item/currency-item.tsx @@ -0,0 +1,137 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC, ComponentProps } from 'react'; +import clsx from 'clsx'; + +import { Logo } from '../../ui/logo'; +import type { LogoProps } from '../../ui/logo'; +import styles from './currency-item.module.css'; + +export interface CurrencyItemProps extends ComponentProps<'button'> { + ticker?: string; + name?: string; + balance?: string; + underBalance?: string; + icon?: string; + isVerified?: boolean; +} + +const Container: FC> = ({ className, children, ...props }) => ( + +); + +const LogoWrapper: FC = ({ className, ...props }) => ( + +); + +const Info: FC> = ({ className, children, ...props }) => ( +
+ {children} +
+); + +const Header: FC> = ({ className, children, ...props }) => ( +
+ {children} +
+); + +const Name: FC> = ({ className, children, ...props }) => ( +

+ {children} +

+); + +const Ticker: FC> = ({ className, children, ...props }) => ( +

+ {children} +

+); + +const VerifiedBadge: FC> = ({ className, ...props }) => ( + + + +); + +const RightSide: FC> = ({ className, children, ...props }) => ( +
+ {children} +
+); + +const MainBalance: FC> = ({ className, children, ...props }) => ( +

+ {children} +

+); + +const UnderBalance: FC> = ({ className, children, ...props }) => ( +

+ {children} +

+); + +const CurrencyItemRoot: FC = ({ + ticker, + name, + balance, + underBalance, + icon, + isVerified, + children, + ...props +}) => { + if (children) { + return {children}; + } + + return ( + + {(icon || ticker) && } + + +
+ {name || ticker} + {isVerified && } +
+ + + {ticker} {name && ticker && <>• {name}} + +
+ + {(balance || underBalance) && ( + + {balance && {balance}} + {underBalance && {underBalance}} + + )} +
+ ); +}; + +export const CurrencyItem = Object.assign(CurrencyItemRoot, { + Container, + Logo: LogoWrapper, + Info, + VerifiedBadge, + Header, + Name, + Ticker, + RightSide, + MainBalance, + UnderBalance, +}); diff --git a/packages/appkit-react/src/features/balances/components/currency-item/index.ts b/packages/appkit-react/src/components/shared/currency-item/index.ts similarity index 100% rename from packages/appkit-react/src/features/balances/components/currency-item/index.ts rename to packages/appkit-react/src/components/shared/currency-item/index.ts diff --git a/packages/appkit-react/src/components/shared/currency-select-modal/currency-select-modal.module.css b/packages/appkit-react/src/components/shared/currency-select-modal/currency-select-modal.module.css new file mode 100644 index 000000000..71287ed53 --- /dev/null +++ b/packages/appkit-react/src/components/shared/currency-select-modal/currency-select-modal.module.css @@ -0,0 +1,89 @@ +.searchWrapper { + margin-bottom: 16px; +} + +.filters { + display: flex; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; + margin-bottom: 12px; +} + +.filters::-webkit-scrollbar { + display: none; +} + +.chip { + composes: labelMedium from "../../../styles/typography.module.css"; + + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: none; + border-radius: var(--ta-border-radius-2xl); + background-color: var(--ta-color-background-secondary); + color: var(--ta-color-text); + cursor: pointer; + flex-shrink: 0; + white-space: nowrap; + transition: background-color 0.2s; +} + +.chip[data-active] { + background-color: var(--ta-color-background-tertiary); +} + +.chipLogo { + width: 16px; + height: 16px; + border-radius: 50%; + object-fit: cover; +} + +.body { + overflow-y: hidden; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 16px; + height: 400px; + max-height: 100%; + overflow-y: auto; + flex: 1; +} + +.section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sectionHeader { + composes: labelSemibold from "../../../styles/typography.module.css"; + + color: var(--ta-color-text-secondary); + margin: 0; + padding: 8px 0 4px; +} + +.section:first-child .sectionHeader { + padding-top: 0; +} + +.empty { + padding: 32px 0; +} + +.emptyText { + composes: bodyRegular from "../../../styles/typography.module.css"; + margin: 0; + color: var(--ta-color-text-secondary); + text-align: center; +} diff --git a/packages/appkit-react/src/components/shared/currency-select-modal/currency-select-modal.stories.tsx b/packages/appkit-react/src/components/shared/currency-select-modal/currency-select-modal.stories.tsx new file mode 100644 index 000000000..fbb9dcb5b --- /dev/null +++ b/packages/appkit-react/src/components/shared/currency-select-modal/currency-select-modal.stories.tsx @@ -0,0 +1,177 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { CurrencySelect } from './currency-select-modal'; +import { Button } from '../../ui/button'; +import { CurrencyItem } from '../currency-item'; + +const meta: Meta = { + title: 'Components/Shared/CurrencySelectModal', + component: CurrencySelect.Modal, +}; + +export default meta; + +type Story = StoryObj; + +const TOKENS = [ + { ticker: 'TON', name: 'Toncoin', icon: 'https://ton.org/download/ton_symbol.png', balance: '55' }, + { ticker: 'USDT', name: 'Tether USD', balance: '10' }, + { ticker: 'NOT', name: 'Notcoin', balance: '500' }, + { ticker: 'STON', name: 'STON.fi Token', balance: '20' }, + { ticker: 'BOLT', name: 'Bolt', balance: '0' }, +]; + +export const Default: Story = { + render: () => { + const Wrapper = () => { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return TOKENS; + return TOKENS.filter((t) => t.ticker.toLowerCase().includes(q) || t.name.toLowerCase().includes(q)); + }, [search]); + + return ( + <> + + { + setOpen(v); + if (!v) setSearch(''); + }} + title="Select token" + > + + + + Popular + {filtered.map((t) => ( + setOpen(false)} + /> + ))} + + + + + ); + }; + return ; + }, +}; + +const FILTER_OPTIONS = [ + { id: 'eip155:1', label: 'Ethereum', logo: 'https://cdn.layerswap.io/layerswap/networks/ethereum_mainnet.png' }, + { id: 'eip155:56', label: 'BSC', logo: 'https://cdn.layerswap.io/layerswap/networks/bsc_mainnet.png' }, + { id: 'eip155:8453', label: 'Base', logo: 'https://cdn.layerswap.io/layerswap/networks/base_mainnet.png' }, + { + id: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + label: 'Solana', + logo: 'https://cdn.layerswap.io/layerswap/networks/solana_mainnet.png', + }, +]; + +export const WithFilters: Story = { + render: () => { + const Wrapper = () => { + const [open, setOpen] = useState(true); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState(null); + + return ( + <> + + { + setOpen(v); + if (!v) { + setSearch(''); + setFilter(null); + } + }} + title="Select token" + > + + + + + {TOKENS.map((t) => ( + setOpen(false)} + /> + ))} + + + + + ); + }; + return ; + }, +}; + +/** + * The three empty states share uniform texts across every currency modal (swap, + * onramp tokens, onramp methods): `loading`, `unavailable` (nothing came from the + * API) and `no-match` (search/filter found nothing). + */ +export const EmptyStates: Story = { + render: () => { + const Wrapper = () => { + const [emptyState, setEmptyState] = useState<'loading' | 'unavailable' | 'no-match'>('loading'); + + return ( + <> +
+ {(['loading', 'unavailable', 'no-match'] as const).map((state) => ( + + ))} +
+ {}} title="Select token"> + {}} placeholder="Search..." /> + + + + ); + }; + return ; + }, +}; diff --git a/packages/appkit-react/src/components/shared/currency-select-modal/currency-select-modal.tsx b/packages/appkit-react/src/components/shared/currency-select-modal/currency-select-modal.tsx new file mode 100644 index 000000000..c2646a529 --- /dev/null +++ b/packages/appkit-react/src/components/shared/currency-select-modal/currency-select-modal.tsx @@ -0,0 +1,171 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC, ComponentProps } from 'react'; +import clsx from 'clsx'; + +import type { InputContainerProps } from '../../ui/input'; +import { Input } from '../../ui/input'; +import type { ModalProps } from '../../ui/modal'; +import { Modal } from '../../ui/modal'; +import { SearchIcon } from '../../ui/icons'; +import { useI18n } from '../../../features/settings/hooks/use-i18n'; +import styles from './currency-select-modal.module.css'; + +export interface CurrencySelectSearchProps extends Omit { + searchValue: string; + onSearchChange: (value: string) => void; + placeholder?: string; +} + +export const CurrencySelectSearch: FC = ({ + searchValue, + onSearchChange, + placeholder, + className, + ...props +}) => { + return ( + + + + + + + onSearchChange(e.target.value)} + autoFocus + /> + + + ); +}; + +/** + * Why the list has nothing to render. Texts are uniform across all currency modals + * (swap, onramp tokens, onramp methods) by design — callers pass the state, not copy. + */ +export type CurrencySelectEmptyState = 'loading' | 'unavailable' | 'no-match'; + +export interface CurrencySelectListContainerProps extends ComponentProps<'div'> { + /** When set, an empty-state message replaces `children`. */ + emptyState?: CurrencySelectEmptyState | null; +} + +export const CurrencySelectListContainer: FC = ({ + emptyState, + children, + className, + ...props +}) => { + const { t } = useI18n(); + + return ( +
+ {emptyState ? ( +
+ {emptyState === 'loading' &&

{t('tokenSelect.loading')}

} + {emptyState === 'unavailable' && ( + <> +

{t('tokenSelect.emptyUnavailable')}

+

{t('tokenSelect.emptyTryLater')}

+ + )} + {emptyState === 'no-match' && ( + <> +

{t('tokenSelect.emptyNoMatch')}

+

{t('tokenSelect.emptyTryAddress')}

+ + )} +
+ ) : ( + children + )} +
+ ); +}; + +export interface CurrencySelectFilterOption { + id: string; + label: string; + logo?: string; +} + +export interface CurrencySelectFiltersProps { + options: CurrencySelectFilterOption[]; + /** Currently active filter id. `null` means the implicit "All" chip is selected. */ + value: string | null; + onChange: (id: string | null) => void; + /** Label for the leading "All" chip — passed in so callers control i18n. */ + allLabel: string; + className?: string; +} + +export const CurrencySelectFilters: FC = ({ + options, + value, + onChange, + allLabel, + className, +}) => { + return ( +
+ + {options.map((opt) => ( + + ))} +
+ ); +}; + +export const CurrencySelectSectionHeader: FC> = ({ className, children, ...props }) => ( +

+ {children} +

+); + +export const CurrencySelectSection: FC> = ({ className, children, ...props }) => ( +
+ {children} +
+); + +export const CurrencySelectModal: FC = ({ className, ...props }) => { + return ; +}; + +export const CurrencySelect = { + Modal: CurrencySelectModal, + Search: CurrencySelectSearch, + Filters: CurrencySelectFilters, + ListContainer: CurrencySelectListContainer, + SectionHeader: CurrencySelectSectionHeader, + Section: CurrencySelectSection, +}; diff --git a/packages/appkit-react/src/components/shared/currency-select-modal/index.ts b/packages/appkit-react/src/components/shared/currency-select-modal/index.ts new file mode 100644 index 000000000..54f117d4f --- /dev/null +++ b/packages/appkit-react/src/components/shared/currency-select-modal/index.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CurrencySelect } from './currency-select-modal'; +export type { + CurrencySelectSearchProps, + CurrencySelectEmptyState, + CurrencySelectListContainerProps, + CurrencySelectFiltersProps, + CurrencySelectFilterOption, +} from './currency-select-modal'; diff --git a/packages/appkit-react/src/components/shared/flow-preview/flow-preview.stories.tsx b/packages/appkit-react/src/components/shared/flow-preview/flow-preview.stories.tsx index 3740e89fd..cf72a1ebd 100644 --- a/packages/appkit-react/src/components/shared/flow-preview/flow-preview.stories.tsx +++ b/packages/appkit-react/src/components/shared/flow-preview/flow-preview.stories.tsx @@ -13,6 +13,7 @@ import { FlowPreview } from './flow-preview'; import type { AppkitUIToken } from '../../../types/appkit-ui-token'; const tonToken: AppkitUIToken = { + id: 'ton', symbol: 'TON', name: 'Toncoin', decimals: 9, @@ -22,6 +23,7 @@ const tonToken: AppkitUIToken = { }; const usdtToken: AppkitUIToken = { + id: 'usdt', symbol: 'USDT', name: 'Tether USD', decimals: 6, diff --git a/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx index 5225213bc..4ff6194ae 100644 --- a/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx +++ b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx @@ -11,6 +11,7 @@ import clsx from 'clsx'; import { ChevronsIcon } from '../../ui/icons'; import { Select } from '../../ui/select'; +import { Skeleton } from '../../ui/skeleton'; import styles from './option-switcher.module.css'; export interface OptionSwitcherOption { @@ -27,21 +28,29 @@ export interface OptionSwitcherProps { onChange: (value: string) => void; /** When true, the trigger is non-interactive and dimmed. */ disabled?: boolean; + /** When true, replaces the trigger content with a skeleton and disables interaction. */ + loading?: boolean; className?: string; } /** * Compact selector used inside settings modals next to a label. */ -export const OptionSwitcher: FC = ({ value, options, onChange, disabled, className }) => { +export const OptionSwitcher: FC = ({ value, options, onChange, disabled, loading, className }) => { const current = options.find((option) => option.value === value); const currentLabel = current?.label ?? value ?? '—'; return ( - + - {currentLabel} - + {loading ? ( + + ) : ( + <> + {currentLabel} + + + )} {options.map((option) => ( diff --git a/packages/appkit-react/src/components/shared/settings-button/settings-button.tsx b/packages/appkit-react/src/components/shared/settings-button/settings-button.tsx index ee37adaf1..358baa187 100644 --- a/packages/appkit-react/src/components/shared/settings-button/settings-button.tsx +++ b/packages/appkit-react/src/components/shared/settings-button/settings-button.tsx @@ -11,6 +11,7 @@ import clsx from 'clsx'; import { Button } from '../../ui/button'; import { SlidersIcon } from '../../ui/icons'; +import { useI18n } from '../../../features/settings/hooks/use-i18n'; import styles from './settings-button.module.css'; export interface SettingsButtonProps extends ComponentProps { @@ -18,6 +19,8 @@ export interface SettingsButtonProps extends ComponentProps { } export const SettingsButton: FC = ({ onClick, className, ...props }) => { + const { t } = useI18n(); + return ( + setOpen(false)} + tokens={[]} + isLoading + onSelect={() => {}} + title="Select Token" + searchPlaceholder="Search by name or symbol" + /> + + ); + }, +}; diff --git a/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx index 0689a6902..4de6b1ab2 100644 --- a/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx +++ b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx @@ -6,46 +6,72 @@ * */ -import { useState } from 'react'; -import type { FC } from 'react'; -import { compareAddress } from '@ton/appkit'; +import { useMemo, useState } from 'react'; +import type { JSX } from 'react'; -import { Input } from '../../ui/input/input'; -import { Modal } from '../../ui/modal/modal'; -import { SearchIcon } from '../../ui/icons'; -import { CurrencyItem } from '../../../features/balances'; -import { useI18n } from '../../../features/settings/hooks/use-i18n'; +import { CurrencySelect } from '../currency-select-modal'; +import { CurrencyItem } from '../currency-item'; import type { AppkitUIToken } from '../../../types/appkit-ui-token'; -import styles from './token-select-modal.module.css'; +import { useI18n } from '../../../features/settings/hooks/use-i18n'; +import { filterTokens, groupTokenSections } from './utils'; + +export interface TokenBase { + id: string; + symbol: string; + name: string; + address: string; + logo?: string; +} -export interface TokenSelectModalProps { +export interface TokenSection { + title: string; + tokens: T[]; +} + +export interface TokenSectionConfig { + title: string; + ids: string[]; +} + +export interface TokenSelectModalProps { open: boolean; onClose: () => void; - tokens: AppkitUIToken[]; - onSelect: (token: AppkitUIToken) => void; + tokens: T[]; + tokenSections?: TokenSectionConfig[]; + onSelect: (token: T) => void; title: string; searchPlaceholder?: string; + /** While true an empty `tokens` list renders the loading state instead of "unavailable". */ + isLoading?: boolean; } -export const TokenSelectModal: FC = ({ +export const TokenSelectModal = ({ open, onClose, tokens, + tokenSections, onSelect, title, searchPlaceholder, -}) => { + isLoading, +}: TokenSelectModalProps): JSX.Element => { const { t } = useI18n(); const [search, setSearch] = useState(''); - const filtered = tokens.filter( - (token) => - token.symbol.toLowerCase().includes(search.toLowerCase()) || - token.name.toLowerCase().includes(search.toLowerCase()) || - compareAddress(token.address, search), - ); + const displaySections = useMemo((): TokenSection[] => { + if (search) { + return [{ title: '', tokens: filterTokens(tokens, search) }]; + } + if (tokenSections) { + return groupTokenSections(tokens, tokenSections, t('tokenSelect.otherTokens')); + } + return [{ title: '', tokens }]; + }, [tokens, tokenSections, search, t]); + + const isEmpty = displaySections.every((s) => s.tokens.length === 0); + const emptyState = isLoading ? 'loading' : tokens.length === 0 ? 'unavailable' : isEmpty ? 'no-match' : null; - const handleSelect = (token: AppkitUIToken) => () => { + const handleSelect = (token: T) => () => { onSelect(token); onClose(); setSearch(''); @@ -59,34 +85,13 @@ export const TokenSelectModal: FC = ({ }; return ( - - - - - - - setSearch(e.target.value)} - autoFocus - /> - - - -
- {tokens.length === 0 ? ( -
-

{t('tokenSelect.emptyForNetwork')}

-
- ) : filtered.length === 0 ? ( -
-

{t('tokenSelect.emptyNoMatch')}

-

{t('tokenSelect.emptyTryAddress')}

-
- ) : ( -
    - {filtered.map((token) => ( + + + + {displaySections.map((section) => ( + + {section.title && {section.title}} + {section.tokens.map((token) => ( = ({ onClick={handleSelect(token)} /> ))} -
- )} -
-
+ + ))} + + ); }; diff --git a/packages/appkit-react/src/components/shared/token-select-modal/utils.ts b/packages/appkit-react/src/components/shared/token-select-modal/utils.ts new file mode 100644 index 000000000..dde2f028c --- /dev/null +++ b/packages/appkit-react/src/components/shared/token-select-modal/utils.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { compareAddress } from '@ton/appkit'; + +import type { TokenBase, TokenSection, TokenSectionConfig } from './token-select-modal'; + +export const filterTokens = (tokens: T[], search: string): T[] => { + if (!search) return tokens; + const lowerSearch = search.toLowerCase(); + return tokens.filter( + (token) => + token.symbol.toLowerCase().includes(lowerSearch) || + token.name.toLowerCase().includes(lowerSearch) || + compareAddress(token.address, search), + ); +}; + +/** + * Converts a flat token list + section configs into TokenSection[] for TokenSelectModal. + * Tokens not covered by any section config are placed in a final untitled section. + */ +export const groupTokenSections = ( + tokens: T[], + sections: TokenSectionConfig[], + otherTitle = 'Other Tokens', +): TokenSection[] => { + const tokenById = new Map(tokens.map((t) => [t.id, t])); + const assignedIds = new Set(); + + const result: TokenSection[] = sections.map(({ title, ids }) => { + const sectionTokens = ids.flatMap((id) => { + const token = tokenById.get(id); + if (token) { + assignedIds.add(id); + return [token]; + } + return []; + }); + return { title, tokens: sectionTokens }; + }); + + const remaining = tokens.filter((t) => !assignedIds.has(t.id)); + if (remaining.length > 0) { + result.push({ title: otherTitle, tokens: remaining }); + } + + return result.filter((s) => s.tokens.length > 0); +}; diff --git a/packages/appkit-react/src/features/swap/components/token-selector/index.ts b/packages/appkit-react/src/components/shared/token-selector/index.ts similarity index 100% rename from packages/appkit-react/src/features/swap/components/token-selector/index.ts rename to packages/appkit-react/src/components/shared/token-selector/index.ts diff --git a/packages/appkit-react/src/components/shared/token-selector/token-selector.module.css b/packages/appkit-react/src/components/shared/token-selector/token-selector.module.css new file mode 100644 index 000000000..4b3691a89 --- /dev/null +++ b/packages/appkit-react/src/components/shared/token-selector/token-selector.module.css @@ -0,0 +1,38 @@ +.tokenSelector:hover { + opacity: 0.8; +} + +.readOnly { + cursor: default; +} + +.readOnly:hover { + opacity: 1; +} + +.symbol { + display: flex; + align-items: center; + padding-right: 2px; + margin-right: auto; +} + +.chevron { + opacity: 0.5; + margin-left: auto; +} + +.placeholderIcon { + width: 24px; + height: 24px; + flex-shrink: 0; + border-radius: 50%; + background-color: var(--ta-color-background-tertiary); +} + +.titleSkeleton { + width: calc(100% - 20px); + min-width: 60px; + margin-right: auto; + border-radius: var(--ta-border-radius-full); +} diff --git a/packages/appkit-react/src/components/shared/token-selector/token-selector.stories.tsx b/packages/appkit-react/src/components/shared/token-selector/token-selector.stories.tsx new file mode 100644 index 000000000..19e0a2d74 --- /dev/null +++ b/packages/appkit-react/src/components/shared/token-selector/token-selector.stories.tsx @@ -0,0 +1,74 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; + +import { TokenSelector } from './token-selector'; + +const meta: Meta = { + title: 'Components/Shared/TokenSelector', + component: TokenSelector, + tags: ['autodocs'], + args: { + onClick: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +const TON_ICON = 'https://ton.org/download/ton_symbol.png'; + +export const Default: Story = { + args: { + title: 'TON', + icon: TON_ICON, + }, +}; + +export const NoIcon: Story = { + args: { + title: 'USDT', + }, +}; + +// `empty` and `loading` are only used on the onramp's `secondary` pill — the placeholder +// circle and skeleton are `tertiary`, so they'd disappear against the default `gray` pill. +export const Empty: Story = { + args: { + title: 'Buy token', + empty: true, + variant: 'secondary', + }, +}; + +export const Loading: Story = { + args: { + title: '', + loading: true, + variant: 'secondary', + }, +}; + +export const WithNetwork: Story = { + args: { + title: 'USDC', + icon: 'https://assets.coingecko.com/coins/images/6319/standard/usdc.png', + networkIcon: TON_ICON, + }, +}; + +export const ReadOnly: Story = { + args: { + title: 'TON', + icon: TON_ICON, + readOnly: true, + }, +}; diff --git a/packages/appkit-react/src/components/shared/token-selector/token-selector.tsx b/packages/appkit-react/src/components/shared/token-selector/token-selector.tsx new file mode 100644 index 000000000..e1fd155af --- /dev/null +++ b/packages/appkit-react/src/components/shared/token-selector/token-selector.tsx @@ -0,0 +1,104 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; +import clsx from 'clsx'; + +import styles from './token-selector.module.css'; +import { Button } from '../../ui/button'; +import type { ButtonProps } from '../../ui/button'; +import { Logo } from '../../ui/logo'; +import { LogoWithNetwork } from '../../ui/logo-with-network'; +import { Skeleton } from '../../ui/skeleton'; + +interface TokenSelectorIconProps { + title: string; + icon?: string; + iconFallback?: string; + networkIcon?: string; + empty?: boolean; +} + +const TokenSelectorIcon: FC = ({ title, icon, iconFallback, networkIcon, empty }) => { + if (empty) { + return ; + } + + const fallback = iconFallback || title[0]; + + if (networkIcon) { + return ; + } + + return ; +}; + +export interface TokenSelectorProps extends ButtonProps { + title: string; + icon?: string; + iconFallback?: string; + /** When provided, renders a network badge overlay on the icon */ + networkIcon?: string; + /** Hide chevron and suppress click handling — use when there's nothing to pick */ + readOnly?: boolean; + /** No token picked yet — render a neutral placeholder circle instead of a logo or fallback letter. */ + empty?: boolean; + /** Replace the icon and title with a single skeleton bar while the selection loads — pill outline stays. */ + loading?: boolean; +} + +export const TokenSelector: FC = ({ + title, + icon, + iconFallback, + networkIcon, + readOnly, + empty, + loading, + onClick, + className, + ...props +}) => { + return ( + + ); +}; diff --git a/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.stories.tsx b/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.stories.tsx new file mode 100644 index 000000000..0effb8daa --- /dev/null +++ b/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.stories.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; + +import { AmountReversed } from './amount-reversed'; + +const meta: Meta = { + title: 'Components/UI/AmountReversed', + component: AmountReversed, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + value: '144.74', + ticker: 'USDT', + decimals: 6, + }, +}; + +export const WithSymbol: Story = { + args: { + value: '144.74', + symbol: '$', + decimals: 2, + }, +}; + +export const WithDirectionToggle: Story = { + args: { + value: '100', + ticker: 'TON', + decimals: 9, + onChangeDirection: fn(), + }, +}; + +export const Loading: Story = { + args: { + value: '100', + ticker: 'TON', + isLoading: true, + }, +}; + +export const ZeroValue: Story = { + args: { + value: '', + ticker: 'TON', + }, +}; + +export const Error: Story = { + args: { + value: '0', + errorMessage: 'Unable to fetch quote', + }, +}; diff --git a/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.module.css b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.module.css index 27879f4d9..96b20d0b2 100644 --- a/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.module.css +++ b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.module.css @@ -32,6 +32,10 @@ opacity: 1; } +.input:disabled { + pointer-events: none; +} + .ticker { composes: inputXlSymbol from "../../../styles/typography.module.css"; color: var(--ta-color-text-secondary); diff --git a/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.tsx b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.tsx index cf526e846..c30e497f2 100644 --- a/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.tsx +++ b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.tsx @@ -20,6 +20,7 @@ export interface CenteredAmountInputProps extends ComponentProps<'div'> { ticker?: string; symbol?: string; placeholder?: string; + disabled?: boolean; } export const CenteredAmountInput: FC = ({ @@ -28,6 +29,7 @@ export const CenteredAmountInput: FC = ({ ticker, symbol, placeholder = '0', + disabled, className, ...props }) => { @@ -97,6 +99,7 @@ export const CenteredAmountInput: FC = ({ inputMode="decimal" placeholder={placeholder} value={value} + disabled={disabled} onChange={(e) => onValueChange(e.target.value)} style={{ width: inputWidth ? `${inputWidth}px` : undefined, diff --git a/packages/appkit-react/src/components/ui/dialog/dialog.stories.tsx b/packages/appkit-react/src/components/ui/dialog/dialog.stories.tsx new file mode 100644 index 000000000..d2992dc97 --- /dev/null +++ b/packages/appkit-react/src/components/ui/dialog/dialog.stories.tsx @@ -0,0 +1,81 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useState } from 'react'; +import type { CSSProperties } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Dialog } from './dialog'; +import { Button } from '../button'; + +const meta: Meta = { + title: 'Components/UI/Dialog', + component: Dialog.Root, +}; + +export default meta; + +type Story = StoryObj; + +const overlayStyle: CSSProperties = { + position: 'fixed', + inset: 0, + background: 'rgba(0, 0, 0, 0.4)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, +}; + +const contentStyle: CSSProperties = { + background: 'var(--ta-color-background)', + color: 'var(--ta-color-text)', + padding: 24, + borderRadius: 16, + minWidth: 320, + boxShadow: '0 10px 25px rgba(0,0,0,0.2)', +}; + +export const Default: Story = { + render: () => { + const Wrapper = () => { + const [open, setOpen] = useState(false); + return ( + <> + + + + setOpen(false)}> + e.stopPropagation()}> + Dialog title +

+ Bare Dialog primitive. Most consumers should use the higher-level Modal + component instead. +

+ + Close + +
+
+
+
+ + ); + }; + return ; + }, +}; diff --git a/packages/appkit-react/src/components/ui/icons/check-icon.tsx b/packages/appkit-react/src/components/ui/icons/check-icon.tsx new file mode 100644 index 000000000..de9f70b0c --- /dev/null +++ b/packages/appkit-react/src/components/ui/icons/check-icon.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; + +import { DEFAULT_ICON_SIZE } from './types'; +import type { IconProps } from './types'; + +export const CheckIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => ( + +); diff --git a/packages/appkit-react/src/components/ui/icons/copy-icon.tsx b/packages/appkit-react/src/components/ui/icons/copy-icon.tsx new file mode 100644 index 000000000..cb7413c5a --- /dev/null +++ b/packages/appkit-react/src/components/ui/icons/copy-icon.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; + +import { DEFAULT_ICON_SIZE } from './types'; +import type { IconProps } from './types'; + +export const CopyIcon: FC = ({ size = DEFAULT_ICON_SIZE, ...props }) => ( + +); diff --git a/packages/appkit-react/src/components/ui/icons/icons.stories.tsx b/packages/appkit-react/src/components/ui/icons/icons.stories.tsx index cf6bc056e..7f723f006 100644 --- a/packages/appkit-react/src/components/ui/icons/icons.stories.tsx +++ b/packages/appkit-react/src/components/ui/icons/icons.stories.tsx @@ -9,9 +9,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { FC } from 'react'; +import { CheckIcon } from './check-icon'; import { ChevronsIcon } from './chevrons-icon'; import { ChevronDownIcon } from './chevron-down-icon'; import { CloseIcon } from './close-icon'; +import { CopyIcon } from './copy-icon'; import { FailedIcon } from './failed-icon'; import { FlipIcon } from './flip-icon'; import { ImageIcon } from './image-icon'; @@ -24,9 +26,11 @@ import { VerifiedIcon } from './verified-icon'; import type { IconProps } from './types'; const ICONS: { name: string; Component: FC }[] = [ + { name: 'CheckIcon', Component: CheckIcon }, { name: 'ChevronsIcon', Component: ChevronsIcon }, { name: 'ChevronDownIcon', Component: ChevronDownIcon }, { name: 'CloseIcon', Component: CloseIcon }, + { name: 'CopyIcon', Component: CopyIcon }, { name: 'FailedIcon', Component: FailedIcon }, { name: 'FlipIcon', Component: FlipIcon }, { name: 'ImageIcon', Component: ImageIcon }, diff --git a/packages/appkit-react/src/components/ui/icons/index.ts b/packages/appkit-react/src/components/ui/icons/index.ts index 0c96d882e..257d14e5a 100644 --- a/packages/appkit-react/src/components/ui/icons/index.ts +++ b/packages/appkit-react/src/components/ui/icons/index.ts @@ -7,9 +7,11 @@ */ export * from './types'; +export * from './check-icon'; export * from './chevrons-icon'; export * from './chevron-down-icon'; export * from './close-icon'; +export * from './copy-icon'; export * from './failed-icon'; export * from './flip-icon'; export * from './image-icon'; diff --git a/packages/appkit-react/src/components/ui/logo-with-network/index.ts b/packages/appkit-react/src/components/ui/logo-with-network/index.ts new file mode 100644 index 000000000..83f096f19 --- /dev/null +++ b/packages/appkit-react/src/components/ui/logo-with-network/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { LogoWithNetwork } from './logo-with-network'; +export type { LogoWithNetworkProps } from './logo-with-network'; diff --git a/packages/appkit-react/src/components/ui/logo-with-network/logo-with-network.module.css b/packages/appkit-react/src/components/ui/logo-with-network/logo-with-network.module.css new file mode 100644 index 000000000..6ca8df325 --- /dev/null +++ b/packages/appkit-react/src/components/ui/logo-with-network/logo-with-network.module.css @@ -0,0 +1,16 @@ +.root { + position: relative; + display: inline-flex; +} + +.networkBadge { + position: absolute; + bottom: -2px; + right: -2px; + border: var(--ta-border-width-m) solid var(--ta-color-background); + border-radius: var(--ta-border-radius-full); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/packages/appkit-react/src/components/ui/logo-with-network/logo-with-network.stories.tsx b/packages/appkit-react/src/components/ui/logo-with-network/logo-with-network.stories.tsx new file mode 100644 index 000000000..7d72901de --- /dev/null +++ b/packages/appkit-react/src/components/ui/logo-with-network/logo-with-network.stories.tsx @@ -0,0 +1,53 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { LogoWithNetwork } from './logo-with-network'; + +const meta: Meta = { + title: 'Components/UI/LogoWithNetwork', + component: LogoWithNetwork, + tags: ['autodocs'], + argTypes: { + size: { + control: { type: 'range', min: 20, max: 100, step: 5 }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const WithNetworkBadge: Story = { + args: { + size: 40, + src: 'https://ton.org/download/ton_symbol.png', + alt: 'TON', + networkSrc: 'https://cryptologos.cc/logos/ethereum-eth-logo.png', + networkAlt: 'ETH', + }, +}; + +export const FallbackOnly: Story = { + args: { + size: 40, + fallback: 'BTC', + alt: 'BTC', + networkAlt: 'ETH', + }, +}; + +export const WithoutNetwork: Story = { + args: { + size: 40, + src: 'https://ton.org/download/ton_symbol.png', + alt: 'TON', + }, +}; diff --git a/packages/appkit-react/src/components/ui/logo-with-network/logo-with-network.tsx b/packages/appkit-react/src/components/ui/logo-with-network/logo-with-network.tsx new file mode 100644 index 000000000..c62f53c74 --- /dev/null +++ b/packages/appkit-react/src/components/ui/logo-with-network/logo-with-network.tsx @@ -0,0 +1,46 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { forwardRef } from 'react'; +import type { ComponentPropsWithoutRef, ComponentRef } from 'react'; +import clsx from 'clsx'; + +import { Logo } from '../logo'; +import styles from './logo-with-network.module.css'; + +export interface LogoWithNetworkProps extends ComponentPropsWithoutRef<'span'> { + /** Size of the main logo in pixels */ + size?: number; + /** Image source for the main logo */ + src?: string; + /** Alt text for the main logo */ + alt?: string; + /** Fallback text for the main logo */ + fallback?: string; + /** Image source for the network badge */ + networkSrc?: string; + /** Alt text for the network badge */ + networkAlt?: string; +} + +export const LogoWithNetwork = forwardRef, LogoWithNetworkProps>( + ({ size = 30, src, alt, fallback, networkSrc, networkAlt, className, ...props }, ref) => { + return ( + + + {!!networkSrc && ( + + + + )} + + ); + }, +); + +LogoWithNetwork.displayName = 'LogoWithNetwork'; diff --git a/packages/appkit-react/src/components/ui/logo/logo.tsx b/packages/appkit-react/src/components/ui/logo/logo.tsx index b325bf4f7..1a2bf3c04 100644 --- a/packages/appkit-react/src/components/ui/logo/logo.tsx +++ b/packages/appkit-react/src/components/ui/logo/logo.tsx @@ -95,7 +95,11 @@ export interface LogoProps extends ComponentPropsWithoutRef<'span'> { export const Logo = forwardRef, LogoProps>(({ size = 30, src, alt, fallback, ...props }, ref) => { return ( - + {(fallback || alt) && {fallback ? fallback : alt?.[0]}} diff --git a/packages/appkit-react/src/components/ui/modal/modal.tsx b/packages/appkit-react/src/components/ui/modal/modal.tsx index 126c1c40f..690abfa16 100644 --- a/packages/appkit-react/src/components/ui/modal/modal.tsx +++ b/packages/appkit-react/src/components/ui/modal/modal.tsx @@ -11,6 +11,7 @@ import clsx from 'clsx'; import { Dialog } from '../dialog'; import { CloseIcon } from '../icons'; +import { useI18n } from '../../../features/settings/hooks/use-i18n'; import styles from './modal.module.css'; export interface ModalProps { @@ -34,9 +35,15 @@ export interface ModalProps { * Additional class name for the content container. */ className?: string; + /** + * Additional class name for the body container. + */ + bodyClassName?: string; } -export const Modal: FC = ({ open, onOpenChange, title, children, className }) => { +export const Modal: FC = ({ open, onOpenChange, title, children, className, bodyClassName }) => { + const { t } = useI18n(); + return ( @@ -44,11 +51,11 @@ export const Modal: FC = ({ open, onOpenChange, title, children, cla e.stopPropagation()}>
{title && {title}} - +
-
{children}
+
{children}
diff --git a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.tsx b/packages/appkit-react/src/features/balances/components/currency-item/currency-item.tsx deleted file mode 100644 index ee65a5471..000000000 --- a/packages/appkit-react/src/features/balances/components/currency-item/currency-item.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import type { FC, ComponentProps } from 'react'; -import clsx from 'clsx'; - -import { Logo } from '../../../../components/ui/logo'; -import { VerifiedIcon } from '../../../../components/ui/icons'; -import styles from './currency-item.module.css'; - -export interface CurrencyItemProps extends ComponentProps<'button'> { - ticker: string; - name?: string; - balance?: string; - icon?: string; - isVerified?: boolean; -} - -export const CurrencyItem: FC = ({ - ticker, - name, - balance, - icon, - isVerified, - className, - ...props -}) => { - return ( - - ); -}; diff --git a/packages/appkit-react/src/features/balances/index.ts b/packages/appkit-react/src/features/balances/index.ts index 819297b1c..cd5ec480d 100644 --- a/packages/appkit-react/src/features/balances/index.ts +++ b/packages/appkit-react/src/features/balances/index.ts @@ -6,7 +6,6 @@ * */ -export * from './components/currency-item'; export * from './components/send-ton-button'; export * from './components/send-jetton-button'; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-token-selectors/index.ts b/packages/appkit-react/src/features/onramp/components/onramp-token-selectors/index.ts new file mode 100644 index 000000000..c8a0ddb8a --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-token-selectors/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampTokenSelectors } from './onramp-token-selectors'; +export type { OnrampTokenSelectorsProps } from './onramp-token-selectors'; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-token-selectors/onramp-token-selectors.module.css b/packages/appkit-react/src/features/onramp/components/onramp-token-selectors/onramp-token-selectors.module.css new file mode 100644 index 000000000..014446ed8 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-token-selectors/onramp-token-selectors.module.css @@ -0,0 +1,12 @@ +.container { + display: grid; + gap: 4px; + padding: 2px; + width: 100%; + grid-template-columns: 1fr 1fr; +} + +.tokenSelector { + width: 100%; + height: 48px; +} diff --git a/packages/appkit-react/src/features/onramp/components/onramp-token-selectors/onramp-token-selectors.tsx b/packages/appkit-react/src/features/onramp/components/onramp-token-selectors/onramp-token-selectors.tsx new file mode 100644 index 000000000..f94e32de4 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-token-selectors/onramp-token-selectors.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC, ComponentProps } from 'react'; +import clsx from 'clsx'; + +import styles from './onramp-token-selectors.module.css'; +import { TokenSelector } from '../../../../components/shared/token-selector'; +import { useI18n } from '../../../settings/hooks/use-i18n'; + +interface SlotProps { + title: string; + logoSrc?: string; + networkLogoSrc?: string; + /** When true, the pill outline stays but icon + title render as skeletons inside. */ + loading?: boolean; + /** Text shown when `title` is empty — bypasses the `buyToken`/`forCurrency` symbol template. */ + placeholder?: string; +} + +export interface OnrampTokenSelectorsProps extends ComponentProps<'div'> { + from: SlotProps; + to: SlotProps; + onFromClick: () => void; + onToClick: () => void; +} + +export const OnrampTokenSelectors: FC = ({ + from, + to, + onFromClick, + onToClick, + className, + ...props +}) => { + const { t } = useI18n(); + + return ( +
+ + + +
+ ); +}; diff --git a/packages/appkit-react/src/features/onramp/constants.ts b/packages/appkit-react/src/features/onramp/constants.ts new file mode 100644 index 000000000..00efe7d1a --- /dev/null +++ b/packages/appkit-react/src/features/onramp/constants.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { OnrampAmountPreset } from './types'; + +export const DEFAULT_ONRAMP_PRESETS: OnrampAmountPreset[] = [ + { amount: '100', label: '100' }, + { amount: '250', label: '250' }, + { amount: '500', label: '500' }, + { amount: '1000', label: '1000' }, +]; + +export const NATIVE_TON_ADDRESS = '0x0000000000000000000000000000000000000000'; + +export const ERROR_THRESHOLD = 10000000; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-create-crypto-onramp-deposit.ts b/packages/appkit-react/src/features/onramp/hooks/use-create-crypto-onramp-deposit.ts new file mode 100644 index 000000000..510536933 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-create-crypto-onramp-deposit.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import type { UseMutationResult } from '@tanstack/react-query'; +import { createCryptoOnrampDepositMutationOptions } from '@ton/appkit/queries'; +import type { + CreateCryptoOnrampDepositData, + CreateCryptoOnrampDepositErrorType, + CreateCryptoOnrampDepositMutationOptions, + CreateCryptoOnrampDepositVariables, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; +import { useMutation } from '../../../libs/query'; + +export type UseCreateCryptoOnrampDepositParameters = + CreateCryptoOnrampDepositMutationOptions; + +export type UseCreateCryptoOnrampDepositReturnType = UseMutationResult< + CreateCryptoOnrampDepositData, + CreateCryptoOnrampDepositErrorType, + CreateCryptoOnrampDepositVariables, + context +>; + +/** + * Hook to create a crypto onramp deposit from a previously obtained quote + */ +export const useCreateCryptoOnrampDeposit = ( + parameters: UseCreateCryptoOnrampDepositParameters = {}, +): UseCreateCryptoOnrampDepositReturnType => { + const appKit = useAppKit(); + + return useMutation({ + ...createCryptoOnrampDepositMutationOptions(appKit), + ...parameters.mutation, + }); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-provider-by-id.ts b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-provider-by-id.ts new file mode 100644 index 000000000..4d7caa68d --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-provider-by-id.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useSyncExternalStore, useCallback } from 'react'; +import { getCryptoOnrampProvider, watchCryptoOnrampProviders } from '@ton/appkit'; +import type { GetCryptoOnrampProviderOptions, GetCryptoOnrampProviderReturnType } from '@ton/appkit'; + +import { useAppKit } from '../../settings/hooks/use-app-kit'; + +export type UseCryptoOnrampProviderByIdReturnType = GetCryptoOnrampProviderReturnType; + +/** + * Hook to get a registered crypto-onramp provider by id, or the default one when no id is given. + */ +export const useCryptoOnrampProviderById = ( + options: GetCryptoOnrampProviderOptions = {}, +): UseCryptoOnrampProviderByIdReturnType | undefined => { + const appKit = useAppKit(); + const { id } = options; + + const subscribe = useCallback( + (onChange: () => void) => { + return watchCryptoOnrampProviders(appKit, { onChange }); + }, + [appKit], + ); + + const getSnapshot = useCallback(() => { + try { + return getCryptoOnrampProvider(appKit, { id }); + } catch { + return undefined; + } + }, [appKit, id]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-provider-metadata.ts b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-provider-metadata.ts new file mode 100644 index 000000000..1245db4d6 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-provider-metadata.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import { getCryptoOnrampProviderMetadataQueryOptions } from '@ton/appkit/queries'; +import type { + GetCryptoOnrampProviderMetadataData, + GetCryptoOnrampProviderMetadataErrorType, + GetCryptoOnrampProviderMetadataQueryConfig, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseCryptoOnrampProviderMetadataParameters = + GetCryptoOnrampProviderMetadataQueryConfig; + +export type UseCryptoOnrampProviderMetadataReturnType = + UseQueryReturnType; + +/** + * Hook to get static metadata for a crypto-onramp provider (display name, logo, url). + */ +export const useCryptoOnrampProviderMetadata = ( + parameters: UseCryptoOnrampProviderMetadataParameters = {}, +): UseCryptoOnrampProviderMetadataReturnType => { + const appKit = useAppKit(); + return useQuery(getCryptoOnrampProviderMetadataQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-provider.ts b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-provider.ts new file mode 100644 index 000000000..d85b4f32b --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-provider.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useSyncExternalStore, useCallback } from 'react'; +import { getCryptoOnrampProvider, setDefaultCryptoOnrampProvider, watchCryptoOnrampProviders } from '@ton/appkit'; +import type { GetCryptoOnrampProviderReturnType } from '@ton/appkit'; + +import { useAppKit } from '../../settings/hooks/use-app-kit'; + +export type UseCryptoOnrampProviderReturnType = readonly [ + GetCryptoOnrampProviderReturnType | undefined, + (providerId: string) => void, +]; + +/** + * Hook to get and set the currently selected crypto-onramp provider. + * Mirrors the tuple shape of `useSwapProvider`. + */ +export const useCryptoOnrampProvider = (): UseCryptoOnrampProviderReturnType => { + const appKit = useAppKit(); + + const subscribe = useCallback( + (onChange: () => void) => { + return watchCryptoOnrampProviders(appKit, { onChange }); + }, + [appKit], + ); + + const getSnapshot = useCallback(() => { + try { + return getCryptoOnrampProvider(appKit); + } catch { + return undefined; + } + }, [appKit]); + + const provider = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + const setProviderId = useCallback( + (providerId: string) => { + setDefaultCryptoOnrampProvider(appKit, { providerId }); + }, + [appKit], + ); + + return [provider, setProviderId] as const; +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-providers.ts b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-providers.ts new file mode 100644 index 000000000..6b2457531 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-providers.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useSyncExternalStore, useCallback } from 'react'; +import { getCryptoOnrampProviders, watchCryptoOnrampProviders } from '@ton/appkit'; +import type { GetCryptoOnrampProvidersReturnType } from '@ton/appkit'; + +import { useAppKit } from '../../settings/hooks/use-app-kit'; + +export type UseCryptoOnrampProvidersReturnType = GetCryptoOnrampProvidersReturnType; + +/** + * Hook to get all registered crypto-onramp providers. + */ +export const useCryptoOnrampProviders = (): UseCryptoOnrampProvidersReturnType => { + const appKit = useAppKit(); + + const subscribe = useCallback( + (onChange: () => void) => { + return watchCryptoOnrampProviders(appKit, { onChange }); + }, + [appKit], + ); + + const getSnapshot = useCallback(() => { + return getCryptoOnrampProviders(appKit); + }, [appKit]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-quote.ts b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-quote.ts new file mode 100644 index 000000000..e77851ee7 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-quote.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import { getCryptoOnrampQuoteQueryOptions } from '@ton/appkit/queries'; +import type { + GetCryptoOnrampQuoteData, + GetCryptoOnrampQuoteErrorType, + GetCryptoOnrampQuoteQueryConfig, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseCryptoOnrampQuoteParameters = + GetCryptoOnrampQuoteQueryConfig; + +export type UseCryptoOnrampQuoteReturnType = UseQueryReturnType< + selectData, + GetCryptoOnrampQuoteErrorType +>; + +/** + * Hook to get a crypto onramp quote + */ +export const useCryptoOnrampQuote = ( + parameters: UseCryptoOnrampQuoteParameters = {}, +): UseCryptoOnrampQuoteReturnType => { + const appKit = useAppKit(); + + return useQuery(getCryptoOnrampQuoteQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-status.ts b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-status.ts new file mode 100644 index 000000000..ef5671fa3 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-status.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import { getCryptoOnrampStatusQueryOptions } from '@ton/appkit/queries'; +import type { + GetCryptoOnrampStatusData, + GetCryptoOnrampStatusErrorType, + GetCryptoOnrampStatusQueryConfig, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseCryptoOnrampStatusParameters = + GetCryptoOnrampStatusQueryConfig; + +export type UseCryptoOnrampStatusReturnType = UseQueryReturnType< + selectData, + GetCryptoOnrampStatusErrorType +>; + +/** + * Hook to get a crypto onramp quote + */ +export const useCryptoOnrampStatus = ( + parameters: UseCryptoOnrampStatusParameters = {}, +): UseCryptoOnrampStatusReturnType => { + const appKit = useAppKit(); + + return useQuery(getCryptoOnrampStatusQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-supported-currencies.ts b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-supported-currencies.ts new file mode 100644 index 000000000..907f60b5e --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-crypto-onramp-supported-currencies.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import { getCryptoOnrampSupportedCurrenciesQueryOptions } from '@ton/appkit/queries'; +import type { + GetCryptoOnrampSupportedCurrenciesData, + GetCryptoOnrampSupportedCurrenciesErrorType, + GetCryptoOnrampSupportedCurrenciesQueryConfig, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseCryptoOnrampSupportedCurrenciesParameters = + GetCryptoOnrampSupportedCurrenciesQueryConfig; + +export type UseCryptoOnrampSupportedCurrenciesReturnType = + UseQueryReturnType; + +/** + * Hook to discover supported source/destination currencies for the current + * crypto-onramp provider. + */ +export const useCryptoOnrampSupportedCurrencies = ( + parameters: UseCryptoOnrampSupportedCurrenciesParameters = {}, +): UseCryptoOnrampSupportedCurrenciesReturnType => { + const appKit = useAppKit(); + + return useQuery(getCryptoOnrampSupportedCurrenciesQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/onramp/index.ts b/packages/appkit-react/src/features/onramp/index.ts new file mode 100644 index 000000000..4f4545def --- /dev/null +++ b/packages/appkit-react/src/features/onramp/index.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './widgets/crypto-onramp/crypto-onramp-widget'; +export * from './widgets/crypto-onramp/crypto-onramp-widget-ui'; +export * from './widgets/crypto-onramp/crypto-onramp-widget-provider'; + +export { useCryptoOnrampProvider, type UseCryptoOnrampProviderReturnType } from './hooks/use-crypto-onramp-provider'; +export { + useCryptoOnrampProviderById, + type UseCryptoOnrampProviderByIdReturnType, +} from './hooks/use-crypto-onramp-provider-by-id'; +export { useCryptoOnrampProviders, type UseCryptoOnrampProvidersReturnType } from './hooks/use-crypto-onramp-providers'; +export { + useCryptoOnrampQuote, + type UseCryptoOnrampQuoteParameters, + type UseCryptoOnrampQuoteReturnType, +} from './hooks/use-crypto-onramp-quote'; +export { + useCreateCryptoOnrampDeposit, + type UseCreateCryptoOnrampDepositParameters, + type UseCreateCryptoOnrampDepositReturnType, +} from './hooks/use-create-crypto-onramp-deposit'; +export { + useCryptoOnrampStatus, + type UseCryptoOnrampStatusParameters, + type UseCryptoOnrampStatusReturnType, +} from './hooks/use-crypto-onramp-status'; +export { + useCryptoOnrampSupportedCurrencies, + type UseCryptoOnrampSupportedCurrenciesParameters, + type UseCryptoOnrampSupportedCurrenciesReturnType, +} from './hooks/use-crypto-onramp-supported-currencies'; +export { + useCryptoOnrampProviderMetadata, + type UseCryptoOnrampProviderMetadataParameters, + type UseCryptoOnrampProviderMetadataReturnType, +} from './hooks/use-crypto-onramp-provider-metadata'; + +export * from './types'; diff --git a/packages/appkit-react/src/features/onramp/types.ts b/packages/appkit-react/src/features/onramp/types.ts new file mode 100644 index 000000000..9ecac4d74 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/types.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export interface OnrampAmountPreset { + amount: string; + label: string; +} diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/crypto-method-select-modal.module.css b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/crypto-method-select-modal.module.css new file mode 100644 index 000000000..e558424d4 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/crypto-method-select-modal.module.css @@ -0,0 +1,34 @@ +.networkTabs { + display: flex; + gap: 6px; + flex-wrap: nowrap; + overflow-x: auto; + padding: 4px 0 8px; + scrollbar-width: none; +} + +.networkTabs::-webkit-scrollbar { + display: none; +} + +.networkTab { + flex-shrink: 0; + padding: 4px 12px; + border: none; + border-radius: var(--ta-border-radius-full); + background-color: var(--ta-color-background-secondary); + color: var(--ta-color-text-secondary); + cursor: pointer; + composes: labelMedium from '../../../../../styles/typography.module.css'; + transition: background-color 0.15s, color 0.15s; +} + +.networkTab:hover { + background-color: var(--ta-color-background-tertiary); + color: var(--ta-color-text); +} + +.networkTabActive { + background-color: var(--ta-color-primary); + color: var(--ta-color-primary-foreground); +} diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/crypto-method-select-modal.stories.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/crypto-method-select-modal.stories.tsx new file mode 100644 index 000000000..14d6ae749 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/crypto-method-select-modal.stories.tsx @@ -0,0 +1,97 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { CryptoOnrampSourceCurrency } from '@ton/appkit'; + +import type { ChainInfo } from '../utils/chains'; +import { CryptoMethodSelectModal } from './crypto-method-select-modal'; + +const CHAINS: Record = { + 'eip155:1': { + name: 'Ethereum', + logo: 'https://assets.coingecko.com/coins/images/279/standard/ethereum.png', + }, + 'eip155:56': { + name: 'BSC', + logo: 'https://assets.coingecko.com/coins/images/825/standard/bnb-icon2_2x.png', + }, + 'eip155:8453': { + name: 'Base', + logo: 'https://avatars.githubusercontent.com/u/108554348?s=280&v=4', + }, +}; + +const METHODS: CryptoOnrampSourceCurrency[] = [ + { + symbol: 'USDC', + name: 'USD Coin', + chain: 'eip155:8453', + decimals: 6, + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + logo: 'https://assets.coingecko.com/coins/images/6319/standard/USDC.png?1769615602', + }, + { + symbol: 'USDT', + name: 'Tether', + chain: 'eip155:56', + decimals: 18, + address: '0x55d398326f99059fF775485246999027B3197955', + logo: 'https://cdn.layerswap.io/layerswap/currencies/usdt.png', + }, + { + symbol: 'ETH', + name: 'Ethereum', + chain: 'eip155:1', + decimals: 18, + address: '0x0000000000000000000000000000000000000000', + logo: 'https://assets.coingecko.com/coins/images/279/standard/ethereum.png', + }, +]; + +const meta: Meta = { + title: 'Features/Onramp/Internal/CryptoMethodSelectModal', + component: CryptoMethodSelectModal, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + open: true, + methods: METHODS, + chains: CHAINS, + onClose: () => {}, + onSelect: () => {}, + }, +}; + +/** Nothing came from the API — renders the "unavailable" empty state. */ +export const Empty: Story = { + args: { + open: true, + methods: [], + onClose: () => {}, + onSelect: () => {}, + }, +}; + +/** + * Currencies are still loading. The widget disables opening the modal in this state, + * so this is a fallback for custom renders that open it anyway. + */ +export const Loading: Story = { + args: { + open: true, + methods: [], + isLoading: true, + onClose: () => {}, + onSelect: () => {}, + }, +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/crypto-method-select-modal.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/crypto-method-select-modal.tsx new file mode 100644 index 000000000..e808dfe21 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/crypto-method-select-modal.tsx @@ -0,0 +1,148 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo, useState } from 'react'; +import type { FC } from 'react'; +import type { CryptoOnrampSourceCurrency } from '@ton/appkit'; + +import { CurrencySelect } from '../../../../../components/shared/currency-select-modal'; +import type { CurrencySelectFilterOption } from '../../../../../components/shared/currency-select-modal'; +import { LogoWithNetwork } from '../../../../../components/ui/logo-with-network'; +import { CurrencyItem } from '../../../../../components/shared/currency-item'; +import { useI18n } from '../../../../settings/hooks/use-i18n'; +import type { ChainInfo } from '../utils/chains'; +import { getChainInfo } from '../utils/chains'; + +export interface CryptoMethodSelectModalProps { + open: boolean; + onClose: () => void; + methods: CryptoOnrampSourceCurrency[]; + /** CAIP-2 → display info map. Defaults to `{}` (helper falls back to the chain reference). */ + chains?: Record; + onSelect: (method: CryptoOnrampSourceCurrency) => void; + /** While true an empty `methods` list renders the loading state instead of "unavailable". */ + isLoading?: boolean; +} + +const filterMethods = ( + methods: CryptoOnrampSourceCurrency[], + search: string, + chains: Record, +): CryptoOnrampSourceCurrency[] => { + const q = search.toLowerCase(); + return methods.filter( + (m) => + m.symbol.toLowerCase().includes(q) || + (m.name?.toLowerCase().includes(q) ?? false) || + getChainInfo(m.chain, chains).name.toLowerCase().includes(q) || + m.address.toLowerCase() === q, + ); +}; + +const methodKey = (m: CryptoOnrampSourceCurrency): string => `${m.chain}:${m.address.toLowerCase()}`; + +export const CryptoMethodSelectModal: FC = ({ + open, + onClose, + methods, + chains = {}, + onSelect, + isLoading, +}) => { + const { t } = useI18n(); + const [search, setSearch] = useState(''); + const [chainFilter, setChainFilter] = useState(null); + + const chainFilterOptions = useMemo(() => { + const seen = new Set(); + const result: CurrencySelectFilterOption[] = []; + for (const m of methods) { + if (seen.has(m.chain)) continue; + seen.add(m.chain); + const info = getChainInfo(m.chain, chains); + result.push({ id: m.chain, label: info.name, logo: info.logo }); + } + return result; + }, [methods, chains]); + + const displayMethods = useMemo(() => { + const byChain = chainFilter ? methods.filter((m) => m.chain === chainFilter) : methods; + return search ? filterMethods(byChain, search, chains) : byChain; + }, [methods, chains, search, chainFilter]); + + const emptyState = isLoading + ? 'loading' + : methods.length === 0 + ? 'unavailable' + : displayMethods.length === 0 + ? 'no-match' + : null; + + const handleSelect = (method: CryptoOnrampSourceCurrency) => () => { + onSelect(method); + onClose(); + setSearch(''); + setChainFilter(null); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + onClose(); + setSearch(''); + setChainFilter(null); + } + }; + + return ( + + + + {chainFilterOptions.length > 1 && ( + + )} + + + + {displayMethods.map((method) => { + const chainInfo = getChainInfo(method.chain, chains); + const displayName = method.name ?? method.symbol; + return ( + + + + + {displayName} + + + {method.symbol} • {chainInfo.name} + + + + ); + })} + + + + ); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/index.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/index.ts new file mode 100644 index 000000000..20d90da35 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-method-select-modal/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CryptoMethodSelectModal } from './crypto-method-select-modal'; +export type { CryptoMethodSelectModalProps } from './crypto-method-select-modal'; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/crypto-onramp-deposit-modal.module.css b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/crypto-onramp-deposit-modal.module.css new file mode 100644 index 000000000..531eaef4b --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/crypto-onramp-deposit-modal.module.css @@ -0,0 +1,142 @@ +.content { + display: flex; + flex-direction: column; + gap: 12px; + padding-bottom: 8px; +} + +.qrWrapper { + display: flex; + justify-content: center; + padding: 16px 0; +} + +.tabsList { + margin-top: 4px; +} + +.infoTitle { + composes: bodyMedium from '../../../../../styles/typography.module.css'; + margin: 0; +} + +.infoCard { + background-color: var(--ta-color-background-secondary); + border-radius: var(--ta-border-radius-l); + overflow: hidden; +} + +.infoRow { + display: flex; + flex-direction: column; + padding: 12px 16px; +} + +.infoLabel { + composes: footnoteRegular from '../../../../../styles/typography.module.css'; + color: var(--ta-color-text-secondary); +} + +.infoValueRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + height: 28px; +} + +.infoValue { + composes: bodyMedium from '../../../../../styles/typography.module.css'; + color: var(--ta-color-text); + word-break: break-all; +} + +.copyButton { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: none; + cursor: pointer; + color: var(--ta-color-text-secondary); + border-radius: var(--ta-border-radius-s); + transition: color 0.15s, background-color 0.15s; +} + +.copyButton:hover { + color: var(--ta-color-text); + background-color: var(--ta-color-background-tertiary); +} + +.divider { + height: 1px; + background-color: var(--ta-color-background-tertiary); + margin: 0 16px; +} + +.detailsToggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 16px; + background-color: var(--ta-color-background-secondary); + border: none; + border-radius: var(--ta-border-radius-l); + cursor: pointer; + color: var(--ta-color-text); + composes: bodyMedium from '../../../../../styles/typography.module.css'; +} + +.warning { + display: flex; + gap: 10px; + padding: 12px 14px; + background-color: rgba(255, 165, 0, 0.1); + border-radius: var(--ta-border-radius-l); + border: 1px solid rgba(255, 165, 0, 0.25); +} + +.warningIcon { + flex-shrink: 0; + color: #ff9500; + display: flex; + align-items: flex-start; + padding-top: 1px; +} + +.warningText { + composes: footnoteRegular from '../../../../../styles/typography.module.css'; + color: var(--ta-color-text-secondary); + margin: 0; +} + +.balanceRow { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + padding: 10px 16px; + background-color: var(--ta-color-background-secondary); + border-radius: var(--ta-border-radius-l); +} + +.balanceLabel { + composes: footnoteRegular from '../../../../../styles/typography.module.css'; + color: var(--ta-color-text-secondary); +} + +.balanceValue { + composes: bodyMedium from '../../../../../styles/typography.module.css'; + color: var(--ta-color-text); +} + +.statusText { + composes: footnoteRegular from '../../../../../styles/typography.module.css'; + color: var(--ta-color-text-secondary); + text-align: center; + margin: 0; +} diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/crypto-onramp-deposit-modal.stories.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/crypto-onramp-deposit-modal.stories.tsx new file mode 100644 index 000000000..23d6d7581 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/crypto-onramp-deposit-modal.stories.tsx @@ -0,0 +1,88 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { CryptoOnrampDepositModal } from './crypto-onramp-deposit-modal'; + +const ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; +const TOKEN_LOGO = 'https://assets.coingecko.com/coins/images/6319/standard/USDC.png?1769615602'; + +const meta: Meta = { + title: 'Features/Onramp/Internal/CryptoOnrampDepositModal', + component: CryptoOnrampDepositModal, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + open: true, + address: ADDRESS, + amount: '100', + symbol: 'USDC', + tokenLogo: TOKEN_LOGO, + depositStatus: null, + targetSymbol: 'USDT', + targetBalance: '12.34', + targetDecimals: 6, + onClose: () => {}, + }, +}; + +export const WithMemo: Story = { + args: { + ...Default.args, + memo: '12345678', + }, +}; + +export const WithRefundAddress: Story = { + args: { + ...Default.args, + refundAddress: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + }, +}; + +/** The chain warning is composed from `symbol` + `networkName` via i18n — providers can't inject free text. */ +export const WithChainWarning: Story = { + args: { + ...Default.args, + networkName: 'Base', + }, +}; + +export const Pending: Story = { + args: { + ...Default.args, + depositStatus: 'pending', + }, +}; + +export const Success: Story = { + args: { + ...Default.args, + depositStatus: 'success', + }, +}; + +export const Failed: Story = { + args: { + ...Default.args, + depositStatus: 'failed', + }, +}; + +export const LoadingTargetBalance: Story = { + args: { + ...Default.args, + targetBalance: undefined, + isLoadingTargetBalance: true, + }, +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/crypto-onramp-deposit-modal.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/crypto-onramp-deposit-modal.tsx new file mode 100644 index 000000000..8b0469bbf --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/crypto-onramp-deposit-modal.tsx @@ -0,0 +1,234 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useState } from 'react'; +import type { FC } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import type { CryptoOnrampStatus } from '@ton/appkit'; + +import { Button } from '../../../../../components/ui/button'; +import { CopyButton } from '../../../../../components/shared/copy-button'; +import { Modal } from '../../../../../components/ui/modal'; +import { Skeleton } from '../../../../../components/ui/skeleton'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../../../../components/ui/tabs'; +import { useI18n } from '../../../../settings/hooks/use-i18n'; +import { formatOnrampAmount } from '../utils/format-onramp-amount'; +import { truncateAddress } from '../utils/truncate-address'; +import styles from './crypto-onramp-deposit-modal.module.css'; + +type QrTab = 'address' | 'memo'; + +const QR_SIZE = 200; +const QR_LOGO_SIZE = 40; +const BALANCE_SKELETON_WIDTH = 80; +const BALANCE_SKELETON_HEIGHT = 16; + +export interface CryptoOnrampDepositModalProps { + open: boolean; + onClose: () => void; + /** Deposit address to display as QR code */ + address: string; + /** Amount to send */ + amount: string; + /** Token symbol, e.g. "BTC" */ + symbol: string; + /** Deposit status */ + depositStatus: CryptoOnrampStatus | null; + /** Optional memo / tag / comment */ + memo?: string; + /** Optional refund address the user provided on the source network */ + refundAddress?: string; + /** URL of the token logo to embed in the QR code center */ + tokenLogo?: string; + /** Display name of the source network. When provided (with `symbol`), the standard chain warning is shown. */ + networkName?: string; + /** Symbol of the target token the user is buying */ + targetSymbol?: string; + /** User's formatted balance of the target token */ + targetBalance?: string; + /** Decimals of the target token */ + targetDecimals?: number; + /** Whether the target balance is loading */ + isLoadingTargetBalance?: boolean; +} + +const WarningIcon: FC = () => ( + + + + +); + +export const CryptoOnrampDepositModal: FC = ({ + open, + onClose, + address, + amount, + symbol, + memo, + refundAddress, + tokenLogo, + networkName, + depositStatus, + targetSymbol, + targetBalance, + targetDecimals, + isLoadingTargetBalance, +}) => { + const { t } = useI18n(); + const [qrTab, setQrTab] = useState('address'); + + const qrImageSettings = tokenLogo + ? { src: tokenLogo, width: QR_LOGO_SIZE, height: QR_LOGO_SIZE, excavate: true } + : undefined; + const qrValue = memo && qrTab === 'memo' ? memo : address; + + return ( + !isOpen && onClose()} title={t('cryptoOnramp.depositModalTitle')}> +
+ {memo ? ( + setQrTab(v as QrTab)}> + + {t('cryptoOnramp.addressTab')} + {t('cryptoOnramp.memoTab')} + + +
+ +
+
+ +
+ +
+
+
+ ) : ( +
+ +
+ )} + + {networkName && symbol && ( +
+ + + +

+ {t('cryptoOnramp.chainWarning', { symbol, network: networkName })} +

+
+ )} + +
+
+ {t('cryptoOnramp.youNeedToSend')} +
+ + {amount} {symbol} + + +
+
+ +
+ +
+ {t('cryptoOnramp.toThisAddress')} +
+ {truncateAddress(address)} + +
+
+ + {refundAddress && ( + <> +
+
+ {t('cryptoOnramp.refundAddress')} +
+ {truncateAddress(refundAddress)} + +
+
+ + )} + + {memo && ( + <> +
+
+ {t('cryptoOnramp.memoTag')} +
+ {memo} + +
+
+ + )} +
+ + {targetSymbol && ( +
+
+ {t('cryptoOnramp.yourBalance')} +
+ {isLoadingTargetBalance ? ( + + ) : ( + + {formatOnrampAmount(targetBalance || '0', targetDecimals)} {targetSymbol} + + )} +
+
+
+ )} + + + + {depositStatus && ( +

+ {depositStatus === 'success' && t('cryptoOnramp.statusSuccess')} + {depositStatus === 'pending' && t('cryptoOnramp.statusPending')} + {depositStatus === 'failed' && t('cryptoOnramp.statusFailed')} +

+ )} +
+ + ); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/index.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/index.ts new file mode 100644 index 000000000..12c22b8da --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-deposit-modal/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CryptoOnrampDepositModal } from './crypto-onramp-deposit-modal'; +export type { CryptoOnrampDepositModalProps } from './crypto-onramp-deposit-modal'; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/crypto-onramp-refund-address-modal.module.css b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/crypto-onramp-refund-address-modal.module.css new file mode 100644 index 000000000..be786e6a8 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/crypto-onramp-refund-address-modal.module.css @@ -0,0 +1,17 @@ +.content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.label { + margin: 0; + font-size: 14px; + line-height: 20px; + color: var(--ta-color-text-secondary); +} + +.buttons { + display: flex; + gap: 8px; +} diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/crypto-onramp-refund-address-modal.stories.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/crypto-onramp-refund-address-modal.stories.tsx new file mode 100644 index 000000000..8f732b9da --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/crypto-onramp-refund-address-modal.stories.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { CryptoOnrampRefundAddressModal } from './crypto-onramp-refund-address-modal'; + +const meta: Meta = { + title: 'Features/Onramp/Internal/CryptoOnrampRefundAddressModal', + component: CryptoOnrampRefundAddressModal, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + open: true, + isLoading: false, + onClose: () => {}, + onConfirm: () => {}, + }, +}; + +export const WithError: Story = { + args: { + ...Default.args, + error: 'Invalid refund address', + }, +}; + +export const Loading: Story = { + args: { + ...Default.args, + isLoading: true, + }, +}; + +export const Optional: Story = { + args: { + ...Default.args, + onSkip: () => {}, + }, +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/crypto-onramp-refund-address-modal.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/crypto-onramp-refund-address-modal.tsx new file mode 100644 index 000000000..7d084e18d --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/crypto-onramp-refund-address-modal.tsx @@ -0,0 +1,83 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useState } from 'react'; +import type { FC } from 'react'; + +import { Modal } from '../../../../../components/ui/modal'; +import { useI18n } from '../../../../settings/hooks/use-i18n'; +import { Button } from '../../../../../components/ui/button'; +import { Input } from '../../../../../components/ui/input'; +import styles from './crypto-onramp-refund-address-modal.module.css'; + +export interface CryptoOnrampRefundAddressModalProps { + open: boolean; + onClose: () => void; + onConfirm: (address: string) => void; + onSkip?: () => void; + isLoading: boolean; + error?: string | null; +} + +export const CryptoOnrampRefundAddressModal: FC = ({ + open, + onClose, + onConfirm, + onSkip, + error, + isLoading, +}) => { + const { t } = useI18n(); + const [address, setAddress] = useState(''); + + useEffect(() => { + if (open) setAddress(''); + }, [open]); + + return ( + !isOpen && onClose()} + title={t('cryptoOnramp.refundAddressModalTitle')} + > +
+

{t('cryptoOnramp.refundAddressLabel')}

+ + + + setAddress(e.target.value)} + placeholder={t('cryptoOnramp.refundAddressPlaceholder')} + autoFocus + /> + + {error && {error}} + + +
+ {onSkip && ( + + )} + + +
+
+
+ ); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/index.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/index.ts new file mode 100644 index 000000000..31568e8a4 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-refund-address-modal/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CryptoOnrampRefundAddressModal } from './crypto-onramp-refund-address-modal'; +export type { CryptoOnrampRefundAddressModalProps } from './crypto-onramp-refund-address-modal'; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/crypto-onramp-settings-modal.module.css b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/crypto-onramp-settings-modal.module.css new file mode 100644 index 000000000..0ebc75485 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/crypto-onramp-settings-modal.module.css @@ -0,0 +1,24 @@ +.rows { + display: flex; + flex-direction: column; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 0; +} + +.label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--ta-color-text); + font-size: 16px; + font-weight: 500; +} + +.saveButton { + margin-top: 16px; +} diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/crypto-onramp-settings-modal.stories.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/crypto-onramp-settings-modal.stories.tsx new file mode 100644 index 000000000..781056851 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/crypto-onramp-settings-modal.stories.tsx @@ -0,0 +1,81 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { CryptoOnrampProvider } from '@ton/appkit'; + +import { CryptoOnrampSettingsModal } from './crypto-onramp-settings-modal'; +import type { CryptoOnrampProvidersMetadata } from '../crypto-onramp-widget-provider/use-crypto-onramp-providers-with-metadata'; + +const makeProvider = (id: string): CryptoOnrampProvider => + ({ + providerId: id, + type: 'crypto-onramp', + }) as unknown as CryptoOnrampProvider; + +const decent = makeProvider('decent'); +const layerswap = makeProvider('layerswap'); + +const metadata: CryptoOnrampProvidersMetadata = { + decent: { name: 'Decent' }, + layerswap: { name: 'Layerswap' }, +}; + +const meta: Meta = { + title: 'Features/Onramp/Internal/CryptoOnrampSettingsModal', + component: CryptoOnrampSettingsModal, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + open: true, + provider: decent, + providers: [decent, layerswap], + providersMetadata: metadata, + onClose: () => {}, + onProviderChange: () => {}, + }, +}; + +export const SingleProvider: Story = { + args: { + open: true, + provider: decent, + providers: [decent], + providersMetadata: { decent: metadata.decent }, + onClose: () => {}, + onProviderChange: () => {}, + }, +}; + +export const MetadataPartiallyLoaded: Story = { + args: { + open: true, + provider: decent, + providers: [decent, layerswap], + // Only one provider has resolved metadata — the other shows its providerId as fallback. + providersMetadata: { decent: metadata.decent }, + onClose: () => {}, + onProviderChange: () => {}, + }, +}; + +export const MetadataLoading: Story = { + args: { + open: true, + provider: decent, + providers: [decent, layerswap], + providersMetadata: {}, + isProvidersMetadataLoading: true, + onClose: () => {}, + onProviderChange: () => {}, + }, +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/crypto-onramp-settings-modal.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/crypto-onramp-settings-modal.tsx new file mode 100644 index 000000000..835e0e3fd --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/crypto-onramp-settings-modal.tsx @@ -0,0 +1,80 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useMemo, useState } from 'react'; +import type { FC } from 'react'; +import type { CryptoOnrampProvider } from '@ton/appkit'; + +import type { CryptoOnrampProvidersMetadata } from '../crypto-onramp-widget-provider/use-crypto-onramp-providers-with-metadata'; +import { Modal } from '../../../../../components/ui/modal/modal'; +import { Button } from '../../../../../components/ui/button'; +import { OptionSwitcher } from '../../../../../components/shared/option-switcher'; +import { useI18n } from '../../../../settings/hooks/use-i18n'; +import styles from './crypto-onramp-settings-modal.module.css'; + +export interface CryptoOnrampSettingsModalProps { + open: boolean; + onClose: () => void; + provider: CryptoOnrampProvider | undefined; + providers: CryptoOnrampProvider[]; + providersMetadata?: CryptoOnrampProvidersMetadata; + isProvidersMetadataLoading?: boolean; + onProviderChange: (providerId: string) => void; +} + +export const CryptoOnrampSettingsModal: FC = ({ + open, + onClose, + provider, + providers, + providersMetadata, + isProvidersMetadataLoading, + onProviderChange, +}) => { + const { t } = useI18n(); + + const [stagedProviderId, setStagedProviderId] = useState(provider?.providerId); + + useEffect(() => { + if (open) setStagedProviderId(provider?.providerId); + }, [open, provider?.providerId]); + + const providerOptions = useMemo( + () => + providers.map((p) => ({ + value: p.providerId, + label: providersMetadata?.[p.providerId]?.name ?? p.providerId, + })), + [providers, providersMetadata], + ); + + const handleSave = () => { + if (stagedProviderId && stagedProviderId !== provider?.providerId) onProviderChange(stagedProviderId); + onClose(); + }; + + return ( + !isOpen && onClose()} title={t('cryptoOnramp.settings')}> +
+
+ {t('cryptoOnramp.provider')} + +
+
+ + +
+ ); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/index.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/index.ts new file mode 100644 index 000000000..cb5e9f0ec --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-settings-modal/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './crypto-onramp-settings-modal'; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/crypto-onramp-context.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/crypto-onramp-context.ts new file mode 100644 index 000000000..d6c27f97a --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/crypto-onramp-context.ts @@ -0,0 +1,157 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { createContext, useContext } from 'react'; +import type { + CryptoOnrampDeposit, + CryptoOnrampDestinationCurrency, + CryptoOnrampProvider, + CryptoOnrampQuote, + CryptoOnrampSourceCurrency, + CryptoOnrampStatus, +} from '@ton/appkit'; + +import type { CryptoOnrampProvidersMetadata } from './use-crypto-onramp-providers-with-metadata'; +import { DEFAULT_ONRAMP_PRESETS } from '../../../constants'; +import type { ChainInfo } from '../utils/chains'; +import { DEFAULT_CHAINS } from '../utils/chains'; +import type { OnrampAmountPreset } from '../../../types'; + +export type CryptoAmountInputMode = 'token' | 'method'; + +export interface CryptoOnrampContextType { + /** Full list of tokens to buy (TON-side) */ + tokens: CryptoOnrampDestinationCurrency[]; + /** Currently selected token to buy */ + selectedToken: CryptoOnrampDestinationCurrency | null; + setSelectedToken: (token: CryptoOnrampDestinationCurrency) => void; + + /** Available crypto payment methods (source side) */ + paymentMethods: CryptoOnrampSourceCurrency[]; + /** Currently selected payment method */ + selectedMethod: CryptoOnrampSourceCurrency | null; + setSelectedMethod: (method: CryptoOnrampSourceCurrency) => void; + /** True while the provider's `/supportedCurrencies` is in its first load. */ + isLoadingSupportedCurrencies: boolean; + /** CAIP-2 → chain display info map (defaults merged with consumer overrides) */ + chains: Record; + + /** Current amount input value */ + amount: string; + setAmount: (value: string) => void; + /** Whether user is entering token amount or payment-method amount */ + amountInputMode: CryptoAmountInputMode; + setAmountInputMode: (mode: CryptoAmountInputMode) => void; + /** Converted amount from quote */ + convertedAmount: string; + presetAmounts: OnrampAmountPreset[]; + + /** Currently selected crypto-onramp provider (defaults to the first registered one) */ + provider: CryptoOnrampProvider | undefined; + /** All registered crypto-onramp providers */ + providers: CryptoOnrampProvider[]; + /** Resolved metadata for each provider, keyed by `providerId`. Entry is `undefined` while loading or on error. */ + providersMetadata: CryptoOnrampProvidersMetadata; + /** True while any provider's metadata query is in its first load. */ + isProvidersMetadataLoading: boolean; + /** Updates the selected crypto-onramp provider */ + setProviderId: (providerId: string) => void; + + /** Current quote from provider */ + quote: CryptoOnrampQuote | null; + /** Whether quote is being fetched */ + isLoadingQuote: boolean; + /** Error from quote fetch (i18n key) */ + quoteError: string | null; + + /** Current deposit offer from provider */ + deposit: CryptoOnrampDeposit | null; + /** Whether deposit is being created */ + isCreatingDeposit: boolean; + /** Error from deposit creation (i18n key) */ + depositError: string | null; + /** Formatted deposit amount */ + depositAmount: string; + /** Function to trigger deposit creation, optionally with a refund address */ + createDeposit: (refundAddress?: string) => void; + /** Deposit status */ + depositStatus: CryptoOnrampStatus | null; + + /** + * Refund-address collection mode for the current provider: + * `'off'` — skip the address modal; `'optional'` — show modal with a Skip button; + * `'required'` — show modal, address mandatory. + */ + refundAddressMode: 'off' | 'optional' | 'required'; + /** Whether the current quote provider supports reversed (target-amount) input */ + isReversedAmountSupported: boolean; + + /** User's balance of the selected target token (formatted, token units) */ + targetBalance: string; + /** Whether the target token balance is being fetched */ + isLoadingTargetBalance: boolean; + + /** Whether a TON wallet is currently connected */ + isWalletConnected: boolean; + + /** Whether the user can proceed (valid amount + quote available + wallet connected) */ + canContinue: boolean; + /** Reset state (invalidate quote and clear deposit) */ + onReset: () => void; +} + +const defaultContext: CryptoOnrampContextType = { + tokens: [], + selectedToken: null, + setSelectedToken: () => {}, + paymentMethods: [], + selectedMethod: null, + setSelectedMethod: () => {}, + isLoadingSupportedCurrencies: false, + chains: DEFAULT_CHAINS, + amount: '', + setAmount: () => {}, + amountInputMode: 'method', + setAmountInputMode: () => {}, + convertedAmount: '', + presetAmounts: DEFAULT_ONRAMP_PRESETS, + + provider: undefined, + providers: [], + providersMetadata: {}, + isProvidersMetadataLoading: false, + setProviderId: () => {}, + + quote: null, + isLoadingQuote: false, + quoteError: null, + + deposit: null, + isCreatingDeposit: false, + depositError: null, + depositAmount: '', + createDeposit: () => {}, + depositStatus: null, + + refundAddressMode: 'off', + isReversedAmountSupported: true, + + targetBalance: '', + isLoadingTargetBalance: false, + + isWalletConnected: false, + + canContinue: false, + onReset: () => {}, +}; + +export const CryptoOnrampContext = createContext(defaultContext); + +export const useCryptoOnrampContext = (): CryptoOnrampContextType => { + return useContext(CryptoOnrampContext); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/crypto-onramp-widget-provider.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/crypto-onramp-widget-provider.tsx new file mode 100644 index 000000000..7cea1cbef --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/crypto-onramp-widget-provider.tsx @@ -0,0 +1,258 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useMemo } from 'react'; +import type { FC, PropsWithChildren } from 'react'; +import { compareAddress } from '@ton/appkit'; + +import { useAddress } from '../../../../wallets'; +import { useCryptoOnrampProvider } from '../../../hooks/use-crypto-onramp-provider'; +import { useCryptoOnrampProviders } from '../../../hooks/use-crypto-onramp-providers'; +import { useCryptoOnrampSupportedCurrencies } from '../../../hooks/use-crypto-onramp-supported-currencies'; +import { DEFAULT_ONRAMP_PRESETS } from '../../../constants'; +import type { ChainInfo } from '../utils/chains'; +import { DEFAULT_CHAINS } from '../utils/chains'; +import { CryptoOnrampContext } from './crypto-onramp-context'; +import { useCryptoOnrampBalance } from './use-crypto-onramp-balance'; +import { useCryptoOnrampProvidersWithMetadata } from './use-crypto-onramp-providers-with-metadata'; +import { useCryptoOnrampQuoteAndDeposit } from './use-crypto-onramp-quote-and-deposit'; +import { useCryptoOnrampTokenState } from './use-crypto-onramp-token-state'; +import { useCryptoOnrampValidation } from './use-crypto-onramp-validation'; + +/** + * Reference to a destination (TON-side) currency by its jetton-master address. + * Compared via `compareAddress`, so EQ/UQ address forms are equivalent. + */ +export interface CryptoOnrampDestinationRef { + address: string; +} + +/** + * Reference to a source currency. Each provided field acts as a filter and the first + * matching list entry wins. `address` is compared lowercase (source chains are non-TON); + * an empty string matches the chain's native coin. + */ +export interface CryptoOnrampSourceRef { + address: string; + /** Optional CAIP-2 chain id (e.g. `'eip155:42161'`) — narrows the match when the same address exists on several chains. */ + chain?: string; +} + +export interface CryptoOnrampProviderProps extends PropsWithChildren { + /** + * Custom CAIP-2 → chain display info overrides. Merged on top of the + * built-in defaults, so consumers only need to provide what they want to + * override or add (e.g. `{ 'eip155:42161': { name: 'Arbitrum', logo: '...' } }`). + */ + chains?: Record; + /** + * Optional default destination (TON-side) currency reference. Resolved against the + * loaded supported-currency list — the selected object always comes from the list + * (canonical decimals/logo/symbol), never from the consumer. When the reference + * doesn't match anything (or is omitted), the first list entry is selected. + */ + defaultDestination?: CryptoOnrampDestinationRef; + /** + * Optional default source currency reference. Same resolution behaviour as + * {@link defaultDestination}. + */ + defaultSource?: CryptoOnrampSourceRef; +} + +export const CryptoOnrampWidgetProvider: FC = ({ + children, + chains: chainsOverride, + defaultDestination, + defaultSource, +}) => { + // 1. Local state + const { + selectedToken, + setSelectedToken, + selectedMethod, + setSelectedMethod, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + } = useCryptoOnrampTokenState(); + + // 2. Queries and external readers + const userAddress = useAddress(); + const [provider, setProviderId] = useCryptoOnrampProvider(); + const providers = useCryptoOnrampProviders(); + const { metadataByProviderId: providersMetadata, isLoading: isProvidersMetadataLoading } = + useCryptoOnrampProvidersWithMetadata(); + + const { data: supportedCurrencies, isLoading: isLoadingSupportedCurrencies } = useCryptoOnrampSupportedCurrencies({ + providerId: provider?.providerId, + query: { enabled: !!provider }, + }); + + const { targetBalance, isLoadingTargetBalance } = useCryptoOnrampBalance({ selectedToken, userAddress }); + + // 3. Derivations (pre-mutation) + const tokens = useMemo(() => supportedCurrencies?.destination ?? [], [supportedCurrencies]); + const paymentMethods = useMemo(() => supportedCurrencies?.source ?? [], [supportedCurrencies]); + const chains = useMemo(() => ({ ...DEFAULT_CHAINS, ...(chainsOverride ?? {}) }), [chainsOverride]); + + // 4. Mutations (quote query + deposit mutation + status query coordinated together) + const { + amountDebounced, + quote, + quoteError, + isQuoteFetching, + refundAddressMode, + isReversedAmountSupported, + deposit, + depositError, + isCreatingDeposit, + depositStatus, + convertedAmount, + depositAmount, + createDeposit, + onReset, + } = useCryptoOnrampQuoteAndDeposit({ + selectedToken, + selectedMethod, + amount, + amountInputMode, + userAddress, + providerId: provider?.providerId, + }); + + // 3. Derivations (post-mutation) + const { + quoteError: validationQuoteError, + depositError: validationDepositError, + canSubmit, + } = useCryptoOnrampValidation({ + amount, + amountDebounced, + amountInputMode, + selectedMethod, + selectedToken, + quoteError, + depositError, + hasQuote: !!quote, + }); + + const isLoadingQuote = isQuoteFetching || amount !== amountDebounced; + const canContinue = canSubmit && !isQuoteFetching && amount === amountDebounced && !!userAddress; + + // 6. Effects + useEffect(() => { + if (!isReversedAmountSupported && amountInputMode === 'token') { + setAmountInputMode('method'); + } + }, [isReversedAmountSupported, amountInputMode, setAmountInputMode]); + + // Resolve the selection once `supportedCurrencies` loads: consumer defaults are + // references looked up in the live list, falling back to the first entry, so the + // selected object always carries canonical decimals/logo/symbol. Fires once per + // side — after the user picks, `selectedX` is non-null and the effect short-circuits. + useEffect(() => { + const first = tokens[0]; + if (selectedToken || !first) return; + const match = defaultDestination + ? tokens.find((token) => compareAddress(token.address, defaultDestination.address)) + : undefined; + setSelectedToken(match ?? first); + }, [tokens, selectedToken, defaultDestination, setSelectedToken]); + + useEffect(() => { + const first = paymentMethods[0]; + if (selectedMethod || !first) return; + const match = defaultSource + ? paymentMethods.find( + (method) => + method.address.toLowerCase() === defaultSource.address.toLowerCase() && + (defaultSource.chain === undefined || method.chain === defaultSource.chain), + ) + : undefined; + setSelectedMethod(match ?? first); + }, [paymentMethods, selectedMethod, defaultSource, setSelectedMethod]); + + const value = useMemo( + () => ({ + tokens, + selectedToken, + setSelectedToken, + paymentMethods, + selectedMethod, + setSelectedMethod, + isLoadingSupportedCurrencies, + chains, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + convertedAmount, + presetAmounts: DEFAULT_ONRAMP_PRESETS, + provider, + providers, + providersMetadata, + isProvidersMetadataLoading, + setProviderId, + quote, + isLoadingQuote, + quoteError: validationQuoteError, + refundAddressMode, + isReversedAmountSupported, + deposit, + isCreatingDeposit, + depositError: validationDepositError, + depositAmount, + createDeposit, + isWalletConnected: !!userAddress, + canContinue, + onReset, + depositStatus, + targetBalance, + isLoadingTargetBalance, + }), + [ + tokens, + selectedToken, + setSelectedToken, + paymentMethods, + selectedMethod, + setSelectedMethod, + isLoadingSupportedCurrencies, + chains, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + convertedAmount, + provider, + providers, + providersMetadata, + isProvidersMetadataLoading, + setProviderId, + quote, + isLoadingQuote, + validationQuoteError, + refundAddressMode, + isReversedAmountSupported, + deposit, + isCreatingDeposit, + validationDepositError, + depositAmount, + createDeposit, + userAddress, + canContinue, + onReset, + depositStatus, + targetBalance, + isLoadingTargetBalance, + ], + ); + + return {children}; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/index.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/index.ts new file mode 100644 index 000000000..e4e0cfaa4 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CryptoOnrampWidgetProvider } from './crypto-onramp-widget-provider'; +export type { + CryptoOnrampProviderProps, + CryptoOnrampDestinationRef, + CryptoOnrampSourceRef, +} from './crypto-onramp-widget-provider'; +export { CryptoOnrampContext, useCryptoOnrampContext } from './crypto-onramp-context'; +export type { CryptoOnrampContextType, CryptoAmountInputMode } from './crypto-onramp-context'; +export type { ChainInfo } from '../utils/chains'; +export { DEFAULT_CHAINS, getChainInfo } from '../utils/chains'; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-balance.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-balance.ts new file mode 100644 index 000000000..0c5c69ca2 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-balance.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampDestinationCurrency } from '@ton/appkit'; + +import { useBalance } from '../../../../balances/hooks/use-balance'; +import { useJettonBalanceByAddress } from '../../../../jettons/hooks/use-jetton-balance-by-address'; +import { NATIVE_TON_ADDRESS } from '../../../constants'; + +interface UseCryptoOnrampBalanceOptions { + selectedToken: CryptoOnrampDestinationCurrency | null; + userAddress: string | undefined; +} + +export const useCryptoOnrampBalance = ({ selectedToken, userAddress }: UseCryptoOnrampBalanceOptions) => { + const isNativeTonTarget = selectedToken?.address === NATIVE_TON_ADDRESS; + + const { data: nativeBalanceData, isLoading: isNativeBalanceLoading } = useBalance({ + query: { enabled: isNativeTonTarget && !!userAddress, refetchInterval: 5000 }, + }); + + const { data: jettonBalanceData, isLoading: isJettonBalanceLoading } = useJettonBalanceByAddress({ + jettonAddress: !isNativeTonTarget ? selectedToken?.address : undefined, + ownerAddress: userAddress, + jettonDecimals: selectedToken?.decimals, + query: { enabled: !isNativeTonTarget && !!selectedToken?.address && !!userAddress, refetchInterval: 5000 }, + }); + + return { + targetBalance: (isNativeTonTarget ? nativeBalanceData : jettonBalanceData) ?? '', + isLoadingTargetBalance: isNativeTonTarget ? isNativeBalanceLoading : isJettonBalanceLoading, + }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-providers-with-metadata.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-providers-with-metadata.ts new file mode 100644 index 000000000..fb40202bd --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-providers-with-metadata.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; +import { useQueries } from '@tanstack/react-query'; +import type { CryptoOnrampProviderMetadata } from '@ton/appkit'; +import { getCryptoOnrampProviderMetadataQueryOptions } from '@ton/appkit/queries'; + +import { useAppKit } from '../../../../settings/hooks/use-app-kit'; +import { useCryptoOnrampProviders } from '../../../hooks/use-crypto-onramp-providers'; + +export type CryptoOnrampProvidersMetadata = Record; + +/** + * Widget-local hook: fetch metadata for every registered crypto-onramp provider, keyed by providerId. + * + * Uses {@link useCryptoOnrampProviders} for the provider list and one TanStack query per provider + * via `useQueries`. Each provider resolves independently — if one fails or is still pending, the + * others remain visible. Consumers render every provider and fall back to `providerId` when the + * entry in the map is `undefined`. `isLoading` is `true` while any metadata query is in its first + * load. + */ +export const useCryptoOnrampProvidersWithMetadata = (): { + metadataByProviderId: CryptoOnrampProvidersMetadata; + isLoading: boolean; +} => { + const appKit = useAppKit(); + const providers = useCryptoOnrampProviders(); + + const metadataQueries = useQueries({ + queries: providers.map((provider) => + getCryptoOnrampProviderMetadataQueryOptions(appKit, { providerId: provider.providerId }), + ), + }); + + const isLoading = metadataQueries.some((q) => q.isLoading); + + const metadataByProviderId = useMemo(() => { + const map: CryptoOnrampProvidersMetadata = {}; + providers.forEach((provider, i) => { + map[provider.providerId] = metadataQueries[i]?.data; + }); + return map; + }, [providers, metadataQueries]); + + return { metadataByProviderId, isLoading }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-quote-and-deposit.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-quote-and-deposit.ts new file mode 100644 index 000000000..9bf386a79 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-quote-and-deposit.ts @@ -0,0 +1,143 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useMemo } from 'react'; +import { formatUnits, parseUnits } from '@ton/appkit'; +import type { CryptoOnrampDestinationCurrency, CryptoOnrampSourceCurrency } from '@ton/appkit'; +import { keepPreviousData } from '@tanstack/react-query'; + +import { useCreateCryptoOnrampDeposit } from '../../../hooks/use-create-crypto-onramp-deposit'; +import { useCryptoOnrampProviderMetadata } from '../../../hooks/use-crypto-onramp-provider-metadata'; +import { useCryptoOnrampQuote } from '../../../hooks/use-crypto-onramp-quote'; +import { useCryptoOnrampStatus } from '../../../hooks/use-crypto-onramp-status'; +import { useDebounceValue } from '../../../../../hooks/use-debounce-value'; +import type { CryptoAmountInputMode } from './crypto-onramp-context'; + +const QUOTE_DEBOUNCE_MS = 500; +const STATUS_REFETCH_MS = 10000; + +interface UseCryptoOnrampQuoteAndDepositOptions { + selectedToken: CryptoOnrampDestinationCurrency | null; + selectedMethod: CryptoOnrampSourceCurrency | null; + amount: string; + amountInputMode: CryptoAmountInputMode; + userAddress: string | undefined; + providerId: string | undefined; +} + +export const useCryptoOnrampQuoteAndDeposit = ({ + selectedToken, + selectedMethod, + amount, + amountInputMode, + userAddress, + providerId, +}: UseCryptoOnrampQuoteAndDepositOptions) => { + const [amountDebounced] = useDebounceValue(amount, QUOTE_DEBOUNCE_MS); + + const requestAmountDecimals = + amountInputMode === 'method' ? (selectedMethod?.decimals ?? 0) : (selectedToken?.decimals ?? 0); + + const requestAmountBase = useMemo(() => { + if (!amountDebounced || isNaN(parseFloat(amountDebounced))) return ''; + try { + return parseUnits(amountDebounced, requestAmountDecimals).toString(); + } catch { + return ''; + } + }, [amountDebounced, requestAmountDecimals]); + + const quoteQuery = useCryptoOnrampQuote({ + amount: requestAmountBase, + sourceCurrency: selectedMethod ?? undefined, + targetCurrency: selectedToken ?? undefined, + recipientAddress: userAddress ?? '', + isSourceAmount: amountInputMode === 'method', + providerId, + query: { + enabled: + !!requestAmountBase && + !!selectedToken && + !!selectedMethod && + !!userAddress && + parseFloat(amountDebounced) > 0, + retry: false, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }, + }); + + const { data: selectedProviderMetadata } = useCryptoOnrampProviderMetadata({ + providerId, + query: { enabled: !!providerId }, + }); + const refundAddressMode = selectedProviderMetadata?.refundAddressMode ?? 'off'; + const isReversedAmountSupported = selectedProviderMetadata?.isReversedAmountSupported ?? true; + + const createDepositMutation = useCreateCryptoOnrampDeposit(); + + const { data: depositStatus } = useCryptoOnrampStatus({ + depositId: createDepositMutation.data?.depositId, + query: { + refetchInterval: STATUS_REFETCH_MS, + retry: false, + }, + }); + + const convertedAmount = useMemo(() => { + // `keepPreviousData` keeps quoteQuery.data after the user clears the input, so we must + // gate on the current amount to avoid showing a stale conversion. + if (!quoteQuery.data || !(parseFloat(amount) > 0)) return ''; + const rawAmount = amountInputMode === 'token' ? quoteQuery.data.sourceAmount : quoteQuery.data.targetAmount; + const decimals = amountInputMode === 'token' ? (selectedMethod?.decimals ?? 0) : (selectedToken?.decimals ?? 0); + return formatUnits(rawAmount, decimals); + }, [quoteQuery.data, amount, amountInputMode, selectedMethod, selectedToken]); + + const depositAmount = useMemo(() => { + if (createDepositMutation.data && selectedMethod) { + return formatUnits(createDepositMutation.data.amount, selectedMethod.decimals); + } + return amount; + }, [createDepositMutation.data, amount, selectedMethod]); + + const createDeposit = useCallback( + (refundAddress?: string) => { + if (!quoteQuery.data || !userAddress) return; + if (refundAddressMode === 'required' && !refundAddress) return; + + createDepositMutation.mutate({ + quote: quoteQuery.data, + providerId: quoteQuery.data.providerId, + refundAddress: refundAddress ?? '', + }); + }, + [quoteQuery.data, userAddress, createDepositMutation, refundAddressMode], + ); + + const onReset = useCallback(() => { + createDepositMutation.reset(); + quoteQuery.refetch(); + }, [createDepositMutation, quoteQuery]); + + return { + amountDebounced, + quote: quoteQuery.data ?? null, + quoteError: quoteQuery.error, + isQuoteFetching: quoteQuery.isFetching, + refundAddressMode, + isReversedAmountSupported, + deposit: createDepositMutation.data ?? null, + depositError: createDepositMutation.error, + isCreatingDeposit: createDepositMutation.isPending, + depositStatus: depositStatus ?? null, + convertedAmount, + depositAmount, + createDeposit, + onReset, + }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-token-state.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-token-state.ts new file mode 100644 index 000000000..c1e20c98e --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-token-state.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useState } from 'react'; +import { validateNumericString } from '@ton/appkit'; +import type { CryptoOnrampDestinationCurrency, CryptoOnrampSourceCurrency } from '@ton/appkit'; + +import type { CryptoAmountInputMode } from './crypto-onramp-context'; + +export const useCryptoOnrampTokenState = () => { + // Selection starts empty and is resolved by the provider once `supportedCurrencies` + // loads (consumer defaults are looked up there by address) — until then the UI shows + // skeletons/placeholders. Selected objects therefore always come from the live list. + const [selectedToken, setSelectedToken] = useState(null); + const [selectedMethod, setSelectedMethod] = useState(null); + const [amount, setAmountRaw] = useState(''); + const [amountInputMode, setAmountInputMode] = useState('method'); + + const amountDecimals = + amountInputMode === 'method' ? (selectedMethod?.decimals ?? 0) : (selectedToken?.decimals ?? 0); + + const setAmount = useCallback( + (value: string) => { + if (value === '' || validateNumericString(value, amountDecimals)) setAmountRaw(value); + }, + [amountDecimals], + ); + + return { + selectedToken, + setSelectedToken, + selectedMethod, + setSelectedMethod, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + amountDecimals, + }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-validation.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-validation.ts new file mode 100644 index 000000000..9ce88fc34 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-provider/use-crypto-onramp-validation.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; +import type { CryptoOnrampDestinationCurrency, CryptoOnrampSourceCurrency } from '@ton/appkit'; + +import { hasTooManyDecimals } from '../../../../../utils/validate-amount'; +import { mapCryptoOnrampError } from '../utils/map-crypto-onramp-error'; +import type { CryptoAmountInputMode } from './crypto-onramp-context'; + +interface UseCryptoOnrampValidationOptions { + amount: string; + amountDebounced: string; + amountInputMode: CryptoAmountInputMode; + selectedMethod: CryptoOnrampSourceCurrency | null; + selectedToken: CryptoOnrampDestinationCurrency | null; + quoteError: Error | null; + depositError: Error | null; + hasQuote: boolean; +} + +interface UseCryptoOnrampValidationResult { + quoteError: string | null; + depositError: string | null; + canSubmit: boolean; +} + +export const useCryptoOnrampValidation = ({ + amount, + amountDebounced, + amountInputMode, + selectedMethod, + selectedToken, + quoteError, + depositError, + hasQuote, +}: UseCryptoOnrampValidationOptions): UseCryptoOnrampValidationResult => { + const decimals = amountInputMode === 'method' ? selectedMethod?.decimals : selectedToken?.decimals; + const tooManyDecimals = hasTooManyDecimals(amount, decimals); + + const mappedQuoteError = useMemo( + () => (amountDebounced && quoteError ? mapCryptoOnrampError(quoteError) : null), + [amountDebounced, quoteError], + ); + + const mappedDepositError = useMemo( + () => (depositError ? mapCryptoOnrampError(depositError) : null), + [depositError], + ); + + const canSubmit = + (parseFloat(amount) || 0) > 0 && + selectedToken !== null && + !tooManyDecimals && + mappedQuoteError === null && + mappedDepositError === null && + hasQuote; + + return { + quoteError: tooManyDecimals ? 'cryptoOnramp.tooManyDecimals' : mappedQuoteError, + depositError: mappedDepositError, + canSubmit, + }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-ui/crypto-onramp-widget-ui.module.css b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-ui/crypto-onramp-widget-ui.module.css new file mode 100644 index 000000000..a53b14546 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-ui/crypto-onramp-widget-ui.module.css @@ -0,0 +1,42 @@ +.widget { + box-sizing: border-box; + width: 100%; + max-width: 370px; + display: flex; + flex-direction: column; +} + +.selectors { + margin-bottom: 24px; +} + +.inputSection { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + height: 152px; + margin-bottom: 24px; +} + +.caption { + composes: bodySemibold from '../../../../../styles/typography.module.css'; + margin: 0; + color: var(--ta-color-text-secondary); + text-align: center; +} + +.info { + margin-top: 16px; +} + +.presets { + margin-bottom: 16px; +} + +.actions { + display: flex; + align-items: stretch; + gap: 8px; +} diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-ui/crypto-onramp-widget-ui.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-ui/crypto-onramp-widget-ui.tsx new file mode 100644 index 000000000..fd8a14fcd --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-ui/crypto-onramp-widget-ui.tsx @@ -0,0 +1,313 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ComponentProps, FC } from 'react'; +import clsx from 'clsx'; + +import { ButtonWithConnect } from '../../../../../components/shared/button-with-connect'; +import { useConnect, useConnectors } from '../../../../wallets'; +import { OnrampTokenSelectors } from '../../../components/onramp-token-selectors'; +import { CenteredAmountInput } from '../../../../../components/ui/centered-amount-input'; +import { AmountPresets } from '../../../../../components/shared/amount-presets'; +import { TokenSelectModal } from '../../../../../components/shared/token-select-modal'; +import { AmountReversed } from '../../../../../components/ui/amount-reversed'; +import { SettingsButton } from '../../../../../components/shared/settings-button'; +import { CryptoMethodSelectModal } from '../crypto-method-select-modal'; +import { CryptoOnrampDepositModal } from '../crypto-onramp-deposit-modal'; +import { CryptoOnrampRefundAddressModal } from '../crypto-onramp-refund-address-modal'; +import { CryptoOnrampSettingsModal } from '../crypto-onramp-settings-modal'; +import { InfoBlock } from '../../../../../components/ui/info-block'; +import type { CryptoOnrampContextType } from '../crypto-onramp-widget-provider'; +import { getChainInfo } from '../utils/chains'; +import { formatOnrampAmount } from '../utils/format-onramp-amount'; +import { useI18n } from '../../../../settings/hooks/use-i18n'; +import styles from './crypto-onramp-widget-ui.module.css'; + +export type CryptoOnrampWidgetRenderProps = CryptoOnrampContextType & + Omit, keyof CryptoOnrampContextType>; + +export const CryptoOnrampWidgetUI: FC = ({ + tokens, + selectedToken, + setSelectedToken, + paymentMethods, + selectedMethod, + setSelectedMethod, + isLoadingSupportedCurrencies, + chains, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + convertedAmount, + presetAmounts, + provider, + providers, + providersMetadata, + isProvidersMetadataLoading, + setProviderId, + quote, + isLoadingQuote, + createDeposit, + isCreatingDeposit, + deposit, + depositAmount, + isWalletConnected, + canContinue, + onReset, + depositStatus, + refundAddressMode, + isReversedAmountSupported, + quoteError, + depositError, + targetBalance, + isLoadingTargetBalance, + className, + ...props +}) => { + const [isTokenSelectOpen, setIsTokenSelectOpen] = useState(false); + const [isMethodSelectOpen, setIsMethodSelectOpen] = useState(false); + const [isRefundAddressOpen, setIsRefundAddressOpen] = useState(false); + const [isDepositOpen, setIsDepositOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [submittedRefundAddress, setSubmittedRefundAddress] = useState(undefined); + + const { t } = useI18n(); + + const connectors = useConnectors(); + const { mutate: connect } = useConnect(); + + // TokenSelectModal generic requires `id` + non-optional `name`; supplement with + // address-derived id and a name fallback so walletkit currencies satisfy the shared type. + const tokensForSelect = useMemo( + () => tokens.map((c) => ({ ...c, id: c.address, name: c.name ?? c.symbol })), + [tokens], + ); + + const providerName = provider ? providersMetadata[provider.providerId]?.name : undefined; + + const handleConnect = useCallback(() => { + if (connectors[0]) connect({ connectorId: connectors[0].id }); + }, [connectors, connect]); + + const handleContinue = useCallback(() => { + if (refundAddressMode === 'off') { + createDeposit(); + return; + } + setIsRefundAddressOpen(true); + }, [refundAddressMode, createDeposit]); + + const handleConfirmRefundAddress = useCallback( + (address: string) => { + setSubmittedRefundAddress(address); + createDeposit(address); + }, + [createDeposit], + ); + + const handleSkipRefundAddress = useCallback(() => { + setSubmittedRefundAddress(undefined); + createDeposit(); + }, [createDeposit]); + + const handleDepositClose = useCallback(() => { + setIsDepositOpen(false); + setSubmittedRefundAddress(undefined); + onReset(); + }, [onReset]); + + const handleRefundAddressClose = useCallback(() => { + setIsRefundAddressOpen(false); + onReset(); + }, [onReset]); + + useEffect(() => { + if (deposit) { + setIsDepositOpen(true); + setIsRefundAddressOpen(false); + } + }, [deposit]); + + const isSelectionIncomplete = !selectedToken || !selectedMethod; + + return ( +
+ setIsTokenSelectOpen(true)} + onToClick={() => setIsMethodSelectOpen(true)} + /> + +
+ + + {isSelectionIncomplete ? ( +

+ {isLoadingSupportedCurrencies ? t('cryptoOnramp.loading') : t('cryptoOnramp.selectToken')} +

+ ) : ( + setAmountInputMode(amountInputMode === 'token' ? 'method' : 'token') + : undefined + } + ticker={amountInputMode === 'token' ? selectedMethod?.symbol : selectedToken?.symbol} + decimals={ + amountInputMode === 'token' + ? (selectedMethod?.decimals ?? 0) + : (selectedToken?.decimals ?? 0) + } + /> + )} +
+ + + +
+ + {quoteError ? t(quoteError) : t('cryptoOnramp.continue')} + + setIsSettingsOpen(true)} /> +
+ + + + {t('cryptoOnramp.youGet')} + + {isLoadingQuote && } + {!isLoadingQuote && isSelectionIncomplete && } + {!isLoadingQuote && !isSelectionIncomplete && ( + + {formatOnrampAmount( + amountInputMode === 'token' ? amount : convertedAmount, + selectedToken?.decimals, + )}{' '} + {selectedToken?.symbol} + + )} + + + {isWalletConnected && ( + + {t('cryptoOnramp.yourBalance')} + + {isLoadingTargetBalance && } + {!isLoadingTargetBalance && !selectedToken && } + {!isLoadingTargetBalance && selectedToken && ( + + {formatOnrampAmount(targetBalance || '0', selectedToken.decimals)}{' '} + {selectedToken.symbol} + + )} + + )} + + + {t('cryptoOnramp.provider')} + {providerName === undefined && isProvidersMetadataLoading ? ( + + ) : ( + {providerName ?? ''} + )} + + + + setIsTokenSelectOpen(false)} + tokens={tokensForSelect} + onSelect={setSelectedToken} + title={t('cryptoOnramp.tokenToBuy')} + searchPlaceholder={t('onramp.searchToken')} + isLoading={isLoadingSupportedCurrencies} + /> + + setIsMethodSelectOpen(false)} + methods={paymentMethods} + chains={chains} + onSelect={setSelectedMethod} + isLoading={isLoadingSupportedCurrencies} + /> + + + + + + setIsSettingsOpen(false)} + provider={provider} + providers={providers} + providersMetadata={providersMetadata} + isProvidersMetadataLoading={isProvidersMetadataLoading} + onProviderChange={setProviderId} + /> +
+ ); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-ui/index.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-ui/index.ts new file mode 100644 index 000000000..96460978c --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget-ui/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CryptoOnrampWidgetUI } from './crypto-onramp-widget-ui'; +export type { CryptoOnrampWidgetRenderProps } from './crypto-onramp-widget-ui'; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/crypto-onramp-widget.module.css b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/crypto-onramp-widget.module.css new file mode 100644 index 000000000..1321f7d89 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/crypto-onramp-widget.module.css @@ -0,0 +1,26 @@ +.widget { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background-color: var(--ta-color-background-secondary); + border-radius: var(--ta-border-radius-xl); +} + +.info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.symbol { + composes: bodyMedium from '../../../../styles/typography.module.css'; + color: var(--ta-color-text); +} + +.amount { + composes: footnoteRegular from '../../../../styles/typography.module.css'; + color: var(--ta-color-text-secondary); +} diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/crypto-onramp-widget.stories.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/crypto-onramp-widget.stories.tsx new file mode 100644 index 000000000..80c647739 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/crypto-onramp-widget.stories.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Caip2ByNetwork } from '@ton/appkit'; + +import type { CryptoOnrampDestinationRef, CryptoOnrampSourceRef } from '../crypto-onramp-widget-provider'; +import { CryptoOnrampWidget } from './crypto-onramp-widget'; + +const USDT_ON_TON: CryptoOnrampDestinationRef = { + address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', +}; + +const USDT0_ON_ARBITRUM: CryptoOnrampSourceRef = { + chain: Caip2ByNetwork.ArbitrumMainnet, + address: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', +}; + +const meta: Meta = { + title: 'Features/Onramp/CryptoOnrampWidget', + component: CryptoOnrampWidget, + tags: ['autodocs'], + argTypes: { + defaultDestination: { + control: 'object', + description: + 'Optional default destination reference (`{ address }`), resolved against the loaded currency list.', + }, + defaultSource: { + control: 'object', + description: + 'Optional default source reference (`{ address, chain? }`), resolved against the loaded currency list.', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * No defaults — while `/supportedCurrencies` is loading the pills show skeletons and the + * amount caption reads "Loading..."; once data arrives the first available token/method + * is auto-picked. The "Select token" empty state only shows when the lists come back empty. + */ +export const Default: Story = { + args: {}, +}; + +/** + * Consumer-supplied default references — once the currency list loads, the matching + * entries are selected instead of the first ones. Unmatched references fall back to + * the first entry. Use the controls panel to tweak the references. + */ +export const WithPresetCurrencies: Story = { + args: { + defaultDestination: USDT_ON_TON, + defaultSource: USDT0_ON_ARBITRUM, + }, +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/crypto-onramp-widget.tsx b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/crypto-onramp-widget.tsx new file mode 100644 index 000000000..75c41e5de --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/crypto-onramp-widget.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { ComponentProps, FC, ReactNode } from 'react'; + +import type { CryptoOnrampWidgetRenderProps } from '../crypto-onramp-widget-ui'; +import { CryptoOnrampWidgetUI } from '../crypto-onramp-widget-ui'; +import { CryptoOnrampWidgetProvider, useCryptoOnrampContext } from '../crypto-onramp-widget-provider'; +import type { CryptoOnrampProviderProps, CryptoOnrampContextType } from '../crypto-onramp-widget-provider'; + +type DivExtras = Omit, 'children' | keyof CryptoOnrampContextType>; + +/** + * Props for the CryptoOnrampWidget component. + * Inherits all configuration from CryptoOnrampProviderProps. + */ +export interface CryptoOnrampWidgetProps extends Omit, DivExtras { + /** + * Custom render function. + * When provided, it replaces the default widget UI and gives full control over the rendering. + * Accesses all state and actions from the crypto onramp context. + */ + children?: (props: CryptoOnrampWidgetRenderProps) => ReactNode; +} + +const CryptoOnrampWidgetContent: FC<{ children?: (props: CryptoOnrampWidgetRenderProps) => ReactNode } & DivExtras> = ({ + children, + ...rest +}) => { + const ctx = useCryptoOnrampContext(); + + if (children) { + return <>{children({ ...ctx, ...rest })}; + } + + return ; +}; + +/** + * A high-level component that provides a complete crypto-to-crypto onramp interface. + * + * It manages payment method selection, quote fetching, deposit creation, and + * deposit status tracking. It can be used as a standalone widget with default UI + * or customized using a render function. + */ +export const CryptoOnrampWidget: FC = ({ + children, + chains, + defaultDestination, + defaultSource, + ...rest +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/index.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/index.ts new file mode 100644 index 000000000..2c710bbac --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/crypto-onramp-widget/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CryptoOnrampWidget } from './crypto-onramp-widget'; +export type { CryptoOnrampWidgetProps } from './crypto-onramp-widget'; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/chains.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/chains.ts new file mode 100644 index 000000000..f0b294950 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/chains.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { Caip2ByNetwork } from '@ton/appkit'; + +/** + * Display info for a CAIP-2 chain — used by the crypto onramp widget to + * render chain names and logos. + */ +export interface ChainInfo { + name: string; + logo?: string; +} + +/** + * Default mapping of CAIP-2 chain identifiers to display info used in the + * crypto onramp widget. Consumers can override or extend this map via the + * `chains` prop on `CryptoOnrampWidgetProvider`. + * + * @see https://chainagnostic.org/CAIPs/caip-2 + */ +export const DEFAULT_CHAINS: Record = { + [Caip2ByNetwork.EthereumMainnet]: { + name: 'Ethereum', + logo: 'https://cdn.layerswap.io/layerswap/networks/ethereum_mainnet.png', + }, + [Caip2ByNetwork.OptimismMainnet]: { + name: 'Optimism', + logo: 'https://cdn.layerswap.io/layerswap/networks/optimism_mainnet.png', + }, + [Caip2ByNetwork.BscMainnet]: { + name: 'BSC', + logo: 'https://cdn.layerswap.io/layerswap/networks/bsc_mainnet.png', + }, + [Caip2ByNetwork.PolygonMainnet]: { + name: 'Polygon', + logo: 'https://cdn.layerswap.io/layerswap/networks/polygon_mainnet.png', + }, + [Caip2ByNetwork.BaseMainnet]: { + name: 'Base', + logo: 'https://cdn.layerswap.io/layerswap/networks/base_mainnet.png', + }, + [Caip2ByNetwork.ArbitrumMainnet]: { + name: 'Arbitrum One', + logo: 'https://cdn.layerswap.io/layerswap/networks/arbitrum_mainnet.png', + }, + [Caip2ByNetwork.AvalancheMainnet]: { + name: 'Avalanche', + logo: 'https://cdn.layerswap.io/layerswap/networks/avax_mainnet.png', + }, + [Caip2ByNetwork.SolanaMainnet]: { + name: 'Solana', + logo: 'https://cdn.layerswap.io/layerswap/networks/solana_mainnet.png', + }, + [Caip2ByNetwork.BitcoinMainnet]: { + name: 'Bitcoin', + logo: 'https://cdn.layerswap.io/layerswap/networks/bitcoin_mainnet.png', + }, + [Caip2ByNetwork.TronMainnet]: { + name: 'Tron', + logo: 'https://cdn.layerswap.io/layerswap/networks/tron_mainnet.png', + }, +}; + +/** + * Resolve display info for a CAIP-2 chain. Falls back to a synthetic info + * object whose `name` is the reference portion of the CAIP-2 string + * (e.g. `eip155:9999` → `9999`), or the raw value if it does not look like + * a CAIP-2 identifier. + */ +export const getChainInfo = (chain: string, chains: Record): ChainInfo => { + const direct = chains[chain]; + if (direct) return direct; + const colonIdx = chain.indexOf(':'); + return { name: colonIdx >= 0 ? chain.slice(colonIdx + 1) : chain }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/format-onramp-amount.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/format-onramp-amount.ts new file mode 100644 index 000000000..38088d386 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/format-onramp-amount.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { formatLargeValue, truncateDecimals } from '@ton/appkit'; + +const MAX_DISPLAY_DECIMALS = 5; + +/** + * Format a token amount for display in the crypto-onramp UI. + * Truncates the fractional part to {@link MAX_DISPLAY_DECIMALS} (or the token's own decimals when + * lower) before passing to {@link formatLargeValue} for grouping. When `decimals` is unknown, + * falls back to `0` so we never invent precision the token may not have. + */ +export const formatOnrampAmount = (amount: string | undefined, decimals?: number): string => { + const trimmed = truncateDecimals(amount || '0', Math.min(MAX_DISPLAY_DECIMALS, decimals ?? 0)); + return formatLargeValue(trimmed, decimals); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/map-crypto-onramp-error.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/map-crypto-onramp-error.ts new file mode 100644 index 000000000..07fa28d87 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/map-crypto-onramp-error.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { CryptoOnrampError, CryptoOnrampErrorCode } from '@ton/appkit'; + +import { mapDefiError } from '../../../../../utils/map-defi-error'; + +/** + * Map a thrown crypto-onramp error to an i18n key. Tries crypto-onramp-specific codes first, + * falls back to the shared {@link mapDefiError} for base DeFi codes, and finally to a generic + * `cryptoOnramp.genericError`. + */ +export const mapCryptoOnrampError = (error: unknown): string => { + if (error instanceof CryptoOnrampError) { + switch (error.code) { + case CryptoOnrampErrorCode.RefundAddressRequired: + return 'cryptoOnramp.refundAddressRequired'; + case CryptoOnrampErrorCode.ReversedAmountNotSupported: + return 'cryptoOnramp.reversedAmountNotSupported'; + case CryptoOnrampErrorCode.UnsupportedSourceChain: + return 'cryptoOnramp.unsupportedSourceChain'; + case CryptoOnrampErrorCode.UnsupportedSourceToken: + return 'cryptoOnramp.unsupportedSourceToken'; + case CryptoOnrampErrorCode.UnsupportedDestinationToken: + return 'cryptoOnramp.unsupportedDestinationToken'; + case CryptoOnrampErrorCode.RouteNotFound: + return 'cryptoOnramp.routeNotFound'; + case CryptoOnrampErrorCode.AmountTooLarge: + return 'cryptoOnramp.amountTooLarge'; + case CryptoOnrampErrorCode.AmountTooSmall: + return 'cryptoOnramp.amountTooSmall'; + case CryptoOnrampErrorCode.InvalidRefundAddress: + return 'cryptoOnramp.invalidRefundAddress'; + case CryptoOnrampErrorCode.QuoteFailed: + return 'cryptoOnramp.quoteError'; + case CryptoOnrampErrorCode.ProviderError: + return 'cryptoOnramp.providerError'; + } + } + + return mapDefiError(error) ?? 'cryptoOnramp.genericError'; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/truncate-address.ts b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/truncate-address.ts new file mode 100644 index 000000000..b66bf1771 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/crypto-onramp/utils/truncate-address.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +const SHORT_ADDRESS_THRESHOLD = 20; +const HEAD_LENGTH = 10; +const TAIL_LENGTH = 8; + +/** + * Shorten a deposit address for display, keeping the first {@link HEAD_LENGTH} and last + * {@link TAIL_LENGTH} characters separated by an ellipsis. Addresses shorter than + * {@link SHORT_ADDRESS_THRESHOLD} are returned unchanged. + */ +export const truncateAddress = (address: string): string => { + if (address.length <= SHORT_ADDRESS_THRESHOLD) return address; + return `${address.slice(0, HEAD_LENGTH)}...${address.slice(-TAIL_LENGTH)}`; +}; diff --git a/packages/appkit-react/src/features/settings/hooks/use-app-kit-theme.ts b/packages/appkit-react/src/features/settings/hooks/use-app-kit-theme.ts index 55d7f3997..917c37684 100644 --- a/packages/appkit-react/src/features/settings/hooks/use-app-kit-theme.ts +++ b/packages/appkit-react/src/features/settings/hooks/use-app-kit-theme.ts @@ -10,7 +10,7 @@ import { useEffect, useState } from 'react'; export type AppKitTheme = 'light' | 'dark' | string; -export function useAppKitTheme() { +export const useAppKitTheme = () => { const [theme, setTheme] = useState('light'); useEffect(() => { @@ -19,4 +19,4 @@ export function useAppKitTheme() { }, [theme]); return [theme, setTheme] as const; -} +}; diff --git a/packages/appkit-react/src/features/settings/hooks/use-app-kit.ts b/packages/appkit-react/src/features/settings/hooks/use-app-kit.ts index 2a56599a7..c08260593 100644 --- a/packages/appkit-react/src/features/settings/hooks/use-app-kit.ts +++ b/packages/appkit-react/src/features/settings/hooks/use-app-kit.ts @@ -10,7 +10,7 @@ import { useContext } from 'react'; import { AppKitContext } from '../../../providers/app-kit-provider'; -export function useAppKit() { +export const useAppKit = () => { const context = useContext(AppKitContext); if (!context) { @@ -18,4 +18,4 @@ export function useAppKit() { } return context; -} +}; diff --git a/packages/appkit-react/src/features/staking/components/staking-confirm-modal/staking-confirm-modal.tsx b/packages/appkit-react/src/features/staking/components/staking-confirm-modal/staking-confirm-modal.tsx index 461bdb393..32bf199ab 100644 --- a/packages/appkit-react/src/features/staking/components/staking-confirm-modal/staking-confirm-modal.tsx +++ b/packages/appkit-react/src/features/staking/components/staking-confirm-modal/staking-confirm-modal.tsx @@ -52,6 +52,7 @@ const toUIToken = ( ): AppkitUIToken | undefined => { if (!token || !network) return undefined; return { + id: `${network.chainId}:${token.address}`, symbol: token.ticker, name: jettonInfo?.name ?? token.ticker, decimals: token.decimals, diff --git a/packages/appkit-react/src/features/staking/components/staking-widget-provider/staking-widget-provider.tsx b/packages/appkit-react/src/features/staking/components/staking-widget-provider/staking-widget-provider.tsx index 1c50351f1..d1c08fe9d 100644 --- a/packages/appkit-react/src/features/staking/components/staking-widget-provider/staking-widget-provider.tsx +++ b/packages/appkit-react/src/features/staking/components/staking-widget-provider/staking-widget-provider.tsx @@ -63,11 +63,11 @@ export interface StakingContextType { /** Staking provider static metadata */ providerMetadata: StakingProviderMetadata | undefined; /** Currently selected staking provider (defaults to the first registered one) */ - stakingProvider: StakingProvider | undefined; + provider: StakingProvider | undefined; /** All registered staking providers */ - stakingProviders: StakingProvider[]; + providers: StakingProvider[]; /** Updates the selected staking provider */ - setStakingProviderId: (providerId: string) => void; + setProviderId: (providerId: string) => void; /** Network the widget is operating on (resolved from prop or wallet) */ network: Network | undefined; /** Current operation direction: 'stake' or 'unstake' */ @@ -122,9 +122,9 @@ export const StakingContext = createContext({ error: null, providerInfo: undefined, providerMetadata: undefined, - stakingProvider: undefined, - stakingProviders: [], - setStakingProviderId: () => {}, + provider: undefined, + providers: [], + setProviderId: () => {}, network: undefined, direction: 'stake', isProviderInfoLoading: false, @@ -180,9 +180,9 @@ export const StakingWidgetProvider: FC = ({ children, netw const address = useAddress(); const appKit = useAppKit(); - const stakingProvider = useStakingProvider(); - const stakingProviders = useStakingProviders(); - const setStakingProviderId = useCallback( + const provider = useStakingProvider(); + const providers = useStakingProviders(); + const setProviderId = useCallback( (providerId: string) => { setDefaultStakingProvider(appKit, { providerId }); }, @@ -190,11 +190,8 @@ export const StakingWidgetProvider: FC = ({ children, netw ); const isNetworkSupported = useMemo( - () => - !stakingProvider || - !network || - stakingProvider.getSupportedNetworks().some((n) => n.chainId === network.chainId), - [stakingProvider, network], + () => !provider || !network || provider.getSupportedNetworks().some((n) => n.chainId === network.chainId), + [provider, network], ); const { data: providerInfo, isLoading: isProviderInfoLoading } = useStakingProviderInfo({ network }); @@ -206,7 +203,7 @@ export const StakingWidgetProvider: FC = ({ children, netw // enough TON to cover network fees before sending. const { data: nativeBalanceData, isLoading: isNativeBalanceLoading } = useBalance({ network, - query: { refetchInterval: 5000 }, + query: { enabled: isNativeTon, refetchInterval: 5000 }, }); const { data: jettonBalanceData, isLoading: isJettonBalanceLoading } = useJettonBalanceByAddress({ @@ -392,9 +389,9 @@ export const StakingWidgetProvider: FC = ({ children, netw error, providerInfo, providerMetadata, - stakingProvider, - stakingProviders, - setStakingProviderId, + provider, + providers, + setProviderId, network, isProviderInfoLoading, balance, @@ -427,9 +424,9 @@ export const StakingWidgetProvider: FC = ({ children, netw error, providerInfo, providerMetadata, - stakingProvider, - stakingProviders, - setStakingProviderId, + provider, + providers, + setProviderId, network, isProviderInfoLoading, balance, diff --git a/packages/appkit-react/src/features/staking/components/staking-widget-ui/staking-widget-ui.tsx b/packages/appkit-react/src/features/staking/components/staking-widget-ui/staking-widget-ui.tsx index f6ff09244..800685dd4 100644 --- a/packages/appkit-react/src/features/staking/components/staking-widget-ui/staking-widget-ui.tsx +++ b/packages/appkit-react/src/features/staking/components/staking-widget-ui/staking-widget-ui.tsx @@ -35,9 +35,9 @@ export const StakingWidgetUI: FC = ({ error, providerInfo, providerMetadata, - stakingProvider, - stakingProviders, - setStakingProviderId, + provider, + providers, + setProviderId, network, isProviderInfoLoading, setAmount, @@ -202,9 +202,9 @@ export const StakingWidgetUI: FC = ({ setIsSettingsOpen(false)} - provider={stakingProvider} - providers={stakingProviders} - onProviderChange={setStakingProviderId} + provider={provider} + providers={providers} + onProviderChange={setProviderId} network={network} /> diff --git a/packages/appkit-react/src/features/swap/components/swap-field/swap-field.tsx b/packages/appkit-react/src/features/swap/components/swap-field/swap-field.tsx index f76c3d86e..5c579deb9 100644 --- a/packages/appkit-react/src/features/swap/components/swap-field/swap-field.tsx +++ b/packages/appkit-react/src/features/swap/components/swap-field/swap-field.tsx @@ -13,7 +13,7 @@ import clsx from 'clsx'; import { useI18n } from '../../../settings/hooks/use-i18n'; import { Input } from '../../../../components/ui/input/input'; import { Skeleton } from '../../../../components/ui/skeleton'; -import { TokenSelector } from '../token-selector/token-selector'; +import { TokenSelector } from '../../../../components/shared/token-selector'; import type { AppkitUIToken } from '../../../../types/appkit-ui-token'; import { getDisplayAmount } from '../../utils/get-display-amount'; import styles from './swap-field.module.css'; @@ -21,6 +21,7 @@ import styles from './swap-field.module.css'; export interface SwapFieldProps extends Omit, 'children'> { type: 'pay' | 'receive'; amount: string; + fiatSymbol?: string; token?: AppkitUIToken; onAmountChange?: (value: string) => void; balance?: string; @@ -29,7 +30,6 @@ export interface SwapFieldProps extends Omit void; onTokenSelectorClick?: () => void; isWalletConnected?: boolean; - fiatSymbol?: string; } export const SwapField: FC = ({ @@ -73,7 +73,7 @@ export const SwapField: FC = ({ disabled={type === 'receive'} /> - + diff --git a/packages/appkit-react/src/features/swap/components/swap-flip-button/swap-flip-button.module.css b/packages/appkit-react/src/features/swap/components/swap-flip-button/swap-flip-button.module.css index 050461239..770fee0fd 100644 --- a/packages/appkit-react/src/features/swap/components/swap-flip-button/swap-flip-button.module.css +++ b/packages/appkit-react/src/features/swap/components/swap-flip-button/swap-flip-button.module.css @@ -6,15 +6,10 @@ cursor: pointer; transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s; box-shadow: var(--ta-shadow-m); - appearance: none; - border: none; - outline: none; - cursor: pointer; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; - text-decoration: none; background-color: var(--ta-color-background-tertiary); color: var(--ta-color-text); } diff --git a/packages/appkit-react/src/features/swap/components/swap-token-select-modal/swap-token-select-modal.module.css b/packages/appkit-react/src/features/swap/components/swap-token-select-modal/swap-token-select-modal.module.css deleted file mode 100644 index fd1da9e6d..000000000 --- a/packages/appkit-react/src/features/swap/components/swap-token-select-modal/swap-token-select-modal.module.css +++ /dev/null @@ -1,67 +0,0 @@ -.searchWrapper { - margin-bottom: 16px; -} - -.list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 16px; - max-height: 400px; - overflow-y: auto; -} - -.item { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - padding: 10px 12px; - border: none; - background: none; - border-radius: var(--ta-border-radius-l); - cursor: pointer; - transition: background-color 0.15s; - text-align: left; -} - -.item:hover { - background: var(--ta-color-background-secondary); -} - -.tokenIcon { - width: 36px; - height: 36px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - flex-shrink: 0; -} - -.tokenInfo { - flex: 1; - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.tokenSymbol { - composes: bodySemibold from "../../../../styles/typography.module.css"; - color: var(--ta-color-text); -} - -.tokenName { - composes: footnoteRegular from "../../../../styles/typography.module.css"; - color: var(--ta-color-text-secondary); -} - -.tokenBalance { - composes: bodyRegular from "../../../../styles/typography.module.css"; - color: var(--ta-color-text-secondary); - flex-shrink: 0; -} diff --git a/packages/appkit-react/src/features/swap/components/swap-widget-provider/swap-widget-provider.tsx b/packages/appkit-react/src/features/swap/components/swap-widget-provider/swap-widget-provider.tsx index 4a9bf0500..ca275d8f5 100644 --- a/packages/appkit-react/src/features/swap/components/swap-widget-provider/swap-widget-provider.tsx +++ b/packages/appkit-react/src/features/swap/components/swap-widget-provider/swap-widget-provider.tsx @@ -27,6 +27,7 @@ import { useSendTransaction } from '../../../transaction/hooks/use-send-transact import { useDebounceValue } from '../../../../hooks/use-debounce-value'; import type { LowBalanceMode } from '../../../../components/shared/low-balance-modal/low-balance-modal'; import type { AppkitUIToken } from '../../../../types/appkit-ui-token'; +import type { TokenSectionConfig } from '../../../../components/shared/token-select-modal'; import { mapSwapWidgetTokens } from '../../utils/map-swap-widget-tokens'; import { useSwapTokenState } from './use-swap-token-state'; import { useSwapBalances } from './use-swap-balances'; @@ -41,6 +42,8 @@ export type { AppkitUIToken }; export interface SwapContextType { /** Full list of available tokens for swapping */ tokens: AppkitUIToken[]; + /** Optional section configs for grouping tokens in the selector */ + tokenSections?: TokenSectionConfig[]; /** Currently selected source token */ fromToken: AppkitUIToken | null; /** Currently selected target token */ @@ -70,11 +73,11 @@ export interface SwapContextType { /** Slippage tolerance in basis points (100 = 1%) */ slippage: number; /** Currently selected swap provider (defaults to the first registered one) */ - swapProvider: SwapProvider | undefined; + provider: SwapProvider | undefined; /** All registered swap providers */ - swapProviders: SwapProvider[]; + providers: SwapProvider[]; /** Updates the selected swap provider */ - setSwapProviderId: (providerId: string) => void; + setProviderId: (providerId: string) => void; /** Updates the source token */ setFromToken: (token: AppkitUIToken) => void; /** Updates the target token */ @@ -105,6 +108,7 @@ export interface SwapContextType { export const SwapContext = createContext({ tokens: [], + tokenSections: undefined, fromToken: null, toToken: null, fromAmount: '', @@ -119,9 +123,9 @@ export const SwapContext = createContext({ isQuoteLoading: false, error: null, slippage: 50, - swapProvider: undefined, - swapProviders: [], - setSwapProviderId: () => {}, + provider: undefined, + providers: [], + setProviderId: () => {}, setFromToken: () => {}, setToToken: () => {}, setFromAmount: () => {}, @@ -141,9 +145,9 @@ export const SwapContext = createContext({ * Hook to access the swap context. * Must be used within a SwapWidgetProvider (or SwapWidget). */ -export function useSwapContext() { +export const useSwapContext = () => { return useContext(SwapContext); -} +}; /** * Props for the SwapWidgetProvider. @@ -151,14 +155,17 @@ export function useSwapContext() { export interface SwapProviderProps extends PropsWithChildren { /** Full list of tokens available for swapping in the UI */ tokens: AppkitUIToken[]; - /** Network to use for quote fetching. When omitted, uses the selected wallet's network. */ - network?: Network; - /** Fiat currency symbol for price display, defaults to "$" */ - fiatSymbol?: string; + /** Optional section configs for grouping tokens in the selector */ + tokenSections?: TokenSectionConfig[]; /** Ticker of the token pre-selected for the source */ defaultFromSymbol?: string; /** Ticker of the token pre-selected for the target */ defaultToSymbol?: string; + /** Initial slippage in basis points (100 = 1%), defaults to 50 (0.5%) */ + /** Network to use for quote fetching. When omitted, uses the selected wallet's network. */ + network?: Network; + /** Fiat currency symbol for price display, defaults to "$" */ + fiatSymbol?: string; /** Initial slippage in basis points (100 = 1%), defaults to 100 (1%) */ defaultSlippage?: number; } @@ -166,6 +173,7 @@ export interface SwapProviderProps extends PropsWithChildren { export const SwapWidgetProvider: FC = ({ children, tokens, + tokenSections, network: networkProp, fiatSymbol = '$', defaultFromSymbol, @@ -194,8 +202,8 @@ export const SwapWidgetProvider: FC = ({ const [fromAmountDebounced] = useDebounceValue(fromAmount, 500); const [pendingSwap, setPendingSwap] = useState(undefined); const address = useAddress(); - const [swapProvider, setSwapProviderId] = useSwapProvider(); - const swapProviders = useSwapProviders(); + const [provider, setProviderId] = useSwapProvider(); + const providers = useSwapProviders(); // Stabilized query inputs — kept next to the query that consumes them. const fromTokenParam = useMemo( @@ -219,9 +227,8 @@ export const SwapWidgetProvider: FC = ({ ); const isNetworkSupported = useMemo( - () => - !swapProvider || !network || swapProvider.getSupportedNetworks().some((n) => n.chainId === network.chainId), - [swapProvider, network], + () => !provider || !network || provider.getSupportedNetworks().some((n) => n.chainId === network.chainId), + [provider, network], ); const { @@ -234,7 +241,7 @@ export const SwapWidgetProvider: FC = ({ amount: fromAmountDebounced, network, slippageBps: slippage, - providerId: swapProvider?.providerId, + providerId: provider?.providerId, query: { enabled: isNetworkSupported, networkMode: 'always', retry: false, gcTime: 0 }, }); // Also show "loading" while the user is still typing (debounce in-flight) so the UI doesn't flash @@ -345,6 +352,7 @@ export const SwapWidgetProvider: FC = ({ const value = useMemo( () => ({ tokens: networkFilteredTokens, + tokenSections, fromToken, toToken, fromAmount, @@ -359,9 +367,9 @@ export const SwapWidgetProvider: FC = ({ isQuoteLoading, error, slippage, - swapProvider, - swapProviders, - setSwapProviderId, + provider, + providers, + setProviderId, setFromToken, setToToken, setFromAmount, @@ -378,6 +386,7 @@ export const SwapWidgetProvider: FC = ({ }), [ networkFilteredTokens, + tokenSections, fromToken, toToken, fromAmount, @@ -392,9 +401,9 @@ export const SwapWidgetProvider: FC = ({ isQuoteLoading, error, slippage, - swapProvider, - swapProviders, - setSwapProviderId, + provider, + providers, + setProviderId, setFromToken, setToToken, setFromAmount, diff --git a/packages/appkit-react/src/features/swap/components/swap-widget-provider/use-swap-balances.ts b/packages/appkit-react/src/features/swap/components/swap-widget-provider/use-swap-balances.ts index 42e77e0df..c1b925bb0 100644 --- a/packages/appkit-react/src/features/swap/components/swap-widget-provider/use-swap-balances.ts +++ b/packages/appkit-react/src/features/swap/components/swap-widget-provider/use-swap-balances.ts @@ -19,7 +19,7 @@ interface UseSwapBalancesOptions { network: Network | undefined; } -export function useSwapBalances({ fromToken, toToken, ownerAddress, network }: UseSwapBalancesOptions) { +export const useSwapBalances = ({ fromToken, toToken, ownerAddress, network }: UseSwapBalancesOptions) => { const isFromNative = fromToken?.address === 'ton'; const isToNative = toToken?.address === 'ton'; @@ -50,4 +50,4 @@ export function useSwapBalances({ fromToken, toToken, ownerAddress, network }: U isFromBalanceLoading: isFromNative ? isTonBalanceLoading : isFromJettonBalanceLoading, isToBalanceLoading: isToNative ? isTonBalanceLoading : isToJettonBalanceLoading, }; -} +}; diff --git a/packages/appkit-react/src/features/swap/components/swap-widget-provider/use-swap-validation.ts b/packages/appkit-react/src/features/swap/components/swap-widget-provider/use-swap-validation.ts index 4d4bc314c..c0578628f 100644 --- a/packages/appkit-react/src/features/swap/components/swap-widget-provider/use-swap-validation.ts +++ b/packages/appkit-react/src/features/swap/components/swap-widget-provider/use-swap-validation.ts @@ -25,7 +25,7 @@ interface UseSwapValidationOptions { isNetworkSupported: boolean; } -export function useSwapValidation({ +export const useSwapValidation = ({ fromAmount, fromAmountDebounced, fromToken, @@ -35,7 +35,7 @@ export function useSwapValidation({ quoteError, sendError, isNetworkSupported, -}: UseSwapValidationOptions) { +}: UseSwapValidationOptions) => { const blockingError: string | null = useMemo(() => { if (!isNetworkSupported) return 'defi.unsupportedNetwork'; @@ -63,4 +63,4 @@ export function useSwapValidation({ quote !== undefined; return { error, canSubmit }; -} +}; diff --git a/packages/appkit-react/src/features/swap/components/swap-widget-ui/swap-widget-ui.module.css b/packages/appkit-react/src/features/swap/components/swap-widget-ui/swap-widget-ui.module.css index 913e61d8a..ac8aa38f4 100644 --- a/packages/appkit-react/src/features/swap/components/swap-widget-ui/swap-widget-ui.module.css +++ b/packages/appkit-react/src/features/swap/components/swap-widget-ui/swap-widget-ui.module.css @@ -7,11 +7,6 @@ gap: 8px; } -.card { - position: relative; - border-radius: var(--ta-border-radius-xl); -} - .fieldsContainer { box-sizing: border-box; width: 100%; diff --git a/packages/appkit-react/src/features/swap/components/swap-widget-ui/swap-widget-ui.tsx b/packages/appkit-react/src/features/swap/components/swap-widget-ui/swap-widget-ui.tsx index eee48dec3..6d4f2707d 100644 --- a/packages/appkit-react/src/features/swap/components/swap-widget-ui/swap-widget-ui.tsx +++ b/packages/appkit-react/src/features/swap/components/swap-widget-ui/swap-widget-ui.tsx @@ -30,6 +30,7 @@ export const SwapWidgetUI: FC = ({ fromToken, toToken, tokens, + tokenSections, fromAmount, toAmount, fiatSymbol, @@ -42,9 +43,9 @@ export const SwapWidgetUI: FC = ({ isQuoteLoading, error, slippage, - swapProvider, - swapProviders, - setSwapProviderId, + provider, + providers, + setProviderId, onFlip, onMaxClick, setFromAmount, @@ -67,6 +68,7 @@ export const SwapWidgetUI: FC = ({ const { t } = useI18n(); const [activeField, setActiveField] = useState<'from' | 'to' | null>(null); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [isFlipped, setIsFlipped] = useState(false); @@ -100,13 +102,13 @@ export const SwapWidgetUI: FC = ({ type="pay" token={fromToken ?? undefined} amount={fromAmount} + fiatSymbol={fiatSymbol} onAmountChange={setFromAmount} balance={fromBalance} isBalanceLoading={isFromBalanceLoading} onMaxClick={onMaxClick} onTokenSelectorClick={() => setActiveField('from')} isWalletConnected={isWalletConnected} - fiatSymbol={fiatSymbol} />
@@ -118,12 +120,12 @@ export const SwapWidgetUI: FC = ({ type="receive" token={toToken ?? undefined} amount={toAmount} + fiatSymbol={fiatSymbol} balance={toBalance} isBalanceLoading={isToBalanceLoading} onTokenSelectorClick={() => setActiveField('to')} loading={isQuoteLoading} isWalletConnected={isWalletConnected} - fiatSymbol={fiatSymbol} />
@@ -131,6 +133,7 @@ export const SwapWidgetUI: FC = ({ open={activeField !== null} onClose={() => setActiveField(null)} tokens={tokens} + tokenSections={tokenSections} onSelect={(token) => { if (activeField === 'from') setFromToken(token); else setToToken(token); @@ -142,9 +145,9 @@ export const SwapWidgetUI: FC = ({ onClose={() => setIsSettingsOpen(false)} slippage={slippage} onSlippageChange={setSlippage} - provider={swapProvider} - providers={swapProviders} - onProviderChange={setSwapProviderId} + provider={provider} + providers={providers} + onProviderChange={setProviderId} /> = ({ toAmount={toAmount} fiatSymbol={fiatSymbol} quote={quote} - swapProvider={swapProvider} + swapProvider={provider} slippage={slippage} isQuoteLoading={isQuoteLoading} /> @@ -185,7 +188,7 @@ export const SwapWidgetUI: FC = ({ = ({ children, tokens, + tokenSections, network, fiatSymbol, defaultFromSymbol, @@ -58,6 +59,7 @@ export const SwapWidget: FC = ({ return ( void; -} - -export const TokenSelector: FC = ({ symbol, icon, onClick }) => { - const { t } = useI18n(); - const label = symbol ?? t('swap.selectToken'); - const fallback = symbol?.[0] ?? '?'; - - return ( - - ); -}; diff --git a/packages/appkit-react/src/features/swap/utils/map-swap-widget-tokens.ts b/packages/appkit-react/src/features/swap/utils/map-swap-widget-tokens.ts index b0744b169..388fec381 100644 --- a/packages/appkit-react/src/features/swap/utils/map-swap-widget-tokens.ts +++ b/packages/appkit-react/src/features/swap/utils/map-swap-widget-tokens.ts @@ -13,7 +13,7 @@ import type { AppkitUIToken } from '../../../types/appkit-ui-token'; export const mapSwapWidgetTokens = (tokens: AppkitUIToken[]): AppkitUIToken[] => { const mapped = tokens.reduce((acc, token) => { if (token.address === 'ton') { - acc.push({ ...token, address: 'ton' }); + acc.push(token); return acc; } diff --git a/packages/appkit-react/src/features/transaction/components/transaction-provider/send-provider.tsx b/packages/appkit-react/src/features/transaction/components/transaction-provider/send-provider.tsx index d3a1be5e6..cd314777e 100644 --- a/packages/appkit-react/src/features/transaction/components/transaction-provider/send-provider.tsx +++ b/packages/appkit-react/src/features/transaction/components/transaction-provider/send-provider.tsx @@ -33,11 +33,11 @@ export const SendContext = createContext({ isLoading: false, }); -export function useSendContext() { +export const useSendContext = () => { const context = useContext(SendContext); return context; -} +}; export interface SendProviderProps extends PropsWithChildren { /** The transaction request parameters */ diff --git a/packages/appkit-react/src/hooks/use-copy.ts b/packages/appkit-react/src/hooks/use-copy.ts new file mode 100644 index 000000000..dbe10e8c9 --- /dev/null +++ b/packages/appkit-react/src/hooks/use-copy.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useRef, useState } from 'react'; + +const COPIED_RESET_MS = 2000; + +/** + * Copy text to the clipboard with a transient `copied` flag that resets after + * {@link COPIED_RESET_MS} milliseconds. Returns the flag and a stable trigger. + */ +export const useCopy = (text: string): [boolean, () => void] => { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + + const copy = useCallback(() => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), COPIED_RESET_MS); + }); + }, [text]); + + return [copied, copy]; +}; diff --git a/packages/appkit-react/src/hooks/use-debounce-callback.ts b/packages/appkit-react/src/hooks/use-debounce-callback.ts index 12e23bea9..e9bc00137 100644 --- a/packages/appkit-react/src/hooks/use-debounce-callback.ts +++ b/packages/appkit-react/src/hooks/use-debounce-callback.ts @@ -25,11 +25,11 @@ export type DebouncedState ReturnType> = (( ControlFunctions; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function useDebounceCallback ReturnType>( +export const useDebounceCallback = ReturnType>( func: T, delay = 500, options?: DebounceOptions, -): DebouncedState { +): DebouncedState => { const debouncedFunc = useRef | null>(null); useUnmount(() => { @@ -66,4 +66,4 @@ export function useDebounceCallback ReturnType> }, [func, delay, options]); return debounced; -} +}; diff --git a/packages/appkit-react/src/index.ts b/packages/appkit-react/src/index.ts index c9b9bc891..6c86c138c 100644 --- a/packages/appkit-react/src/index.ts +++ b/packages/appkit-react/src/index.ts @@ -11,25 +11,33 @@ export { I18nProvider } from './providers/i18n-provider'; export * from '@ton/appkit'; +// UI primitives export * from './components/ui/block'; -export * from './components/ui/info-block'; export * from './components/ui/button'; +export * from './components/ui/centered-amount-input'; +export * from './components/ui/collapsible'; +export * from './components/ui/icons'; +export * from './components/ui/info-block'; +export * from './components/ui/input'; export * from './components/ui/logo'; +export * from './components/ui/logo-with-network'; export * from './components/ui/modal'; +export * from './components/ui/select'; export * from './components/ui/skeleton'; -export * from './components/ui/input'; export * from './components/ui/switch'; -export * from './components/shared/token-select-modal'; export * from './components/ui/tabs'; -export * from './components/ui/centered-amount-input'; + +// Shared composites export * from './components/shared/amount-presets'; -export * from './components/ui/collapsible'; -export * from './components/ui/select'; +export * from './components/shared/button-with-connect'; +export * from './components/shared/copy-button'; +export * from './components/shared/currency-item'; +export * from './components/shared/currency-select-modal'; export * from './components/shared/low-balance-modal'; -export * from './components/shared/settings-button'; export * from './components/shared/option-switcher'; -export * from './components/shared/button-with-connect'; -export * from './components/ui/icons'; +export * from './components/shared/settings-button'; +export * from './components/shared/token-select-modal'; +export * from './components/shared/token-selector'; export * from './features/balances'; export * from './features/jettons'; @@ -41,6 +49,7 @@ export * from './features/settings'; export * from './features/swap'; export * from './features/signing'; export * from './features/staking'; +export * from './features/onramp'; export * from './features/gasless'; export * from './types/appkit-ui-token'; diff --git a/packages/appkit-react/src/libs/query.ts b/packages/appkit-react/src/libs/query.ts index 3a4017316..e027b2236 100644 --- a/packages/appkit-react/src/libs/query.ts +++ b/packages/appkit-react/src/libs/query.ts @@ -38,18 +38,18 @@ export type UseMutationReturnType< } >; -export function useQuery( +export const useQuery = ( parameters: UseQueryParameters & { queryKey: QueryKey; }, -): UseQueryReturnType { +): UseQueryReturnType => { const result = tanstack_useQuery({ ...parameters, // queryKeyHashFn: hashFn, // for bigint support }) as UseQueryReturnType; result.queryKey = parameters.queryKey; return result; -} +}; export type UseQueryParameters< queryFnData = unknown, diff --git a/packages/appkit-react/src/locales/en.ts b/packages/appkit-react/src/locales/en.ts index 973707ae6..055521573 100644 --- a/packages/appkit-react/src/locales/en.ts +++ b/packages/appkit-react/src/locales/en.ts @@ -38,11 +38,22 @@ export default { onSale: 'On Sale', }, + // Shared UI primitives + ui: { + close: 'Close', + settings: 'Settings', + }, + // Token select modal (shared between swap, etc.) tokenSelect: { + loading: 'Loading...', emptyNoMatch: "We didn't find any tokens.", emptyTryAddress: 'Try searching by address.', + emptyUnavailable: 'No tokens available right now.', + emptyTryLater: 'Try again later.', emptyForNetwork: 'No tokens available for the selected network.', + otherTokens: 'Other Tokens', + otherCurrencies: 'Other Currencies', }, // Shared DeFi error messages (rendered by `mapDefiError`) @@ -71,7 +82,7 @@ export default { buildTxFailed: "Couldn't build the swap transaction", selectToken: 'Select Token', searchToken: 'Search...', - settings: 'Swap settings', + settings: 'Settings', slippage: 'Slippage', slippageError: 'The maximum slippage tolerance cannot be more than 50%. The recommended range is 1%', slippageWarning: 'High slippage tolerance increases the risk of an unfavorable trade', @@ -99,6 +110,82 @@ export default { close: 'Close', }, + // Crypto Onramp + cryptoOnramp: { + depositModalTitle: 'Crypto deposit', + youNeedToSend: 'You need to send', + toThisAddress: 'To this address', + refundAddress: 'Refund address', + memoTag: 'Memo / Tag', + copyAmount: 'Copy amount', + copyAddress: 'Copy address', + copyRefundAddress: 'Copy refund address', + copyMemo: 'Copy memo', + chainWarning: + 'This address only accepts {{ symbol }} on the {{ network }} network. Sending other assets will result in loss.', + transactionDetails: 'Transaction details', + deposit: 'Deposit', + continue: 'Continue', + methodOfPurchase: 'Method of purchase', + tokenToBuy: 'Token to buy', + method: 'Method', + buyToken: 'Buy token', + forToken: 'for token', + selectToken: 'Select token', + loading: 'Loading...', + allNetworks: 'All networks', + selectMethod: 'Select payment method', + searchMethod: 'Search', + quoteError: 'Failed to get a quote', + tooManyDecimals: 'Too many decimals', + providerError: 'Provider error', + genericError: 'Something went wrong', + addressTab: 'Address', + memoTab: 'Memo', + youGet: 'You get', + exchangeRate: 'Exchange rate', + provider: 'Provider', + refundAddressModalTitle: 'Refund address', + refundAddressLabel: + 'Enter the address on the source network where the funds will be returned in case of a problem with the exchange', + refundAddressRequired: 'Refund address is required', + skipRefundAddress: 'Skip', + reversedAmountNotSupported: 'This provider only supports entering the source amount', + unsupportedSourceChain: 'Unsupported chain', + unsupportedSourceToken: 'Unsupported source token', + unsupportedDestinationToken: 'Unsupported destination token', + routeNotFound: 'No route available for this pair', + amountTooLarge: 'Amount is too high', + amountTooSmall: 'Amount is too low', + invalidRefundAddress: 'Invalid refund address', + refundAddressPlaceholder: 'Your address', + yourBalance: 'Your balance', + statusPending: 'Waiting for your transfer', + statusSuccess: 'Transfer completed', + statusFailed: 'Transfer failed', + done: 'Done', + close: 'Close', + settings: 'Settings', + save: 'Save', + }, + + // Onramp + onramp: { + continue: 'Continue', + selectToken: 'Token to buy', + searchToken: 'Search tokens', + selectCurrency: 'Select currency', + searchCurrency: 'Search currencies', + checkout: 'Checkout', + buyToken: 'Buy {{ symbol }}', + forCurrency: 'for {{ symbol }}', + noQuotesFound: 'No quotes found', + connectWallet: 'Connect a wallet to continue', + tonPayError: 'Failed to start TonPay checkout', + youGet: 'You get', + exchangeRate: 'Exchange rate', + }, + // Staking staking: { stake: 'Stake', @@ -123,7 +210,7 @@ export default { whenAvailableLimit: 'No limits', yourBalance: 'Your balance', provider: 'Provider', - settings: 'Staking settings', + settings: 'Settings', save: 'Save', confirmStakingTitle: 'Confirm staking', confirmUnstakingTitle: 'Confirm unstaking', diff --git a/packages/appkit-react/src/providers/app-kit-provider.tsx b/packages/appkit-react/src/providers/app-kit-provider.tsx index be5c9d0a3..82d859195 100644 --- a/packages/appkit-react/src/providers/app-kit-provider.tsx +++ b/packages/appkit-react/src/providers/app-kit-provider.tsx @@ -6,7 +6,7 @@ * */ -import type { PropsWithChildren } from 'react'; +import type { PropsWithChildren, FC } from 'react'; import { createContext } from 'react'; import type { AppKit } from '@ton/appkit'; @@ -19,7 +19,7 @@ export interface AppKitProviderProps extends PropsWithChildren { appKit: AppKit; } -export function AppKitProvider({ appKit, children }: AppKitProviderProps) { +export const AppKitProvider: FC = ({ appKit, children }) => { return ( @@ -27,4 +27,4 @@ export function AppKitProvider({ appKit, children }: AppKitProviderProps) { ); -} +}; diff --git a/packages/appkit-react/src/storybook/fixtures/tokens.ts b/packages/appkit-react/src/storybook/fixtures/tokens.ts index 860b69100..043d38322 100644 --- a/packages/appkit-react/src/storybook/fixtures/tokens.ts +++ b/packages/appkit-react/src/storybook/fixtures/tokens.ts @@ -12,6 +12,7 @@ import type { AppkitUIToken } from '../../types/appkit-ui-token'; export const STORY_TOKENS: AppkitUIToken[] = [ { + id: 'ton', symbol: 'TON', name: 'Toncoin', decimals: 9, @@ -20,7 +21,8 @@ export const STORY_TOKENS: AppkitUIToken[] = [ logo: './tokens/ton.png', }, { - symbol: 'USD₮', + id: 'usdt', + symbol: 'USDT', name: 'Tether USD', decimals: 6, address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', @@ -29,6 +31,7 @@ export const STORY_TOKENS: AppkitUIToken[] = [ logo: `./tokens/usdt.png`, }, { + id: 'ston', symbol: 'STON', name: 'STON', decimals: 9, @@ -37,6 +40,7 @@ export const STORY_TOKENS: AppkitUIToken[] = [ logo: './tokens/ston.png', }, { + id: 'xaut', symbol: 'XAUt0', name: 'Tether Gold', decimals: 6, @@ -45,6 +49,7 @@ export const STORY_TOKENS: AppkitUIToken[] = [ logo: './tokens/xaut0.png', }, { + id: 'usde', symbol: 'USDe', name: 'Ethena USDe', decimals: 6, @@ -54,6 +59,7 @@ export const STORY_TOKENS: AppkitUIToken[] = [ logo: './tokens/usde.png', }, { + id: 'tston', symbol: 'tsTON', name: 'Tonstakers TON', decimals: 9, @@ -62,6 +68,7 @@ export const STORY_TOKENS: AppkitUIToken[] = [ logo: './tokens/tston.svg', }, { + id: 'gemston', symbol: 'GEMSTON', name: 'GEMSTON', decimals: 9, @@ -70,6 +77,7 @@ export const STORY_TOKENS: AppkitUIToken[] = [ logo: './tokens/gemston.png', }, { + id: 'utya', symbol: 'UTYA', name: 'Utya', decimals: 9, @@ -78,6 +86,7 @@ export const STORY_TOKENS: AppkitUIToken[] = [ logo: './tokens/utya.png', }, { + id: 'weth', symbol: 'WETH', name: 'Wrapped Ether', decimals: 18, diff --git a/packages/appkit-react/src/types/appkit-ui-token.ts b/packages/appkit-react/src/types/appkit-ui-token.ts index cfb1fcd63..2b866109b 100644 --- a/packages/appkit-react/src/types/appkit-ui-token.ts +++ b/packages/appkit-react/src/types/appkit-ui-token.ts @@ -9,6 +9,8 @@ import type { Network } from '@ton/appkit'; export interface AppkitUIToken { + /** Unique identifier for the token, used for section grouping */ + id: string; /** Token symbol, e.g. "TON" */ symbol: string; /** Full token name, e.g. "Toncoin" */ diff --git a/packages/appkit/docs/actions.md b/packages/appkit/docs/actions.md index 0219836a9..93b69dd72 100644 --- a/packages/appkit/docs/actions.md +++ b/packages/appkit/docs/actions.md @@ -429,6 +429,44 @@ const result = await transferNft(appKit, { console.log('NFT Transfer Result:', result); ``` +## Crypto Onramp + +### `getCryptoOnrampProvider` + +Get a registered crypto-onramp provider by id, or the default one when no id is given. + +```ts +const provider = getCryptoOnrampProvider(appKit, { id: 'layerswap' }); +console.log('Crypto onramp provider:', provider.providerId); +``` + +### `getCryptoOnrampProviders` + +Get all registered crypto-onramp providers. + +```ts +const providers = getCryptoOnrampProviders(appKit); +console.log( + 'Registered crypto onramp providers:', + providers.map((p) => p.providerId), +); +``` + +### `getCryptoOnrampProviderMetadata` + +Get static metadata for a crypto-onramp provider (display name, logo, url). + +```ts +const metadata = await getCryptoOnrampProviderMetadata(appKit, { + providerId: 'layerswap', +}); +console.log('Crypto onramp provider metadata:', metadata); +``` + +### `watchCryptoOnrampProviders` + +Watch for new crypto-onramp providers registration and default-provider changes. + ## Providers ### `registerProvider` diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 6fbebc4f8..c0ee9678c 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -61,6 +61,26 @@ "default": "./dist/cjs/staking/tonstakers/index.js" } }, + "./crypto-onramp/decent": { + "import": { + "types": "./dist/esm/crypto-onramp/decent/index.d.ts", + "default": "./dist/esm/crypto-onramp/decent/index.js" + }, + "require": { + "types": "./dist/cjs/crypto-onramp/decent/index.d.ts", + "default": "./dist/cjs/crypto-onramp/decent/index.js" + } + }, + "./crypto-onramp/layerswap": { + "import": { + "types": "./dist/esm/crypto-onramp/layerswap/index.d.ts", + "default": "./dist/esm/crypto-onramp/layerswap/index.js" + }, + "require": { + "types": "./dist/cjs/crypto-onramp/layerswap/index.d.ts", + "default": "./dist/cjs/crypto-onramp/layerswap/index.js" + } + }, "./gasless/tonapi": { "import": { "types": "./dist/esm/gasless/tonapi/index.d.ts", @@ -86,6 +106,12 @@ "staking/tonstakers": [ "./dist/esm/staking/tonstakers/index.d.ts" ], + "crypto-onramp/decent": [ + "./dist/esm/crypto-onramp/decent/index.d.ts" + ], + "crypto-onramp/layerswap": [ + "./dist/esm/crypto-onramp/layerswap/index.d.ts" + ], "gasless/tonapi": [ "./dist/esm/gasless/tonapi/index.d.ts" ] diff --git a/packages/appkit/src/actions/crypto-onramp/create-crypto-onramp-deposit.ts b/packages/appkit/src/actions/crypto-onramp/create-crypto-onramp-deposit.ts new file mode 100644 index 000000000..8796a42b4 --- /dev/null +++ b/packages/appkit/src/actions/crypto-onramp/create-crypto-onramp-deposit.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampDeposit, CryptoOnrampDepositParams } from '../../crypto-onramp'; +import type { AppKit } from '../../core/app-kit'; + +export type CreateCryptoOnrampDepositOptions = CryptoOnrampDepositParams & { + providerId?: string; +}; + +export type CreateCryptoOnrampDepositReturnType = Promise; + +/** + * Create a crypto onramp deposit from a previously obtained quote + */ +export const createCryptoOnrampDeposit = async ( + appKit: AppKit, + options: CreateCryptoOnrampDepositOptions, +): CreateCryptoOnrampDepositReturnType => { + return appKit.cryptoOnrampManager.createDeposit(options, options.providerId); +}; diff --git a/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-provider-metadata.ts b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-provider-metadata.ts new file mode 100644 index 000000000..83086a935 --- /dev/null +++ b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-provider-metadata.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampProviderMetadata } from '../../crypto-onramp'; +import type { AppKit } from '../../core/app-kit'; + +export interface GetCryptoOnrampProviderMetadataOptions { + providerId?: string; +} + +export type GetCryptoOnrampProviderMetadataReturnType = Promise; + +/** + * Get static metadata for a crypto-onramp provider (display name, logo, url). + */ +export const getCryptoOnrampProviderMetadata = async ( + appKit: AppKit, + options: GetCryptoOnrampProviderMetadataOptions = {}, +): GetCryptoOnrampProviderMetadataReturnType => { + return appKit.cryptoOnrampManager.getMetadata(options.providerId); +}; diff --git a/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-provider.ts b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-provider.ts new file mode 100644 index 000000000..47cf0a91f --- /dev/null +++ b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-provider.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampProviderInterface } from '@ton/walletkit'; + +import type { AppKit } from '../../core/app-kit'; + +export interface GetCryptoOnrampProviderOptions { + id?: string; +} + +export type GetCryptoOnrampProviderReturnType = CryptoOnrampProviderInterface; + +/** + * Get a registered crypto-onramp provider by id, or the default one when no id is given. + */ +export const getCryptoOnrampProvider = ( + appKit: AppKit, + options: GetCryptoOnrampProviderOptions = {}, +): GetCryptoOnrampProviderReturnType => { + return appKit.cryptoOnrampManager.getProvider(options.id); +}; diff --git a/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-providers.ts b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-providers.ts new file mode 100644 index 000000000..ea4add53b --- /dev/null +++ b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-providers.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampProviderInterface } from '@ton/walletkit'; + +import type { AppKit } from '../../core/app-kit'; + +export type GetCryptoOnrampProvidersReturnType = CryptoOnrampProviderInterface[]; + +/** + * Get all registered crypto-onramp providers. + */ +export const getCryptoOnrampProviders = (appKit: AppKit): GetCryptoOnrampProvidersReturnType => { + return appKit.cryptoOnrampManager.getProviders(); +}; diff --git a/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-quote.ts b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-quote.ts new file mode 100644 index 000000000..32fee07da --- /dev/null +++ b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-quote.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampQuote, CryptoOnrampQuoteParams } from '../../crypto-onramp'; +import type { AppKit } from '../../core/app-kit'; + +export type GetCryptoOnrampQuoteOptions = CryptoOnrampQuoteParams & { + providerId?: string; +}; + +export type GetCryptoOnrampQuoteReturnType = Promise; + +/** + * Get a crypto onramp quote + */ +export const getCryptoOnrampQuote = async ( + appKit: AppKit, + options: GetCryptoOnrampQuoteOptions, +): GetCryptoOnrampQuoteReturnType => { + return appKit.cryptoOnrampManager.getQuote(options, options.providerId); +}; diff --git a/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-status.ts b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-status.ts new file mode 100644 index 000000000..7be56078d --- /dev/null +++ b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-status.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampStatus, CryptoOnrampStatusParams } from '../../crypto-onramp'; +import type { AppKit } from '../../core/app-kit'; + +export type GetCryptoOnrampStatusOptions = CryptoOnrampStatusParams & { + providerId?: string; +}; + +export type GetCryptoOnrampStatusReturnType = Promise; + +/** + * Get a crypto onramp quote + */ +export const getCryptoOnrampStatus = async ( + appKit: AppKit, + options: GetCryptoOnrampStatusOptions, +): GetCryptoOnrampStatusReturnType => { + return appKit.cryptoOnrampManager.getStatus(options, options.providerId); +}; diff --git a/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-supported-currencies.ts b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-supported-currencies.ts new file mode 100644 index 000000000..d53dfdd74 --- /dev/null +++ b/packages/appkit/src/actions/crypto-onramp/get-crypto-onramp-supported-currencies.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampSupportedCurrencies } from '../../crypto-onramp'; +import type { AppKit } from '../../core/app-kit'; + +export interface GetCryptoOnrampSupportedCurrenciesOptions { + providerId?: string; +} + +export type GetCryptoOnrampSupportedCurrenciesReturnType = Promise; + +/** + * Discover supported source/destination currencies for a crypto-onramp provider. + */ +export const getCryptoOnrampSupportedCurrencies = async ( + appKit: AppKit, + options: GetCryptoOnrampSupportedCurrenciesOptions = {}, +): GetCryptoOnrampSupportedCurrenciesReturnType => { + return appKit.cryptoOnrampManager.getSupportedCurrencies(options.providerId); +}; diff --git a/packages/appkit/src/actions/crypto-onramp/set-default-crypto-onramp-provider.ts b/packages/appkit/src/actions/crypto-onramp/set-default-crypto-onramp-provider.ts new file mode 100644 index 000000000..19f6e4122 --- /dev/null +++ b/packages/appkit/src/actions/crypto-onramp/set-default-crypto-onramp-provider.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { AppKit } from '../../core/app-kit'; + +export interface SetDefaultCryptoOnrampProviderParameters { + providerId: string; +} + +export type SetDefaultCryptoOnrampProviderReturnType = void; + +/** + * Set the default crypto-onramp provider. + * Subsequent quote, deposit and status calls will use this provider when none is specified. + */ +export const setDefaultCryptoOnrampProvider = ( + appKit: AppKit, + parameters: SetDefaultCryptoOnrampProviderParameters, +): SetDefaultCryptoOnrampProviderReturnType => { + appKit.cryptoOnrampManager.setDefaultProvider(parameters.providerId); +}; diff --git a/packages/appkit/src/actions/crypto-onramp/watch-crypto-onramp-providers.ts b/packages/appkit/src/actions/crypto-onramp/watch-crypto-onramp-providers.ts new file mode 100644 index 000000000..dea15b8eb --- /dev/null +++ b/packages/appkit/src/actions/crypto-onramp/watch-crypto-onramp-providers.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { AppKit } from '../../core/app-kit'; + +export interface WatchCryptoOnrampProvidersParameters { + onChange: () => void; +} + +export type WatchCryptoOnrampProvidersReturnType = () => void; + +/** + * Watch for new crypto-onramp providers registration and default-provider changes. + */ +export const watchCryptoOnrampProviders = ( + appKit: AppKit, + parameters: WatchCryptoOnrampProvidersParameters, +): WatchCryptoOnrampProvidersReturnType => { + const { onChange } = parameters; + + const unsubscribeRegistered = appKit.emitter.on('provider:registered', (event) => { + if (event.payload.type === 'crypto-onramp') onChange(); + }); + + const unsubscribeDefaultChanged = appKit.emitter.on('provider:default-changed', (event) => { + if (event.payload.type === 'crypto-onramp') onChange(); + }); + + return () => { + unsubscribeRegistered(); + unsubscribeDefaultChanged(); + }; +}; diff --git a/packages/appkit/src/actions/index.ts b/packages/appkit/src/actions/index.ts index 89cd07c40..20fa54f0a 100644 --- a/packages/appkit/src/actions/index.ts +++ b/packages/appkit/src/actions/index.ts @@ -41,6 +41,52 @@ export { type WatchConnectorByIdReturnType, } from './connectors/watch-connector-by-id'; +// Crypto onramp +export { + getCryptoOnrampProvider, + type GetCryptoOnrampProviderOptions, + type GetCryptoOnrampProviderReturnType, +} from './crypto-onramp/get-crypto-onramp-provider'; +export { + getCryptoOnrampProviders, + type GetCryptoOnrampProvidersReturnType, +} from './crypto-onramp/get-crypto-onramp-providers'; +export { + watchCryptoOnrampProviders, + type WatchCryptoOnrampProvidersParameters, + type WatchCryptoOnrampProvidersReturnType, +} from './crypto-onramp/watch-crypto-onramp-providers'; +export { + getCryptoOnrampQuote, + type GetCryptoOnrampQuoteOptions, + type GetCryptoOnrampQuoteReturnType, +} from './crypto-onramp/get-crypto-onramp-quote'; +export { + createCryptoOnrampDeposit, + type CreateCryptoOnrampDepositOptions, + type CreateCryptoOnrampDepositReturnType, +} from './crypto-onramp/create-crypto-onramp-deposit'; +export { + getCryptoOnrampStatus, + type GetCryptoOnrampStatusOptions, + type GetCryptoOnrampStatusReturnType, +} from './crypto-onramp/get-crypto-onramp-status'; +export { + setDefaultCryptoOnrampProvider, + type SetDefaultCryptoOnrampProviderParameters, + type SetDefaultCryptoOnrampProviderReturnType, +} from './crypto-onramp/set-default-crypto-onramp-provider'; +export { + getCryptoOnrampSupportedCurrencies, + type GetCryptoOnrampSupportedCurrenciesOptions, + type GetCryptoOnrampSupportedCurrenciesReturnType, +} from './crypto-onramp/get-crypto-onramp-supported-currencies'; +export { + getCryptoOnrampProviderMetadata, + type GetCryptoOnrampProviderMetadataOptions, + type GetCryptoOnrampProviderMetadataReturnType, +} from './crypto-onramp/get-crypto-onramp-provider-metadata'; + // Jettons export { getJettonInfo, type GetJettonInfoOptions, type GetJettonInfoReturnType } from './jettons/get-jetton-info'; export { diff --git a/packages/appkit/src/connectors/tonconnect/connectors/ton-connect-connector.ts b/packages/appkit/src/connectors/tonconnect/connectors/ton-connect-connector.ts index 731cf9278..888ee7d38 100644 --- a/packages/appkit/src/connectors/tonconnect/connectors/ton-connect-connector.ts +++ b/packages/appkit/src/connectors/tonconnect/connectors/ton-connect-connector.ts @@ -66,7 +66,7 @@ export const createTonConnectConnector = (config: TonConnectConnectorConfig) => return originalTonConnectUI; }; - function getConnectedWallets(): WalletInterface[] { + const getConnectedWallets = (): WalletInterface[] => { const ui = getTonConnectUI(); if (ui && ui.connected && ui.wallet) { @@ -82,9 +82,9 @@ export const createTonConnectConnector = (config: TonConnectConnectorConfig) => } return []; - } + }; - function setupListeners() { + const setupListeners = (): void => { if (!originalTonConnectUI || unsubscribeTonConnect) { return; } @@ -104,7 +104,7 @@ export const createTonConnectConnector = (config: TonConnectConnectorConfig) => originalTonConnectUI.setConnectionNetwork(payload.network?.chainId); } }); - } + }; return { id, diff --git a/packages/appkit/src/connectors/tonconnect/utils/transaction.ts b/packages/appkit/src/connectors/tonconnect/utils/transaction.ts index 23f69bd9b..2d6d10069 100644 --- a/packages/appkit/src/connectors/tonconnect/utils/transaction.ts +++ b/packages/appkit/src/connectors/tonconnect/utils/transaction.ts @@ -18,33 +18,35 @@ export const DEFAULT_TRANSACTION_VALIDITY_SECONDS = 300; /** * Convert TonWalletKit TransactionRequest to TonConnect SendTransactionRequest format */ -export function toTonConnectTransaction(request: TransactionRequest): SendTransactionRequest { +export const toTonConnectTransaction = (request: TransactionRequest): SendTransactionRequest => { return { validUntil: request.validUntil ?? Math.floor(Date.now() / 1000) + DEFAULT_TRANSACTION_VALIDITY_SECONDS, messages: request.messages.map(toTonConnectMessage), }; -} +}; /** * Convert a single TransactionRequestMessage to TonConnect message format */ -export function toTonConnectMessage(msg: TransactionRequestMessage): { +export const toTonConnectMessage = ( + msg: TransactionRequestMessage, +): { address: string; amount: string; payload?: string; stateInit?: string; -} { +} => { return { address: msg.address, amount: String(msg.amount), payload: msg.payload, stateInit: msg.stateInit, }; -} +}; /** * Get current timestamp plus validity duration */ -export function getValidUntil(validitySeconds = DEFAULT_TRANSACTION_VALIDITY_SECONDS): number { +export const getValidUntil = (validitySeconds = DEFAULT_TRANSACTION_VALIDITY_SECONDS): number => { return Math.floor(Date.now() / 1000) + validitySeconds; -} +}; diff --git a/packages/appkit/src/core/app-kit/services/app-kit.ts b/packages/appkit/src/core/app-kit/services/app-kit.ts index 8c0048ee0..bf778a595 100644 --- a/packages/appkit/src/core/app-kit/services/app-kit.ts +++ b/packages/appkit/src/core/app-kit/services/app-kit.ts @@ -6,8 +6,14 @@ * */ -import { SwapManager, StreamingManager } from '@ton/walletkit'; -import type { ProviderInput, SwapProviderInterface, StakingProviderInterface, StreamingProvider } from '@ton/walletkit'; +import { SwapManager, StreamingManager, CryptoOnrampManager } from '@ton/walletkit'; +import type { + ProviderInput, + SwapProviderInterface, + StakingProviderInterface, + CryptoOnrampProviderInterface, + StreamingProvider, +} from '@ton/walletkit'; import type { AppKitConfig } from '../types/config'; import { CONNECTOR_EVENTS, WALLETS_EVENTS } from '../constants/events'; @@ -35,6 +41,7 @@ export class AppKit { readonly walletsManager: WalletsManager; readonly swapManager: SwapManager; readonly stakingManager: StakingManager; + readonly cryptoOnrampManager: CryptoOnrampManager; readonly gaslessManager: GaslessManager; readonly networkManager: AppKitNetworkManager; @@ -59,6 +66,7 @@ export class AppKit { this.swapManager = new SwapManager(() => this.createFactoryContext()); this.stakingManager = new StakingManager(() => this.createFactoryContext()); + this.cryptoOnrampManager = new CryptoOnrampManager(() => this.createFactoryContext()); this.gaslessManager = new GaslessManager(() => this.createFactoryContext()); this.streamingManager = new StreamingManager(() => this.createFactoryContext()); @@ -127,6 +135,9 @@ export class AppKit { case 'staking': this.stakingManager.registerProvider(provider as StakingProviderInterface); break; + case 'crypto-onramp': + this.cryptoOnrampManager.registerProvider(provider as CryptoOnrampProviderInterface); + break; case 'streaming': this.streamingManager.registerProvider(provider as StreamingProvider); break; diff --git a/packages/appkit/src/crypto-onramp/decent/index.ts b/packages/appkit/src/crypto-onramp/decent/index.ts new file mode 100644 index 000000000..674dab000 --- /dev/null +++ b/packages/appkit/src/crypto-onramp/decent/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from '@ton/walletkit/crypto-onramp/decent'; diff --git a/packages/appkit/src/crypto-onramp/index.ts b/packages/appkit/src/crypto-onramp/index.ts new file mode 100644 index 000000000..dd4926bca --- /dev/null +++ b/packages/appkit/src/crypto-onramp/index.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { + CryptoOnrampProvider, + CryptoOnrampManager, + CryptoOnrampError, + CryptoOnrampErrorCode, + Caip2ByNetwork, +} from '@ton/walletkit'; + +export type { + CryptoOnrampAPI, + CryptoOnrampProviderInterface, + CryptoOnrampProviderMetadata, + CryptoOnrampProviderMetadataOverride, + CryptoOnrampQuote, + CryptoOnrampQuoteParams, + CryptoOnrampDeposit, + CryptoOnrampDepositParams, + CryptoOnrampStatus, + CryptoOnrampStatusParams, + CryptoOnrampSourceCurrency, + CryptoOnrampDestinationCurrency, + CryptoOnrampSupportedCurrencies, +} from '@ton/walletkit'; diff --git a/packages/appkit/src/crypto-onramp/layerswap/index.ts b/packages/appkit/src/crypto-onramp/layerswap/index.ts new file mode 100644 index 000000000..8894baf33 --- /dev/null +++ b/packages/appkit/src/crypto-onramp/layerswap/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from '@ton/walletkit/crypto-onramp/layerswap'; diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 855cc962d..b3a014be1 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -35,6 +35,7 @@ export * from './connectors/tonconnect'; export * from './swap'; export * from './staking'; +export * from './crypto-onramp'; export * from './gasless'; // Actions diff --git a/packages/appkit/src/queries/crypto-onramp/create-crypto-onramp-deposit.ts b/packages/appkit/src/queries/crypto-onramp/create-crypto-onramp-deposit.ts new file mode 100644 index 000000000..df12493a8 --- /dev/null +++ b/packages/appkit/src/queries/crypto-onramp/create-crypto-onramp-deposit.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { MutationOptions } from '@tanstack/query-core'; + +import type { AppKit } from '../../core/app-kit'; +import { createCryptoOnrampDeposit } from '../../actions/crypto-onramp/create-crypto-onramp-deposit'; +import type { + CreateCryptoOnrampDepositOptions, + CreateCryptoOnrampDepositReturnType, +} from '../../actions/crypto-onramp/create-crypto-onramp-deposit'; +import type { MutationParameter } from '../../types/query'; + +export type CreateCryptoOnrampDepositErrorType = Error; +export type CreateCryptoOnrampDepositData = Awaited; +export type CreateCryptoOnrampDepositVariables = CreateCryptoOnrampDepositOptions; +export type CreateCryptoOnrampDepositMutationOptions = MutationParameter< + CreateCryptoOnrampDepositData, + CreateCryptoOnrampDepositErrorType, + CreateCryptoOnrampDepositVariables, + context +>; + +export type CreateCryptoOnrampDepositMutationConfig = MutationOptions< + CreateCryptoOnrampDepositData, + CreateCryptoOnrampDepositErrorType, + CreateCryptoOnrampDepositVariables, + context +>; + +export const createCryptoOnrampDepositMutationOptions = ( + appKit: AppKit, +): CreateCryptoOnrampDepositMutationConfig => ({ + mutationFn: (variables: CreateCryptoOnrampDepositVariables) => createCryptoOnrampDeposit(appKit, variables), +}); diff --git a/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-provider-metadata.ts b/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-provider-metadata.ts new file mode 100644 index 000000000..baf305d72 --- /dev/null +++ b/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-provider-metadata.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { AppKit } from '../../core/app-kit'; +import { getCryptoOnrampProviderMetadata } from '../../actions/crypto-onramp/get-crypto-onramp-provider-metadata'; +import type { + GetCryptoOnrampProviderMetadataOptions, + GetCryptoOnrampProviderMetadataReturnType, +} from '../../actions/crypto-onramp/get-crypto-onramp-provider-metadata'; +import type { QueryOptions, QueryParameter } from '../../types/query'; +import type { Compute, ExactPartial } from '../../types/utils'; +import { filterQueryOptions } from '../../utils'; + +export type GetCryptoOnrampProviderMetadataErrorType = Error; +export type GetCryptoOnrampProviderMetadataData = GetCryptoOnrampProviderMetadataQueryFnData; +export type GetCryptoOnrampProviderMetadataQueryConfig = Compute< + ExactPartial +> & + QueryParameter< + GetCryptoOnrampProviderMetadataQueryFnData, + GetCryptoOnrampProviderMetadataErrorType, + selectData, + GetCryptoOnrampProviderMetadataQueryKey + >; + +export const getCryptoOnrampProviderMetadataQueryOptions = ( + appKit: AppKit, + options: GetCryptoOnrampProviderMetadataQueryConfig = {}, +): GetCryptoOnrampProviderMetadataQueryOptions => { + return { + ...options.query, + queryFn: async (context) => { + const [, parameters] = context.queryKey as [string, GetCryptoOnrampProviderMetadataOptions]; + return getCryptoOnrampProviderMetadata(appKit, parameters); + }, + queryKey: getCryptoOnrampProviderMetadataQueryKey(options), + }; +}; + +export type GetCryptoOnrampProviderMetadataQueryFnData = Compute>; + +export const getCryptoOnrampProviderMetadataQueryKey = ( + options: Compute> = {}, +): GetCryptoOnrampProviderMetadataQueryKey => ['crypto-onramp-provider-metadata', filterQueryOptions(options)] as const; + +export type GetCryptoOnrampProviderMetadataQueryKey = readonly [ + 'crypto-onramp-provider-metadata', + Compute>, +]; + +export type GetCryptoOnrampProviderMetadataQueryOptions = + QueryOptions< + GetCryptoOnrampProviderMetadataQueryFnData, + GetCryptoOnrampProviderMetadataErrorType, + selectData, + GetCryptoOnrampProviderMetadataQueryKey + >; diff --git a/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-quote.ts b/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-quote.ts new file mode 100644 index 000000000..f51f3a1a5 --- /dev/null +++ b/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-quote.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { AppKit } from '../../core/app-kit'; +import { getCryptoOnrampQuote } from '../../actions/crypto-onramp/get-crypto-onramp-quote'; +import type { + GetCryptoOnrampQuoteOptions, + GetCryptoOnrampQuoteReturnType, +} from '../../actions/crypto-onramp/get-crypto-onramp-quote'; +import type { QueryOptions, QueryParameter } from '../../types/query'; +import type { Compute, ExactPartial } from '../../types/utils'; +import { filterQueryOptions } from '../../utils'; + +export type GetCryptoOnrampQuoteErrorType = Error; +export type GetCryptoOnrampQuoteData = GetCryptoOnrampQuoteQueryFnData; +export type GetCryptoOnrampQuoteQueryConfig = Compute< + ExactPartial +> & + QueryParameter< + GetCryptoOnrampQuoteQueryFnData, + GetCryptoOnrampQuoteErrorType, + selectData, + GetCryptoOnrampQuoteQueryKey + >; + +export const getCryptoOnrampQuoteQueryOptions = ( + appKit: AppKit, + options: GetCryptoOnrampQuoteQueryConfig = {}, +): GetCryptoOnrampQuoteQueryOptions => { + return { + ...options.query, + queryFn: async (context) => { + const [, parameters] = context.queryKey as [string, GetCryptoOnrampQuoteOptions]; + return getCryptoOnrampQuote(appKit, parameters); + }, + queryKey: getCryptoOnrampQuoteQueryKey(options), + }; +}; + +export type GetCryptoOnrampQuoteQueryFnData = Compute>; +export const getCryptoOnrampQuoteQueryKey = ( + options: Compute> = {}, +): GetCryptoOnrampQuoteQueryKey => ['crypto-onramp-quote', filterQueryOptions(options)] as const; +export type GetCryptoOnrampQuoteQueryKey = readonly [ + 'crypto-onramp-quote', + Compute>, +]; +export type GetCryptoOnrampQuoteQueryOptions = QueryOptions< + GetCryptoOnrampQuoteQueryFnData, + GetCryptoOnrampQuoteErrorType, + selectData, + GetCryptoOnrampQuoteQueryKey +>; diff --git a/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-status.ts b/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-status.ts new file mode 100644 index 000000000..f6d81a897 --- /dev/null +++ b/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-status.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { AppKit } from '../../core/app-kit'; +import { getCryptoOnrampStatus } from '../../actions/crypto-onramp/get-crypto-onramp-status'; +import type { + GetCryptoOnrampStatusOptions, + GetCryptoOnrampStatusReturnType, +} from '../../actions/crypto-onramp/get-crypto-onramp-status'; +import type { QueryOptions, QueryParameter } from '../../types/query'; +import type { Compute, ExactPartial } from '../../types/utils'; +import { filterQueryOptions } from '../../utils'; + +export type GetCryptoOnrampStatusErrorType = Error; +export type GetCryptoOnrampStatusData = GetCryptoOnrampStatusQueryFnData; +export type GetCryptoOnrampStatusQueryConfig = Compute< + ExactPartial +> & + QueryParameter< + GetCryptoOnrampStatusQueryFnData, + GetCryptoOnrampStatusErrorType, + selectData, + GetCryptoOnrampStatusQueryKey + >; + +export const getCryptoOnrampStatusQueryOptions = ( + appKit: AppKit, + options: GetCryptoOnrampStatusQueryConfig = {}, +): GetCryptoOnrampStatusQueryOptions => { + return { + ...options.query, + queryFn: async (context) => { + const [, parameters] = context.queryKey as [string, GetCryptoOnrampStatusOptions]; + return getCryptoOnrampStatus(appKit, parameters); + }, + queryKey: getCryptoOnrampStatusQueryKey(options), + enabled: options.depositId !== undefined, + }; +}; + +export type GetCryptoOnrampStatusQueryFnData = Compute>; +export const getCryptoOnrampStatusQueryKey = ( + options: Compute> = {}, +): GetCryptoOnrampStatusQueryKey => ['crypto-onramp-status', filterQueryOptions(options)] as const; +export type GetCryptoOnrampStatusQueryKey = readonly [ + 'crypto-onramp-status', + Compute>, +]; +export type GetCryptoOnrampStatusQueryOptions = QueryOptions< + GetCryptoOnrampStatusQueryFnData, + GetCryptoOnrampStatusErrorType, + selectData, + GetCryptoOnrampStatusQueryKey +>; diff --git a/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-supported-currencies.ts b/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-supported-currencies.ts new file mode 100644 index 000000000..4970aa0f6 --- /dev/null +++ b/packages/appkit/src/queries/crypto-onramp/get-crypto-onramp-supported-currencies.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { AppKit } from '../../core/app-kit'; +import { getCryptoOnrampSupportedCurrencies } from '../../actions/crypto-onramp/get-crypto-onramp-supported-currencies'; +import type { + GetCryptoOnrampSupportedCurrenciesOptions, + GetCryptoOnrampSupportedCurrenciesReturnType, +} from '../../actions/crypto-onramp/get-crypto-onramp-supported-currencies'; +import type { QueryOptions, QueryParameter } from '../../types/query'; +import type { Compute, ExactPartial } from '../../types/utils'; +import { filterQueryOptions } from '../../utils'; + +export type GetCryptoOnrampSupportedCurrenciesErrorType = Error; +export type GetCryptoOnrampSupportedCurrenciesData = GetCryptoOnrampSupportedCurrenciesQueryFnData; +export type GetCryptoOnrampSupportedCurrenciesQueryConfig = + Compute> & + QueryParameter< + GetCryptoOnrampSupportedCurrenciesQueryFnData, + GetCryptoOnrampSupportedCurrenciesErrorType, + selectData, + GetCryptoOnrampSupportedCurrenciesQueryKey + >; + +const ONE_HOUR_MS = 60 * 60 * 1000; +const ONE_DAY_MS = 24 * ONE_HOUR_MS; + +export const getCryptoOnrampSupportedCurrenciesQueryOptions = ( + appKit: AppKit, + options: GetCryptoOnrampSupportedCurrenciesQueryConfig = {}, +): GetCryptoOnrampSupportedCurrenciesQueryOptions => { + return { + // The supported-currencies list for a provider rarely changes. Keep the fetched data + // in-cache long enough that the widget never refetches it within a normal session. + // Consumers can override via `options.query` if they need different behavior. + staleTime: ONE_HOUR_MS, + gcTime: ONE_DAY_MS, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + ...options.query, + queryFn: async (context) => { + const [, parameters] = context.queryKey as [string, GetCryptoOnrampSupportedCurrenciesOptions]; + return getCryptoOnrampSupportedCurrencies(appKit, parameters); + }, + queryKey: getCryptoOnrampSupportedCurrenciesQueryKey(options), + }; +}; + +export type GetCryptoOnrampSupportedCurrenciesQueryFnData = Compute< + Awaited +>; +export const getCryptoOnrampSupportedCurrenciesQueryKey = ( + options: Compute> = {}, +): GetCryptoOnrampSupportedCurrenciesQueryKey => + ['crypto-onramp-supported-currencies', filterQueryOptions(options)] as const; +export type GetCryptoOnrampSupportedCurrenciesQueryKey = readonly [ + 'crypto-onramp-supported-currencies', + Compute>, +]; +export type GetCryptoOnrampSupportedCurrenciesQueryOptions = + QueryOptions< + GetCryptoOnrampSupportedCurrenciesQueryFnData, + GetCryptoOnrampSupportedCurrenciesErrorType, + selectData, + GetCryptoOnrampSupportedCurrenciesQueryKey + >; diff --git a/packages/appkit/src/queries/index.ts b/packages/appkit/src/queries/index.ts index 0d195b4b7..2287a6d36 100644 --- a/packages/appkit/src/queries/index.ts +++ b/packages/appkit/src/queries/index.ts @@ -37,6 +37,52 @@ export { type DisconnectVariables, } from './connectors/disconnect'; +// Crypto onramp +export { + getCryptoOnrampQuoteQueryOptions, + type GetCryptoOnrampQuoteQueryConfig, + type GetCryptoOnrampQuoteQueryOptions, + type GetCryptoOnrampQuoteData, + type GetCryptoOnrampQuoteErrorType, + type GetCryptoOnrampQuoteQueryFnData, + type GetCryptoOnrampQuoteQueryKey, +} from './crypto-onramp/get-crypto-onramp-quote'; +export { + getCryptoOnrampStatusQueryOptions, + type GetCryptoOnrampStatusQueryConfig, + type GetCryptoOnrampStatusQueryOptions, + type GetCryptoOnrampStatusData, + type GetCryptoOnrampStatusErrorType, + type GetCryptoOnrampStatusQueryFnData, + type GetCryptoOnrampStatusQueryKey, +} from './crypto-onramp/get-crypto-onramp-status'; +export { + createCryptoOnrampDepositMutationOptions, + type CreateCryptoOnrampDepositMutationOptions, + type CreateCryptoOnrampDepositData, + type CreateCryptoOnrampDepositErrorType, + type CreateCryptoOnrampDepositVariables, +} from './crypto-onramp/create-crypto-onramp-deposit'; +export { + getCryptoOnrampSupportedCurrenciesQueryOptions, + type GetCryptoOnrampSupportedCurrenciesQueryConfig, + type GetCryptoOnrampSupportedCurrenciesQueryOptions, + type GetCryptoOnrampSupportedCurrenciesData, + type GetCryptoOnrampSupportedCurrenciesErrorType, + type GetCryptoOnrampSupportedCurrenciesQueryFnData, + type GetCryptoOnrampSupportedCurrenciesQueryKey, +} from './crypto-onramp/get-crypto-onramp-supported-currencies'; +export { + getCryptoOnrampProviderMetadataQueryOptions, + getCryptoOnrampProviderMetadataQueryKey, + type GetCryptoOnrampProviderMetadataQueryConfig, + type GetCryptoOnrampProviderMetadataQueryOptions, + type GetCryptoOnrampProviderMetadataData, + type GetCryptoOnrampProviderMetadataErrorType, + type GetCryptoOnrampProviderMetadataQueryFnData, + type GetCryptoOnrampProviderMetadataQueryKey, +} from './crypto-onramp/get-crypto-onramp-provider-metadata'; + // Jettons export { getJettonInfoQueryOptions, diff --git a/packages/appkit/src/types/connector.ts b/packages/appkit/src/types/connector.ts index 4eb8b412f..973412053 100644 --- a/packages/appkit/src/types/connector.ts +++ b/packages/appkit/src/types/connector.ts @@ -49,6 +49,6 @@ export type ConnectorFactory = (ctx: ConnectorFactoryContext) => Connector; export type ConnectorInput = Connector | ConnectorFactory; /** Helper for creating typed connector factories */ -export function createConnector(factory: ConnectorFactory): ConnectorFactory { +export const createConnector = (factory: ConnectorFactory): ConnectorFactory => { return factory; -} +}; diff --git a/packages/appkit/src/types/provider.ts b/packages/appkit/src/types/provider.ts index 50ba16ea5..31954676e 100644 --- a/packages/appkit/src/types/provider.ts +++ b/packages/appkit/src/types/provider.ts @@ -10,6 +10,7 @@ import type { SwapProviderInterface, StakingProviderInterface, StreamingProvider, + CryptoOnrampProviderInterface, GaslessProviderInterface, } from '@ton/walletkit'; @@ -20,4 +21,5 @@ export type AppKitProvider = | SwapProviderInterface | StakingProviderInterface | StreamingProvider + | CryptoOnrampProviderInterface | GaslessProviderInterface; diff --git a/packages/appkit/src/utils/amount/calc-fiat-value.test.ts b/packages/appkit/src/utils/amount/calc-fiat-value.test.ts index beb006347..19cb11455 100644 --- a/packages/appkit/src/utils/amount/calc-fiat-value.test.ts +++ b/packages/appkit/src/utils/amount/calc-fiat-value.test.ts @@ -32,15 +32,15 @@ describe('calcFiatValue', () => { expect(calcFiatValue('', '1.5')).toBe('0'); }); - it('should calculate fiat value without rounding', () => { + it('should calculate fiat value rounded to 2 decimal places', () => { expect(calcFiatValue('100', '1.5')).toBe('150'); - expect(calcFiatValue('1', '0.001')).toBe('0.001'); - expect(calcFiatValue('3', '1.005')).toBe('3.015'); + expect(calcFiatValue('1', '0.001')).toBe('0'); + expect(calcFiatValue('10', '1.005')).toBe('10.05'); }); - it('should handle decimal amounts without rounding', () => { + it('should handle decimal amounts rounded to 2 decimal places', () => { expect(calcFiatValue('0.5', '2')).toBe('1'); - expect(calcFiatValue('1.23456', '100')).toBe('123.456'); - expect(calcFiatValue('2.996876', '1')).toBe('2.996876'); + expect(calcFiatValue('1.23456', '100')).toBe('123.46'); + expect(calcFiatValue('2.994', '1')).toBe('2.99'); }); }); diff --git a/packages/appkit/src/utils/amount/calc-fiat-value.ts b/packages/appkit/src/utils/amount/calc-fiat-value.ts index 973e60efd..8fc9de483 100644 --- a/packages/appkit/src/utils/amount/calc-fiat-value.ts +++ b/packages/appkit/src/utils/amount/calc-fiat-value.ts @@ -10,9 +10,9 @@ * Calculates the fiat value of a token amount. * Returns null when rate is unavailable or amount is zero/invalid. */ -export function calcFiatValue(amount: string, rate: string | undefined): string { +export const calcFiatValue = (amount: string, rate: string | undefined): string => { if (!rate) return '0'; const num = parseFloat(amount); if (!num || num <= 0) return '0'; - return Number((num * parseFloat(rate)).toFixed(10)).toString(); -} + return Number((num * parseFloat(rate)).toFixed(2)).toString(); +}; diff --git a/packages/appkit/src/utils/arrays/key-by.ts b/packages/appkit/src/utils/arrays/key-by.ts index 85a9ed0dd..6478b9645 100644 --- a/packages/appkit/src/utils/arrays/key-by.ts +++ b/packages/appkit/src/utils/arrays/key-by.ts @@ -6,7 +6,7 @@ * */ -export function keyBy(arr: readonly T[], getKeyFromItem: (item: T) => K): Record { +export const keyBy = (arr: readonly T[], getKeyFromItem: (item: T) => K): Record => { const result = {} as Record; arr.forEach((item) => { @@ -15,4 +15,4 @@ export function keyBy(arr: readonly T[], getKeyFromIte }); return result; -} +}; diff --git a/packages/appkit/src/utils/object/map-values.ts b/packages/appkit/src/utils/object/map-values.ts index 0ccff6fde..c1c02fe17 100644 --- a/packages/appkit/src/utils/object/map-values.ts +++ b/packages/appkit/src/utils/object/map-values.ts @@ -6,10 +6,10 @@ * */ -export function mapValues( +export const mapValues = ( object: T, getNewValue: (value: T[K], key: K, obj: T) => V, -): Record { +): Record => { const result = {} as Record; const keys = Object.keys(object) as K[]; @@ -20,4 +20,4 @@ export function mapValues( } return result; -} +}; diff --git a/packages/appkit/src/utils/query/filter-query-options.ts b/packages/appkit/src/utils/query/filter-query-options.ts index 860ecc977..97d615a7d 100644 --- a/packages/appkit/src/utils/query/filter-query-options.ts +++ b/packages/appkit/src/utils/query/filter-query-options.ts @@ -8,8 +8,8 @@ import type { Compute } from '../../types/utils'; -export function filterQueryOptions(options: type): Compute> { +export const filterQueryOptions = (options: type): Compute> => { const { query, ...rest } = options as unknown as { query: unknown }; return rest as Compute>; -} +}; diff --git a/packages/walletkit/package.json b/packages/walletkit/package.json index cd0364ad0..e2544db71 100644 --- a/packages/walletkit/package.json +++ b/packages/walletkit/package.json @@ -61,6 +61,26 @@ "default": "./dist/cjs/defi/staking/tonstakers/index.js" } }, + "./crypto-onramp/decent": { + "import": { + "types": "./dist/esm/defi/crypto-onramp/decent/index.d.ts", + "default": "./dist/esm/defi/crypto-onramp/decent/index.js" + }, + "require": { + "types": "./dist/cjs/defi/crypto-onramp/decent/index.d.ts", + "default": "./dist/cjs/defi/crypto-onramp/decent/index.js" + } + }, + "./crypto-onramp/layerswap": { + "import": { + "types": "./dist/esm/defi/crypto-onramp/layerswap/index.d.ts", + "default": "./dist/esm/defi/crypto-onramp/layerswap/index.js" + }, + "require": { + "types": "./dist/cjs/defi/crypto-onramp/layerswap/index.d.ts", + "default": "./dist/cjs/defi/crypto-onramp/layerswap/index.js" + } + }, "./gasless/tonapi": { "import": { "types": "./dist/esm/defi/gasless/tonapi/index.d.ts", @@ -86,6 +106,12 @@ "staking/tonstakers": [ "./dist/cjs/defi/staking/tonstakers/index.d.ts" ], + "crypto-onramp/decent": [ + "./dist/cjs/defi/crypto-onramp/decent/index.d.ts" + ], + "crypto-onramp/layerswap": [ + "./dist/cjs/defi/crypto-onramp/layerswap/index.d.ts" + ], "gasless/tonapi": [ "./dist/cjs/defi/gasless/tonapi/index.d.ts" ] diff --git a/packages/walletkit/src/api/interfaces/CryptoOnrampAPI.ts b/packages/walletkit/src/api/interfaces/CryptoOnrampAPI.ts new file mode 100644 index 000000000..6bdd06d32 --- /dev/null +++ b/packages/walletkit/src/api/interfaces/CryptoOnrampAPI.ts @@ -0,0 +1,113 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + CryptoOnrampDeposit, + CryptoOnrampDepositParams, + CryptoOnrampProviderMetadata, + CryptoOnrampQuote, + CryptoOnrampQuoteParams, + CryptoOnrampStatus, + CryptoOnrampStatusParams, + CryptoOnrampSupportedCurrencies, +} from '../models'; +import type { DefiManagerAPI } from './DefiManagerAPI'; +import type { DefiProvider } from './DefiProvider'; + +/** + * Crypto onramp API interface exposed by CryptoOnrampManager + */ +export interface CryptoOnrampAPI extends DefiManagerAPI { + /** + * Get static metadata for a crypto onramp provider + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns A promise that resolves to provider metadata + */ + getMetadata(providerId?: string): Promise; + + /** + * Get a quote for onramping from another crypto asset into a TON asset + * @param params Quote parameters (source currency/network, target currency, amount) + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns A promise that resolves to a CryptoOnrampQuote + */ + getQuote(params: CryptoOnrampQuoteParams, providerId?: string): Promise; + + /** + * Create a deposit for a previously obtained quote + * @param params Deposit parameters (quote, user TON address) + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns A promise that resolves to a CryptoOnrampDeposit + */ + createDeposit(params: CryptoOnrampDepositParams, providerId?: string): Promise; + + /** + * Get the status of a deposit + * @param params - Deposit status parameters including the deposit ID + * @returns Promise resolving to the deposit status + */ + getStatus(params: CryptoOnrampStatusParams, providerId?: string): Promise; + + /** + * Discover supported source/destination currencies for a provider. + * Source currencies are tokens the user can spend; destination currencies are TON-side + * tokens the user can receive. + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns Promise resolving to the supported currencies for both directions + */ + getSupportedCurrencies(providerId?: string): Promise; +} + +/** + * Interface that all crypto onramp providers must implement + */ +export interface CryptoOnrampProviderInterface< + TQuoteOptions = unknown, + TDepositOptions = unknown, +> extends DefiProvider { + readonly type: 'crypto-onramp'; + + /** + * Unique identifier for the provider + */ + readonly providerId: string; + + /** + * Get static metadata for the provider (display name, logo, url). + * @returns A promise that resolves to provider metadata + */ + getMetadata(): Promise; + + /** + * Get a quote for onramping from another crypto asset into a TON asset + * @param params Quote parameters including provider-specific options + * @returns A promise that resolves to a CryptoOnrampQuote + */ + getQuote(params: CryptoOnrampQuoteParams): Promise; + + /** + * Create a deposit for a previously obtained quote + * @param params Deposit parameters including provider-specific options + * @returns A promise that resolves to a CryptoOnrampDeposit + */ + createDeposit(params: CryptoOnrampDepositParams): Promise; + + /** + * Get the status of a deposit + * @param params - Deposit status parameters including the deposit ID + * @returns Promise resolving to the deposit status + */ + getStatus(params: CryptoOnrampStatusParams): Promise; + + /** + * Discover supported source/destination currencies. May involve network calls + * (e.g. fetching the provider's `/sources` or `/paths` endpoint), or return a + * statically-curated list when the provider has no enumeration API. + */ + getSupportedCurrencies(): Promise; +} diff --git a/packages/walletkit/src/api/interfaces/index.ts b/packages/walletkit/src/api/interfaces/index.ts index 9a78d4b31..549d28990 100644 --- a/packages/walletkit/src/api/interfaces/index.ts +++ b/packages/walletkit/src/api/interfaces/index.ts @@ -12,8 +12,9 @@ export type { WalletSigner, ISigner } from './WalletSigner'; // Defi interfaces export type { DefiManagerAPI } from './DefiManagerAPI'; -export type { DefiProvider } from './DefiProvider'; export type { SwapAPI, SwapProviderInterface } from './SwapAPI'; +export type { CryptoOnrampAPI, CryptoOnrampProviderInterface } from './CryptoOnrampAPI'; +export type { DefiProvider } from './DefiProvider'; export type { StakingAPI, StakingProviderInterface } from './StakingAPI'; export type { GaslessAPI, GaslessProviderInterface } from './GaslessAPI'; diff --git a/packages/walletkit/src/api/models/core/DefiProviderType.ts b/packages/walletkit/src/api/models/core/DefiProviderType.ts index 3273928fc..f75ef2a49 100644 --- a/packages/walletkit/src/api/models/core/DefiProviderType.ts +++ b/packages/walletkit/src/api/models/core/DefiProviderType.ts @@ -7,6 +7,6 @@ */ /** - * Discriminator for DeFi-style providers (swap quotes, staking, gasless relayers). + * Discriminator for DeFi-style providers (swap quotes, staking, gasless relayers, crypto onramp). */ -export type DefiProviderType = 'swap' | 'staking' | 'gasless'; +export type DefiProviderType = 'swap' | 'staking' | 'gasless' | 'crypto-onramp'; diff --git a/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampCurrency.ts b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampCurrency.ts new file mode 100644 index 000000000..dc04731c6 --- /dev/null +++ b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampCurrency.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * A source currency the user can spend to onramp into a TON asset. Always + * lives on a non-TON chain (identified by CAIP-2). + */ +export interface CryptoOnrampSourceCurrency { + /** CAIP-2 source chain identifier, e.g. `'eip155:42161'`. */ + chain: string; + /** Token contract address on the source chain, or zero address for the native gas token. */ + address: string; + /** Token symbol, e.g. `'USDT0'`, `'ETH'`. */ + symbol: string; + /** Full token name, e.g. `'Tether USD0'`. Optional. */ + name?: string; + /** Decimals used to convert between display and base units. */ + decimals: number; + /** Logo URL. */ + logo?: string; +} + +/** + * A destination currency the user receives on TON. Chain is implicit (always TON). + */ +export interface CryptoOnrampDestinationCurrency { + /** Address as the provider expects it (raw form, e.g. `'EQCx...'` for jettons, + * `'0x000...'` for native TON). */ + address: string; + /** Token symbol, e.g. `'TON'`, `'USDT'`. */ + symbol: string; + /** Full token name. */ + name?: string; + /** Decimals. */ + decimals: number; + /** Logo URL. */ + logo?: string; +} + +/** + * Combined currency listing returned by `CryptoOnrampProvider.getSupportedCurrencies()`. + */ +export interface CryptoOnrampSupportedCurrencies { + source: CryptoOnrampSourceCurrency[]; + destination: CryptoOnrampDestinationCurrency[]; +} diff --git a/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampDeposit.ts b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampDeposit.ts new file mode 100644 index 000000000..a0469268f --- /dev/null +++ b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampDeposit.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampSourceCurrency } from './CryptoOnrampCurrency'; + +/** + * Deposit details returned by a crypto onramp provider. + * + * The user must send `amount` of `sourceCurrency` to `address` to complete the onramp; + * the provider then delivers the target crypto to the user's TON address. + */ +export interface CryptoOnrampDeposit { + /** + * Deposit id + */ + depositId: string; + + /** + * Deposit address on the source chain + */ + address: string; + + /** + * Exact amount of source crypto the user must send (in base units of `sourceCurrency.decimals`). + */ + amount: string; + + /** + * Source currency the user is sending. Mirrors the `sourceCurrency` from the originating quote. + */ + sourceCurrency: CryptoOnrampSourceCurrency; + + /** + * Optional memo / tag required by some chains (e.g. XRP, TON comment) + */ + memo?: string; + + /** + * Unix timestamp (ms) after which the deposit offer is no longer valid + */ + expiresAt?: number; + + /** + * Identifier of the provider that issued this deposit + */ + providerId: string; +} diff --git a/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampDepositParams.ts b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampDepositParams.ts new file mode 100644 index 000000000..3ec0aa586 --- /dev/null +++ b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampDepositParams.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampQuote } from './CryptoOnrampQuote'; + +/** + * Parameters for creating a crypto onramp deposit. + * + * The recipient is taken from `quote.recipientAddress` set at quote time. + */ +export interface CryptoOnrampDepositParams { + /** + * Quote to execute the deposit against (contains recipientAddress and provider metadata) + */ + quote: CryptoOnrampQuote; + + /** + * Address to refund the crypto to in case of failure + */ + refundAddress: string; + + /** + * Provider-specific options + */ + providerOptions?: TProviderOptions; +} diff --git a/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampProviderMetadata.ts b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampProviderMetadata.ts new file mode 100644 index 000000000..f45d22ac2 --- /dev/null +++ b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampProviderMetadata.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Static metadata for a crypto-onramp provider. + */ +export interface CryptoOnrampProviderMetadata { + /** + * Human-readable provider name (e.g. 'Decent') + */ + name: string; + + /** + * URL to the provider's logo image + */ + logo?: string; + + /** + * URL to the provider's website + */ + url?: string; + + /** + * Refund-address collection mode for this provider: + * - `'off'` (default): no refund address — the UI skips the address modal entirely. + * - `'optional'`: the UI shows the address modal with a "Skip" button — users may + * enter an address or proceed without one. + * - `'required'`: the UI shows the address modal and blocks submission until a + * non-empty address is entered. + */ + refundAddressMode?: 'off' | 'optional' | 'required'; + + /** + * Whether this provider supports reversed (target-amount) quotes. + * When false, the UI should hide the direction toggle and only allow source-amount input. + */ + isReversedAmountSupported?: boolean; +} + +/** + * Used in provider configuration to override fields of the provider's metadata. + */ +export interface CryptoOnrampProviderMetadataOverride { + /** + * Override the provider's display name + */ + name?: string; + + /** + * Override the provider's logo URL + */ + logo?: string; + + /** + * Override the provider's website URL + */ + url?: string; +} diff --git a/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampQuote.ts b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampQuote.ts new file mode 100644 index 000000000..16b81564a --- /dev/null +++ b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampQuote.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampDestinationCurrency, CryptoOnrampSourceCurrency } from './CryptoOnrampCurrency'; + +/** + * Crypto onramp quote response with pricing information. + */ +export interface CryptoOnrampQuote { + /** + * Source currency that will be spent. Mirrors the `sourceCurrency` from quote params, + * possibly normalised by the provider. + */ + sourceCurrency: CryptoOnrampSourceCurrency; + + /** + * Target currency on TON the user receives. + */ + targetCurrency: CryptoOnrampDestinationCurrency; + + /** + * Amount of source crypto to send (in base units of `sourceCurrency.decimals`). + */ + sourceAmount: string; + + /** + * Amount of target crypto to receive (in base units of `targetCurrency.decimals`). + */ + targetAmount: string; + + /** + * Exchange rate (amount of target per 1 unit of source) + */ + rate: string; + + /** + * TON address that will receive the target crypto + */ + recipientAddress: string; + + /** + * Identifier of the crypto onramp provider + */ + providerId: string; + + /** + * Provider-specific metadata for the quote (e.g. raw response needed to execute) + */ + metadata?: TMetadata; +} diff --git a/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampQuoteParams.ts b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampQuoteParams.ts new file mode 100644 index 000000000..dd2911a5a --- /dev/null +++ b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampQuoteParams.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampDestinationCurrency, CryptoOnrampSourceCurrency } from './CryptoOnrampCurrency'; + +/** + * Parameters for requesting a crypto-to-crypto onramp quote. + * + * The target network is always TON, so only the source side carries chain info. + */ +export interface CryptoOnrampQuoteParams { + /** + * Amount to onramp (either source or target crypto, depending on isSourceAmount) + */ + amount: string; + + /** + * Source currency the user is spending. Carries chain (CAIP-2), address, symbol, + * decimals — everything the provider needs to build its API request. + */ + sourceCurrency: CryptoOnrampSourceCurrency; + + /** + * Target currency the user receives on TON. + */ + targetCurrency: CryptoOnrampDestinationCurrency; + + /** + * TON address that will receive the target crypto + */ + recipientAddress: string; + + /** + * Refund address for the source crypto + */ + refundAddress?: string; + + /** + * If true, `amount` is the source amount to spend. + * If false, `amount` is the target amount to receive. + * Defaults to true when omitted. + */ + isSourceAmount?: boolean; + + /** + * Provider-specific options + */ + providerOptions?: TProviderOptions; +} diff --git a/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampStatus.ts b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampStatus.ts new file mode 100644 index 000000000..29d3dfe89 --- /dev/null +++ b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampStatus.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Deposit details returned by a crypto onramp provider. + * + * The user must send `amount` of `sourceCurrencyAddress` to `address` on `sourceChain` + * to complete the onramp; the provider then delivers the target crypto to the + * user's TON address. + */ +export type CryptoOnrampStatus = 'success' | 'pending' | 'failed'; diff --git a/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampStatusParams.ts b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampStatusParams.ts new file mode 100644 index 000000000..91eb30647 --- /dev/null +++ b/packages/walletkit/src/api/models/crypto-onramp/CryptoOnrampStatusParams.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Deposit details returned by a crypto onramp provider. + * + * The user must send `amount` of `sourceCurrencyAddress` to `address` on `sourceChain` + * to complete the onramp; the provider then delivers the target crypto to the + * user's TON address. + */ +export interface CryptoOnrampStatusParams { + /** + * Deposit id + */ + depositId: string; + + /** + * Identifier of the provider that issued this deposit + */ + providerId: string; +} diff --git a/packages/walletkit/src/api/models/index.ts b/packages/walletkit/src/api/models/index.ts index 947f62c16..f1f9c97ce 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -139,6 +139,23 @@ export type { StakingQuoteParams } from './staking/StakingQuoteParams'; export type { UnstakeModes } from './staking/UnstakeMode'; export { UnstakeMode } from './staking/UnstakeMode'; +// Crypto onramp models +export type { CryptoOnrampQuote } from './crypto-onramp/CryptoOnrampQuote'; +export type { CryptoOnrampQuoteParams } from './crypto-onramp/CryptoOnrampQuoteParams'; +export type { CryptoOnrampDepositParams } from './crypto-onramp/CryptoOnrampDepositParams'; +export type { CryptoOnrampDeposit } from './crypto-onramp/CryptoOnrampDeposit'; +export type { CryptoOnrampStatusParams } from './crypto-onramp/CryptoOnrampStatusParams'; +export type { CryptoOnrampStatus } from './crypto-onramp/CryptoOnrampStatus'; +export type { + CryptoOnrampProviderMetadata, + CryptoOnrampProviderMetadataOverride, +} from './crypto-onramp/CryptoOnrampProviderMetadata'; +export type { + CryptoOnrampSourceCurrency, + CryptoOnrampDestinationCurrency, + CryptoOnrampSupportedCurrencies, +} from './crypto-onramp/CryptoOnrampCurrency'; + // Gasless models export type { GaslessSupportedAsset } from './gasless/GaslessSupportedAsset'; export type { GaslessConfig } from './gasless/GaslessConfig'; diff --git a/packages/walletkit/src/defi/crypto-onramp/CryptoOnrampManager.ts b/packages/walletkit/src/defi/crypto-onramp/CryptoOnrampManager.ts new file mode 100644 index 000000000..73fd5ab17 --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/CryptoOnrampManager.ts @@ -0,0 +1,167 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampAPI, CryptoOnrampProviderInterface } from '../../api/interfaces'; +import type { + CryptoOnrampDeposit, + CryptoOnrampDepositParams, + CryptoOnrampProviderMetadata, + CryptoOnrampQuote, + CryptoOnrampQuoteParams, + CryptoOnrampStatus, + CryptoOnrampStatusParams, + CryptoOnrampSupportedCurrencies, +} from '../../api/models'; +import type { CryptoOnrampErrorCode } from './errors'; +import { CryptoOnrampError } from './errors'; +import { globalLogger } from '../../core/Logger'; +import { DefiManager } from '../DefiManager'; + +const log = globalLogger.createChild('CryptoOnrampManager'); + +/** + * CryptoOnrampManager — manages crypto onramp providers and delegates crypto onramp operations. + * + * Allows registration of multiple crypto onramp providers and provides a unified API + * for crypto-to-TON onramp operations. Providers can be switched dynamically. + */ +export class CryptoOnrampManager extends DefiManager implements CryptoOnrampAPI { + /** + * Get static metadata for a crypto onramp provider + * @param providerId - Optional provider id to use + */ + async getMetadata(providerId?: string): Promise { + const selectedProviderId = providerId || this.defaultProviderId; + log.debug('Getting crypto onramp metadata', { providerId: selectedProviderId }); + + try { + return await this.getProvider(selectedProviderId).getMetadata(); + } catch (error) { + log.error('Failed to get crypto onramp metadata', { error }); + throw error; + } + } + + /** + * Get a quote for onramping from another crypto asset into a TON asset + * @param params - Quote parameters + * @param providerId - Optional provider name to use + * @returns Promise resolving to a crypto onramp quote + */ + async getQuote( + params: CryptoOnrampQuoteParams, + providerId?: string, + ): Promise { + const selectedProviderId = providerId || this.defaultProviderId; + log.debug('Getting crypto onramp quote', { + sourceChain: params.sourceCurrency.chain, + sourceAddress: params.sourceCurrency.address, + targetAddress: params.targetCurrency.address, + amount: params.amount, + isSourceAmount: params.isSourceAmount, + providerId: selectedProviderId, + }); + + try { + const quote = await this.getProvider(selectedProviderId).getQuote(params); + + log.debug('Received crypto onramp quote', { + sourceAmount: quote.sourceAmount, + targetAmount: quote.targetAmount, + rate: quote.rate, + }); + + return quote; + } catch (error) { + log.error('Failed to get crypto onramp quote', { error, params }); + throw error; + } + } + + /** + * Create a deposit for a previously obtained quote + * @param params - Deposit parameters including the quote and user TON address + * @param providerId - Optional provider name to use + * @returns Promise resolving to deposit details + */ + async createDeposit( + params: CryptoOnrampDepositParams, + providerId?: string, + ): Promise { + const selectedProviderId = providerId || params.quote?.providerId || this.defaultProviderId; + + log.debug('Creating crypto onramp deposit', { + providerId: selectedProviderId, + recipientAddress: params.quote.recipientAddress, + }); + + try { + const deposit = await this.getProvider(selectedProviderId).createDeposit(params); + + log.debug('Created crypto onramp deposit', { + address: deposit.address, + amount: deposit.amount, + sourceChain: deposit.sourceCurrency.chain, + sourceAddress: deposit.sourceCurrency.address, + }); + + return deposit; + } catch (error) { + log.error('Failed to create crypto onramp deposit', { error, params }); + throw error; + } + } + + /** + * Get the status of a deposit + * @param params - Deposit status parameters including the deposit ID + * @param providerId - Optional provider name to use + * @returns Promise resolving to the deposit status + */ + async getStatus(params: CryptoOnrampStatusParams, providerId?: string): Promise { + const selectedProviderId = providerId || this.defaultProviderId; + + log.debug('Getting crypto onramp deposit status', { + providerId: selectedProviderId, + depositId: params.depositId, + }); + + try { + const status = await this.getProvider(selectedProviderId).getStatus(params); + + log.debug('Received crypto onramp deposit status', { + status, + }); + + return status; + } catch (error) { + log.error('Failed to get crypto onramp deposit status', { error, params }); + throw error; + } + } + + /** + * Discover supported source/destination currencies for a provider. + * @param providerId Optional provider name to use + */ + async getSupportedCurrencies(providerId?: string): Promise { + const selectedProviderId = providerId || this.defaultProviderId; + log.debug('Discovering crypto onramp supported currencies', { providerId: selectedProviderId }); + + try { + return await this.getProvider(selectedProviderId).getSupportedCurrencies(); + } catch (error) { + log.error('Failed to discover crypto onramp supported currencies', { error }); + throw error; + } + } + + protected createError(message: string, code: CryptoOnrampErrorCode, details?: unknown): CryptoOnrampError { + return new CryptoOnrampError(message, code, details); + } +} diff --git a/packages/walletkit/src/defi/crypto-onramp/CryptoOnrampProvider.ts b/packages/walletkit/src/defi/crypto-onramp/CryptoOnrampProvider.ts new file mode 100644 index 000000000..ca07307cd --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/CryptoOnrampProvider.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + CryptoOnrampDeposit, + CryptoOnrampDepositParams, + CryptoOnrampProviderMetadata, + CryptoOnrampQuote, + CryptoOnrampQuoteParams, + CryptoOnrampStatus, + CryptoOnrampStatusParams, + CryptoOnrampSupportedCurrencies, + Network, +} from '../../api/models'; +import type { CryptoOnrampProviderInterface } from '../../api/interfaces'; + +/** + * Abstract base class for crypto onramp providers + * + * Provides a common interface for implementing crypto-to-TON onramp functionality + * across different gateways. + * + * @example + * ```typescript + * class MyCryptoOnrampProvider extends CryptoOnrampProvider { + * async getQuote(params: CryptoOnrampQuoteParams): Promise { + * // Implementation + * } + * + * async createDeposit(params: CryptoOnrampDepositParams): Promise { + * // Implementation + * } + * } + * ``` + */ +export abstract class CryptoOnrampProvider< + TQuoteOptions = undefined, + TDepositOptions = undefined, +> implements CryptoOnrampProviderInterface { + readonly type = 'crypto-onramp'; + abstract readonly providerId: string; + abstract getSupportedNetworks(): Network[]; + + /** + * Get static metadata for the provider (display name, logo, url). + * @returns Promise resolving to provider metadata + */ + abstract getMetadata(): Promise; + + /** + * Get a quote for onramping from another crypto asset into a TON asset + * @param params - Quote parameters + * @returns Promise resolving to a crypto onramp quote with pricing information + */ + abstract getQuote(params: CryptoOnrampQuoteParams): Promise; + + /** + * Create a deposit that the user must fund to complete the onramp + * @param params - Deposit parameters including the quote and user TON address + * @returns Promise resolving to deposit details (address, amount, memo, etc.) + */ + abstract createDeposit(params: CryptoOnrampDepositParams): Promise; + + /** + * Get the status of a deposit + * @param params - Deposit status parameters including the deposit ID + * @returns Promise resolving to the deposit status + */ + abstract getStatus(params: CryptoOnrampStatusParams): Promise; + + /** + * Discover supported source/destination currencies. May involve network calls (e.g. + * Layerswap `/sources`) or return a statically-curated list when the provider has no + * enumeration API (e.g. Decent). + */ + abstract getSupportedCurrencies(): Promise; +} diff --git a/packages/walletkit/src/defi/crypto-onramp/README.md b/packages/walletkit/src/defi/crypto-onramp/README.md new file mode 100644 index 000000000..2d7700a4b --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/README.md @@ -0,0 +1,184 @@ + + +# Crypto Onramp Manager + +CryptoOnrampManager provides a unified interface for bridging crypto assets from external chains into TON assets via third-party bridge providers. + +## Quick Start + +```typescript +import { TonWalletKit, Network } from '@ton/walletkit'; +import { createDecentProvider } from '@ton/walletkit/crypto-onramp/decent'; + +const kit = new TonWalletKit({ + networks: { + [Network.mainnet().chainId]: { + apiClient: { url: 'https://toncenter.com', key: 'optional-api-key' }, + }, + }, +}); + +kit.cryptoOnramp.registerProvider( + createDecentProvider({ apiKey: 'your-api-key' }), +); +kit.cryptoOnramp.setDefaultProvider('decent'); +``` + +## Quote Parameters + +All providers accept the same base parameters for `getQuote`: + +```typescript +interface CryptoOnrampQuoteParams { + amount: string; // Amount in base units (source or target, see isSourceAmount) + sourceCurrencyAddress: string; // Source token contract address (or native zero address) + sourceNetwork: string; // Source chain identifier + targetCurrencyAddress: string; // Target TON token address + recipientAddress: string; // TON address that will receive the target crypto + refundAddress?: string; // Refund address on the source chain (required by some providers) + isSourceAmount?: boolean; // true = spend `amount` source tokens, false = receive `amount` target tokens (default: true) + providerOptions?: TProviderOptions; // Provider-specific options (slippage, etc.) +} +``` + +## Getting a Quote + +```typescript +const quote = await kit.cryptoOnramp.getQuote({ + sourceCurrencyAddress: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', // USDT on Arbitrum + sourceNetwork: '42161', // Arbitrum One chain ID + targetCurrencyAddress: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', // USDT on TON + amount: '1000000', // 1 USDT (6 decimals) + recipientAddress: 'UQ...', // TON address to receive the bridged tokens +}); + +console.log('Source amount:', quote.sourceAmount); +console.log('Target amount:', quote.targetAmount); +console.log('Rate:', quote.rate); +console.log('Recipient:', quote.recipientAddress); +``` + +## Creating a Deposit + +```typescript +const deposit = await kit.cryptoOnramp.createDeposit({ + quote, + refundAddress: '0x...', // EVM address to refund on failure (required by some providers) +}); + +console.log('Send to:', deposit.address); +console.log('Amount:', deposit.amount); +console.log('Deposit ID:', deposit.depositId); +``` + +## Checking Deposit Status + +```typescript +const status = await kit.cryptoOnramp.getStatus({ + depositId: deposit.depositId, +}); +// status: 'pending' | 'success' | 'failed' +``` + +## Creating a Custom Provider + +Extend `CryptoOnrampProvider` to integrate a new bridge: + +```typescript +import { + CryptoOnrampProvider, + type CryptoOnrampQuoteParams, + type CryptoOnrampQuote, + type CryptoOnrampDepositParams, + type CryptoOnrampDeposit, + type CryptoOnrampStatusParams, + type CryptoOnrampStatus, + type Network, +} from '@ton/walletkit'; + +interface MyQuoteOptions { + slippageBps?: number; +} + +export class MyCryptoOnrampProvider extends CryptoOnrampProvider { + readonly providerId = 'my-provider'; + + getSupportedNetworks(): Network[] { + return [Network.mainnet()]; + } + + getMetadata() { + return { name: 'My Provider', url: 'https://my-provider.com', isRefundAddressRequired: true }; + } + + async getQuote(params: CryptoOnrampQuoteParams): Promise { + const { recipientAddress, sourceCurrencyAddress, targetCurrencyAddress } = params; + // Fetch quote from your bridge API... + return { + sourceCurrencyAddress, + sourceNetwork: params.sourceNetwork, + targetCurrencyAddress, + sourceAmount: '...', + targetAmount: '...', + rate: '...', + recipientAddress, + providerId: this.providerId, + }; + } + + async createDeposit(params: CryptoOnrampDepositParams): Promise { + // params.quote.recipientAddress holds the TON recipient set at quote time + return { depositId: '...', address: '0x...', amount: '...', sourceCurrencyAddress: '...', sourceNetwork: '...', providerId: this.providerId }; + } + + async getStatus(params: CryptoOnrampStatusParams): Promise { + return 'pending'; + } +} +``` + +## Available Providers + +- **[Decent](https://github.com/ton-connect/kit/blob/main/packages/walletkit/src/defi/crypto-onramp/decent/README.md)**: Multi-chain bridge to TON via Decent (formerly Swaps.xyz), supporting a wide range of source networks and tokens +- **[Layerswap](https://github.com/ton-connect/kit/blob/main/packages/walletkit/src/defi/crypto-onramp/layerswap/README.md)**: Multi-chain bridge to TON via Layerswap + +## API Reference + +### CryptoOnrampManager + +#### `getQuote(params, providerId?)` +Get a quote for a crypto-to-TON bridge operation. + +**Parameters:** +- `params: CryptoOnrampQuoteParams` – `sourceCurrencyAddress`, `sourceNetwork`, `targetCurrencyAddress`, `amount`, `recipientAddress`, `isSourceAmount?`, `refundAddress?`, `providerOptions?` +- `providerId?: string` – Provider to use (uses default if omitted) + +**Returns:** `Promise` + +#### `createDeposit(params, providerId?)` +Create a deposit from a previously obtained quote. Returns the address the user must fund on the source chain. + +**Parameters:** +- `params: CryptoOnrampDepositParams` – `quote`, `refundAddress` +- `providerId?: string` – Resolved from `quote.providerId` when omitted + +**Returns:** `Promise` + +#### `getStatus(params, providerId?)` +Poll the status of a deposit. + +**Parameters:** +- `params: CryptoOnrampStatusParams` – `depositId` +- `providerId?: string` – Provider to use (uses default if omitted) + +**Returns:** `Promise` — `'pending' | 'success' | 'failed'` + +#### `registerProvider(provider)` +Register a new crypto onramp provider (instance or factory). + +#### `setDefaultProvider(providerId)` +Set the default provider for operations that do not specify one. diff --git a/packages/walletkit/src/defi/crypto-onramp/caip2.ts b/packages/walletkit/src/defi/crypto-onramp/caip2.ts new file mode 100644 index 000000000..67608c81f --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/caip2.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Parsed CAIP-2 chain identifier — `:`. + * + * @see https://chainagnostic.org/CAIPs/caip-2 + */ +export interface Caip2 { + namespace: string; + reference: string; +} + +/** + * CAIP-2 identifiers for networks our crypto-onramp providers care about. + * Single source of truth — providers reference these by name instead of + * repeating raw `'eip155:1'` strings in each `supportedChains` config. + * + * Key naming follows the `_` convention (e.g. `ETHEREUM_MAINNET`) + * so the entries leave room for testnet additions later. + */ +export const Caip2ByNetwork = { + EthereumMainnet: 'eip155:1', + OptimismMainnet: 'eip155:10', + BscMainnet: 'eip155:56', + PolygonMainnet: 'eip155:137', + BaseMainnet: 'eip155:8453', + ArbitrumMainnet: 'eip155:42161', + AvalancheMainnet: 'eip155:43114', + SolanaMainnet: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + BitcoinMainnet: 'bip122:000000000019d6689c085ae165831e93', + TronMainnet: 'tron:mainnet', +} as const; diff --git a/packages/walletkit/src/defi/crypto-onramp/decent/DecentCryptoOnrampProvider.ts b/packages/walletkit/src/defi/crypto-onramp/decent/DecentCryptoOnrampProvider.ts new file mode 100644 index 000000000..1117bb37e --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/decent/DecentCryptoOnrampProvider.ts @@ -0,0 +1,327 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + CryptoOnrampDeposit, + CryptoOnrampDepositParams, + CryptoOnrampQuote, + CryptoOnrampQuoteParams, + CryptoOnrampStatus, + CryptoOnrampStatusParams, + CryptoOnrampSupportedCurrencies, +} from '../../../api/models'; +import { Network } from '../../../api/models'; +import { CryptoOnrampProvider } from '../CryptoOnrampProvider'; +import { CryptoOnrampError, CryptoOnrampErrorCode } from '../errors'; +import { createProvider } from '../../../types/factory'; +import type { DecentGetActionResponse, DecentSwapDirection } from './types'; +import { + DEFAULT_DECENT_SUPPORTED_CHAINS, + DEFAULT_DECENT_SUPPORTED_CURRENCIES, + isErrorResponse, + isEvmAddress, + mapDecentErrorCode, + mapStatus, +} from './utils'; +import type { DecentChainConfig } from './utils'; + +// Decent (formerly Swaps.xyz) — they rebranded but kept the existing API endpoints. +const DECENT_API_URL = 'https://api-v2.swaps.xyz/api'; +const TON_CHAIN_ID = 999000337; +const DEFAULT_SLIPPAGE_BPS = 100; +const DEFAULT_SENDER = '0x0000000000000000000000000000000000000000'; + +export interface DecentProviderConfig { + /** + * API key issued by Decent (passed as `x-api-key`) + */ + apiKey: string; + + /** + * Override the base API URL. Defaults to https://api-v2.swaps.xyz/api + */ + apiUrl?: string; + + /** + * EVM address used as `sender` on getAction requests. Required by the API + * even for deposit flows where the actual payer is unknown. Defaults to a + * null address when omitted. + */ + defaultSender?: string; + + /** + * Mapping of CAIP-2 source chain identifiers to Decent network configs + * (currently just the Decent chain id; address-format regex will be added + * once non-EVM source chains are supported). When omitted, defaults to + * {@link DEFAULT_DECENT_SUPPORTED_CHAINS}. Pass a full map (not a partial) + * — the override replaces the default. Spread the default to extend it. + */ + supportedChains?: Record; + + /** + * Curated supported-currencies list. Decent's API has no enumeration endpoint, + * so the list is bundled statically. When omitted, defaults to + * {@link DEFAULT_DECENT_SUPPORTED_CURRENCIES}. Spread the default to extend it. + */ + supportedCurrencies?: CryptoOnrampSupportedCurrencies; +} + +export interface DecentQuoteOptions { + /** + * Slippage tolerance in basis points (0-10000). Defaults to 100 (1%). + */ + slippageBps?: number; +} + +/** + * Metadata stored on the CryptoOnrampQuote returned by this provider. + * + * The raw getAction response is kept here so that createDeposit can build a + * CryptoOnrampDeposit without an extra network round-trip. + */ +export interface DecentQuoteMetadata { + sender: string; + response: DecentGetActionResponse; +} + +/** + * Provider implementation that routes crypto onramps through Decent. + * + * Supports EVM source chains only — quotes where the source chain's `vmId` + * is not `evm` are rejected (non-EVM chains require a separate registerTxs + * flow that we do not implement yet). + */ +export class DecentCryptoOnrampProvider extends CryptoOnrampProvider { + readonly providerId = 'decent'; + + getSupportedNetworks(): Network[] { + return [Network.mainnet()]; + } + + async getMetadata() { + return { name: 'Decent', url: 'https://decent.xyz', refundAddressMode: 'required' as const }; + } + + private readonly apiKey: string; + private readonly apiUrl: string; + private readonly defaultSender: string; + private readonly supportedChains: Record; + private readonly supportedCurrencies: CryptoOnrampSupportedCurrencies; + + constructor(config: DecentProviderConfig) { + super(); + this.apiKey = config.apiKey; + this.apiUrl = config.apiUrl ?? DECENT_API_URL; + this.defaultSender = config.defaultSender ?? DEFAULT_SENDER; + this.supportedChains = config.supportedChains ?? DEFAULT_DECENT_SUPPORTED_CHAINS; + this.supportedCurrencies = config.supportedCurrencies ?? DEFAULT_DECENT_SUPPORTED_CURRENCIES; + } + + async getQuote( + params: CryptoOnrampQuoteParams, + ): Promise> { + const { sourceCurrency, targetCurrency, recipientAddress } = params; + const sender = params.refundAddress ?? this.defaultSender; + + const chainConfig = this.supportedChains[sourceCurrency.chain]; + if (!chainConfig) { + throw new CryptoOnrampError( + `Decent: unsupported source chain "${sourceCurrency.chain}"`, + CryptoOnrampErrorCode.UnsupportedSourceChain, + { supportedChains: Object.keys(this.supportedChains) }, + ); + } + const srcChainId = chainConfig.slug; + + const swapDirection: DecentSwapDirection = + params.isSourceAmount === false ? 'exact-amount-out' : 'exact-amount-in'; + + if (!isEvmAddress(sender)) { + throw new CryptoOnrampError( + 'Decent: senderAddress must be a valid EVM address (got "' + sender + '")', + CryptoOnrampErrorCode.InvalidRefundAddress, + ); + } + + const url = new URL(`${this.apiUrl}/getAction`); + url.searchParams.set('actionType', 'swap-action'); + url.searchParams.set('sender', sender); + url.searchParams.set('srcChainId', String(srcChainId)); + url.searchParams.set('srcToken', sourceCurrency.address); + url.searchParams.set('dstChainId', String(TON_CHAIN_ID)); + url.searchParams.set('dstToken', targetCurrency.address); + url.searchParams.set('amount', params.amount); + url.searchParams.set('swapDirection', swapDirection); + url.searchParams.set('slippage', String(params.providerOptions?.slippageBps ?? DEFAULT_SLIPPAGE_BPS)); + url.searchParams.set('recipient', recipientAddress); + url.searchParams.set('returnDepositAddress', 'true'); + + let response: Response; + try { + response = await fetch(url.toString(), { + method: 'GET', + headers: { 'x-api-key': this.apiKey }, + }); + } catch (error) { + throw new CryptoOnrampError( + 'Decent: network error while calling getAction', + CryptoOnrampErrorCode.QuoteFailed, + error, + ); + } + + const body = (await response.json().catch(() => undefined)) as DecentGetActionResponse; + + if (!response.ok || isErrorResponse(body)) { + const err = isErrorResponse(body) ? body.error : undefined; + throw new CryptoOnrampError( + err?.message ?? `Decent getAction failed (HTTP ${response.status})`, + mapDecentErrorCode(err?.code, CryptoOnrampErrorCode.QuoteFailed), + err ?? { status: response.status }, + ); + } + + if (body.vmId !== 'evm') { + throw new CryptoOnrampError( + `Decent: only EVM source chains are supported (got vmId="${body.vmId}")`, + CryptoOnrampErrorCode.InvalidParams, + { vmId: body.vmId, srcChainId }, + ); + } + + const metadata: DecentQuoteMetadata = { sender, response: body }; + + return { + sourceCurrency, + targetCurrency, + sourceAmount: body.amountIn.amount, + targetAmount: body.amountOut.amount, + rate: String(body.exchangeRate), + recipientAddress, + providerId: this.providerId, + metadata, + }; + } + + async createDeposit(params: CryptoOnrampDepositParams): Promise { + const metadata = params.quote.metadata; + if (!metadata?.response?.tx?.to) { + throw new CryptoOnrampError( + 'Decent: quote metadata is missing — quote must be obtained from this provider', + CryptoOnrampErrorCode.InvalidParams, + ); + } + + const { response } = metadata; + + const needsRefetch = + metadata.sender === this.defaultSender || + (params.refundAddress !== undefined && params.refundAddress !== metadata.sender); + + if (needsRefetch) { + if (!params.refundAddress) { + throw new CryptoOnrampError( + 'Decent: a refund address is required to create a deposit', + CryptoOnrampErrorCode.RefundAddressRequired, + ); + } + + if (!isEvmAddress(params.refundAddress)) { + throw new CryptoOnrampError( + 'Decent: senderAddress must be a valid EVM address (got "' + params.refundAddress + '")', + CryptoOnrampErrorCode.InvalidRefundAddress, + ); + } + + const newQuote = await this.getQuote({ + amount: params.quote.sourceAmount, + sourceCurrency: params.quote.sourceCurrency, + targetCurrency: params.quote.targetCurrency, + recipientAddress: params.quote.recipientAddress, + refundAddress: params.refundAddress, + isSourceAmount: true, + }); + const newMetadata = newQuote.metadata; + + if (!newMetadata) { + throw new CryptoOnrampError( + 'Decent: quote metadata is missing — quote must be obtained from this provider', + CryptoOnrampErrorCode.InvalidParams, + ); + } + + return { + depositId: newMetadata.response.txId, + address: newMetadata.response.tx.to, + amount: newMetadata.response.amountIn.amount, + sourceCurrency: params.quote.sourceCurrency, + providerId: this.providerId, + }; + } + + return { + depositId: response.txId, + address: response.tx.to, + amount: response.amountIn.amount, + sourceCurrency: params.quote.sourceCurrency, + providerId: this.providerId, + }; + } + + async getStatus(params: CryptoOnrampStatusParams): Promise { + const url = new URL(`${this.apiUrl}/getStatus`); + url.searchParams.set('txId', params.depositId); + + let response: Response; + try { + response = await fetch(url.toString(), { + method: 'GET', + headers: { 'x-api-key': this.apiKey }, + }); + } catch (error) { + throw new CryptoOnrampError( + 'Decent: network error while fetching status', + CryptoOnrampErrorCode.ProviderError, + error, + ); + } + + const body = (await response.json().catch(() => undefined)) as { status: string }; + + if (!response.ok || isErrorResponse(body)) { + const err = isErrorResponse(body) ? body.error : undefined; + + if (isErrorResponse(body) && err?.code === 'NOT_FOUND') { + return 'pending'; + } + + throw new CryptoOnrampError( + err?.message ?? `Decent getStatus failed (HTTP ${response.status})`, + mapDecentErrorCode(err?.code, CryptoOnrampErrorCode.ProviderError), + err ?? { status: response.status }, + ); + } + + return mapStatus(body.status); + } + + /** + * Decent's API has no token-enumeration endpoint, so we just return the curated + * static list. Consumers can override via {@link DecentProviderConfig.supportedCurrencies}. + */ + async getSupportedCurrencies(): Promise { + return this.supportedCurrencies; + } +} + +/** + * Returns a `ProviderFactory` for `DecentCryptoOnrampProvider`. + * Pass to `providers: [createDecentProvider(config)]`. + */ +export const createDecentProvider = (config: DecentProviderConfig) => + createProvider(() => new DecentCryptoOnrampProvider(config)); diff --git a/packages/walletkit/src/defi/crypto-onramp/decent/README.md b/packages/walletkit/src/defi/crypto-onramp/decent/README.md new file mode 100644 index 000000000..b9a80a694 --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/decent/README.md @@ -0,0 +1,73 @@ + + +# Decent Crypto Onramp Provider + +Decent (formerly Swaps.xyz) is a multi-chain bridge aggregator that routes crypto from a wide range of source networks and tokens into TON assets. + +For more information about supported chains and tokens, see the [official documentation](https://docs.swaps.xyz). + +## Quick Start + +```typescript +import { createDecentProvider } from '@ton/walletkit/crypto-onramp/decent'; + +kit.cryptoOnramp.registerProvider( + createDecentProvider({ apiKey: 'your-api-key' }), +); +kit.cryptoOnramp.setDefaultProvider('decent'); +``` + +## Configuration + +```typescript +interface DecentProviderConfig { + apiKey: string; // API key issued by Decent + apiUrl?: string; // Default: 'https://api-v2.swaps.xyz/api' + defaultSender?: string; // Default EVM sender address used at quote time +} +``` + +## Quote Options + +```typescript +interface DecentQuoteOptions { + slippageBps?: number; // Slippage tolerance in basis points (default: 100 = 1%) +} +``` + +See [Crypto Onramp README](../README.md) for base `CryptoOnrampQuoteParams`. + +## Usage Example + +```typescript +import type { DecentQuoteOptions } from '@ton/walletkit/crypto-onramp/decent'; + +const quote = await kit.cryptoOnramp.getQuote({ + sourceCurrencyAddress: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', // USDT on Arbitrum + sourceNetwork: '42161', + targetCurrencyAddress: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', // USDT on TON + amount: '1000000', // 1 USDT (6 decimals) + recipientAddress: 'UQ...', // TON address to receive tokens + providerOptions: { + slippageBps: 50, // 0.5% + }, +}); + +const deposit = await kit.cryptoOnramp.createDeposit({ + quote, + refundAddress: '0x...', // EVM address to refund if the bridge fails +}); + +// deposit.address — contract to approve + call on the source chain +// deposit.amount — amount to send (in source token base units) +``` + +## How It Works + +1. **`getQuote`** — calls the Decent `getAction` endpoint. The raw response is stored in `quote.metadata` so that `createDeposit` can avoid a second network round-trip when `refundAddress` matches the address used at quote time. +2. **`createDeposit`** — if `refundAddress` differs from the address used at quote time (or if a placeholder was used), a fresh `getAction` call is made with the correct sender before returning the deposit details. +3. **`getStatus`** — polls `getStatus` by `txId` and maps the provider status to the canonical `'pending' | 'success' | 'failed'` enum. diff --git a/packages/walletkit/src/defi/crypto-onramp/decent/index.ts b/packages/walletkit/src/defi/crypto-onramp/decent/index.ts new file mode 100644 index 000000000..8069663da --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/decent/index.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './DecentCryptoOnrampProvider'; +export { DEFAULT_DECENT_SUPPORTED_CHAINS, DEFAULT_DECENT_SUPPORTED_CURRENCIES } from './utils'; +export type { DecentChainConfig } from './utils'; +export type { + DecentVmId, + DecentSwapDirection, + DecentPayment, + DecentEvmTx, + DecentBridgeRouteStep, + DecentGetActionResponse, + DecentErrorResponse, + DecentTokenInfo, + DecentChainPath, + DecentGetPathsResponse, +} from './types'; diff --git a/packages/walletkit/src/defi/crypto-onramp/decent/types.ts b/packages/walletkit/src/defi/crypto-onramp/decent/types.ts new file mode 100644 index 000000000..e7ef61372 --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/decent/types.ts @@ -0,0 +1,114 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * VM type identifier returned by the Decent API. Matches `vmId` from /getChainList. + */ +export type DecentVmId = 'evm' | 'solana' | 'alt-vm' | 'hypercore'; + +export type DecentSwapDirection = 'exact-amount-in' | 'exact-amount-out'; + +/** + * Token / amount entry as returned by the Decent API (Payment object). + */ +export interface DecentPayment { + chainId: number; + address: string; + name: string; + symbol: string; + decimals: number; + isNative: boolean; + amount: string; + usdAmount: number; + logo: string | null; + swapsXyzCode: string; +} + +export interface DecentEvmTx { + to: string; + toExtra: string | null; + value: string; + chainId: number; + chainKey: string; +} + +export interface DecentBridgeRouteStep { + srcChainId: number; + dstChainId: number; + srcBridgeToken: string; + dstBridgeToken: string; + bridgeId: string; +} + +/** + * Successful response of GET /api/getAction for actionType=swap-action. + * + * Non-exhaustive — only the fields we consume. `allRoutes` is an array of the + * same shape and is not typed here. + */ +export interface DecentGetActionResponse { + tx: DecentEvmTx; + txId: string; + vmId: DecentVmId; + amountIn: DecentPayment; + amountInMax: DecentPayment; + amountOut: DecentPayment; + amountOutMin: DecentPayment; + protocolFee: DecentPayment; + applicationFee: DecentPayment; + bridgeFee: DecentPayment; + bridgeIds: string[]; + bridgeRoute: DecentBridgeRouteStep[]; + exchangeRate: number; + estimatedTxTime: number; + estimatedPriceImpact: number; + requiresTokenApproval: boolean; + executionsType: 'DEFAULT' | 'GASLESS'; +} + +/** + * Token info entry as returned in `paths[].tokens` from `/getPaths`. + */ +export interface DecentTokenInfo { + chainId: number; + address: string; + name: string; + symbol: string; + decimals: number; + isNative: boolean; + logo: string | null; + swapsXyzCode?: string; +} + +export interface DecentChainPath { + chainId: number; + /** Either the literal string `'all'` or a concrete list of supported tokens. */ + tokens: 'all' | DecentTokenInfo[]; + supportsExactAmountIn?: boolean; + supportsExactAmountOut?: boolean; +} + +export interface DecentGetPathsResponse { + srcChainId: number; + srcToken: DecentTokenInfo; + paths: DecentChainPath[]; + timestamp: string; +} + +export interface DecentErrorResponse { + success: false; + error: { + code: string; + name: string; + message: string; + title: string; + statusCode: number; + details?: unknown; + timestamp: string; + }; +} diff --git a/packages/walletkit/src/defi/crypto-onramp/decent/utils.ts b/packages/walletkit/src/defi/crypto-onramp/decent/utils.ts new file mode 100644 index 000000000..321af646a --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/decent/utils.ts @@ -0,0 +1,287 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampStatus, CryptoOnrampSupportedCurrencies } from '../../../api/models'; +import { Caip2ByNetwork } from '../caip2'; +import { CryptoOnrampErrorCode } from '../errors'; +import type { DecentErrorResponse } from './types'; + +const EVM_ADDRESS_REGEX = /^(0x)?[0-9a-fA-F]{40}$/; + +/** + * Per-source-chain configuration for Decent. Currently just the Decent chain id; + * a future address-format regex will live alongside it once non-EVM source chains + * are supported (see `vmId === 'evm'` guard in `DecentCryptoOnrampProvider`). + */ +export interface DecentChainConfig { + /** Decent chain identifier — numeric chain id for EVM. */ + slug: string; +} + +/** + * Default mapping of CAIP-2 source chains to Decent network configs. + * Used by `DecentCryptoOnrampProvider` when no override is passed via config. + * Exported so consumers can spread/extend it rather than redefining from scratch. + */ +export const DEFAULT_DECENT_SUPPORTED_CHAINS: Record = { + [Caip2ByNetwork.EthereumMainnet]: { slug: '1' }, + [Caip2ByNetwork.OptimismMainnet]: { slug: '10' }, + [Caip2ByNetwork.BscMainnet]: { slug: '56' }, + [Caip2ByNetwork.PolygonMainnet]: { slug: '137' }, + [Caip2ByNetwork.BaseMainnet]: { slug: '8453' }, + [Caip2ByNetwork.ArbitrumMainnet]: { slug: '42161' }, + [Caip2ByNetwork.AvalancheMainnet]: { slug: '43114' }, +}; + +const LS = 'https://cdn.layerswap.io/layerswap/currencies'; + +/** + * Statically-curated supported-currencies list for Decent. Decent's API has no token + * enumeration endpoint, so we ship a hand-picked list of the most common (chain, token) + * pairs the provider routes into TON. Consumers can override via + * `DecentProviderConfig.supportedCurrencies`. + */ +export const DEFAULT_DECENT_SUPPORTED_CURRENCIES: CryptoOnrampSupportedCurrencies = { + source: [ + // Ethereum + { + chain: Caip2ByNetwork.EthereumMainnet, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + logo: `${LS}/eth.png`, + }, + { + chain: Caip2ByNetwork.EthereumMainnet, + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + symbol: 'USDT', + name: 'Tether', + decimals: 6, + logo: `${LS}/usdt.png`, + }, + { + chain: Caip2ByNetwork.EthereumMainnet, + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + logo: `${LS}/usdc.png`, + }, + // Optimism + { + chain: Caip2ByNetwork.OptimismMainnet, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + logo: `${LS}/eth.png`, + }, + { + chain: Caip2ByNetwork.OptimismMainnet, + address: '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58', + symbol: 'USDT', + name: 'Tether', + decimals: 6, + logo: `${LS}/usdt.png`, + }, + { + chain: Caip2ByNetwork.OptimismMainnet, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + logo: `${LS}/usdc.png`, + }, + // BSC + { + chain: Caip2ByNetwork.BscMainnet, + address: '0x0000000000000000000000000000000000000000', + symbol: 'BNB', + name: 'BNB', + decimals: 18, + logo: `${LS}/bnb.png`, + }, + { + chain: Caip2ByNetwork.BscMainnet, + address: '0x55d398326f99059fF775485246999027B3197955', + symbol: 'USDT', + name: 'Tether', + decimals: 18, + logo: `${LS}/usdt.png`, + }, + { + chain: Caip2ByNetwork.BscMainnet, + address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + symbol: 'USDC', + name: 'USD Coin', + decimals: 18, + logo: `${LS}/usdc.png`, + }, + // Polygon + { + chain: Caip2ByNetwork.PolygonMainnet, + address: '0x0000000000000000000000000000000000000000', + symbol: 'POL', + name: 'Polygon', + decimals: 18, + logo: `${LS}/pol.png`, + }, + { + chain: Caip2ByNetwork.PolygonMainnet, + address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', + symbol: 'USDT', + name: 'Tether', + decimals: 6, + logo: `${LS}/usdt.png`, + }, + { + chain: Caip2ByNetwork.PolygonMainnet, + address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + logo: `${LS}/usdc.png`, + }, + // Base + { + chain: Caip2ByNetwork.BaseMainnet, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + logo: `${LS}/eth.png`, + }, + { + chain: Caip2ByNetwork.BaseMainnet, + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + logo: `${LS}/usdc.png`, + }, + // Arbitrum + { + chain: Caip2ByNetwork.ArbitrumMainnet, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + logo: `${LS}/eth.png`, + }, + { + chain: Caip2ByNetwork.ArbitrumMainnet, + address: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', + symbol: 'USDT0', + name: 'Tether USD0', + decimals: 6, + logo: `${LS}/usdt0.png`, + }, + { + chain: Caip2ByNetwork.ArbitrumMainnet, + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + logo: `${LS}/usdc.png`, + }, + // Avalanche + { + chain: Caip2ByNetwork.AvalancheMainnet, + address: '0x0000000000000000000000000000000000000000', + symbol: 'AVAX', + name: 'Avalanche', + decimals: 18, + logo: `${LS}/avax.png`, + }, + { + chain: Caip2ByNetwork.AvalancheMainnet, + address: '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7', + symbol: 'USDT', + name: 'Tether', + decimals: 6, + logo: `${LS}/usdt.png`, + }, + { + chain: Caip2ByNetwork.AvalancheMainnet, + address: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + logo: `${LS}/usdc.png`, + }, + ], + destination: [ + { + address: '0x0000000000000000000000000000000000000000', + symbol: 'TON', + name: 'Toncoin', + decimals: 9, + logo: 'https://cdn.layerswap.io/layerswap/networks/ton_mainnet.png', + }, + { + address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', + symbol: 'USDT', + name: 'Tether', + decimals: 6, + logo: `${LS}/usdt.png`, + }, + ], +}; + +/** + * Translate a Decent-specific API error code into a provider-agnostic CryptoOnrampError code. + * Falls back to the original code when there is no known mapping. + */ +export const mapDecentErrorCode = ( + apiCode: string | undefined, + fallback: CryptoOnrampErrorCode, +): CryptoOnrampErrorCode => { + switch (apiCode) { + case 'NO_AVAILABLE_ROUTE': + return CryptoOnrampErrorCode.RouteNotFound; + case 'AMOUNT_TOO_HIGH': + return CryptoOnrampErrorCode.AmountTooLarge; + case 'AMOUNT_TOO_LOW': + return CryptoOnrampErrorCode.AmountTooSmall; + case 'INVALID_SOURCE_TOKEN': + return CryptoOnrampErrorCode.UnsupportedSourceToken; + case 'INVALID_DESTINATION_TOKEN': + return CryptoOnrampErrorCode.UnsupportedDestinationToken; + default: + return fallback; + } +}; + +export const isErrorResponse = (body: unknown): body is DecentErrorResponse => { + return ( + typeof body === 'object' && + body !== null && + (body as { success?: unknown }).success === false && + typeof (body as { error?: unknown }).error === 'object' + ); +}; + +export const mapStatus = (status: string): CryptoOnrampStatus => { + switch (status) { + case 'success': + return 'success'; + case 'pending': + return 'pending'; + case 'failed': + case 'requires refund': + case 'refunded': + return 'failed'; + default: + return 'pending'; + } +}; + +export const isEvmAddress = (address: string): boolean => { + return EVM_ADDRESS_REGEX.test(address); +}; diff --git a/packages/walletkit/src/defi/crypto-onramp/errors.ts b/packages/walletkit/src/defi/crypto-onramp/errors.ts new file mode 100644 index 000000000..e805e5fc1 --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/errors.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { DefiError } from '../errors'; + +export enum CryptoOnrampErrorCode { + ProviderError = 'PROVIDER_ERROR', + QuoteFailed = 'QUOTE_FAILED', + RefundAddressRequired = 'REFUND_ADDRESS_REQUIRED', + InvalidRefundAddress = 'INVALID_REFUND_ADDRESS', + ReversedAmountNotSupported = 'REVERSED_AMOUNT_NOT_SUPPORTED', + UnsupportedSourceChain = 'UNSUPPORTED_SOURCE_CHAIN', + UnsupportedSourceToken = 'UNSUPPORTED_SOURCE_TOKEN', + UnsupportedDestinationToken = 'UNSUPPORTED_DESTINATION_TOKEN', + RouteNotFound = 'ROUTE_NOT_FOUND', + AmountTooLarge = 'AMOUNT_TOO_LARGE', + AmountTooSmall = 'AMOUNT_TOO_SMALL', + InvalidParams = 'INVALID_PARAMS', +} + +export class CryptoOnrampError extends DefiError { + public readonly code: CryptoOnrampErrorCode; + + constructor(message: string, code: CryptoOnrampErrorCode, details?: unknown) { + super(message, code, details); + this.name = 'CryptoOnrampError'; + this.code = code; + } +} diff --git a/packages/walletkit/src/defi/crypto-onramp/index.ts b/packages/walletkit/src/defi/crypto-onramp/index.ts new file mode 100644 index 000000000..039ab2b2b --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/index.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './errors'; +export * from './CryptoOnrampManager'; +export * from './CryptoOnrampProvider'; +export { Caip2ByNetwork } from './caip2'; +export type { Caip2 } from './caip2'; diff --git a/packages/walletkit/src/defi/crypto-onramp/layerswap/LayerswapCryptoOnrampProvider.ts b/packages/walletkit/src/defi/crypto-onramp/layerswap/LayerswapCryptoOnrampProvider.ts new file mode 100644 index 000000000..052b892c8 --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/layerswap/LayerswapCryptoOnrampProvider.ts @@ -0,0 +1,417 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + CryptoOnrampDeposit, + CryptoOnrampDepositParams, + CryptoOnrampDestinationCurrency, + CryptoOnrampQuote, + CryptoOnrampQuoteParams, + CryptoOnrampSourceCurrency, + CryptoOnrampStatus, + CryptoOnrampStatusParams, + CryptoOnrampSupportedCurrencies, +} from '../../../api/models'; +import { Network } from '../../../api/models'; +import { CryptoOnrampProvider } from '../CryptoOnrampProvider'; +import { CryptoOnrampError, CryptoOnrampErrorCode } from '../errors'; +import { createProvider } from '../../../types/factory'; +import type { LayerswapCreateSwapResponse, LayerswapGetSwapResponse, LayerswapNetwork, LayerswapToken } from './types'; +import { + DEFAULT_LAYERSWAP_SUPPORTED_CHAINS, + LAYERSWAP_DESTINATION_NETWORK, + LAYERSWAP_DESTINATION_TOKENS, + formatBaseUnits, + isErrorResponse, + mapLayerswapErrorCode, + mapStatus, + parseBaseUnits, +} from './utils'; +import type { LayerswapChainConfig } from './utils'; + +const LAYERSWAP_API_URL = 'https://api.layerswap.io/api/v2'; + +export interface LayerswapProviderConfig { + /** + * Optional API key. Forwarded as `X-LS-APIKEY` when provided. + */ + apiKey?: string; + + /** + * Override the base API URL. Defaults to https://api.layerswap.io/api/v2 + */ + apiUrl?: string; + + /** + * Mapping of CAIP-2 source chain identifiers to Layerswap network configs + * (slug + refund-address regex). When omitted, defaults to + * {@link DEFAULT_LAYERSWAP_SUPPORTED_CHAINS}. Pass a full map (not a partial) + * — the override replaces the default. Spread the default to extend it. + */ + supportedChains?: Record; + + /** + * TON-side destination tokens that the provider asks Layerswap to route into. + * Drives the `/sources` discovery used by `getSupportedCurrencies`. When omitted, + * defaults to {@link LAYERSWAP_DESTINATION_TOKENS}. Pass a full list (not a partial) + * — the override replaces the default. Spread the default to extend it. + */ + destinationTokens?: CryptoOnrampDestinationCurrency[]; +} + +/** + * Metadata stored on the CryptoOnrampQuote returned by this provider. + * + * The swap is created at quote time, so we cache the swap id and deposit + * action here; `createDeposit` just reads them out. + */ +export interface LayerswapQuoteMetadata { + swapId: string; + depositAddress: string; + sourceAmountBaseUnits: string; + targetAmountBaseUnits: string; + /** Source address used when the swap was created, if any. */ + sourceAddress?: string; +} + +/** + * Provider implementation that routes crypto onramps through Layerswap. + * + * The supported set of (chain, token) pairs is discovered at runtime via the + * `/sources` endpoint. `getQuote` reads the symbol slug and decimals directly + * from the caller-supplied `params.sourceCurrency` — no internal cache lookup. + */ +export class LayerswapCryptoOnrampProvider extends CryptoOnrampProvider { + readonly providerId = 'layerswap'; + + getSupportedNetworks(): Network[] { + return [Network.mainnet()]; + } + + async getMetadata() { + return { + name: 'Layerswap', + url: 'https://layerswap.io', + isReversedAmountSupported: false, + refundAddressMode: 'optional' as const, + }; + } + + private readonly apiKey: string | undefined; + private readonly apiUrl: string; + private readonly supportedChains: Record; + private readonly destinationTokens: CryptoOnrampDestinationCurrency[]; + + constructor(config: LayerswapProviderConfig = {}) { + super(); + this.apiKey = config.apiKey; + this.apiUrl = config.apiUrl ?? LAYERSWAP_API_URL; + this.supportedChains = config.supportedChains ?? DEFAULT_LAYERSWAP_SUPPORTED_CHAINS; + this.destinationTokens = config.destinationTokens ?? LAYERSWAP_DESTINATION_TOKENS; + } + + async getQuote(params: CryptoOnrampQuoteParams): Promise> { + const { sourceCurrency, targetCurrency, recipientAddress, refundAddress } = params; + + const chainConfig = this.supportedChains[sourceCurrency.chain]; + if (!chainConfig) { + throw new CryptoOnrampError( + `Layerswap: unsupported source chain "${sourceCurrency.chain}"`, + CryptoOnrampErrorCode.UnsupportedSourceChain, + { supportedChains: Object.keys(this.supportedChains) }, + ); + } + + if (params.isSourceAmount === false) { + throw new CryptoOnrampError( + 'Layerswap: only source-amount quotes are supported', + CryptoOnrampErrorCode.ReversedAmountNotSupported, + ); + } + + if ( + refundAddress !== undefined && + refundAddress !== '' && + !new RegExp(chainConfig.addressRegex).test(refundAddress) + ) { + throw new CryptoOnrampError( + 'Layerswap: refundAddress is not in the expected format (got "' + refundAddress + '")', + CryptoOnrampErrorCode.InvalidRefundAddress, + ); + } + + const amountDecimal = formatBaseUnits(params.amount, sourceCurrency.decimals); + + const body = { + amount: amountDecimal, + source_network: chainConfig.slug, + destination_network: LAYERSWAP_DESTINATION_NETWORK, + source_token: sourceCurrency.symbol, + destination_token: targetCurrency.symbol, + destination_address: recipientAddress, + ...(refundAddress ? { source_address: refundAddress } : {}), + refuel: false, + use_deposit_address: true, + }; + + let response: Response; + try { + response = await fetch(`${this.apiUrl}/swaps`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.apiKey ? { 'X-LS-APIKEY': this.apiKey } : {}), + }, + body: JSON.stringify(body), + }); + } catch (error) { + throw new CryptoOnrampError( + 'Layerswap: network error while creating swap', + CryptoOnrampErrorCode.QuoteFailed, + error, + ); + } + + const json = (await response.json().catch(() => undefined)) as LayerswapCreateSwapResponse | undefined; + + if (!response.ok || !json || isErrorResponse(json)) { + const err = isErrorResponse(json) ? json.error : undefined; + throw new CryptoOnrampError( + err?.message ?? `Layerswap create swap failed (HTTP ${response.status})`, + mapLayerswapErrorCode(err?.code, err?.message, CryptoOnrampErrorCode.QuoteFailed), + err ?? { status: response.status }, + ); + } + + const data = json.data; + const depositAction = data.deposit_actions[0]; + if (!depositAction) { + throw new CryptoOnrampError( + 'Layerswap: swap was created but no deposit action was returned', + CryptoOnrampErrorCode.QuoteFailed, + data, + ); + } + + const targetAmountBaseUnits = parseBaseUnits(data.quote.receive_amount, targetCurrency.decimals); + const rate = + data.quote.requested_amount > 0 + ? (data.quote.receive_amount / data.quote.requested_amount).toString() + : '0'; + + const metadata: LayerswapQuoteMetadata = { + swapId: data.swap.id, + depositAddress: depositAction.to_address, + sourceAmountBaseUnits: depositAction.amount_in_base_units, + targetAmountBaseUnits, + sourceAddress: refundAddress || undefined, + }; + + return { + sourceCurrency, + targetCurrency, + sourceAmount: metadata.sourceAmountBaseUnits, + targetAmount: metadata.targetAmountBaseUnits, + rate, + recipientAddress, + providerId: this.providerId, + metadata, + }; + } + + async createDeposit(params: CryptoOnrampDepositParams): Promise { + const metadata = params.quote.metadata; + if (!metadata?.swapId) { + throw new CryptoOnrampError( + 'Layerswap: quote metadata is missing — quote must be obtained from this provider', + CryptoOnrampErrorCode.InvalidParams, + ); + } + + const requestedAddress = params.refundAddress || undefined; + if (requestedAddress !== metadata.sourceAddress) { + const newQuote = await this.getQuote({ + amount: metadata.sourceAmountBaseUnits, + sourceCurrency: params.quote.sourceCurrency, + targetCurrency: params.quote.targetCurrency, + recipientAddress: params.quote.recipientAddress, + refundAddress: requestedAddress, + isSourceAmount: true, + }); + const newMetadata = newQuote.metadata; + if (!newMetadata) { + throw new CryptoOnrampError( + 'Layerswap: quote metadata is missing — quote must be obtained from this provider', + CryptoOnrampErrorCode.InvalidParams, + ); + } + return { + depositId: newMetadata.swapId, + address: newMetadata.depositAddress, + amount: newMetadata.sourceAmountBaseUnits, + sourceCurrency: params.quote.sourceCurrency, + providerId: this.providerId, + }; + } + + return { + depositId: metadata.swapId, + address: metadata.depositAddress, + amount: metadata.sourceAmountBaseUnits, + sourceCurrency: params.quote.sourceCurrency, + providerId: this.providerId, + }; + } + + async getStatus(params: CryptoOnrampStatusParams): Promise { + const url = new URL(`${this.apiUrl}/swaps/${params.depositId}`); + url.searchParams.set('exclude_deposit_actions', 'true'); + + let response: Response; + try { + response = await fetch(url.toString(), { + method: 'GET', + headers: this.apiKey ? { 'X-LS-APIKEY': this.apiKey } : undefined, + }); + } catch (error) { + throw new CryptoOnrampError( + 'Layerswap: network error while fetching swap status', + CryptoOnrampErrorCode.ProviderError, + error, + ); + } + + if (response.status === 404) { + return 'pending'; + } + + const json = (await response.json().catch(() => undefined)) as LayerswapGetSwapResponse | undefined; + + if (!response.ok || !json || isErrorResponse(json)) { + const err = isErrorResponse(json) ? json.error : undefined; + throw new CryptoOnrampError( + err?.message ?? `Layerswap get swap failed (HTTP ${response.status})`, + mapLayerswapErrorCode(err?.code, err?.message, CryptoOnrampErrorCode.ProviderError), + err ?? { status: response.status }, + ); + } + + return mapStatus(json.data.swap.status); + } + + /** + * Destination side is static (TON has a small, known set of supported tokens — Layerswap won't + * surprise us there). For source, we query `/sources?destination_network=TON_MAINNET&destination_token=` + * once per destination token. The endpoint only returns pairs that actually route through, so + * subsequent `getQuote` calls won't run into `ROUTE_NOT_FOUND`. Multiple destination queries are + * merged with dedup by `(chain, address)`. + */ + async getSupportedCurrencies(): Promise { + const slugToCaip2 = buildSlugToCaip2Map(this.supportedChains); + const destination = this.destinationTokens; + + const results = await Promise.allSettled( + destination.map((dest) => this.fetchSources(LAYERSWAP_DESTINATION_NETWORK, dest.symbol)), + ); + + const sourceMap = new Map(); + for (const result of results) { + if (result.status !== 'fulfilled') continue; + for (const network of result.value) { + const caip2 = slugToCaip2[network.name]; + if (!caip2) continue; + for (const token of network.tokens ?? []) { + if (token.status && token.status !== 'active') continue; + const mapped = mapLayerswapTokenToSource(token, caip2); + const key = `${mapped.chain}:${mapped.address.toLowerCase()}`; + if (!sourceMap.has(key)) sourceMap.set(key, mapped); + } + } + } + + return { source: Array.from(sourceMap.values()), destination }; + } + + private async fetchSources( + destinationNetwork: string, + destinationToken: string, + ): Promise { + const url = new URL(`${this.apiUrl}/sources`); + url.searchParams.set('destination_network', destinationNetwork); + url.searchParams.set('destination_token', destinationToken); + url.searchParams.set('has_deposit_address', 'true'); + + let response: Response; + try { + response = await fetch(url.toString(), { + method: 'GET', + headers: this.apiKey ? { 'X-LS-APIKEY': this.apiKey } : undefined, + }); + } catch (error) { + throw new CryptoOnrampError( + 'Layerswap: network error while fetching sources', + CryptoOnrampErrorCode.ProviderError, + error, + ); + } + + const json = (await response.json().catch(() => undefined)) as + | { data?: LayerswapNetworkWithTokens[] } + | undefined; + + if (!response.ok || !json || !Array.isArray(json.data)) { + const err = isErrorResponse(json) ? json.error : undefined; + throw new CryptoOnrampError( + err?.message ?? `Layerswap /sources failed (HTTP ${response.status})`, + mapLayerswapErrorCode(err?.code, err?.message, CryptoOnrampErrorCode.ProviderError), + err ?? { status: response.status }, + ); + } + + return json.data; + } +} + +interface LayerswapNetworkWithTokens extends LayerswapNetwork { + source_rank?: number; + destination_rank?: number; + tokens?: Array< + LayerswapToken & { + source_rank?: number; + destination_rank?: number; + contract: string | null; + /** `/sources` marks each route token with a status string; we only keep `'active'`. */ + status?: string; + } + >; +} + +const buildSlugToCaip2Map = (caip2ToConfig: Record): Record => { + const reversed: Record = {}; + for (const [caip2, config] of Object.entries(caip2ToConfig)) reversed[config.slug] = caip2; + return reversed; +}; + +const mapLayerswapTokenToSource = ( + token: LayerswapToken & { contract: string | null }, + caip2: string, +): CryptoOnrampSourceCurrency => ({ + chain: caip2, + address: token.contract ?? '', + symbol: token.symbol, + name: token.display_asset ?? token.symbol, + decimals: token.decimals, + logo: token.logo ?? undefined, +}); + +/** + * Returns a `ProviderFactory` for `LayerswapCryptoOnrampProvider`. + * Pass to `providers: [createLayerswapProvider(config)]`. + */ +export const createLayerswapProvider = (config: LayerswapProviderConfig = {}) => + createProvider(() => new LayerswapCryptoOnrampProvider(config)); diff --git a/packages/walletkit/src/defi/crypto-onramp/layerswap/README.md b/packages/walletkit/src/defi/crypto-onramp/layerswap/README.md new file mode 100644 index 000000000..0ec2e3e5b --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/layerswap/README.md @@ -0,0 +1,56 @@ + + +# Layerswap Crypto Onramp Provider + +Layerswap is a multi-chain bridge that routes crypto assets from a wide range of source networks and tokens into TON assets. + +For more information about supported chains and tokens, see the [official documentation](https://docs.layerswap.io). + +## Quick Start + +```typescript +import { createLayerswapProvider } from '@ton/walletkit/crypto-onramp/layerswap'; + +kit.cryptoOnramp.registerProvider( + createLayerswapProvider({ apiKey: 'your-api-key' }), +); +kit.cryptoOnramp.setDefaultProvider('layerswap'); +``` + +## Configuration + +```typescript +interface LayerswapProviderConfig { + apiKey?: string; // Optional API key forwarded as X-LS-APIKEY + apiUrl?: string; // Default: 'https://api.layerswap.io/api/v2' +} +``` + +See [Crypto Onramp README](../README.md) for base `CryptoOnrampQuoteParams`. Layerswap does not define any additional provider-specific quote options. + +## Usage Example + +```typescript +const quote = await kit.cryptoOnramp.getQuote({ + sourceCurrencyAddress: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', // USDT on Arbitrum + sourceNetwork: '42161', + targetCurrencyAddress: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', // USDT on TON + amount: '1000000', // 1 USDT (6 decimals) + recipientAddress: 'UQ...', // TON address to receive tokens +}); + +const deposit = await kit.cryptoOnramp.createDeposit({ quote }); + +// deposit.address — address to send tokens to on the source chain +// deposit.amount — amount to send (in source token base units) +``` + +## How It Works + +1. **`getQuote`** — creates a Layerswap swap at quote time (POST `/swaps`) and caches the `swapId` and `depositAddress` in `quote.metadata`. No extra network call is needed at deposit time. +2. **`createDeposit`** — reads `swapId` and `depositAddress` directly from `quote.metadata`. No `refundAddress` is required for Layerswap. +3. **`getStatus`** — polls GET `/swaps/{swapId}` and maps the Layerswap status to the canonical `'pending' | 'success' | 'failed'` enum. diff --git a/packages/walletkit/src/defi/crypto-onramp/layerswap/index.ts b/packages/walletkit/src/defi/crypto-onramp/layerswap/index.ts new file mode 100644 index 000000000..a282beb5f --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/layerswap/index.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './LayerswapCryptoOnrampProvider'; +export { DEFAULT_LAYERSWAP_SUPPORTED_CHAINS, LAYERSWAP_DESTINATION_TOKENS } from './utils'; +export type { LayerswapChainConfig } from './utils'; +export type { + LayerswapToken, + LayerswapNetwork, + LayerswapDepositAction, + LayerswapSwap, + LayerswapSwapStatus, + LayerswapQuote, + LayerswapSwapData, + LayerswapCreateSwapResponse, + LayerswapGetSwapResponse, + LayerswapErrorResponse, +} from './types'; diff --git a/packages/walletkit/src/defi/crypto-onramp/layerswap/types.ts b/packages/walletkit/src/defi/crypto-onramp/layerswap/types.ts new file mode 100644 index 000000000..26fdadfe5 --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/layerswap/types.ts @@ -0,0 +1,118 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Token / network descriptors returned by Layerswap. Non-exhaustive — only the + * fields we consume. + */ +export interface LayerswapToken { + symbol: string; + display_asset: string; + logo: string | null; + contract: string | null; + decimals: number; + price_in_usd: number; + precision: number; + listing_date: string; + source_rank: number; + destination_rank: number; + group: string; +} + +export interface LayerswapNetwork { + name: string; + display_name: string; + logo: string; + chain_id: string | null; + node_url: string | null; + nodes: string[]; + type: string; + transaction_explorer_template: string; + account_explorer_template: string; + token?: LayerswapToken; +} + +export interface LayerswapDepositAction { + type: string; + to_address: string; + amount: number; + order: number; + amount_in_base_units: string; + network: LayerswapNetwork; + token: LayerswapToken; + fee_token: LayerswapToken; + call_data: string | null; + encoded_args: unknown[]; +} + +export type LayerswapSwapStatus = + | 'user_transfer_pending' + | 'ls_transfer_pending' + | 'completed' + | 'failed' + | 'refund_pending' + | 'refunded'; + +export interface LayerswapSwap { + id: string; + created_date: string; + source_network: LayerswapNetwork; + source_token: LayerswapToken; + destination_network: LayerswapNetwork; + destination_token: LayerswapToken; + requested_amount: number; + destination_address: string; + status: LayerswapSwapStatus | string; + fail_reason: string | null; + use_deposit_address: boolean; + metadata: Record; + transactions: unknown[]; +} + +export interface LayerswapQuote { + source_network: LayerswapNetwork; + source_token: LayerswapToken; + destination_network: LayerswapNetwork; + destination_token: LayerswapToken; + requested_amount: number; + receive_amount: number; + fee_discount: number; + min_receive_amount: number; + blockchain_fee: number; + service_fee: number; + avg_completion_time: string; + refuel_in_source: number; + slippage: number; + rate: number; + total_fee: number; + total_fee_in_usd: number; +} + +export interface LayerswapSwapData { + deposit_actions: LayerswapDepositAction[]; + swap: LayerswapSwap; + quote: LayerswapQuote; + refuel: unknown | null; + reward: unknown | null; +} + +export interface LayerswapCreateSwapResponse { + data: LayerswapSwapData; +} + +export interface LayerswapGetSwapResponse { + data: LayerswapSwapData; +} + +export interface LayerswapErrorResponse { + error: { + code?: string; + message: string; + [key: string]: unknown; + }; +} diff --git a/packages/walletkit/src/defi/crypto-onramp/layerswap/utils.ts b/packages/walletkit/src/defi/crypto-onramp/layerswap/utils.ts new file mode 100644 index 000000000..9e7010b71 --- /dev/null +++ b/packages/walletkit/src/defi/crypto-onramp/layerswap/utils.ts @@ -0,0 +1,168 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { CryptoOnrampDestinationCurrency, CryptoOnrampStatus } from '../../../api/models'; +import { Caip2ByNetwork } from '../caip2'; +import { CryptoOnrampErrorCode } from '../errors'; +import type { LayerswapErrorResponse, LayerswapSwapStatus } from './types'; + +export const LAYERSWAP_DESTINATION_NETWORK = 'TON_MAINNET'; + +/** + * Per-source-chain configuration for Layerswap. + */ +export interface LayerswapChainConfig { + /** Layerswap network slug, e.g. `'ETHEREUM_MAINNET'`. */ + slug: string; + /** + * Source-format regex (string form) used to validate the optional `refundAddress` + * supplied for this chain. Stored as a string so overrides stay easy to serialize. + * Format-only — checksums are not verified here; Layerswap's API does that. + */ + addressRegex: string; +} + +const EVM_ADDRESS_REGEX = '^0x[0-9a-fA-F]{40}$'; +const SOLANA_ADDRESS_REGEX = '^[1-9A-HJ-NP-Za-km-z]{32,44}$'; +const BITCOIN_ADDRESS_REGEX = '^([13][1-9A-HJ-NP-Za-km-z]{25,34}|bc1[02-9ac-hj-np-z]{6,87})$'; +const TRON_ADDRESS_REGEX = '^T[1-9A-HJ-NP-Za-km-z]{33}$'; + +/** + * Default mapping of CAIP-2 source chains to Layerswap network configs. + * Used by `LayerswapCryptoOnrampProvider` when no override is passed via config. + * Exported so consumers can spread/extend it rather than redefining from scratch. + */ +export const DEFAULT_LAYERSWAP_SUPPORTED_CHAINS: Record = { + [Caip2ByNetwork.EthereumMainnet]: { slug: 'ETHEREUM_MAINNET', addressRegex: EVM_ADDRESS_REGEX }, + [Caip2ByNetwork.OptimismMainnet]: { slug: 'OPTIMISM_MAINNET', addressRegex: EVM_ADDRESS_REGEX }, + [Caip2ByNetwork.BscMainnet]: { slug: 'BSC_MAINNET', addressRegex: EVM_ADDRESS_REGEX }, + [Caip2ByNetwork.PolygonMainnet]: { slug: 'POLYGON_MAINNET', addressRegex: EVM_ADDRESS_REGEX }, + [Caip2ByNetwork.BaseMainnet]: { slug: 'BASE_MAINNET', addressRegex: EVM_ADDRESS_REGEX }, + [Caip2ByNetwork.ArbitrumMainnet]: { slug: 'ARBITRUM_MAINNET', addressRegex: EVM_ADDRESS_REGEX }, + [Caip2ByNetwork.AvalancheMainnet]: { slug: 'AVALANCHE_MAINNET', addressRegex: EVM_ADDRESS_REGEX }, + [Caip2ByNetwork.SolanaMainnet]: { slug: 'SOLANA_MAINNET', addressRegex: SOLANA_ADDRESS_REGEX }, + [Caip2ByNetwork.BitcoinMainnet]: { slug: 'BITCOIN_MAINNET', addressRegex: BITCOIN_ADDRESS_REGEX }, + [Caip2ByNetwork.TronMainnet]: { slug: 'TRON_MAINNET', addressRegex: TRON_ADDRESS_REGEX }, +}; + +/** + * Default set of TON-side destination tokens queried via `/sources`. Drives which + * source (chain, token) pairs the provider discovers — Layerswap is asked for each + * destination token in turn, and the union is returned from `getSupportedCurrencies`. + * Exported so consumers can spread/extend it. + */ +export const LAYERSWAP_DESTINATION_TOKENS: CryptoOnrampDestinationCurrency[] = [ + { + address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', + symbol: 'USDT', + name: 'Tether', + decimals: 6, + logo: 'https://cdn.layerswap.io/layerswap/currencies/usdt.png', + }, + { + address: '0x0000000000000000000000000000000000000000', + symbol: 'TON', + name: 'Toncoin', + decimals: 9, + logo: 'https://cdn.layerswap.io/layerswap/networks/ton_mainnet.png', + }, +]; + +export const isErrorResponse = (body: unknown): body is LayerswapErrorResponse => { + return ( + typeof body === 'object' && + body !== null && + typeof (body as { error?: unknown }).error === 'object' && + (body as { error: { message?: unknown } }).error !== null + ); +}; + +/** + * Translate a Layerswap-specific API error into a provider-agnostic CryptoOnrampError code. + * + * Layerswap reuses `VALIDATION_ERROR` for unsupported source/destination tokens — the only + * differentiator is the network name in the message. Source/destination is disambiguated by + * checking whether the message references {@link LAYERSWAP_DESTINATION_NETWORK} (TON). When + * the message shape changes, the parser falls back to the caller's fallback code. + */ +export const mapLayerswapErrorCode = ( + apiCode: string | undefined, + apiMessage: string | undefined, + fallback: CryptoOnrampErrorCode, +): CryptoOnrampErrorCode => { + switch (apiCode) { + case 'ROUTE_NOT_FOUND_ERROR': + return CryptoOnrampErrorCode.RouteNotFound; + case 'GREATER_THAN_MAX_ERROR': + return CryptoOnrampErrorCode.AmountTooLarge; + case 'LESS_THAN_MIN_ERROR': + return CryptoOnrampErrorCode.AmountTooSmall; + case 'VALIDATION_ERROR': + if (apiMessage && /is not supported on/.test(apiMessage)) { + return apiMessage.includes(`'${LAYERSWAP_DESTINATION_NETWORK}'`) + ? CryptoOnrampErrorCode.UnsupportedDestinationToken + : CryptoOnrampErrorCode.UnsupportedSourceToken; + } + return fallback; + default: + return fallback; + } +}; + +export const mapStatus = (status: LayerswapSwapStatus | string): CryptoOnrampStatus => { + switch (status) { + case 'completed': + case 'user_payout_completed': + case 'payout_completed': + return 'success'; + case 'user_transfer_pending': + case 'user_transfer_delayed': + case 'ls_transfer_pending': + case 'initiated': + case 'created': + return 'pending'; + case 'expired': + case 'failed': + case 'cancelled': + case 'refunded': + case 'requires_refund': + return 'failed'; + default: + return 'pending'; + } +}; + +/** + * Format a base-units integer string into a decimal token-units string. + * e.g. formatBaseUnits('2000000', 6) === '2' + */ +export const formatBaseUnits = (base: string, decimals: number): string => { + if (!/^\d+$/.test(base)) { + throw new Error(`formatBaseUnits: not a non-negative integer string: "${base}"`); + } + if (decimals === 0) return base; + const padded = base.padStart(decimals + 1, '0'); + const whole = padded.slice(0, padded.length - decimals); + const frac = padded.slice(padded.length - decimals).replace(/0+$/, ''); + return frac.length > 0 ? `${whole}.${frac}` : whole; +}; + +/** + * Scale a decimal token-units string by 10^decimals and return the integer + * base-units string, truncating any excess fractional digits. + */ +export const parseBaseUnits = (value: number | string, decimals: number): string => { + const str = typeof value === 'number' ? value.toString() : value; + if (!/^\d+(\.\d+)?$/.test(str)) { + throw new Error(`parseBaseUnits: not a non-negative decimal: "${str}"`); + } + const [whole, frac = ''] = str.split('.'); + const truncated = frac.slice(0, decimals).padEnd(decimals, '0'); + const combined = `${whole}${truncated}`.replace(/^0+/, ''); + return combined.length > 0 ? combined : '0'; +}; diff --git a/packages/walletkit/src/defi/index.ts b/packages/walletkit/src/defi/index.ts new file mode 100644 index 000000000..41d157fbc --- /dev/null +++ b/packages/walletkit/src/defi/index.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './errors'; +export * from './DefiManager'; +export * from './swap'; +export * from './crypto-onramp'; diff --git a/packages/walletkit/src/defi/swap/dedust/README.md b/packages/walletkit/src/defi/swap/dedust/README.md index 767d9d2a1..e2b5ba316 100644 --- a/packages/walletkit/src/defi/swap/dedust/README.md +++ b/packages/walletkit/src/defi/swap/dedust/README.md @@ -21,7 +21,7 @@ kit.registerProvider( ```typescript interface DeDustSwapProviderConfig { providerId?: string; // Default: 'dedust' - apiUrl?: string; // Default: 'https://mainnet.api.dedust.io/v4/router' + apiUrl?: string; // Default: 'https://api-mainnet.dedust.io' defaultSlippageBps?: number; // Default: 100 (1%) referralAddress?: string; // Optional referral address referralFeeBps?: number; // Referral fee in bps (max 100 = 1%) diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 8faa78e9f..0f20c4461 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -22,6 +22,13 @@ export { JettonsManager } from './core/JettonsManager'; export { DefiError, DefiErrorCode } from './defi/errors'; export { SwapManager, SwapProvider, SwapError, SwapErrorCode } from './defi/swap'; export { StakingManager, StakingProvider, StakingError, StakingErrorCode } from './defi/staking'; +export { + CryptoOnrampManager, + CryptoOnrampProvider, + CryptoOnrampError, + CryptoOnrampErrorCode, + Caip2ByNetwork, +} from './defi/crypto-onramp'; export { GaslessManager, GaslessProvider, GaslessError, GaslessErrorCode } from './defi/gasless'; export { EventEmitter } from './core/EventEmitter'; export type { EventListener, EventPayload, KitEvent } from './core/EventEmitter'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1adf75fa..175c58d77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -506,6 +506,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.3) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -6561,6 +6564,11 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -14135,6 +14143,10 @@ snapshots: pure-rand@7.0.1: {} + qrcode.react@4.2.0(react@19.2.3): + dependencies: + react: 19.2.3 + qs@6.15.1: dependencies: side-channel: 1.1.0 diff --git a/template/packages/appkit-react/docs/hooks.md b/template/packages/appkit-react/docs/hooks.md index 37dcf8575..2a213699e 100644 --- a/template/packages/appkit-react/docs/hooks.md +++ b/template/packages/appkit-react/docs/hooks.md @@ -194,6 +194,26 @@ Hook to get all registered swap providers. The returned array keeps a stable ref %%demo/examples/src/appkit/hooks/swap#USE_SWAP_PROVIDERS%% +## Crypto Onramp + +### `useCryptoOnrampProvider` + +Hook to get a registered crypto-onramp provider by id, or the default one when no id is given. + +%%demo/examples/src/appkit/hooks/onramp#USE_CRYPTO_ONRAMP_PROVIDER%% + +### `useCryptoOnrampProviders` + +Hook to get all registered crypto-onramp providers. + +%%demo/examples/src/appkit/hooks/onramp#USE_CRYPTO_ONRAMP_PROVIDERS%% + +### `useCryptoOnrampProviderMetadata` + +Hook to get static metadata for a crypto-onramp provider (display name, logo, url). + +%%demo/examples/src/appkit/hooks/onramp#USE_CRYPTO_ONRAMP_PROVIDER_METADATA%% + ## Staking ### `useStakingProviders` diff --git a/template/packages/appkit/docs/actions.md b/template/packages/appkit/docs/actions.md index 7338e00cc..529f12a89 100644 --- a/template/packages/appkit/docs/actions.md +++ b/template/packages/appkit/docs/actions.md @@ -208,6 +208,60 @@ Transfer a NFT to a recipient address. %%demo/examples/src/appkit/actions/nft#TRANSFER_NFT%% +## Onramp + +### `getOnrampManager` + +Get the `OnrampManager` instance. + +### `getOnrampProvider` + +Get a specific onramp provider by its ID. + +### `getOnrampProviders` + +Get all registered onramp providers. + +### `watchOnrampProviders` + +Watch for new onramp providers registration. + +### `getOnrampQuote` + +Get an onramp quote from registered providers. + +%%demo/examples/src/appkit/actions/onramp#GET_ONRAMP_QUOTE%% + +### `buildOnrampUrl` + +Build an onramp URL for redirecting the user to the provider. + +%%demo/examples/src/appkit/actions/onramp#BUILD_ONRAMP_URL%% + +## Crypto Onramp + +### `getCryptoOnrampProvider` + +Get a registered crypto-onramp provider by id, or the default one when no id is given. + +%%demo/examples/src/appkit/actions/onramp#GET_CRYPTO_ONRAMP_PROVIDER%% + +### `getCryptoOnrampProviders` + +Get all registered crypto-onramp providers. + +%%demo/examples/src/appkit/actions/onramp#GET_CRYPTO_ONRAMP_PROVIDERS%% + +### `getCryptoOnrampProviderMetadata` + +Get static metadata for a crypto-onramp provider (display name, logo, url). + +%%demo/examples/src/appkit/actions/onramp#GET_CRYPTO_ONRAMP_PROVIDER_METADATA%% + +### `watchCryptoOnrampProviders` + +Watch for new crypto-onramp providers registration and default-provider changes. + ## Providers ### `registerProvider` diff --git a/template/packages/walletkit/src/defi/crypto-onramp/README.md b/template/packages/walletkit/src/defi/crypto-onramp/README.md new file mode 100644 index 000000000..dbcf0b592 --- /dev/null +++ b/template/packages/walletkit/src/defi/crypto-onramp/README.md @@ -0,0 +1,182 @@ +--- +target: packages/walletkit/src/defi/crypto-onramp/README.md +--- + +# Crypto Onramp Manager + +CryptoOnrampManager provides a unified interface for bridging crypto assets from external chains into TON assets via third-party bridge providers. + +## Quick Start + +```typescript +import { TonWalletKit, Network } from '@ton/walletkit'; +import { createDecentProvider } from '@ton/walletkit/crypto-onramp/decent'; + +const kit = new TonWalletKit({ + networks: { + [Network.mainnet().chainId]: { + apiClient: { url: 'https://toncenter.com', key: 'optional-api-key' }, + }, + }, +}); + +kit.cryptoOnramp.registerProvider( + createDecentProvider({ apiKey: 'your-api-key' }), +); +kit.cryptoOnramp.setDefaultProvider('decent'); +``` + +## Quote Parameters + +All providers accept the same base parameters for `getQuote`: + +```typescript +interface CryptoOnrampQuoteParams { + amount: string; // Amount in base units (source or target, see isSourceAmount) + sourceCurrencyAddress: string; // Source token contract address (or native zero address) + sourceNetwork: string; // Source chain identifier + targetCurrencyAddress: string; // Target TON token address + recipientAddress: string; // TON address that will receive the target crypto + refundAddress?: string; // Refund address on the source chain (required by some providers) + isSourceAmount?: boolean; // true = spend `amount` source tokens, false = receive `amount` target tokens (default: true) + providerOptions?: TProviderOptions; // Provider-specific options (slippage, etc.) +} +``` + +## Getting a Quote + +```typescript +const quote = await kit.cryptoOnramp.getQuote({ + sourceCurrencyAddress: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', // USDT on Arbitrum + sourceNetwork: '42161', // Arbitrum One chain ID + targetCurrencyAddress: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', // USDT on TON + amount: '1000000', // 1 USDT (6 decimals) + recipientAddress: 'UQ...', // TON address to receive the bridged tokens +}); + +console.log('Source amount:', quote.sourceAmount); +console.log('Target amount:', quote.targetAmount); +console.log('Rate:', quote.rate); +console.log('Recipient:', quote.recipientAddress); +``` + +## Creating a Deposit + +```typescript +const deposit = await kit.cryptoOnramp.createDeposit({ + quote, + refundAddress: '0x...', // EVM address to refund on failure (required by some providers) +}); + +console.log('Send to:', deposit.address); +console.log('Amount:', deposit.amount); +console.log('Deposit ID:', deposit.depositId); +``` + +## Checking Deposit Status + +```typescript +const status = await kit.cryptoOnramp.getStatus({ + depositId: deposit.depositId, +}); +// status: 'pending' | 'success' | 'failed' +``` + +## Creating a Custom Provider + +Extend `CryptoOnrampProvider` to integrate a new bridge: + +```typescript +import { + CryptoOnrampProvider, + type CryptoOnrampQuoteParams, + type CryptoOnrampQuote, + type CryptoOnrampDepositParams, + type CryptoOnrampDeposit, + type CryptoOnrampStatusParams, + type CryptoOnrampStatus, + type Network, +} from '@ton/walletkit'; + +interface MyQuoteOptions { + slippageBps?: number; +} + +export class MyCryptoOnrampProvider extends CryptoOnrampProvider { + readonly providerId = 'my-provider'; + + getSupportedNetworks(): Network[] { + return [Network.mainnet()]; + } + + getMetadata() { + return { name: 'My Provider', url: 'https://my-provider.com', isRefundAddressRequired: true }; + } + + async getQuote(params: CryptoOnrampQuoteParams): Promise { + const { recipientAddress, sourceCurrencyAddress, targetCurrencyAddress } = params; + // Fetch quote from your bridge API... + return { + sourceCurrencyAddress, + sourceNetwork: params.sourceNetwork, + targetCurrencyAddress, + sourceAmount: '...', + targetAmount: '...', + rate: '...', + recipientAddress, + providerId: this.providerId, + }; + } + + async createDeposit(params: CryptoOnrampDepositParams): Promise { + // params.quote.recipientAddress holds the TON recipient set at quote time + return { depositId: '...', address: '0x...', amount: '...', sourceCurrencyAddress: '...', sourceNetwork: '...', providerId: this.providerId }; + } + + async getStatus(params: CryptoOnrampStatusParams): Promise { + return 'pending'; + } +} +``` + +## Available Providers + +- **[Decent](https://github.com/ton-connect/kit/blob/main/packages/walletkit/src/defi/crypto-onramp/decent/README.md)**: Multi-chain bridge to TON via Decent (formerly Swaps.xyz), supporting a wide range of source networks and tokens +- **[Layerswap](https://github.com/ton-connect/kit/blob/main/packages/walletkit/src/defi/crypto-onramp/layerswap/README.md)**: Multi-chain bridge to TON via Layerswap + +## API Reference + +### CryptoOnrampManager + +#### `getQuote(params, providerId?)` +Get a quote for a crypto-to-TON bridge operation. + +**Parameters:** +- `params: CryptoOnrampQuoteParams` – `sourceCurrencyAddress`, `sourceNetwork`, `targetCurrencyAddress`, `amount`, `recipientAddress`, `isSourceAmount?`, `refundAddress?`, `providerOptions?` +- `providerId?: string` – Provider to use (uses default if omitted) + +**Returns:** `Promise` + +#### `createDeposit(params, providerId?)` +Create a deposit from a previously obtained quote. Returns the address the user must fund on the source chain. + +**Parameters:** +- `params: CryptoOnrampDepositParams` – `quote`, `refundAddress` +- `providerId?: string` – Resolved from `quote.providerId` when omitted + +**Returns:** `Promise` + +#### `getStatus(params, providerId?)` +Poll the status of a deposit. + +**Parameters:** +- `params: CryptoOnrampStatusParams` – `depositId` +- `providerId?: string` – Provider to use (uses default if omitted) + +**Returns:** `Promise` — `'pending' | 'success' | 'failed'` + +#### `registerProvider(provider)` +Register a new crypto onramp provider (instance or factory). + +#### `setDefaultProvider(providerId)` +Set the default provider for operations that do not specify one. diff --git a/template/packages/walletkit/src/defi/crypto-onramp/decent/README.md b/template/packages/walletkit/src/defi/crypto-onramp/decent/README.md new file mode 100644 index 000000000..e5ab01a51 --- /dev/null +++ b/template/packages/walletkit/src/defi/crypto-onramp/decent/README.md @@ -0,0 +1,71 @@ +--- +target: packages/walletkit/src/defi/crypto-onramp/decent/README.md +--- + +# Decent Crypto Onramp Provider + +Decent (formerly Swaps.xyz) is a multi-chain bridge aggregator that routes crypto from a wide range of source networks and tokens into TON assets. + +For more information about supported chains and tokens, see the [official documentation](https://docs.swaps.xyz). + +## Quick Start + +```typescript +import { createDecentProvider } from '@ton/walletkit/crypto-onramp/decent'; + +kit.cryptoOnramp.registerProvider( + createDecentProvider({ apiKey: 'your-api-key' }), +); +kit.cryptoOnramp.setDefaultProvider('decent'); +``` + +## Configuration + +```typescript +interface DecentProviderConfig { + apiKey: string; // API key issued by Decent + apiUrl?: string; // Default: 'https://api-v2.swaps.xyz/api' + defaultSender?: string; // Default EVM sender address used at quote time +} +``` + +## Quote Options + +```typescript +interface DecentQuoteOptions { + slippageBps?: number; // Slippage tolerance in basis points (default: 100 = 1%) +} +``` + +See [Crypto Onramp README](../README.md) for base `CryptoOnrampQuoteParams`. + +## Usage Example + +```typescript +import type { DecentQuoteOptions } from '@ton/walletkit/crypto-onramp/decent'; + +const quote = await kit.cryptoOnramp.getQuote({ + sourceCurrencyAddress: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', // USDT on Arbitrum + sourceNetwork: '42161', + targetCurrencyAddress: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', // USDT on TON + amount: '1000000', // 1 USDT (6 decimals) + recipientAddress: 'UQ...', // TON address to receive tokens + providerOptions: { + slippageBps: 50, // 0.5% + }, +}); + +const deposit = await kit.cryptoOnramp.createDeposit({ + quote, + refundAddress: '0x...', // EVM address to refund if the bridge fails +}); + +// deposit.address — contract to approve + call on the source chain +// deposit.amount — amount to send (in source token base units) +``` + +## How It Works + +1. **`getQuote`** — calls the Decent `getAction` endpoint. The raw response is stored in `quote.metadata` so that `createDeposit` can avoid a second network round-trip when `refundAddress` matches the address used at quote time. +2. **`createDeposit`** — if `refundAddress` differs from the address used at quote time (or if a placeholder was used), a fresh `getAction` call is made with the correct sender before returning the deposit details. +3. **`getStatus`** — polls `getStatus` by `txId` and maps the provider status to the canonical `'pending' | 'success' | 'failed'` enum. diff --git a/template/packages/walletkit/src/defi/crypto-onramp/layerswap/README.md b/template/packages/walletkit/src/defi/crypto-onramp/layerswap/README.md new file mode 100644 index 000000000..b30767329 --- /dev/null +++ b/template/packages/walletkit/src/defi/crypto-onramp/layerswap/README.md @@ -0,0 +1,54 @@ +--- +target: packages/walletkit/src/defi/crypto-onramp/layerswap/README.md +--- + +# Layerswap Crypto Onramp Provider + +Layerswap is a multi-chain bridge that routes crypto assets from a wide range of source networks and tokens into TON assets. + +For more information about supported chains and tokens, see the [official documentation](https://docs.layerswap.io). + +## Quick Start + +```typescript +import { createLayerswapProvider } from '@ton/walletkit/crypto-onramp/layerswap'; + +kit.cryptoOnramp.registerProvider( + createLayerswapProvider({ apiKey: 'your-api-key' }), +); +kit.cryptoOnramp.setDefaultProvider('layerswap'); +``` + +## Configuration + +```typescript +interface LayerswapProviderConfig { + apiKey?: string; // Optional API key forwarded as X-LS-APIKEY + apiUrl?: string; // Default: 'https://api.layerswap.io/api/v2' +} +``` + +See [Crypto Onramp README](../README.md) for base `CryptoOnrampQuoteParams`. Layerswap does not define any additional provider-specific quote options. + +## Usage Example + +```typescript +const quote = await kit.cryptoOnramp.getQuote({ + sourceCurrencyAddress: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', // USDT on Arbitrum + sourceNetwork: '42161', + targetCurrencyAddress: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', // USDT on TON + amount: '1000000', // 1 USDT (6 decimals) + recipientAddress: 'UQ...', // TON address to receive tokens +}); + +const deposit = await kit.cryptoOnramp.createDeposit({ quote }); + +// deposit.address — address to send tokens to on the source chain +// deposit.amount — amount to send (in source token base units) +``` + +## How It Works + +1. **`getQuote`** — creates a Layerswap swap at quote time (POST `/swaps`) and caches the `swapId` and `depositAddress` in `quote.metadata`. No extra network call is needed at deposit time. +2. **`createDeposit`** — reads `swapId` and `depositAddress` directly from `quote.metadata`. No `refundAddress` is required for Layerswap. +3. **`getStatus`** — polls GET `/swaps/{swapId}` and maps the Layerswap status to the canonical `'pending' | 'success' | 'failed'` enum.