From 74c1ed326fafe25d1e6b48ad73dd29452445d838 Mon Sep 17 00:00:00 2001 From: Ilyar Date: Thu, 15 Jan 2026 10:47:58 +0100 Subject: [PATCH 01/15] feat: add staking api --- .../src/defi/staking/StakingManager.ts | 63 +++++ .../src/defi/staking/StakingProvider.ts | 55 ++++ packages/walletkit/src/defi/staking/errors.ts | 20 ++ packages/walletkit/src/defi/staking/index.ts | 12 + .../tonstakers/TonstakersProvider.spec.ts | 43 +++ .../staking/tonstakers/TonstakersProvider.ts | 257 ++++++++++++++++++ .../src/defi/staking/tonstakers/index.ts | 10 + .../src/defi/staking/tonstakers/types.ts | 21 ++ .../src/defi/staking/tonstakers/utils.ts | 67 +++++ packages/walletkit/src/defi/staking/types.ts | 111 ++++++++ 10 files changed, 659 insertions(+) create mode 100644 packages/walletkit/src/defi/staking/StakingManager.ts create mode 100644 packages/walletkit/src/defi/staking/StakingProvider.ts create mode 100644 packages/walletkit/src/defi/staking/errors.ts create mode 100644 packages/walletkit/src/defi/staking/index.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.spec.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/index.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/types.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/utils.ts create mode 100644 packages/walletkit/src/defi/staking/types.ts diff --git a/packages/walletkit/src/defi/staking/StakingManager.ts b/packages/walletkit/src/defi/staking/StakingManager.ts new file mode 100644 index 000000000..3129be51c --- /dev/null +++ b/packages/walletkit/src/defi/staking/StakingManager.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. + * + */ + +import type { TransactionRequest } from '../../api/models'; +import type { StakingAPI, SwapQuoteParams, SwapQuote, SwapParams, StakingProviderInterface } from './types'; +import { StakingError } from './errors'; +import { globalLogger } from '../../core/Logger'; +import { DefiManager } from '../DefiManager'; + +const log = globalLogger.createChild('StakingManager'); + +export class StakingManager extends DefiManager implements StakingAPI { + async getQuote(params: SwapQuoteParams, provider?: string): Promise { + log.debug('Getting staking quote', { + fromToken: params.fromToken, + toToken: params.toToken, + amount: params.amount, + provider: provider || this.defaultProvider, + }); + + try { + const quote = await this.getProvider(provider).getQuote(params); + + log.debug('Received staking quote', { + fromAmount: quote.fromAmount, + toAmount: quote.toAmount, + priceImpact: quote.priceImpact, + }); + + return quote; + } catch (error) { + log.error('Failed to get staking quote', { error, params }); + throw error; + } + } + + async buildStakingTransaction(params: SwapParams, provider?: string): Promise { + log.debug('Building staking transaction', { + userAddress: params.userAddress, + provider: provider || this.defaultProvider, + }); + + try { + const transaction = await this.getProvider(provider).buildSwapTransaction(params); + + log.debug('Built staking transaction', params.quote); + + return transaction; + } catch (error) { + log.error('Failed to build staking transaction', { error, params }); + throw error; + } + } + + protected createError(message: string, code: string, details?: unknown): StakingError { + return new StakingError(message, code, details); + } +} diff --git a/packages/walletkit/src/defi/staking/StakingProvider.ts b/packages/walletkit/src/defi/staking/StakingProvider.ts new file mode 100644 index 000000000..f512618cc --- /dev/null +++ b/packages/walletkit/src/defi/staking/StakingProvider.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 { ApiClient } from '../../types/toncenter/ApiClient'; +import type { Network, TransactionRequest } from '../../api/models'; +import type { NetworkManager } from '../../core/NetworkManager'; +import type { EventEmitter } from '../../core/EventEmitter'; +import type { SwapQuoteParams, SwapQuote, SwapParams, StakingProviderInterface } from './types'; + +export abstract class StakingProvider implements StakingProviderInterface { + protected networkManager: NetworkManager; + protected eventEmitter: EventEmitter; + + constructor(networkManager: NetworkManager, eventEmitter: EventEmitter) { + this.networkManager = networkManager; + this.eventEmitter = eventEmitter; + } + + /** + * Get a quote for swapping tokens + * @param params - Quote parameters including tokens, amount, and network + * @returns Promise resolving to swap quote with pricing information + */ + abstract getQuote(params: SwapQuoteParams): Promise; + + /** + * Build a transaction for executing the swap + * @param params - Swap parameters including quote and user address + * @returns Promise resolving to transaction request ready to be signed + */ + abstract buildSwapTransaction(params: SwapParams): Promise; + + /** + * Get API client for a specific network + * @param network - The network to get client for + * @returns API client instance + */ + protected getApiClient(network: Network): ApiClient { + return this.networkManager.getClient(network); + } + + /** + * Emit an event through the event emitter + * @param event - Event name + * @param data - Event data + */ + protected emitEvent(event: string, data: unknown): void { + this.eventEmitter.emit(event, data); + } +} diff --git a/packages/walletkit/src/defi/staking/errors.ts b/packages/walletkit/src/defi/staking/errors.ts new file mode 100644 index 000000000..d4d0b1717 --- /dev/null +++ b/packages/walletkit/src/defi/staking/errors.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 { DefiManagerError } from '../errors'; + +export class StakingError extends DefiManagerError { + static readonly INVALID_QUOTE = 'INVALID_QUOTE'; // TODO rewrite + static readonly INSUFFICIENT_LIQUIDITY = 'INSUFFICIENT_LIQUIDITY'; // TODO rewrite + static readonly QUOTE_EXPIRED = 'QUOTE_EXPIRED'; // TODO rewrite + + constructor(message: string, code: string, details?: unknown) { + super(message, code, details); + this.name = 'StakingError'; + } +} diff --git a/packages/walletkit/src/defi/staking/index.ts b/packages/walletkit/src/defi/staking/index.ts new file mode 100644 index 000000000..d717e4564 --- /dev/null +++ b/packages/walletkit/src/defi/staking/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 { StakingProvider } from './StakingProvider'; +export { StakingManager } from './StakingManager'; +export { StakingError } from './errors'; +export type * from './types'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.spec.ts new file mode 100644 index 000000000..7d583c89a --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.spec.ts @@ -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 { describe, it, expect, vi } from 'vitest'; + +import { TonstakersProvider } from './TonstakersProvider'; +import { Network } from '../../../api/models'; +import type { NetworkManager } from '../../../core/NetworkManager'; +import type { EventEmitter } from '../../../core/EventEmitter'; +import { isOmnistonQuoteMetadata } from './utils'; + +// Skip integration tests +describe.skip('OmnistonSwapProvider.getQuote', () => { + let mockNetworkManager: NetworkManager = {} as NetworkManager; + let mockEventEmitter = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + } as unknown as EventEmitter; + + it('should return quote for TON to USDT swap', async () => { + const provider = new TonstakersProvider(mockNetworkManager, mockEventEmitter, { + defaultSlippageBps: 100, + quoteTimeoutMs: 30000, + }); + + const quote = await provider.getQuote({ + fromToken: 'TON', + toToken: 'EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO', // USDT + amount: '1000000000', // 1 TON + network: Network.mainnet(), + }); + + expect(BigInt(quote.toAmount)).toBeGreaterThan(0); + expect(isOmnistonQuoteMetadata(quote.metadata)).toBeTruthy(); + expect(mockEventEmitter.emit).toHaveBeenCalledWith('swap:quote:received', expect.any(Object)); + }, 30000); +}); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.ts new file mode 100644 index 000000000..c4cf14ac5 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.ts @@ -0,0 +1,257 @@ +/** + * 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 { SettlementMethod, Omniston, GaslessSettlement } from '@ston-fi/omniston-sdk'; +import type { Quote, QuoteResponseEvent, QuoteRequest } from '@ston-fi/omniston-sdk'; + +import type { OmnistonQuoteMetadata } from './types'; +import { StakingProvider } from '../StakingProvider'; +import type { SwapQuoteParams, SwapQuote, SwapParams, SwapFee } from '../types'; +import { SwapError } from '../errors'; +import type { NetworkManager } from '../../../core/NetworkManager'; +import type { EventEmitter } from '../../../core/EventEmitter'; +import { globalLogger } from '../../../core/Logger'; +import { tokenToAddress, addressToToken, toOmnistonAddress, isOmnistonQuoteMetadata } from './utils'; +import type { TransactionRequest } from '../../../api/models'; + +const log = globalLogger.createChild('TonstakersProvider'); + +export interface TonstakersProviderConfig { + apiUrl?: string; + defaultSlippageBps?: number; + quoteTimeoutMs?: number; + referrerAddress?: string; + referrerFeeBps?: number; + flexibleReferrerFee?: boolean; +} + +/** + * Swap provider implementation for Omniston (STON.fi) protocol + * + * Uses the Omniston SDK to get quotes and build swap transactions + * across multiple DEXs on TON blockchain. + * + * @example + * ```typescript + * // Import from separate entry point to avoid bundling Omniston SDK + * import { OmnistonSwapProvider } from '@ton/walletkit/swap/omniston'; + * import { Omniston } from '@ston-fi/omniston-sdk'; + * + * const omniston = new Omniston({ + * apiUrl: 'wss://omni-ws.ston.fi' + * }); + * + * const provider = new OmnistonSwapProvider( + * kit.getNetworkManager(), + * kit.getEventEmitter(), + * { + * omnistonInstance: omniston, + * defaultSlippageBps: 100, // 1% + * referrerAddress: 'EQ...', + * referrerFeeBps: 10 // 0.1% + * } + * ); + * + * kit.swap.registerProvider('omniston', provider); + * ``` + */ +export class TonstakersProvider extends StakingProvider { + private readonly apiUrl: string; + private readonly defaultSlippageBps: number; + private readonly quoteTimeoutMs: number; + private readonly referrerAddress?: string; + private readonly referrerFeeBps?: number; + private readonly flexibleReferrerFee: boolean; + + private omniston$?: Omniston; + + constructor(networkManager: NetworkManager, eventEmitter: EventEmitter, config: TonstakersProviderConfig) { + super(networkManager, eventEmitter); + this.apiUrl = config.apiUrl ?? 'wss://omni-ws.ston.fi'; + this.defaultSlippageBps = config.defaultSlippageBps ?? 100; // 1% default + this.quoteTimeoutMs = config.quoteTimeoutMs ?? 7000; // 10 seconds + this.referrerAddress = config.referrerAddress; + this.referrerFeeBps = config.referrerFeeBps; + this.flexibleReferrerFee = config.flexibleReferrerFee ?? false; + + log.info('OmnistonSwapProvider initialized', { + defaultSlippageBps: this.defaultSlippageBps, + hasReferrer: !!this.referrerAddress, + }); + } + + private get omniston(): Omniston { + if (!this.omniston$) { + this.omniston$ = new Omniston({ apiUrl: this.apiUrl }); + } + + return this.omniston$; + } + + async getQuote(params: SwapQuoteParams): Promise { + log.debug('Getting Omniston quote', { + fromToken: params.fromToken, + toToken: params.toToken, + amount: params.amount, + }); + + try { + const bidAssetAddress = tokenToAddress(params.fromToken); + const askAssetAddress = tokenToAddress(params.toToken); + + const slippageBps = params.slippageBps ?? this.defaultSlippageBps; + + const quoteRequest: QuoteRequest = { + settlementMethods: [SettlementMethod.SETTLEMENT_METHOD_SWAP], + bidAssetAddress: toOmnistonAddress(bidAssetAddress, params.network), + askAssetAddress: toOmnistonAddress(askAssetAddress, params.network), + amount: { bidUnits: params.amount }, + referrerAddress: this.referrerAddress + ? toOmnistonAddress(this.referrerAddress, params.network) + : undefined, + referrerFeeBps: this.referrerFeeBps, + settlementParams: { + gaslessSettlement: GaslessSettlement.GASLESS_SETTLEMENT_POSSIBLE, + maxPriceSlippageBps: slippageBps, + maxOutgoingMessages: 4, + flexibleReferrerFee: this.flexibleReferrerFee, + }, + }; + + const quoteEvent = await new Promise((resolve, reject) => { + let isSettled = false; + + log.debug('Requesting quote'); + + const timeoutId = setTimeout(() => { + log.debug('Timeout reached'); + + if (!isSettled) { + isSettled = true; + reject(new SwapError('Quote request timed out', SwapError.NETWORK_ERROR)); + } + + unsubscribe.unsubscribe(); + }, this.quoteTimeoutMs); + + const unsubscribe = this.omniston.requestForQuote(quoteRequest).subscribe({ + next: (event) => { + log.debug('Received quote event', event); + + if (isSettled) return; + + if (event.type === 'noQuote') { + isSettled = true; + clearTimeout(timeoutId); + unsubscribe.unsubscribe(); + reject(new SwapError('No quote available for this swap', SwapError.INSUFFICIENT_LIQUIDITY)); + return; + } + + if (event.type === 'quoteUpdated') { + isSettled = true; + clearTimeout(timeoutId); + unsubscribe.unsubscribe(); + resolve(event); + } + }, + error: (error) => { + if (!isSettled) { + isSettled = true; + clearTimeout(timeoutId); + unsubscribe.unsubscribe(); + reject(error); + } + }, + }); + }); + + if (quoteEvent.type !== 'quoteUpdated') { + throw new SwapError('Quote data is missing', SwapError.INVALID_QUOTE); + } + + const quote = quoteEvent.quote; + const swapQuote = this.mapOmnistonQuoteToSwapQuote(quote, params); + + log.debug('Received Omniston quote', { + quoteId: quote.quoteId, + bidUnits: quote.bidUnits, + askUnits: quote.askUnits, + }); + + this.emitEvent('swap:quote:received', { + provider: 'omniston', + quote: swapQuote, + }); + + return swapQuote; + } catch (error) { + log.error('Failed to get Omniston quote', { error, params }); + + if (error instanceof SwapError) { + throw error; + } + + throw new SwapError( + `Omniston quote request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + SwapError.NETWORK_ERROR, + error, + ); + } + } + + async buildSwapTransaction(params: SwapParams): Promise { + const metadata = params.quote.metadata; + + if (!metadata || !isOmnistonQuoteMetadata(metadata)) { + throw new SwapError('Invalid quote: missing Omniston quote data', SwapError.INVALID_QUOTE); + } + + throw new Error('buildSwapTransaction is not implemented'); + } + + private mapOmnistonQuoteToSwapQuote(quote: Quote, params: SwapQuoteParams): SwapQuote { + const metadata: OmnistonQuoteMetadata = { + quoteId: quote.quoteId, + resolverId: quote.resolverId, + resolverName: quote.resolverName, + omnistonQuote: quote, + network: params.network, + gasBudget: quote.gasBudget, + estimatedGasConsumption: quote.estimatedGasConsumption, + }; + + const fee: SwapFee[] = []; + + if (quote.protocolFeeAsset) { + fee.push({ + amount: quote.protocolFeeUnits, + token: addressToToken(quote.protocolFeeAsset.address), + }); + } + + if (quote.referrerFeeAsset) { + fee.push({ + amount: quote.referrerFeeUnits, + token: addressToToken(quote.referrerFeeAsset.address), + }); + } + + return { + metadata, + provider: 'omniston', + fromToken: params.fromToken, + toToken: params.toToken, + fromAmount: quote.bidUnits, + toAmount: quote.askUnits, + minReceived: quote.askUnits, + expiresAt: quote.tradeStartDeadline ? quote.tradeStartDeadline : undefined, + fee: fee?.length ? fee : undefined, + }; + } +} diff --git a/packages/walletkit/src/defi/staking/tonstakers/index.ts b/packages/walletkit/src/defi/staking/tonstakers/index.ts new file mode 100644 index 000000000..4428ee83c --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/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 { TonstakersProvider } from './TonstakersProvider'; +export type { TonstakersProviderConfig } from './TonstakersProvider'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/types.ts b/packages/walletkit/src/defi/staking/tonstakers/types.ts new file mode 100644 index 000000000..8e604c756 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/types.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. + * + */ + +import type { Quote } from '@ston-fi/omniston-sdk'; + +import type { Network } from '../../../api/models'; + +export interface OmnistonQuoteMetadata { + quoteId: string; + resolverId: string; + resolverName?: string; + omnistonQuote: Quote; + network: Network; + gasBudget?: string; + estimatedGasConsumption?: string; +} diff --git a/packages/walletkit/src/defi/staking/tonstakers/utils.ts b/packages/walletkit/src/defi/staking/tonstakers/utils.ts new file mode 100644 index 000000000..5e5e3a41b --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/utils.ts @@ -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 { Address } from '@ton/core'; +import type { Address as OmnistonAddress } from '@ston-fi/omniston-sdk'; + +import { Network } from '../../../api/models'; +import type { OmnistonQuoteMetadata } from './types'; + +export const tokenToAddress = (token: string): string => { + if (token === 'TON') { + return 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c'; + } + return Address.parse(token).toRawString(); +}; + +export const addressToToken = (address: string): string => { + if (address === 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c') { + return 'TON'; + } + try { + return Address.parseRaw(address).toString(); + } catch { + return address; + } +}; + +export const toOmnistonAddress = (address: string, network: Network): OmnistonAddress => { + return { + address, + blockchain: mapNetworkToBlockchainId(network), + }; +}; + +export const mapNetworkToBlockchainId = (network: Network): number => { + switch (network.chainId) { + case Network.mainnet().chainId: { + return 607; + } + + default: { + throw new Error(`Unsupported network: ${network.chainId}`); + } + } +}; + +export const isOmnistonQuoteMetadata = (metadata: unknown): metadata is OmnistonQuoteMetadata => { + if (!metadata || typeof metadata !== 'object') { + return false; + } + + const meta = metadata as Record; + + return ( + typeof meta.quoteId === 'string' && + typeof meta.resolverId === 'string' && + typeof meta.omnistonQuote === 'object' && + meta.omnistonQuote !== null && + typeof meta.network === 'object' && + meta.network !== null + ); +}; diff --git a/packages/walletkit/src/defi/staking/types.ts b/packages/walletkit/src/defi/staking/types.ts new file mode 100644 index 000000000..0a25efe08 --- /dev/null +++ b/packages/walletkit/src/defi/staking/types.ts @@ -0,0 +1,111 @@ +/** + * 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 { Network, TransactionRequest, UserFriendlyAddress, TokenAmount } from '../../api/models'; +import {StakingError} from "./errors"; + +/** + * Parameters for requesting a swap quote + */ +export interface SwapQuoteParams { + fromToken: UserFriendlyAddress | 'TON'; + toToken: UserFriendlyAddress | 'TON'; + amount: string; + network: Network; + slippageBps?: number; +} + +/** + * Swap quote response with pricing information + */ +export interface SwapQuote { + fromToken: UserFriendlyAddress | 'TON'; + toToken: UserFriendlyAddress | 'TON'; + fromAmount: string; + toAmount: string; + minReceived: string; + priceImpact?: number; + fee?: SwapFee[]; + provider: string; + expiresAt?: number; // Unix timestamp in seconds + metadata?: unknown; +} + +/** + * Fee information for swap + */ +export interface SwapFee { + amount: string; + token: UserFriendlyAddress | 'TON'; +} + +/** + * Parameters for building swap transaction + */ +export interface SwapParams { + quote: SwapQuote; + userAddress: UserFriendlyAddress; + slippageBps?: number; + deadline?: number; + referralAddress?: UserFriendlyAddress; +} + +export interface StakingAPI { + getQuote(params: SwapQuoteParams, provider?: string): Promise; + buildStakingTransaction(params: SwapParams, provider?: string): Promise; +} + +export interface StakingProviderInterface { + getQuote(params: SwapQuoteParams): Promise; + buildSwapTransaction(params: SwapParams): Promise; +} + +export interface StakeParams { + amount: TokenAmount; + userAddress: UserFriendlyAddress; + network: Network; +} + +export interface UnstakeParams { + amount: TokenAmount; + userAddress: UserFriendlyAddress; + network: Network; +} + +export interface StakingBalance { + stakedBalance: TokenAmount; + availableBalance: TokenAmount; + instantUnstakeAvailable: TokenAmount; + network: Network; + provider: string; +} + +export interface StakingInfo { + apy: number; + network: Network; + provider: string; +} + +export interface StakingAPI { + registerProvider(name: string, provider: StakingProviderInterface): void; + setDefaultProvider(name: string): void; + getProvider(name?: string): StakingProviderInterface; + stake(params: StakeParams, provider?: string): Promise; + unstake(params: UnstakeParams, provider?: string): Promise; + getBalance(userAddress: UserFriendlyAddress, network: Network, provider?: string): Promise; + getStakingInfo(network: Network, provider?: string): Promise; + getRegisteredProviders(): string[]; + hasProvider(name: string): boolean; +} + +export interface StakingProviderInterface { + stake(params: StakeParams): Promise; + unstake(params: UnstakeParams): Promise; + getBalance(userAddress: UserFriendlyAddress, network: Network): Promise; + getStakingInfo(network: Network): Promise; +} From ebe451bd36cd79c3a10f88260f4271ef0fffdd16 Mon Sep 17 00:00:00 2001 From: Ilyar Date: Fri, 16 Jan 2026 09:15:30 +0100 Subject: [PATCH 02/15] fixup! feat: add staking api --- packages/walletkit/src/core/TonWalletKit.ts | 11 + .../src/defi/staking/StakingManager.ts | 128 +++++++-- .../src/defi/staking/StakingProvider.ts | 51 +++- packages/walletkit/src/defi/staking/errors.ts | 12 +- packages/walletkit/src/defi/staking/index.ts | 3 + .../tonstakers/TonStakersStakingProvider.ts | 64 +++++ .../tonstakers/TonstakersProvider.spec.ts | 43 --- .../staking/tonstakers/TonstakersProvider.ts | 257 ------------------ .../src/defi/staking/tonstakers/constants.ts | 35 +++ .../src/defi/staking/tonstakers/index.ts | 10 - .../src/defi/staking/tonstakers/types.ts | 14 +- .../src/defi/staking/tonstakers/utils.ts | 67 ----- packages/walletkit/src/defi/staking/types.ts | 126 +++++---- packages/walletkit/src/index.ts | 2 + 14 files changed, 346 insertions(+), 477 deletions(-) create mode 100644 packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts delete mode 100644 packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.spec.ts delete mode 100644 packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/constants.ts delete mode 100644 packages/walletkit/src/defi/staking/tonstakers/index.ts delete mode 100644 packages/walletkit/src/defi/staking/tonstakers/utils.ts diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 2e98a42db..953b0cf9d 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -29,6 +29,7 @@ import type { RequestProcessor } from './RequestProcessor'; import { JettonsManager } from './JettonsManager'; import type { JettonsAPI } from '../types/jettons'; import { SwapManager } from '../defi/swap'; +import { StakingManager } from '../defi/staking'; import type { RawBridgeEventConnect, RawBridgeEventRestoreConnection, @@ -86,6 +87,7 @@ export class TonWalletKit implements ITonWalletKit { private networkManager: NetworkManager; private jettonsManager!: JettonsManager; private swapManager: SwapManager; + private stakingManager: StakingManager; private initializer: Initializer; private eventProcessor!: StorageEventProcessor; private bridgeManager!: BridgeManager; @@ -127,6 +129,8 @@ export class TonWalletKit implements ITonWalletKit { // Initialize SwapManager this.swapManager = new SwapManager(); + // Initialize StakingManager + this.stakingManager = new StakingManager(); this.eventEmitter.on('restoreConnection', async (event: RawBridgeEventRestoreConnection) => { if (!event.domain) { @@ -756,6 +760,13 @@ export class TonWalletKit implements ITonWalletKit { return this.swapManager; } + /** + * Staking API access + */ + get staking(): StakingManager { + return this.stakingManager; + } + /** * Get the event emitter for this kit instance * Allows external components to listen to and emit events diff --git a/packages/walletkit/src/defi/staking/StakingManager.ts b/packages/walletkit/src/defi/staking/StakingManager.ts index 3129be51c..be8e3ae31 100644 --- a/packages/walletkit/src/defi/staking/StakingManager.ts +++ b/packages/walletkit/src/defi/staking/StakingManager.ts @@ -6,58 +6,146 @@ * */ -import type { TransactionRequest } from '../../api/models'; -import type { StakingAPI, SwapQuoteParams, SwapQuote, SwapParams, StakingProviderInterface } from './types'; -import { StakingError } from './errors'; +import type { TransactionRequest, UserFriendlyAddress, Network } from '../../api/models'; +import type { + StakingAPI, + StakeParams, + UnstakeParams, + StakingBalance, + StakingInfo, + StakingProviderInterface, + StakingQuoteParams, + StakingQuote, +} from './types'; +import { StakingError, StakingErrorCode } from './errors'; import { globalLogger } from '../../core/Logger'; import { DefiManager } from '../DefiManager'; const log = globalLogger.createChild('StakingManager'); +/** + * StakingManager - manages staking providers and delegates staking operations + * + * Allows registration of multiple staking providers and provides a unified API + * for staking operations. Providers can be switched dynamically. + */ export class StakingManager extends DefiManager implements StakingAPI { - async getQuote(params: SwapQuoteParams, provider?: string): Promise { + /** + * Get a quote for staking or unstaking + * @param params - Quote parameters + * @param provider - Optional provider name to use + */ + async getQuote(params: StakingQuoteParams, provider?: string): Promise { log.debug('Getting staking quote', { - fromToken: params.fromToken, - toToken: params.toToken, + direction: params.direction, amount: params.amount, provider: provider || this.defaultProvider, }); try { const quote = await this.getProvider(provider).getQuote(params); - log.debug('Received staking quote', { - fromAmount: quote.fromAmount, - toAmount: quote.toAmount, - priceImpact: quote.priceImpact, + direction: quote.direction, + amountIn: quote.amountIn, + amountOut: quote.amountOut, }); - return quote; } catch (error) { - log.error('Failed to get staking quote', { error, params }); - throw error; + throw this.createError('Failed to get staking quote', StakingErrorCode.InvalidParams, { error, params }); } } - async buildStakingTransaction(params: SwapParams, provider?: string): Promise { + /** + * Stake TON using a provider + * @param params - Staking parameters + * @param provider - Optional provider name to use + */ + async stake(params: StakeParams, provider?: string): Promise { log.debug('Building staking transaction', { userAddress: params.userAddress, + amount: params.amount, provider: provider || this.defaultProvider, }); try { - const transaction = await this.getProvider(provider).buildSwapTransaction(params); + return await this.getProvider(provider).stake(params); + } catch (error) { + throw this.createError('Failed to build staking transaction', StakingErrorCode.InvalidParams, { + error, + params, + }); + } + } - log.debug('Built staking transaction', params.quote); + /** + * Unstake TON using a provider + * @param params - Unstaking parameters + * @param provider - Optional provider name to use + */ + async unstake(params: UnstakeParams, provider?: string): Promise { + log.debug('Building unstaking transaction', { + userAddress: params.userAddress, + amount: params.amount, + provider: provider || this.defaultProvider, + }); - return transaction; + try { + return await this.getProvider(provider).unstake(params); + } catch (error) { + throw this.createError('Failed to build unstaking transaction', StakingErrorCode.InvalidParams, { + error, + params, + }); + } + } + + /** + * Get staking balance for a user + * @param userAddress - User address + * @param network - Network to query + * @param provider - Optional provider name to use + */ + async getBalance(userAddress: UserFriendlyAddress, network?: Network, provider?: string): Promise { + log.debug('Getting staking balance', { + userAddress, + network, + provider: provider || this.defaultProvider, + }); + + try { + return await this.getProvider(provider).getBalance(userAddress, network); + } catch (error) { + throw this.createError('Failed to get staking balance', StakingErrorCode.InvalidParams, { + error, + userAddress, + network, + }); + } + } + + /** + * Get staking information for a network + * @param network - Network to query + * @param provider - Optional provider name to use + */ + async getStakingInfo(network?: Network, provider?: string): Promise { + log.debug('Getting staking info', { + network, + provider: provider || this.defaultProvider, + }); + + try { + return await this.getProvider(provider).getStakingInfo(network); } catch (error) { - log.error('Failed to build staking transaction', { error, params }); - throw error; + throw this.createError('Failed to get staking info', StakingErrorCode.InvalidParams, { error, network }); } } protected createError(message: string, code: string, details?: unknown): StakingError { - return new StakingError(message, code, details); + const errorCode = Object.values(StakingErrorCode).includes(code as StakingErrorCode) + ? (code as StakingErrorCode) + : StakingErrorCode.InvalidParams; + log.error(message, { code, details }); + return new StakingError(message, errorCode, details); } } diff --git a/packages/walletkit/src/defi/staking/StakingProvider.ts b/packages/walletkit/src/defi/staking/StakingProvider.ts index f512618cc..b46bb753b 100644 --- a/packages/walletkit/src/defi/staking/StakingProvider.ts +++ b/packages/walletkit/src/defi/staking/StakingProvider.ts @@ -7,11 +7,25 @@ */ import type { ApiClient } from '../../types/toncenter/ApiClient'; -import type { Network, TransactionRequest } from '../../api/models'; +import type { Network, TransactionRequest, UserFriendlyAddress } from '../../api/models'; import type { NetworkManager } from '../../core/NetworkManager'; import type { EventEmitter } from '../../core/EventEmitter'; -import type { SwapQuoteParams, SwapQuote, SwapParams, StakingProviderInterface } from './types'; +import type { + StakeParams, + UnstakeParams, + StakingBalance, + StakingInfo, + StakingProviderInterface, + StakingQuoteParams, + StakingQuote, +} from './types'; +/** + * Abstract base class for staking providers + * + * Provides common utilities and enforces implementation of core staking methods. + * Users can extend this class to create custom staking providers. + */ export abstract class StakingProvider implements StakingProviderInterface { protected networkManager: NetworkManager; protected eventEmitter: EventEmitter; @@ -22,18 +36,37 @@ export abstract class StakingProvider implements StakingProviderInterface { } /** - * Get a quote for swapping tokens - * @param params - Quote parameters including tokens, amount, and network - * @returns Promise resolving to swap quote with pricing information + * Get a quote for staking or unstaking + * @param params - Quote parameters including direction and amount + */ + abstract getQuote(params: StakingQuoteParams): Promise; + + /** + * Build a transaction for staking + * @param params - Staking parameters including amount and user address + * @returns Promise resolving to transaction request ready to be signed */ - abstract getQuote(params: SwapQuoteParams): Promise; + abstract stake(params: StakeParams): Promise; /** - * Build a transaction for executing the swap - * @param params - Swap parameters including quote and user address + * Build a transaction for unstaking + * @param params - Unstaking parameters including amount and user address * @returns Promise resolving to transaction request ready to be signed */ - abstract buildSwapTransaction(params: SwapParams): Promise; + abstract unstake(params: UnstakeParams): Promise; + + /** + * Get staking balance for a user + * @param userAddress - User address to fetch balance for + * @param network - Optional network to use for balance query + */ + abstract getBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; + + /** + * Get staking information for a network + * @param network - Optional network to fetch info for + */ + abstract getStakingInfo(network?: Network): Promise; /** * Get API client for a specific network diff --git a/packages/walletkit/src/defi/staking/errors.ts b/packages/walletkit/src/defi/staking/errors.ts index d4d0b1717..e6295cda4 100644 --- a/packages/walletkit/src/defi/staking/errors.ts +++ b/packages/walletkit/src/defi/staking/errors.ts @@ -8,13 +8,17 @@ import { DefiManagerError } from '../errors'; +export enum StakingErrorCode { + InvalidParams = 'INVALID_PARAMS', + UnsupportedOperation = 'UNSUPPORTED_OPERATION', +} + export class StakingError extends DefiManagerError { - static readonly INVALID_QUOTE = 'INVALID_QUOTE'; // TODO rewrite - static readonly INSUFFICIENT_LIQUIDITY = 'INSUFFICIENT_LIQUIDITY'; // TODO rewrite - static readonly QUOTE_EXPIRED = 'QUOTE_EXPIRED'; // TODO rewrite + public readonly code: StakingErrorCode; - constructor(message: string, code: string, details?: unknown) { + constructor(message: string, code: StakingErrorCode, details?: unknown) { super(message, code, details); this.name = 'StakingError'; + this.code = code; } } diff --git a/packages/walletkit/src/defi/staking/index.ts b/packages/walletkit/src/defi/staking/index.ts index d717e4564..5994708cf 100644 --- a/packages/walletkit/src/defi/staking/index.ts +++ b/packages/walletkit/src/defi/staking/index.ts @@ -9,4 +9,7 @@ export { StakingProvider } from './StakingProvider'; export { StakingManager } from './StakingManager'; export { StakingError } from './errors'; +export type { StakingErrorCode } from './errors'; export type * from './types'; +export { TonStakersStakingProvider } from './tonstakers/TonStakersStakingProvider'; +export type { TonStakersProviderConfig } from './tonstakers/types'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts new file mode 100644 index 000000000..f2cdd3436 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -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 { TransactionRequest, UserFriendlyAddress, Network } from '../../../api/models'; +import { globalLogger } from '../../../core/Logger'; +import type { NetworkManager } from '../../../core/NetworkManager'; +import type { EventEmitter } from '../../../core/EventEmitter'; +import { StakingProvider } from '../StakingProvider'; +import type { + StakeParams, + UnstakeParams, + StakingBalance, + StakingInfo, + StakingQuoteParams, + StakingQuote, +} from '../types'; +import { StakingError, StakingErrorCode } from '../errors'; +import type { TonStakersProviderConfig } from './types'; + +const log = globalLogger.createChild('TonStakersStakingProvider'); + +export class TonStakersStakingProvider extends StakingProvider { + private readonly apiUrl?: string; + + constructor(networkManager: NetworkManager, eventEmitter: EventEmitter, config: TonStakersProviderConfig = {}) { + super(networkManager, eventEmitter); + this.apiUrl = config.apiUrl; + log.info('TonStakersStakingProvider initialized', { apiUrl: this.apiUrl }); + } + + async getQuote(params: StakingQuoteParams): Promise { + log.debug('TonStakers quote requested', { + direction: params.direction, + amount: params.amount, + userAddress: params.userAddress, + }); + throw new StakingError('TonStakers quote is not implemented', StakingErrorCode.UnsupportedOperation); + } + + async stake(params: StakeParams): Promise { + log.debug('TonStakers stake requested', { amount: params.amount, userAddress: params.userAddress }); + throw new StakingError('TonStakers staking is not implemented', StakingErrorCode.UnsupportedOperation); + } + + async unstake(params: UnstakeParams): Promise { + log.debug('TonStakers unstake requested', { amount: params.amount, userAddress: params.userAddress }); + throw new StakingError('TonStakers unstaking is not implemented', StakingErrorCode.UnsupportedOperation); + } + + async getBalance(userAddress: UserFriendlyAddress, network?: Network): Promise { + log.debug('TonStakers balance requested', { userAddress, network }); + throw new StakingError('TonStakers balance is not implemented', StakingErrorCode.UnsupportedOperation); + } + + async getStakingInfo(network?: Network): Promise { + log.debug('TonStakers info requested', { network }); + throw new StakingError('TonStakers info is not implemented', StakingErrorCode.UnsupportedOperation); + } +} diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.spec.ts deleted file mode 100644 index 7d583c89a..000000000 --- a/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.spec.ts +++ /dev/null @@ -1,43 +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 { describe, it, expect, vi } from 'vitest'; - -import { TonstakersProvider } from './TonstakersProvider'; -import { Network } from '../../../api/models'; -import type { NetworkManager } from '../../../core/NetworkManager'; -import type { EventEmitter } from '../../../core/EventEmitter'; -import { isOmnistonQuoteMetadata } from './utils'; - -// Skip integration tests -describe.skip('OmnistonSwapProvider.getQuote', () => { - let mockNetworkManager: NetworkManager = {} as NetworkManager; - let mockEventEmitter = { - emit: vi.fn(), - on: vi.fn(), - off: vi.fn(), - } as unknown as EventEmitter; - - it('should return quote for TON to USDT swap', async () => { - const provider = new TonstakersProvider(mockNetworkManager, mockEventEmitter, { - defaultSlippageBps: 100, - quoteTimeoutMs: 30000, - }); - - const quote = await provider.getQuote({ - fromToken: 'TON', - toToken: 'EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO', // USDT - amount: '1000000000', // 1 TON - network: Network.mainnet(), - }); - - expect(BigInt(quote.toAmount)).toBeGreaterThan(0); - expect(isOmnistonQuoteMetadata(quote.metadata)).toBeTruthy(); - expect(mockEventEmitter.emit).toHaveBeenCalledWith('swap:quote:received', expect.any(Object)); - }, 30000); -}); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.ts deleted file mode 100644 index c4cf14ac5..000000000 --- a/packages/walletkit/src/defi/staking/tonstakers/TonstakersProvider.ts +++ /dev/null @@ -1,257 +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 { SettlementMethod, Omniston, GaslessSettlement } from '@ston-fi/omniston-sdk'; -import type { Quote, QuoteResponseEvent, QuoteRequest } from '@ston-fi/omniston-sdk'; - -import type { OmnistonQuoteMetadata } from './types'; -import { StakingProvider } from '../StakingProvider'; -import type { SwapQuoteParams, SwapQuote, SwapParams, SwapFee } from '../types'; -import { SwapError } from '../errors'; -import type { NetworkManager } from '../../../core/NetworkManager'; -import type { EventEmitter } from '../../../core/EventEmitter'; -import { globalLogger } from '../../../core/Logger'; -import { tokenToAddress, addressToToken, toOmnistonAddress, isOmnistonQuoteMetadata } from './utils'; -import type { TransactionRequest } from '../../../api/models'; - -const log = globalLogger.createChild('TonstakersProvider'); - -export interface TonstakersProviderConfig { - apiUrl?: string; - defaultSlippageBps?: number; - quoteTimeoutMs?: number; - referrerAddress?: string; - referrerFeeBps?: number; - flexibleReferrerFee?: boolean; -} - -/** - * Swap provider implementation for Omniston (STON.fi) protocol - * - * Uses the Omniston SDK to get quotes and build swap transactions - * across multiple DEXs on TON blockchain. - * - * @example - * ```typescript - * // Import from separate entry point to avoid bundling Omniston SDK - * import { OmnistonSwapProvider } from '@ton/walletkit/swap/omniston'; - * import { Omniston } from '@ston-fi/omniston-sdk'; - * - * const omniston = new Omniston({ - * apiUrl: 'wss://omni-ws.ston.fi' - * }); - * - * const provider = new OmnistonSwapProvider( - * kit.getNetworkManager(), - * kit.getEventEmitter(), - * { - * omnistonInstance: omniston, - * defaultSlippageBps: 100, // 1% - * referrerAddress: 'EQ...', - * referrerFeeBps: 10 // 0.1% - * } - * ); - * - * kit.swap.registerProvider('omniston', provider); - * ``` - */ -export class TonstakersProvider extends StakingProvider { - private readonly apiUrl: string; - private readonly defaultSlippageBps: number; - private readonly quoteTimeoutMs: number; - private readonly referrerAddress?: string; - private readonly referrerFeeBps?: number; - private readonly flexibleReferrerFee: boolean; - - private omniston$?: Omniston; - - constructor(networkManager: NetworkManager, eventEmitter: EventEmitter, config: TonstakersProviderConfig) { - super(networkManager, eventEmitter); - this.apiUrl = config.apiUrl ?? 'wss://omni-ws.ston.fi'; - this.defaultSlippageBps = config.defaultSlippageBps ?? 100; // 1% default - this.quoteTimeoutMs = config.quoteTimeoutMs ?? 7000; // 10 seconds - this.referrerAddress = config.referrerAddress; - this.referrerFeeBps = config.referrerFeeBps; - this.flexibleReferrerFee = config.flexibleReferrerFee ?? false; - - log.info('OmnistonSwapProvider initialized', { - defaultSlippageBps: this.defaultSlippageBps, - hasReferrer: !!this.referrerAddress, - }); - } - - private get omniston(): Omniston { - if (!this.omniston$) { - this.omniston$ = new Omniston({ apiUrl: this.apiUrl }); - } - - return this.omniston$; - } - - async getQuote(params: SwapQuoteParams): Promise { - log.debug('Getting Omniston quote', { - fromToken: params.fromToken, - toToken: params.toToken, - amount: params.amount, - }); - - try { - const bidAssetAddress = tokenToAddress(params.fromToken); - const askAssetAddress = tokenToAddress(params.toToken); - - const slippageBps = params.slippageBps ?? this.defaultSlippageBps; - - const quoteRequest: QuoteRequest = { - settlementMethods: [SettlementMethod.SETTLEMENT_METHOD_SWAP], - bidAssetAddress: toOmnistonAddress(bidAssetAddress, params.network), - askAssetAddress: toOmnistonAddress(askAssetAddress, params.network), - amount: { bidUnits: params.amount }, - referrerAddress: this.referrerAddress - ? toOmnistonAddress(this.referrerAddress, params.network) - : undefined, - referrerFeeBps: this.referrerFeeBps, - settlementParams: { - gaslessSettlement: GaslessSettlement.GASLESS_SETTLEMENT_POSSIBLE, - maxPriceSlippageBps: slippageBps, - maxOutgoingMessages: 4, - flexibleReferrerFee: this.flexibleReferrerFee, - }, - }; - - const quoteEvent = await new Promise((resolve, reject) => { - let isSettled = false; - - log.debug('Requesting quote'); - - const timeoutId = setTimeout(() => { - log.debug('Timeout reached'); - - if (!isSettled) { - isSettled = true; - reject(new SwapError('Quote request timed out', SwapError.NETWORK_ERROR)); - } - - unsubscribe.unsubscribe(); - }, this.quoteTimeoutMs); - - const unsubscribe = this.omniston.requestForQuote(quoteRequest).subscribe({ - next: (event) => { - log.debug('Received quote event', event); - - if (isSettled) return; - - if (event.type === 'noQuote') { - isSettled = true; - clearTimeout(timeoutId); - unsubscribe.unsubscribe(); - reject(new SwapError('No quote available for this swap', SwapError.INSUFFICIENT_LIQUIDITY)); - return; - } - - if (event.type === 'quoteUpdated') { - isSettled = true; - clearTimeout(timeoutId); - unsubscribe.unsubscribe(); - resolve(event); - } - }, - error: (error) => { - if (!isSettled) { - isSettled = true; - clearTimeout(timeoutId); - unsubscribe.unsubscribe(); - reject(error); - } - }, - }); - }); - - if (quoteEvent.type !== 'quoteUpdated') { - throw new SwapError('Quote data is missing', SwapError.INVALID_QUOTE); - } - - const quote = quoteEvent.quote; - const swapQuote = this.mapOmnistonQuoteToSwapQuote(quote, params); - - log.debug('Received Omniston quote', { - quoteId: quote.quoteId, - bidUnits: quote.bidUnits, - askUnits: quote.askUnits, - }); - - this.emitEvent('swap:quote:received', { - provider: 'omniston', - quote: swapQuote, - }); - - return swapQuote; - } catch (error) { - log.error('Failed to get Omniston quote', { error, params }); - - if (error instanceof SwapError) { - throw error; - } - - throw new SwapError( - `Omniston quote request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - SwapError.NETWORK_ERROR, - error, - ); - } - } - - async buildSwapTransaction(params: SwapParams): Promise { - const metadata = params.quote.metadata; - - if (!metadata || !isOmnistonQuoteMetadata(metadata)) { - throw new SwapError('Invalid quote: missing Omniston quote data', SwapError.INVALID_QUOTE); - } - - throw new Error('buildSwapTransaction is not implemented'); - } - - private mapOmnistonQuoteToSwapQuote(quote: Quote, params: SwapQuoteParams): SwapQuote { - const metadata: OmnistonQuoteMetadata = { - quoteId: quote.quoteId, - resolverId: quote.resolverId, - resolverName: quote.resolverName, - omnistonQuote: quote, - network: params.network, - gasBudget: quote.gasBudget, - estimatedGasConsumption: quote.estimatedGasConsumption, - }; - - const fee: SwapFee[] = []; - - if (quote.protocolFeeAsset) { - fee.push({ - amount: quote.protocolFeeUnits, - token: addressToToken(quote.protocolFeeAsset.address), - }); - } - - if (quote.referrerFeeAsset) { - fee.push({ - amount: quote.referrerFeeUnits, - token: addressToToken(quote.referrerFeeAsset.address), - }); - } - - return { - metadata, - provider: 'omniston', - fromToken: params.fromToken, - toToken: params.toToken, - fromAmount: quote.bidUnits, - toAmount: quote.askUnits, - minReceived: quote.askUnits, - expiresAt: quote.tradeStartDeadline ? quote.tradeStartDeadline : undefined, - fee: fee?.length ? fee : undefined, - }; - } -} diff --git a/packages/walletkit/src/defi/staking/tonstakers/constants.ts b/packages/walletkit/src/defi/staking/tonstakers/constants.ts new file mode 100644 index 000000000..526fcf546 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/constants.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. + * + */ + +// Timing-related constants +export const TIMING = { + DEFAULT_INTERVAL: 5000, + TIMEOUT: 600000, + CACHE_TIMEOUT: 30000, + ESTIMATED_TIME_BW_TX_S: 3, + ESTIMATED_TIME_AFTER_ROUND_S: 10 * 60, +}; + +// Blockchain-related constants +export const BLOCKCHAIN = { + CHAIN_DEV: '-3', + API_URL: 'https://tonapi.io', + API_URL_TESTNET: 'https://testnet.tonapi.io', +}; + +// Contract-related constants +export const CONTRACT = { + STAKING_CONTRACT_ADDRESS: 'EQCkWxfyhAkim3g2DjKQQg8T5P4g-Q1-K_jErGcDJZ4i-vqR', + STAKING_CONTRACT_ADDRESS_TESTNET: 'kQANFsYyYn-GSZ4oajUJmboDURZU-udMHf9JxzO4vYM_hFP3', + PARTNER_CODE: 0x000000106796caef, + PAYLOAD_UNSTAKE: 0x595f07bc, + PAYLOAD_STAKE: 0x47d54391, + STAKE_FEE_RES: 1, + UNSTAKE_FEE_RES: 1.05, + RECOMMENDED_FEE_RESERVE: 1.1, +}; diff --git a/packages/walletkit/src/defi/staking/tonstakers/index.ts b/packages/walletkit/src/defi/staking/tonstakers/index.ts deleted file mode 100644 index 4428ee83c..000000000 --- a/packages/walletkit/src/defi/staking/tonstakers/index.ts +++ /dev/null @@ -1,10 +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. - * - */ - -export { TonstakersProvider } from './TonstakersProvider'; -export type { TonstakersProviderConfig } from './TonstakersProvider'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/types.ts b/packages/walletkit/src/defi/staking/tonstakers/types.ts index 8e604c756..86d585951 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/types.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/types.ts @@ -6,16 +6,6 @@ * */ -import type { Quote } from '@ston-fi/omniston-sdk'; - -import type { Network } from '../../../api/models'; - -export interface OmnistonQuoteMetadata { - quoteId: string; - resolverId: string; - resolverName?: string; - omnistonQuote: Quote; - network: Network; - gasBudget?: string; - estimatedGasConsumption?: string; +export interface TonStakersProviderConfig { + apiUrl?: string; } diff --git a/packages/walletkit/src/defi/staking/tonstakers/utils.ts b/packages/walletkit/src/defi/staking/tonstakers/utils.ts deleted file mode 100644 index 5e5e3a41b..000000000 --- a/packages/walletkit/src/defi/staking/tonstakers/utils.ts +++ /dev/null @@ -1,67 +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 { Address } from '@ton/core'; -import type { Address as OmnistonAddress } from '@ston-fi/omniston-sdk'; - -import { Network } from '../../../api/models'; -import type { OmnistonQuoteMetadata } from './types'; - -export const tokenToAddress = (token: string): string => { - if (token === 'TON') { - return 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c'; - } - return Address.parse(token).toRawString(); -}; - -export const addressToToken = (address: string): string => { - if (address === 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c') { - return 'TON'; - } - try { - return Address.parseRaw(address).toString(); - } catch { - return address; - } -}; - -export const toOmnistonAddress = (address: string, network: Network): OmnistonAddress => { - return { - address, - blockchain: mapNetworkToBlockchainId(network), - }; -}; - -export const mapNetworkToBlockchainId = (network: Network): number => { - switch (network.chainId) { - case Network.mainnet().chainId: { - return 607; - } - - default: { - throw new Error(`Unsupported network: ${network.chainId}`); - } - } -}; - -export const isOmnistonQuoteMetadata = (metadata: unknown): metadata is OmnistonQuoteMetadata => { - if (!metadata || typeof metadata !== 'object') { - return false; - } - - const meta = metadata as Record; - - return ( - typeof meta.quoteId === 'string' && - typeof meta.resolverId === 'string' && - typeof meta.omnistonQuote === 'object' && - meta.omnistonQuote !== null && - typeof meta.network === 'object' && - meta.network !== null - ); -}; diff --git a/packages/walletkit/src/defi/staking/types.ts b/packages/walletkit/src/defi/staking/types.ts index 0a25efe08..841ac4782 100644 --- a/packages/walletkit/src/defi/staking/types.ts +++ b/packages/walletkit/src/defi/staking/types.ts @@ -6,106 +6,122 @@ * */ -import type { Network, TransactionRequest, UserFriendlyAddress, TokenAmount } from '../../api/models'; -import {StakingError} from "./errors"; +import type { Network, TokenAmount, TransactionRequest, UserFriendlyAddress } from '../../api/models'; + +export enum StakingQuoteDirection { + Stake = 'stake', + Unstake = 'unstake', +} + +export enum UnstakeMode { + Instant = 'instant', + Delayed = 'delayed', +} /** - * Parameters for requesting a swap quote + * Parameters for requesting a staking quote */ -export interface SwapQuoteParams { - fromToken: UserFriendlyAddress | 'TON'; - toToken: UserFriendlyAddress | 'TON'; - amount: string; - network: Network; - slippageBps?: number; +export interface StakingQuoteParams { + direction: StakingQuoteDirection; + amount: TokenAmount; + userAddress?: UserFriendlyAddress; + network?: Network; + unstakeMode?: UnstakeMode; } /** - * Swap quote response with pricing information + * Staking quote response with pricing information */ -export interface SwapQuote { - fromToken: UserFriendlyAddress | 'TON'; - toToken: UserFriendlyAddress | 'TON'; - fromAmount: string; - toAmount: string; - minReceived: string; - priceImpact?: number; - fee?: SwapFee[]; +export interface StakingQuote { + direction: StakingQuoteDirection; + amountIn: TokenAmount; + amountOut: TokenAmount; provider: string; - expiresAt?: number; // Unix timestamp in seconds + apy?: number; + unstakeMode?: UnstakeMode; + estimatedUnstakeDelayHours?: number; + instantUnstakeAvailable?: TokenAmount; metadata?: unknown; } /** - * Fee information for swap + * Parameters for building a market swap transaction for st-tokens */ -export interface SwapFee { - amount: string; - token: UserFriendlyAddress | 'TON'; +export interface StakingMarketSwapParams { + quote: StakingQuote; + userAddress: UserFriendlyAddress; } /** - * Parameters for building swap transaction + * Parameters for staking TON */ -export interface SwapParams { - quote: SwapQuote; - userAddress: UserFriendlyAddress; - slippageBps?: number; - deadline?: number; - referralAddress?: UserFriendlyAddress; -} - -export interface StakingAPI { - getQuote(params: SwapQuoteParams, provider?: string): Promise; - buildStakingTransaction(params: SwapParams, provider?: string): Promise; -} - -export interface StakingProviderInterface { - getQuote(params: SwapQuoteParams): Promise; - buildSwapTransaction(params: SwapParams): Promise; -} - export interface StakeParams { amount: TokenAmount; userAddress: UserFriendlyAddress; - network: Network; + network?: Network; } +/** + * Parameters for unstaking TON + */ export interface UnstakeParams { amount: TokenAmount; userAddress: UserFriendlyAddress; - network: Network; + network?: Network; + unstakeMode?: UnstakeMode; + /** + * Optional upper bound for delayed unstake waiting time. + * Providers can use this to decide between instant and delayed flows. + */ + maxDelayHours?: number; } +/** + * Staking balance information for a user + */ export interface StakingBalance { stakedBalance: TokenAmount; availableBalance: TokenAmount; instantUnstakeAvailable: TokenAmount; - network: Network; provider: string; } +/** + * Staking information for a provider + */ export interface StakingInfo { apy: number; - network: Network; + instantUnstakeAvailable?: TokenAmount; provider: string; } +/** + * Staking API interface exposed by StakingManager + */ export interface StakingAPI { - registerProvider(name: string, provider: StakingProviderInterface): void; - setDefaultProvider(name: string): void; - getProvider(name?: string): StakingProviderInterface; + getQuote(params: StakingQuoteParams, provider?: string): Promise; stake(params: StakeParams, provider?: string): Promise; unstake(params: UnstakeParams, provider?: string): Promise; - getBalance(userAddress: UserFriendlyAddress, network: Network, provider?: string): Promise; - getStakingInfo(network: Network, provider?: string): Promise; - getRegisteredProviders(): string[]; - hasProvider(name: string): boolean; + getBalance(userAddress: UserFriendlyAddress, network?: Network, provider?: string): Promise; + getStakingInfo(network?: Network, provider?: string): Promise; } +/** + * Interface that all staking providers must implement + */ export interface StakingProviderInterface { + getQuote(params: StakingQuoteParams): Promise; stake(params: StakeParams): Promise; unstake(params: UnstakeParams): Promise; - getBalance(userAddress: UserFriendlyAddress, network: Network): Promise; - getStakingInfo(network: Network): Promise; + getBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; + getStakingInfo(network?: Network): Promise; +} + +/** + * Optional interface for market providers that exchange st-tokens. + * This should remain separate from staking provider logic. + */ +export interface StakingMarketProviderInterface { + getQuote(params: StakingQuoteParams): Promise; + buildSwapTransaction(params: StakingMarketSwapParams): Promise; } diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 139229526..336b87538 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -21,6 +21,8 @@ export { Initializer } from './core/Initializer'; export { JettonsManager } from './core/JettonsManager'; export { SwapManager, SwapProvider, SwapError } from './defi/swap'; export type * from './defi/swap/types'; +export { StakingManager, StakingProvider, StakingError, TonStakersStakingProvider } from './defi/staking'; +export type * from './defi/staking/types'; export { EventEmitter } from './core/EventEmitter'; export type { EventListener } from './core/EventEmitter'; export { ApiClientToncenter } from './core/ApiClientToncenter'; From e0af2cc09c8327918bcd2221b1c77ac6e2e39efe Mon Sep 17 00:00:00 2001 From: Ilyar Date: Wed, 21 Jan 2026 10:34:32 +0100 Subject: [PATCH 03/15] feat(staking): add demo staking --- demo/examples/env.d.ts | 20 + demo/examples/index.html | 375 +++++++ demo/examples/package.json | 10 + demo/examples/staking.ts | 967 ++++++++++++++++++ demo/examples/vite.config.ts | 42 + packages/walletkit/src/defi/staking/index.ts | 1 + .../tonstakers/TonStakersStakingProvider.ts | 260 ++++- .../src/defi/staking/tonstakers/constants.ts | 22 +- .../src/defi/staking/tonstakers/types.ts | 8 +- packages/walletkit/src/index.ts | 10 +- pnpm-lock.yaml | 591 ++++++++++- 11 files changed, 2281 insertions(+), 25 deletions(-) create mode 100644 demo/examples/env.d.ts create mode 100644 demo/examples/index.html create mode 100644 demo/examples/staking.ts create mode 100644 demo/examples/vite.config.ts diff --git a/demo/examples/env.d.ts b/demo/examples/env.d.ts new file mode 100644 index 000000000..5bdd6bd69 --- /dev/null +++ b/demo/examples/env.d.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. + * + */ + +/// + +interface ImportMetaEnv { + readonly VITE_WALLET_MNEMONIC: string; + readonly VITE_TON_API_KEY_MAINNET: string; + readonly VITE_TON_API_KEY_TESTNET: string; + readonly VITE_BRIDGE_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/demo/examples/index.html b/demo/examples/index.html new file mode 100644 index 000000000..a3480c164 --- /dev/null +++ b/demo/examples/index.html @@ -0,0 +1,375 @@ + + + + + + WalletKit Staking Demo + + + + + +
+

WalletKit Staking Demo

+

Restore Wallet

+ +
+ + +
+ +
+ + +
+

Select Network

+ +

Click on a wallet address to select network and continue:

+
+
+

Mainnet

+
-
+
+
+

Testnet

+
-
+
+
+
+ + +
+

Staking Demo

+ + + +
+
+
+
Wallet (Mainnet)
+
-
+
+ +
+
+ +

Balances

+
+
+

TON Balance

+
-
+
+
+

Available for Staking

+
-
+
+
+

Staked (tsTON)

+
-
+
+
+ +

Pool Information

+
+
+

APY

+
-
+
+
+

TVL TON

+
-
+
+
+

Stakers

+
-
+
+
+ +

Operations

+
+ + + + + + + + +
+ +

Transaction Status

+ + +

Settings

+
+ +
+ +

Active Withdrawals

+
+

No active withdrawals

+
+ +

Rounds Information

+
-
+
+ + + + diff --git a/demo/examples/package.json b/demo/examples/package.json index 90cb575f8..641794207 100644 --- a/demo/examples/package.json +++ b/demo/examples/package.json @@ -2,15 +2,25 @@ "name": "@ton/walletkit-examples", "version": "1.0.0", "description": "Examples for @ton/walletkit", + "type": "module", "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", "quality": "pnpm test", "test": "tsx src/index.ts" }, "dependencies": { + "@ton/core": "^0.62.0", "@ton/walletkit": "workspace:*", "@types/node": "^24.10.1", + "buffer": "^6.0.3", "dotenv": "^17.2.3", "tsx": "^4.21.0", "typescript": "^5.9.3" + }, + "devDependencies": { + "vite": "^7.2.2", + "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/demo/examples/staking.ts b/demo/examples/staking.ts new file mode 100644 index 000000000..bfa99d8ca --- /dev/null +++ b/demo/examples/staking.ts @@ -0,0 +1,967 @@ +/** + * 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 { + TonWalletKit, + Signer, + WalletV5R1Adapter, + Network, + LocalStorageAdapter, + StakingManager, + TonStakersStakingProvider, + createDeviceInfo, + createWalletManifest, + ParseStack, +} from '@ton/walletkit'; +import type { Wallet, ApiClient } from '@ton/walletkit'; +import { Address, beginCell, toNano, fromNano } from '@ton/core'; +import { CONTRACT } from '@ton/walletkit'; +import type { Base64String } from '@ton/walletkit'; + +// Storage keys +const STORAGE_KEY_SEED = 'staking-demo:seed'; +const STORAGE_KEY_NETWORK = 'staking-demo:network'; +const STORAGE_KEY_WALLET_ADDRESS = 'staking-demo:wallet-address'; + +// Environment variables (Vite injects these as import.meta.env.VITE_*) +const ENV_WALLET_MNEMONIC = import.meta.env.VITE_WALLET_MNEMONIC || ''; +const ENV_TON_API_KEY_MAINNET = import.meta.env.VITE_TON_API_KEY_MAINNET || ''; +const ENV_TON_API_KEY_TESTNET = import.meta.env.VITE_TON_API_KEY_TESTNET || ''; + +// UI Elements - will be initialized in init() +let screens: { + seed: HTMLElement; + network: HTMLElement; + staking: HTMLElement; +}; +let seedInput: HTMLTextAreaElement; +let restoreWalletBtn: HTMLButtonElement; +let seedError: HTMLElement; +let networkMainnetCard: HTMLElement; +let networkTestnetCard: HTMLElement; +let walletAddressMainnet: HTMLElement; +let walletAddressTestnet: HTMLElement; +let networkError: HTMLElement; +let deleteDataBtn: HTMLButtonElement; + +const stakingError = document.getElementById('staking-error')!; +const stakingSuccess = document.getElementById('staking-success')!; +const balanceTon = document.getElementById('balance-ton')!; +const balanceAvailable = document.getElementById('balance-available')!; +const balanceStaked = document.getElementById('balance-staked')!; +const poolApy = document.getElementById('pool-apy')!; +const poolTvl = document.getElementById('pool-tvl')!; +const poolStakers = document.getElementById('pool-stakers')!; +const withdrawalsList = document.getElementById('withdrawals-list')!; +const roundsInfo = document.getElementById('rounds-info')!; + +// State +let kit: TonWalletKit | null = null; +let wallet: Wallet | null = null; +let stakingManager: StakingManager | null = null; +let currentNetwork: 'mainnet' | 'testnet' = 'mainnet'; +let jettonWalletAddress: Address | null = null; +let mainnetAddress: string = ''; +let testnetAddress: string = ''; + +// Additional UI elements +let currentNetworkLabel: HTMLElement; +let currentWalletAddress: HTMLElement; +let changeNetworkBtn: HTMLButtonElement; +let txStatusEl: HTMLElement; +let txStatusIcon: HTMLElement; +let txStatusText: HTMLElement; +let txStatusDetails: HTMLElement; +let stakeBtn: HTMLButtonElement; +let stakeMaxBtn: HTMLButtonElement; +let unstakeBtn: HTMLButtonElement; +let unstakeInstantBtn: HTMLButtonElement; +let unstakeBestRateBtn: HTMLButtonElement; + +let stakedBalanceNano = 0n; + +// Initialize app +async function init() { + console.log('Initializing app...'); + + // Initialize DOM elements + screens = { + seed: document.getElementById('screen-seed')!, + network: document.getElementById('screen-network')!, + staking: document.getElementById('screen-staking')!, + }; + + seedInput = document.getElementById('seed-input') as HTMLTextAreaElement; + restoreWalletBtn = document.getElementById('restore-wallet-btn') as HTMLButtonElement; + seedError = document.getElementById('seed-error')!; + networkMainnetCard = document.getElementById('network-mainnet')!; + networkTestnetCard = document.getElementById('network-testnet')!; + walletAddressMainnet = document.getElementById('wallet-address-mainnet')!; + walletAddressTestnet = document.getElementById('wallet-address-testnet')!; + networkError = document.getElementById('network-error')!; + deleteDataBtn = document.getElementById('delete-data-btn') as HTMLButtonElement; + + // Validate critical elements + if (!seedInput || !restoreWalletBtn || !seedError) { + console.error('Critical DOM elements not found:', { + seedInput: !!seedInput, + restoreWalletBtn: !!restoreWalletBtn, + seedError: !!seedError, + }); + alert('Error: Required DOM elements not found. Please check the HTML file.'); + return; + } + + console.log('DOM elements found, setting up event listeners...'); + + // Check if wallet is already restored + const savedSeed = localStorage.getItem(STORAGE_KEY_SEED); + const savedNetwork = localStorage.getItem(STORAGE_KEY_NETWORK) as 'mainnet' | 'testnet' | null; + + if (savedSeed) { + currentNetwork = savedNetwork || 'mainnet'; + await computeAddresses(savedSeed); + showScreen('network'); + } else { + showScreen('seed'); + } + + // Setup event listeners + + console.log('Adding click listener to restore button'); + restoreWalletBtn.addEventListener('click', (e) => { + console.log('Restore button clicked!', e); + handleRestoreWallet(); + }); + networkMainnetCard.addEventListener('click', () => selectNetworkAndContinue('mainnet')); + networkTestnetCard.addEventListener('click', () => selectNetworkAndContinue('testnet')); + deleteDataBtn.addEventListener('click', handleDeleteData); + + // Additional UI element references + currentNetworkLabel = document.getElementById('current-network-label')!; + currentWalletAddress = document.getElementById('current-wallet-address')!; + changeNetworkBtn = document.getElementById('btn-change-network') as HTMLButtonElement; + changeNetworkBtn.addEventListener('click', handleChangeNetwork); + + // Check for environment mnemonic - auto-restore if available + const envMnemonic = ENV_WALLET_MNEMONIC; + if (envMnemonic && !savedSeed) { + console.log('Using mnemonic from environment variable'); + seedInput.value = envMnemonic; + await handleRestoreWallet(); + } + + stakeBtn = document.getElementById('btn-stake') as HTMLButtonElement; + stakeMaxBtn = document.getElementById('btn-stake-max') as HTMLButtonElement; + txStatusEl = document.getElementById('tx-status')!; + txStatusIcon = document.getElementById('tx-status-icon')!; + txStatusText = document.getElementById('tx-status-text')!; + txStatusDetails = document.getElementById('tx-status-details')!; + + // Staking operations + stakeBtn.addEventListener('click', () => handleStake(toNano('1'))); + stakeMaxBtn.addEventListener('click', handleStakeMax); + + unstakeBtn = document.getElementById('btn-unstake')! as HTMLButtonElement; + unstakeInstantBtn = document.getElementById('btn-unstake-instant')! as HTMLButtonElement; + unstakeBestRateBtn = document.getElementById('btn-unstake-best-rate')! as HTMLButtonElement; + + unstakeBtn.addEventListener('click', () => { + const amount = stakedBalanceNano < toNano('1') ? stakedBalanceNano : toNano('1'); + handleUnstake(amount, 'delayed'); + }); + unstakeInstantBtn.addEventListener('click', () => { + const amount = stakedBalanceNano < toNano('1') ? stakedBalanceNano : toNano('1'); + handleUnstake(amount, 'instant'); + }); + unstakeBestRateBtn.addEventListener('click', () => { + const amount = stakedBalanceNano < toNano('1') ? stakedBalanceNano : toNano('1'); + handleUnstake(amount, 'delayed', true); + }); + document.getElementById('btn-refresh')!.addEventListener('click', handleRefreshBalances); + document.getElementById('btn-get-rounds')!.addEventListener('click', handleGetRounds); + // Initial button state + updateUnstakeButtonsState(); +} + +function showScreen(screenName: 'seed' | 'network' | 'staking') { + Object.values(screens).forEach((screen) => screen.classList.remove('active')); + screens[screenName].classList.add('active'); +} + +function showError(element: HTMLElement, message: string) { + element.textContent = message; + element.style.display = 'block'; + setTimeout(() => { + element.style.display = 'none'; + }, 5000); +} + +function showSuccess(message: string) { + stakingSuccess.textContent = message; + stakingSuccess.style.display = 'block'; + setTimeout(() => { + stakingSuccess.style.display = 'none'; + }, 3000); +} + +function formatBalance(nanoValue: string | bigint): string { + const value = parseFloat(fromNano(nanoValue.toString())); + return value.toFixed(2); +} + +function showTxStatus(status: 'pending' | 'success' | 'error', text: string, details?: string) { + txStatusEl.style.display = 'block'; + txStatusEl.className = 'tx-status ' + status; + + if (status === 'pending') { + txStatusIcon.textContent = '⏳'; + } else if (status === 'success') { + txStatusIcon.textContent = '✅'; + } else { + txStatusIcon.textContent = '❌'; + } + + txStatusText.textContent = text; + txStatusDetails.textContent = details || ''; +} + +function updateUnstakeButtonsState() { + const disabled = stakedBalanceNano <= 0n; + if (unstakeBtn) unstakeBtn.disabled = disabled; + if (unstakeInstantBtn) unstakeInstantBtn.disabled = disabled; + if (unstakeBestRateBtn) unstakeBestRateBtn.disabled = disabled; +} + +function hideTxStatus() { + txStatusEl.style.display = 'none'; +} + +async function handleRestoreWallet() { + const seedPhrase = seedInput.value.trim(); + const words = seedPhrase.split(/\s+/).filter((w) => w.length > 0); + + console.log('handleRestoreWallet called', { wordsCount: words.length }); + + if (words.length !== 12 && words.length !== 24) { + showError(seedError, 'Seed phrase must contain 12 or 24 words'); + return; + } + + try { + restoreWalletBtn.disabled = true; + restoreWalletBtn.textContent = 'Restoring...'; + + console.log('Getting wallet kit...'); + const walletKit = getKit(); + + console.log('Waiting for kit to be ready...'); + await walletKit.waitForReady(); + + console.log('Kit is ready, creating signer...'); + + const signer = await Signer.fromMnemonic(words, { type: 'ton' }); + + console.log('Signer created, creating wallet adapter...'); + const network = Network.mainnet(); + const walletAdapter = await WalletV5R1Adapter.create(signer, { + client: walletKit.getApiClient(network), + network, + }); + + console.log('Wallet adapter created, adding wallet...'); + const addedWallet = await walletKit.addWallet(walletAdapter); + if (!addedWallet) { + throw new Error('Failed to create wallet'); + } + wallet = addedWallet; + + const address = wallet.getAddress(); + + console.log('Wallet created successfully:', address); + + localStorage.setItem(STORAGE_KEY_SEED, seedPhrase); + localStorage.setItem(STORAGE_KEY_NETWORK, 'mainnet'); + + await computeAddresses(seedPhrase); + showScreen('network'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + showError(seedError, `Error restoring wallet: ${errorMessage}`); + // Log to console for debugging + + console.error('Wallet restoration error:', error); + } finally { + restoreWalletBtn.disabled = false; + restoreWalletBtn.textContent = 'Restore Wallet'; + } +} + +async function computeAddresses(seedPhrase: string) { + const words = seedPhrase.split(/\s+/); + const walletKit = getKit(); + await walletKit.waitForReady(); + + const signer = await Signer.fromMnemonic(words, { type: 'ton' }); + + // Create mainnet wallet and get address (bounceable format) + const mainnetNetwork = Network.mainnet(); + const mainnetAdapter = await WalletV5R1Adapter.create(signer, { + client: walletKit.getApiClient(mainnetNetwork), + network: mainnetNetwork, + }); + const mainnetRawAddress = mainnetAdapter.getAddress(); + mainnetAddress = Address.parse(mainnetRawAddress).toString({ bounceable: true, testOnly: false }); + + // Create testnet wallet and get address (bounceable format) + const testnetNetwork = Network.testnet(); + const testnetAdapter = await WalletV5R1Adapter.create(signer, { + client: walletKit.getApiClient(testnetNetwork), + network: testnetNetwork, + }); + const testnetRawAddress = testnetAdapter.getAddress({ testnet: true }); + testnetAddress = Address.parse(testnetRawAddress).toString({ bounceable: true, testOnly: true }); + + // Update UI + walletAddressMainnet.textContent = mainnetAddress; + walletAddressTestnet.textContent = testnetAddress; + + console.log('Addresses computed (bounceable):', { mainnetAddress, testnetAddress }); +} + +async function selectNetworkAndContinue(network: 'mainnet' | 'testnet') { + currentNetwork = network; + localStorage.setItem(STORAGE_KEY_NETWORK, network); + localStorage.setItem(STORAGE_KEY_WALLET_ADDRESS, network === 'mainnet' ? mainnetAddress : testnetAddress); + + try { + networkMainnetCard.style.opacity = '0.5'; + networkTestnetCard.style.opacity = '0.5'; + (network === 'mainnet' ? networkMainnetCard : networkTestnetCard).style.opacity = '1'; + + await initializeStaking(); + + // Update wallet info on staking screen + updateWalletInfoDisplay(); + + showScreen('staking'); + await handleRefreshBalances(); + } catch (error) { + showError(networkError, `Initialization error: ${error instanceof Error ? error.message : String(error)}`); + networkMainnetCard.style.opacity = '1'; + networkTestnetCard.style.opacity = '1'; + } +} + +function updateWalletInfoDisplay() { + currentNetworkLabel.textContent = currentNetwork === 'mainnet' ? 'Mainnet' : 'Testnet'; + currentWalletAddress.textContent = currentNetwork === 'mainnet' ? mainnetAddress : testnetAddress; +} + +function handleChangeNetwork() { + // Reset staking state and go back to network selection + wallet = null; + stakingManager = null; + jettonWalletAddress = null; + showScreen('network'); +} + +function handleDeleteData() { + if (confirm('Are you sure you want to delete all wallet data?')) { + localStorage.removeItem(STORAGE_KEY_SEED); + localStorage.removeItem(STORAGE_KEY_NETWORK); + localStorage.removeItem(STORAGE_KEY_WALLET_ADDRESS); + if (kit) { + kit.clearWallets(); + } + kit = null; + wallet = null; + stakingManager = null; + jettonWalletAddress = null; + mainnetAddress = ''; + testnetAddress = ''; + seedInput.value = ''; + showScreen('seed'); + } +} + +function getKit(): TonWalletKit { + if (!kit) { + kit = new TonWalletKit({ + deviceInfo: createDeviceInfo({ + platform: 'browser', + appName: 'StakingDemo', + appVersion: '1.0.0', + maxProtocolVersion: 2, + features: [ + { + name: 'SendTransaction', + maxMessages: 4, + extraCurrencySupported: false, + }, + ], + }), + walletManifest: createWalletManifest({ + name: 'staking_demo', + appName: 'Staking Demo', + imageUrl: 'https://ton.org/download/ton_symbol.png', + bridgeUrl: 'https://walletbot.me/tonconnect-bridge/bridge', + universalLink: window.location.origin, + aboutUrl: window.location.origin, + platforms: ['chrome'], + }), + storage: new LocalStorageAdapter({ prefix: 'staking-demo:' }), + networks: { + [Network.mainnet().chainId]: { + apiClient: { + url: 'https://toncenter.com', + ...(ENV_TON_API_KEY_MAINNET && { apiKey: ENV_TON_API_KEY_MAINNET }), + }, + }, + [Network.testnet().chainId]: { + apiClient: { + url: 'https://testnet.toncenter.com', + ...(ENV_TON_API_KEY_TESTNET && { apiKey: ENV_TON_API_KEY_TESTNET }), + }, + }, + }, + }); + } + return kit; +} + +async function initializeStaking() { + const savedSeed = localStorage.getItem(STORAGE_KEY_SEED); + if (!savedSeed) { + throw new Error('Seed phrase not found'); + } + + const walletKit = getKit(); + await walletKit.waitForReady(); + + const words = savedSeed.split(/\s+/); + const signer = await Signer.fromMnemonic(words, { type: 'ton' }); + const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); + const walletAdapter = await WalletV5R1Adapter.create(signer, { + client: walletKit.getApiClient(network), + network, + }); + + const addedWallet = await walletKit.addWallet(walletAdapter); + if (!addedWallet) { + throw new Error('Failed to create wallet'); + } + wallet = addedWallet; + + // Initialize StakingManager + stakingManager = new StakingManager(); + + // Register TonStakersStakingProvider + const stakingProvider = new TonStakersStakingProvider( + walletKit.getNetworkManager(), + walletKit.getEventEmitter(), + {}, + ); + stakingManager.registerProvider('tonstakers', stakingProvider); + stakingManager.setDefaultProvider('tonstakers'); + + // Get jetton wallet address + await updateJettonWalletAddress(); +} + +async function updateJettonWalletAddress() { + if (!wallet || !kit) return; + + try { + const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); + const contractAddress = getStakingContractAddress(); + const apiClient = kit.getApiClient(network); + + // Try to get jetton minter address from staking contract + // The staking contract should have a method to get liquid_jetton_master + // For now, we'll try to get it from get_pool_full_data or use a known address + // Since we can't use tonapi.io, we'll use wallet.getJettonWalletAddress + // which will call the jetton minter contract directly + + // Try to get jetton minter address from staking contract + // First, try to get it from contract state + await updateJettonWalletAddressFromContract(apiClient, contractAddress); + + // If that didn't work, try using stakingManager.getBalance which internally gets jetton wallet + if (!jettonWalletAddress && stakingManager) { + try { + const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); + await stakingManager.getBalance(wallet.getAddress(), network); + // This will internally call getJettonWalletAddress, but we still need the address + // So we'll try the contract approach again with a different method + } catch { + // Ignore + } + } + } catch (error) { + console.warn('Error updating jetton wallet address:', error); + // Error handled silently - jetton wallet address will remain null + } +} + +async function updateJettonWalletAddressFromContract(apiClient: ApiClient, contractAddress: string) { + if (!wallet) return; + + try { + // Try to get jetton minter from contract using runGetMethod + // This is contract-specific and may not work for all contracts + const result = await apiClient.runGetMethod(contractAddress, 'get_pool_full_data'); + const parsedStack = ParseStack(result.stack); + + // Try to find address in stack items + for (const item of parsedStack) { + if (item.type === 'cell') { + try { + const slice = item.cell.beginParse(); + const addr = slice.loadMaybeAddress(); + if (addr) { + const jettonWalletAddr = await wallet.getJettonWalletAddress(addr.toString()); + jettonWalletAddress = Address.parse(jettonWalletAddr); + return; + } + } catch { + // Continue searching + } + } else if (item.type === 'tuple' && item.items) { + for (const tupleItem of item.items) { + if (tupleItem.type === 'cell') { + try { + const slice = tupleItem.cell.beginParse(); + const addr = slice.loadMaybeAddress(); + if (addr) { + const jettonWalletAddr = await wallet.getJettonWalletAddress(addr.toString()); + jettonWalletAddress = Address.parse(jettonWalletAddr); + return; + } + } catch { + // Continue searching + } + } + } + } + } + } catch (error) { + console.warn('Failed to get jetton wallet address from contract:', error); + } +} + +function getStakingContractAddress(): string { + return currentNetwork === 'mainnet' ? CONTRACT.STAKING_CONTRACT_ADDRESS : CONTRACT.STAKING_CONTRACT_ADDRESS_TESTNET; +} + +async function handleStake(amount: bigint) { + if (!wallet || !stakingManager) { + showError(stakingError, 'Wallet not initialized'); + return; + } + + // Disable buttons and show pending status + stakeBtn.disabled = true; + stakeMaxBtn.disabled = true; + const originalText = stakeBtn.textContent; + stakeBtn.textContent = 'Processing...'; + + showTxStatus('pending', 'Creating transaction...', `Staking ${fromNano(amount)} TON`); + + try { + const contractAddress = getStakingContractAddress(); + const totalAmount = amount + CONTRACT.STAKE_FEE_RES; + + console.log('Creating stake transaction:', { + contractAddress, + amount: fromNano(amount), + totalAmount: fromNano(totalAmount), + }); + + const payload = beginCell() + .storeUint(CONTRACT.PAYLOAD_STAKE, 32) + .storeUint(1, 64) + .storeUint(CONTRACT.PARTNER_CODE, 64) + .endCell() + .toBoc() + .toString('base64') as Base64String; + + showTxStatus('pending', 'Building transaction...', `To: ${contractAddress}`); + + const transaction = await wallet.createTransferTonTransaction({ + recipientAddress: contractAddress, + transferAmount: totalAmount.toString(), + payload, + }); + + console.log('Transaction created:', transaction); + showTxStatus('pending', 'Sending transaction...', 'Please wait...'); + + await wallet.sendTransaction(transaction); + + showTxStatus( + 'success', + 'Transaction sent!', + `Staked ${fromNano(amount)} TON successfully. Refreshing balances...`, + ); + console.log('Transaction sent successfully'); + + setTimeout(async () => { + await handleRefreshBalances(); + setTimeout(() => hideTxStatus(), 3000); + }, 5000); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Staking error:', error); + showTxStatus('error', 'Transaction failed', errorMessage); + showError(stakingError, `Staking error: ${errorMessage}`); + } finally { + stakeBtn.disabled = false; + stakeMaxBtn.disabled = false; + stakeBtn.textContent = originalText; + } +} + +async function handleStakeMax() { + if (!wallet) { + showError(stakingError, 'Wallet not initialized'); + return; + } + + try { + const balance = BigInt(await wallet.getBalance()); + const available = balance > CONTRACT.RECOMMENDED_FEE_RESERVE ? balance - CONTRACT.RECOMMENDED_FEE_RESERVE : 0n; + if (available > 0n) { + await handleStake(available); + } else { + showError(stakingError, 'Insufficient funds for staking'); + } + } catch (error) { + showError(stakingError, `Error getting balance: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function handleUnstake(amount: bigint, mode: 'instant' | 'delayed', waitTillRoundEnd = false) { + if (stakedBalanceNano <= 0n) { + showError(stakingError, 'No staked balance available to unstake'); + return; + } + + if (amount > stakedBalanceNano) { + showError(stakingError, `Insufficient staked balance. Available: ${fromNano(stakedBalanceNano)}`); + return; + } + + if (!wallet || !jettonWalletAddress) { + showError(stakingError, 'Wallet not initialized'); + return; + } + + try { + const fillOrKill = mode === 'instant'; + const payload = beginCell() + .storeUint(CONTRACT.PAYLOAD_UNSTAKE, 32) + .storeUint(0, 64) + .storeCoins(amount) + .storeAddress(Address.parse(wallet.getAddress())) + .storeMaybeRef( + beginCell() + .storeUint(waitTillRoundEnd ? 1 : 0, 1) + .storeUint(fillOrKill ? 1 : 0, 1) + .endCell(), + ) + .endCell() + .toBoc() + .toString('base64') as Base64String; + + const transaction = await wallet.createTransferTonTransaction({ + recipientAddress: jettonWalletAddress.toString(), + transferAmount: CONTRACT.UNSTAKE_FEE_RES.toString(), + payload, + }); + + await wallet.sendTransaction(transaction); + showSuccess('Unstaking transaction sent'); + setTimeout(() => handleRefreshBalances(), 2000); + } catch (error) { + showError(stakingError, `Unstaking error: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function handleRefreshBalances() { + if (!wallet) { + console.warn('handleRefreshBalances: wallet not initialized'); + return; + } + + console.log('Refreshing balances...'); + + try { + // Get TON balance + const tonBalance = await wallet.getBalance(); + console.log('TON balance:', fromNano(tonBalance)); + balanceTon.textContent = formatBalance(tonBalance); + + // Get available balance + const available = + BigInt(tonBalance) > CONTRACT.RECOMMENDED_FEE_RESERVE + ? BigInt(tonBalance) - CONTRACT.RECOMMENDED_FEE_RESERVE + : 0n; + balanceAvailable.textContent = formatBalance(available); + + // Get staked balance (tsTON) using TonAPI directly + try { + const isTestnet = currentNetwork === 'testnet'; + const tonApiUrl = isTestnet ? 'https://testnet.tonapi.io' : 'https://tonapi.io'; + const walletAddr = currentNetwork === 'mainnet' ? mainnetAddress : testnetAddress; + + console.log('Getting tsTON balance from TonAPI for:', walletAddr); + + // Get jettons for the wallet + const jettonsResponse = await fetch(`${tonApiUrl}/v2/accounts/${encodeURIComponent(walletAddr)}/jettons`); + if (jettonsResponse.ok) { + const jettonsData = await jettonsResponse.json(); + console.log('Jettons data:', jettonsData); + + // Find tsTON jetton (look for the staking pool jetton) + let stakedAmount = '0'; + if (jettonsData.balances && Array.isArray(jettonsData.balances)) { + for (const jetton of jettonsData.balances) { + // Check if this is the tsTON jetton by looking at the name or symbol + const name = jetton.jetton?.name?.toLowerCase() || ''; + const symbol = jetton.jetton?.symbol?.toLowerCase() || ''; + if ( + name.includes('tstok') || + name.includes('tston') || + symbol === 'tston' || + symbol.includes('ts') + ) { + stakedAmount = jetton.balance || '0'; + console.log('Found tsTON jetton:', jetton); + break; + } + } + } + stakedBalanceNano = BigInt(stakedAmount); + balanceStaked.textContent = formatBalance(stakedAmount); + } else { + console.warn('TonAPI jettons request failed:', jettonsResponse.status); + stakedBalanceNano = 0n; + balanceStaked.textContent = '0'; + } + } catch (error) { + console.warn('Failed to get staked balance from TonAPI:', error); + + // Fallback to stakingManager + if (stakingManager && wallet) { + try { + const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); + const stakingBalance = await stakingManager.getBalance(wallet.getAddress(), network); + console.log('Staked balance from manager:', stakingBalance); + stakedBalanceNano = BigInt(stakingBalance.stakedBalance); + balanceStaked.textContent = formatBalance(stakingBalance.stakedBalance); + } catch (err) { + console.warn('Failed to get staked balance from manager:', err); + stakedBalanceNano = 0n; + balanceStaked.textContent = '0'; + } + } else { + stakedBalanceNano = 0n; + balanceStaked.textContent = '0'; + } + } + + updateUnstakeButtonsState(); + + // Get pool info using TonAPI for TVL and Stakers + await refreshPoolInfo(); + } catch (error) { + console.error('Error in handleRefreshBalances:', error); + } +} + +async function refreshPoolInfo() { + const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); + const isTestnet = currentNetwork === 'testnet'; + const tonApiUrl = isTestnet ? 'https://testnet.tonapi.io' : 'https://tonapi.io'; + const contractAddress = getStakingContractAddress(); + + console.log('Refreshing pool info from TonAPI:', tonApiUrl, contractAddress); + + try { + // Get APY from staking manager + if (stakingManager) { + try { + const stakingInfo = await stakingManager.getStakingInfo(network); + console.log('Staking info:', stakingInfo); + poolApy.textContent = `${(stakingInfo.apy * 100).toFixed(2)}%`; + } catch (error) { + console.warn('Failed to get APY:', error); + poolApy.textContent = '-'; + } + } + + // Get TVL and Stakers from TonAPI + try { + const poolInfoResponse = await fetch(`${tonApiUrl}/v2/staking/pool/${contractAddress}`); + if (poolInfoResponse.ok) { + const poolInfo = await poolInfoResponse.json(); + console.log('Pool info from TonAPI:', poolInfo); + + if (poolInfo.pool) { + // TVL + if (poolInfo.pool.total_amount) { + poolTvl.textContent = formatBalance(poolInfo.pool.total_amount); + } + // Stakers count + if (poolInfo.pool.current_nominators !== undefined) { + poolStakers.textContent = poolInfo.pool.current_nominators.toString(); + } + } + } else { + console.warn('TonAPI pool info request failed:', poolInfoResponse.status); + } + } catch (error) { + console.warn('Failed to get TVL/Stakers from TonAPI:', error); + } + } catch (error) { + console.error('Error in refreshPoolInfo:', error); + } +} + +async function handleGetRounds() { + console.log('Getting rounds info...'); + + try { + const isTestnet = currentNetwork === 'testnet'; + const tonApiUrl = isTestnet ? 'https://testnet.tonapi.io' : 'https://tonapi.io'; + const contractAddress = getStakingContractAddress(); + + console.log('Fetching pool data from TonAPI:', tonApiUrl, contractAddress); + + // Get pool info from TonAPI staking endpoint + const response = await fetch(`${tonApiUrl}/v2/staking/pool/${contractAddress}`); + + if (!response.ok) { + throw new Error(`TonAPI request failed: ${response.status}`); + } + + const data = await response.json(); + console.log('Pool staking data:', data); + + // Get cycle info from pool data + if (data.pool) { + const cycleStart = data.pool.cycle_start; + const cycleEnd = data.pool.cycle_end; + const cycleLength = data.pool.cycle_length; + + if (cycleStart && cycleEnd) { + const startDate = new Date(cycleStart * 1000); + const endDate = new Date(cycleEnd * 1000); + const now = new Date(); + const remaining = endDate.getTime() - now.getTime(); + const hoursRemaining = Math.max(0, Math.floor(remaining / (1000 * 60 * 60))); + const minutesRemaining = Math.max(0, Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))); + + roundsInfo.innerHTML = ` +
Current Round
+
Start: ${startDate.toLocaleString()}
+
End: ${endDate.toLocaleString()}
+
Remaining: ${hoursRemaining}h ${minutesRemaining}m
+ ${cycleLength ? `
Cycle Length: ${Math.floor(cycleLength / 3600)}h
` : ''} + `; + return; + } + } + + roundsInfo.textContent = 'Unable to get rounds info from pool'; + } catch (error) { + console.error('Error getting rounds info:', error); + showError(stakingError, `Error getting rounds info: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function _handleGetWithdrawals() { + if (!wallet) { + showError(stakingError, 'Wallet not initialized'); + return; + } + + try { + // Fetch withdrawal payouts + const response = await fetch('https://api.tonstakers.com/api/v1/pool/withdrawal_payout'); + const data = await response.json(); + const activeCollections = data.data?.active_collections || []; + + if (activeCollections.length === 0) { + withdrawalsList.innerHTML = '

No active withdrawals

'; + return; + } + + const userAddress = wallet.getAddress(); + let hasWithdrawals = false; + + withdrawalsList.innerHTML = ''; + + for (const collection of activeCollections) { + try { + if (!kit) continue; + + const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); + const apiClient = kit.getApiClient(network); + + // Use apiClient to get NFT items by owner + const nftData = await apiClient.nftItemsByOwner({ + ownerAddress: userAddress, + collectionAddress: collection.withdrawal_payout, + limit: 100, + offset: 0, + }); + + interface NftItem { + owner?: { address?: string }; + metadata?: { name?: string }; + } + + const userNfts = (nftData.nft_items as NftItem[] | undefined) || []; + + for (const nft of userNfts) { + hasWithdrawals = true; + const withdrawalDiv = document.createElement('div'); + withdrawalDiv.className = 'withdrawal-item'; + const tsTONAmount = nft.metadata?.name?.match(/[\d.]+/)?.[0] || '0'; + withdrawalDiv.innerHTML = ` + Withdrawal ${tsTONAmount} tsTON
+ Collection: ${collection.withdrawal_payout}
+ Round ends: ${new Date(collection.cycle_end * 1000).toLocaleString()} + `; + withdrawalsList.appendChild(withdrawalDiv); + } + } catch (error) { + console.warn('Error fetching withdrawal NFTs:', error); + // Error handled silently - withdrawal will be skipped + } + } + + if (!hasWithdrawals) { + withdrawalsList.innerHTML = '

No active withdrawals

'; + } + } catch (error) { + showError( + stakingError, + `Error getting withdrawals list: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +// Initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/demo/examples/vite.config.ts b/demo/examples/vite.config.ts new file mode 100644 index 000000000..3561e52ba --- /dev/null +++ b/demo/examples/vite.config.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 path from 'path'; + +import { defineConfig } from 'vite'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; + +// https://vite.dev/config/ +export default defineConfig({ + root: '.', + build: { + outDir: 'dist', + rollupOptions: { + input: { + main: path.resolve(__dirname, 'index.html'), + }, + }, + }, + plugins: [ + nodePolyfills({ + // Enable Buffer polyfill + include: ['buffer'], + globals: { + Buffer: true, + global: true, + process: true, + }, + }), + ], + define: { + 'process.env': {}, + }, + optimizeDeps: { + include: ['buffer'], + }, +}); diff --git a/packages/walletkit/src/defi/staking/index.ts b/packages/walletkit/src/defi/staking/index.ts index 5994708cf..2d7b82261 100644 --- a/packages/walletkit/src/defi/staking/index.ts +++ b/packages/walletkit/src/defi/staking/index.ts @@ -13,3 +13,4 @@ export type { StakingErrorCode } from './errors'; export type * from './types'; export { TonStakersStakingProvider } from './tonstakers/TonStakersStakingProvider'; export type { TonStakersProviderConfig } from './tonstakers/types'; +export { CONTRACT, BLOCKCHAIN, TIMING } from './tonstakers/constants'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts index f2cdd3436..29f78cbf2 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -6,7 +6,15 @@ * */ -import type { TransactionRequest, UserFriendlyAddress, Network } from '../../../api/models'; +import { Address, beginCell } from '@ton/core'; + +import type { + TransactionRequest, + UserFriendlyAddress, + TransactionRequestMessage, + Base64String, +} from '../../../api/models'; +import { Network } from '../../../api/models'; import { globalLogger } from '../../../core/Logger'; import type { NetworkManager } from '../../../core/NetworkManager'; import type { EventEmitter } from '../../../core/EventEmitter'; @@ -20,17 +28,43 @@ import type { StakingQuote, } from '../types'; import { StakingError, StakingErrorCode } from '../errors'; +import { StakingQuoteDirection, UnstakeMode } from '../types'; import type { TonStakersProviderConfig } from './types'; +import { CONTRACT } from './constants'; +import { ParseStack } from '../../../utils'; const log = globalLogger.createChild('TonStakersStakingProvider'); export class TonStakersStakingProvider extends StakingProvider { - private readonly apiUrl?: string; + protected config: TonStakersProviderConfig; constructor(networkManager: NetworkManager, eventEmitter: EventEmitter, config: TonStakersProviderConfig = {}) { super(networkManager, eventEmitter); - this.apiUrl = config.apiUrl; - log.info('TonStakersStakingProvider initialized', { apiUrl: this.apiUrl }); + this.config = { + [Network.mainnet().chainId]: { + contractAddress: CONTRACT.STAKING_CONTRACT_ADDRESS, + }, + [Network.testnet().chainId]: { + contractAddress: CONTRACT.STAKING_CONTRACT_ADDRESS_TESTNET, + }, + ...config, + }; + log.info('TonStakersStakingProvider initialized'); + } + + private getStakingContractAddress(network?: Network): string { + const targetNetwork = network ?? Network.mainnet(); + const networkConfig = this.config[targetNetwork.chainId]; + + if (!networkConfig || !networkConfig.contractAddress) { + throw new StakingError( + 'Staking contract address is not configured for the selected network', + StakingErrorCode.InvalidParams, + { network: targetNetwork }, + ); + } + + return networkConfig.contractAddress; } async getQuote(params: StakingQuoteParams): Promise { @@ -39,26 +73,232 @@ export class TonStakersStakingProvider extends StakingProvider { amount: params.amount, userAddress: params.userAddress, }); - throw new StakingError('TonStakers quote is not implemented', StakingErrorCode.UnsupportedOperation); + + const stakingInfo = await this.getStakingInfo(params.network); + + if (params.direction === StakingQuoteDirection.Stake) { + return { + direction: StakingQuoteDirection.Stake, + amountIn: params.amount, + amountOut: params.amount, // 1:1 for staking + provider: 'tonstakers', + apy: stakingInfo.apy, + }; + } else { + // For unstaking, amount is the same (1:1) + return { + direction: StakingQuoteDirection.Unstake, + amountIn: params.amount, + amountOut: params.amount, + provider: 'tonstakers', + unstakeMode: params.unstakeMode || UnstakeMode.Delayed, + }; + } } async stake(params: StakeParams): Promise { - log.debug('TonStakers stake requested', { amount: params.amount, userAddress: params.userAddress }); - throw new StakingError('TonStakers staking is not implemented', StakingErrorCode.UnsupportedOperation); + log.debug('TonStakers stake requested', { params }); + + const network = params.network; + const contractAddress = this.getStakingContractAddress(network); + const amount = BigInt(params.amount); + const totalAmount = amount + CONTRACT.STAKE_FEE_RES; + + const payload = beginCell() + .storeUint(CONTRACT.PAYLOAD_STAKE, 32) + .storeUint(1, 64) + .storeUint(CONTRACT.PARTNER_CODE, 64) + .endCell() + .toBoc() + .toString('base64'); + + const message: TransactionRequestMessage = { + address: contractAddress, + amount: totalAmount.toString(), + payload: payload as Base64String, + }; + + return { + messages: [message], + fromAddress: params.userAddress, + network, + }; } async unstake(params: UnstakeParams): Promise { log.debug('TonStakers unstake requested', { amount: params.amount, userAddress: params.userAddress }); - throw new StakingError('TonStakers unstaking is not implemented', StakingErrorCode.UnsupportedOperation); + + const network = params.network; + const amount = BigInt(params.amount); + const unstakeMode = params.unstakeMode || UnstakeMode.Delayed; + const waitTillRoundEnd = unstakeMode === UnstakeMode.Delayed && params.maxDelayHours !== undefined; + const fillOrKill = unstakeMode === UnstakeMode.Instant; + + // Get jetton wallet address + const jettonWalletAddress = await this.getJettonWalletAddress(params.userAddress, network); + + const payload = beginCell() + .storeUint(CONTRACT.PAYLOAD_UNSTAKE, 32) + .storeUint(0, 64) + .storeCoins(amount) + .storeAddress(Address.parse(params.userAddress)) + .storeMaybeRef( + beginCell() + .storeUint(waitTillRoundEnd ? 1 : 0, 1) + .storeUint(fillOrKill ? 1 : 0, 1) + .endCell(), + ) + .endCell() + .toBoc() + .toString('base64'); + + const message: TransactionRequestMessage = { + address: jettonWalletAddress, + amount: CONTRACT.UNSTAKE_FEE_RES.toString(), + payload: payload as Base64String, + }; + + return { + messages: [message], + fromAddress: params.userAddress, + network, + }; } async getBalance(userAddress: UserFriendlyAddress, network?: Network): Promise { log.debug('TonStakers balance requested', { userAddress, network }); - throw new StakingError('TonStakers balance is not implemented', StakingErrorCode.UnsupportedOperation); + + try { + const targetNetwork = network ?? Network.mainnet(); + const apiClient = this.getApiClient(targetNetwork); + const tonBalance = await apiClient.getBalance(userAddress); + const availableBalance = + BigInt(tonBalance) > CONTRACT.RECOMMENDED_FEE_RESERVE + ? BigInt(tonBalance) - CONTRACT.RECOMMENDED_FEE_RESERVE + : 0n; + + // Get staked balance (tsTON) + let stakedBalance = 0n; + let instantUnstakeAvailable = 0n; + + try { + const jettonWalletAddress = await this.getJettonWalletAddress(userAddress, network); + const result = await apiClient.runGetMethod(jettonWalletAddress, 'get_wallet_data'); + + // Parse balance from stack (first item is balance) + if (result.stack && result.stack.length > 0) { + const parsedStack = ParseStack(result.stack); + if (parsedStack.length > 0 && parsedStack[0].type === 'int') { + stakedBalance = parsedStack[0].value; + } + } + } catch (error) { + log.warn('Failed to get staked balance', { error }); + } + + return { + stakedBalance: stakedBalance.toString(), + availableBalance: availableBalance.toString(), + instantUnstakeAvailable: instantUnstakeAvailable.toString(), + provider: 'tonstakers', + }; + } catch (error) { + log.error('Failed to get balance', { error, userAddress, network }); + throw new StakingError('Failed to get staking balance', StakingErrorCode.InvalidParams, { + error, + userAddress, + network, + }); + } } async getStakingInfo(network?: Network): Promise { log.debug('TonStakers info requested', { network }); - throw new StakingError('TonStakers info is not implemented', StakingErrorCode.UnsupportedOperation); + + // Note: APY and other dynamic info is not easily available on-chain without historical data. + // We return default values here to avoid dependency on external APIs like tonapi.io + return Promise.resolve({ + apy: 0, + instantUnstakeAvailable: '0', + provider: 'tonstakers', + }); + } + + private async getJettonWalletAddress( + userAddress: UserFriendlyAddress, + network?: Network, + ): Promise { + try { + const targetNetwork = network ?? Network.mainnet(); + const contractAddress = this.getStakingContractAddress(targetNetwork); + const apiClient = this.getApiClient(targetNetwork); + + // 1. Get pool info (get_pool_full_data) to find jetton minter + // runGetMethod is used instead of fetch to avoid external API dependencies + const poolInfoResult = await apiClient.runGetMethod(contractAddress, 'get_pool_full_data'); + + let jettonMinterAddress: string | undefined; + + if (poolInfoResult.stack && poolInfoResult.stack.length > 0) { + const parsedStack = ParseStack(poolInfoResult.stack); + // Look for an address in the stack which is likely the jetton minter + // Based on observation, it is usually one of the addresses in the stack + for (const item of parsedStack) { + if (item.type === 'cell') { + try { + const slice = item.cell.beginParse(); + const addr = slice.loadAddress(); + // Basic check if it looks like a valid address (not null address) + if ( + addr && + addr.hash && + addr.hash.length > 0 && + !addr.equals(Address.parse(contractAddress)) + ) { + jettonMinterAddress = addr.toString(); + break; // Take the first valid address found + } + } catch { + // Ignore parse errors + } + } + } + } + + if (!jettonMinterAddress) { + throw new Error('Jetton minter address not found in pool data'); + } + + // 2. Get jetton wallet address using API client via get_wallet_address method on minter + const addressCell = beginCell() + .storeAddress(Address.parse(userAddress)) + .endCell() + .toBoc() + .toString('base64'); + + const result = await apiClient.runGetMethod(jettonMinterAddress, 'get_wallet_address', [ + { type: 'cell', value: addressCell }, + ]); + + // Parse result from stack + if (result.stack && result.stack.length > 0) { + const parsedStack = ParseStack(result.stack); + if (parsedStack.length > 0 && parsedStack[0].type === 'cell') { + // Extract address from cell + const addressSlice = parsedStack[0].cell.beginParse(); + const address = addressSlice.loadAddress(); + return address.toString() as UserFriendlyAddress; + } + } + + throw new Error('Failed to get jetton wallet address from minter'); + } catch (error) { + log.error('Failed to get jetton wallet address', { error, userAddress, network }); + throw new StakingError('Failed to get jetton wallet address', StakingErrorCode.InvalidParams, { + error, + userAddress, + network, + }); + } } } diff --git a/packages/walletkit/src/defi/staking/tonstakers/constants.ts b/packages/walletkit/src/defi/staking/tonstakers/constants.ts index 526fcf546..0b2dd9cea 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/constants.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/constants.ts @@ -6,6 +6,8 @@ * */ +import { toNano } from '@ton/core'; + // Timing-related constants export const TIMING = { DEFAULT_INTERVAL: 5000, @@ -15,13 +17,6 @@ export const TIMING = { ESTIMATED_TIME_AFTER_ROUND_S: 10 * 60, }; -// Blockchain-related constants -export const BLOCKCHAIN = { - CHAIN_DEV: '-3', - API_URL: 'https://tonapi.io', - API_URL_TESTNET: 'https://testnet.tonapi.io', -}; - // Contract-related constants export const CONTRACT = { STAKING_CONTRACT_ADDRESS: 'EQCkWxfyhAkim3g2DjKQQg8T5P4g-Q1-K_jErGcDJZ4i-vqR', @@ -29,7 +24,14 @@ export const CONTRACT = { PARTNER_CODE: 0x000000106796caef, PAYLOAD_UNSTAKE: 0x595f07bc, PAYLOAD_STAKE: 0x47d54391, - STAKE_FEE_RES: 1, - UNSTAKE_FEE_RES: 1.05, - RECOMMENDED_FEE_RESERVE: 1.1, + STAKE_FEE_RES: toNano('1'), + UNSTAKE_FEE_RES: toNano('1.05'), + RECOMMENDED_FEE_RESERVE: toNano('1.1'), }; + +// Blockchain identifiers exposed as part of the public staking API. +// Currently not used internally but kept for library consumers. +export const BLOCKCHAIN = { + MAINNET: 'mainnet', + TESTNET: 'testnet', +} as const; diff --git a/packages/walletkit/src/defi/staking/tonstakers/types.ts b/packages/walletkit/src/defi/staking/tonstakers/types.ts index 86d585951..471a731f4 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/types.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/types.ts @@ -6,6 +6,8 @@ * */ -export interface TonStakersProviderConfig { - apiUrl?: string; -} +export type TonStakersProviderConfig = { + [key: string]: { + contractAddress: string; + }; +}; diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 336b87538..76894c513 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -21,7 +21,15 @@ export { Initializer } from './core/Initializer'; export { JettonsManager } from './core/JettonsManager'; export { SwapManager, SwapProvider, SwapError } from './defi/swap'; export type * from './defi/swap/types'; -export { StakingManager, StakingProvider, StakingError, TonStakersStakingProvider } from './defi/staking'; +export { + StakingManager, + StakingProvider, + StakingError, + TonStakersStakingProvider, + CONTRACT, + BLOCKCHAIN, + TIMING, +} from './defi/staking'; export type * from './defi/staking/types'; export { EventEmitter } from './core/EventEmitter'; export type { EventListener } from './core/EventEmitter'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d17030867..58094bc3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -373,12 +373,18 @@ importers: demo/examples: dependencies: + '@ton/core': + specifier: ^0.62.0 + version: 0.62.0(@ton/crypto@3.3.0) '@ton/walletkit': specifier: workspace:* version: link:../../packages/walletkit '@types/node': specifier: ^24.10.1 version: 24.10.1 + buffer: + specifier: ^6.0.3 + version: 6.0.3 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -388,6 +394,13 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + devDependencies: + vite: + specifier: ^7.2.2 + version: 7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vite-plugin-node-polyfills: + specifier: ^0.25.0 + version: 0.25.0(rollup@4.46.4)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) demo/v4ledger-adapter: dependencies: @@ -2518,6 +2531,24 @@ packages: '@rolldown/pluginutils@1.0.0-beta.43': resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==} + '@rollup/plugin-inject@5.0.5': + resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.46.4': resolution: {integrity: sha512-B2wfzCJ+ps/OBzRjeds7DlJumCU3rXMxJJS1vzURyj7+KBHGONm7c9q1TfdBl4vCuNMkDvARn3PBl2wZzuR5mw==} cpu: [arm] @@ -3397,6 +3428,12 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@4.10.1: + resolution: {integrity: sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==} + + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3558,6 +3595,12 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + bn.js@4.12.2: + resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} + + bn.js@5.2.2: + resolution: {integrity: sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -3586,6 +3629,32 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + + browser-resolve@2.0.0: + resolution: {integrity: sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==} + + browserify-aes@1.2.0: + resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} + + browserify-cipher@1.0.1: + resolution: {integrity: sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==} + + browserify-des@1.0.2: + resolution: {integrity: sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==} + + browserify-rsa@4.1.1: + resolution: {integrity: sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==} + engines: {node: '>= 0.10'} + + browserify-sign@4.2.5: + resolution: {integrity: sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==} + engines: {node: '>= 0.10'} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + browserslist@4.28.0: resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3601,12 +3670,18 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-xor@1.0.3: + resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builtin-status-codes@3.0.0: + resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3725,6 +3800,10 @@ packages: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} + cipher-base@1.0.7: + resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} + engines: {node: '>= 0.10'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -3863,6 +3942,12 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console-browserify@1.2.0: + resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==} + + constants-browserify@1.0.0: + resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -3876,6 +3961,15 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + create-ecdh@4.0.4: + resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==} + + create-hash@1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + + create-hmac@1.1.7: + resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -3891,6 +3985,10 @@ packages: crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + crypto-browserify@3.12.1: + resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==} + engines: {node: '>= 0.10'} + crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} @@ -4069,6 +4167,9 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + diffie-hellman@5.0.3: + resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} + dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -4083,6 +4184,10 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + domain-browser@4.22.0: + resolution: {integrity: sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==} + engines: {node: '>=10'} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} @@ -4126,6 +4231,9 @@ packages: electron-to-chromium@1.5.250: resolution: {integrity: sha512-/5UMj9IiGDMOFBnN4i7/Ry5onJrAGSbOGo3s9FEKmwobGq6xw832ccET0CE3CkkMBZ8GJSlUIesZofpyurqDXw==} + elliptic@6.6.1: + resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -4363,6 +4471,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -4390,6 +4501,9 @@ packages: resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} engines: {node: '>=12.0.0'} + evp_bytestokey@1.0.3: + resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} @@ -4930,6 +5044,17 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hash-base@3.0.5: + resolution: {integrity: sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==} + engines: {node: '>= 0.10'} + + hash-base@3.1.2: + resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} + engines: {node: '>= 0.8'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4946,6 +5071,9 @@ packages: hermes-parser@0.32.0: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -4976,6 +5104,9 @@ packages: http2-client@1.3.5: resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} + https-browserify@1.0.0: + resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -5155,6 +5286,10 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -5275,6 +5410,10 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isomorphic-timers-promises@1.0.1: + resolution: {integrity: sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==} + engines: {node: '>=10'} + isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: @@ -5766,6 +5905,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + md5.js@1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} @@ -5910,6 +6052,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + miller-rabin@4.0.1: + resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} + hasBin: true + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -5942,6 +6088,9 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + minimatch@10.0.3: resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} engines: {node: 20 || >=22} @@ -6115,6 +6264,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-stdlib-browser@1.3.1: + resolution: {integrity: sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==} + engines: {node: '>=10'} + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -6181,6 +6334,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -6254,6 +6411,9 @@ packages: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} + os-browserify@0.3.0: + resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} + os-shim@0.1.3: resolution: {integrity: sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==} engines: {node: '>= 0.4.0'} @@ -6310,6 +6470,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-asn1@5.1.9: + resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==} + engines: {node: '>= 0.10'} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -6330,6 +6494,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -6364,6 +6531,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pbkdf2@3.1.5: + resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==} + engines: {node: '>= 0.10'} + perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} @@ -6404,6 +6575,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-dir@5.0.0: + resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} + engines: {node: '>=10'} + pkg-prebuilds@1.0.0: resolution: {integrity: sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==} engines: {node: '>= 14.15.0'} @@ -6523,9 +6698,15 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + public-encrypt@4.0.3: + resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -6557,6 +6738,10 @@ packages: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} + querystring-es3@0.2.1: + resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} + engines: {node: '>=0.4.x'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -6570,6 +6755,12 @@ packages: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + randomfill@1.0.4: + resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -6957,6 +7148,10 @@ packages: engines: {node: 20 || >=22} hasBin: true + ripemd160@2.0.3: + resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} + engines: {node: '>= 0.8'} + rollup@4.46.4: resolution: {integrity: sha512-YbxoxvoqNg9zAmw4+vzh1FkGAiZRK+LhnSrbSrSXMdZYsRPDWoshcSd/pldKRO6lWzv/e9TiJAVQyirYIeSIPQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -7083,6 +7278,11 @@ packages: resolution: {integrity: sha512-ezT7gu/SHTPIOEEoG6TF+O0m5eewl0ZDAO4AtdBi5HjsrUI6JdCG17+Q8+aKp0heM06wZKApRCn5olNbs0Wb/A==} engines: {node: '>=10'} + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + shallowequal@1.1.0: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} @@ -7263,10 +7463,16 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + stream-buffers@2.2.0: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} + stream-http@3.2.0: + resolution: {integrity: sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==} + strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -7480,6 +7686,10 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + timers-browserify@2.0.12: + resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} + engines: {node: '>=0.6.0'} + tiny-uid@1.1.2: resolution: {integrity: sha512-0beRFXR+fv4C40ND2PqgNjq6iyB1dKXciKJjslLw0kPYCcR82aNd2b+Tt2yy06LimIlvtoehgvrm/fUZCutSfg==} @@ -7508,6 +7718,10 @@ packages: tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -7606,6 +7820,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tty-browserify@0.0.1: + resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -7816,6 +8033,10 @@ packages: url-polyfill@1.1.14: resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==} + url@0.11.4: + resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} + engines: {node: '>= 0.4'} + usb@2.9.0: resolution: {integrity: sha512-G0I/fPgfHUzWH8xo2KkDxTTFruUWfppgSFJ+bQxz/kVY2x15EQ/XDB7dqD1G432G4gBG4jYQuF3U7j/orSs5nw==} engines: {node: '>=10.20.0 <11.x || >=12.17.0 <13.0 || >=14.0.0'} @@ -7904,6 +8125,11 @@ packages: resolution: {integrity: sha512-8nhwDGHWMKKgg6oegAOpDgTT7/yzTVzeYzLF4y8WBJoYu9gO7h29UpHiQnXD2rAvfQzDy5Wqe/Za5cgqhnxi5g==} hasBin: true + vite-plugin-node-polyfills@0.25.0: + resolution: {integrity: sha512-rHZ324W3LhfGPxWwQb2N048TThB6nVvnipsqBUJEzh3R9xeK9KI3si+GMQxCuAcpPJBVf0LpDtJ+beYzB3/chg==} + peerDependencies: + vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite@7.2.2: resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8021,6 +8247,9 @@ packages: vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + vm-browserify@1.1.2: + resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -8232,6 +8461,10 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -10659,6 +10892,22 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.43': {} + '@rollup/plugin-inject@5.0.5(rollup@4.46.4)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.46.4) + estree-walker: 2.0.2 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.46.4 + + '@rollup/pluginutils@5.3.0(rollup@4.46.4)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.46.4 + '@rollup/rollup-android-arm-eabi@4.46.4': optional: true @@ -11674,6 +11923,20 @@ snapshots: asap@2.0.6: {} + asn1.js@4.10.1: + dependencies: + bn.js: 4.12.2 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + assert@2.1.0: + dependencies: + call-bind: 1.0.8 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + assertion-error@2.0.1: {} ast-v8-to-istanbul@0.3.8: @@ -11907,6 +12170,10 @@ snapshots: bluebird@3.7.2: {} + bn.js@4.12.2: {} + + bn.js@5.2.2: {} + boolbase@1.0.0: {} boxen@8.0.1: @@ -11945,6 +12212,56 @@ snapshots: dependencies: fill-range: 7.1.1 + brorand@1.1.0: {} + + browser-resolve@2.0.0: + dependencies: + resolve: 1.22.10 + + browserify-aes@1.2.0: + dependencies: + buffer-xor: 1.0.3 + cipher-base: 1.0.7 + create-hash: 1.2.0 + evp_bytestokey: 1.0.3 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + browserify-cipher@1.0.1: + dependencies: + browserify-aes: 1.2.0 + browserify-des: 1.0.2 + evp_bytestokey: 1.0.3 + + browserify-des@1.0.2: + dependencies: + cipher-base: 1.0.7 + des.js: 1.1.0 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + browserify-rsa@4.1.1: + dependencies: + bn.js: 5.2.2 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + + browserify-sign@4.2.5: + dependencies: + bn.js: 5.2.2 + browserify-rsa: 4.1.1 + create-hash: 1.2.0 + create-hmac: 1.1.7 + elliptic: 6.6.1 + inherits: 2.0.4 + parse-asn1: 5.1.9 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + browserslist@4.28.0: dependencies: baseline-browser-mapping: 2.8.26 @@ -11963,6 +12280,8 @@ snapshots: buffer-from@1.1.2: {} + buffer-xor@1.0.3: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -11973,6 +12292,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + builtin-status-codes@3.0.0: {} + bytes@3.1.2: {} c12@3.3.3(magicast@0.5.1): @@ -12095,6 +12416,12 @@ snapshots: ci-info@4.3.1: {} + cipher-base@1.0.7: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + citty@0.1.6: dependencies: consola: 3.4.2 @@ -12231,6 +12558,10 @@ snapshots: consola@3.4.2: {} + console-browserify@1.2.0: {} + + constants-browserify@1.0.0: {} + convert-source-map@2.0.0: {} cookie@1.0.2: {} @@ -12241,6 +12572,28 @@ snapshots: core-util-is@1.0.3: {} + create-ecdh@4.0.4: + dependencies: + bn.js: 4.12.2 + elliptic: 6.6.1 + + create-hash@1.2.0: + dependencies: + cipher-base: 1.0.7 + inherits: 2.0.4 + md5.js: 1.3.5 + ripemd160: 2.0.3 + sha.js: 2.4.12 + + create-hmac@1.1.7: + dependencies: + cipher-base: 1.0.7 + create-hash: 1.2.0 + inherits: 2.0.4 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + create-require@1.1.1: {} cross-env@10.1.0: @@ -12256,6 +12609,21 @@ snapshots: crypt@0.0.2: {} + crypto-browserify@3.12.1: + dependencies: + browserify-cipher: 1.0.1 + browserify-sign: 4.2.5 + create-ecdh: 4.0.4 + create-hash: 1.2.0 + create-hmac: 1.1.7 + diffie-hellman: 5.0.3 + hash-base: 3.0.5 + inherits: 2.0.4 + pbkdf2: 3.1.5 + public-encrypt: 4.0.3 + randombytes: 2.1.0 + randomfill: 1.0.4 + crypto-random-string@2.0.0: {} css-select@5.2.2: @@ -12387,6 +12755,12 @@ snapshots: diff@4.0.2: {} + diffie-hellman@5.0.3: + dependencies: + bn.js: 4.12.2 + miller-rabin: 4.0.1 + randombytes: 2.1.0 + dijkstrajs@1.0.3: {} dir-glob@3.0.1: @@ -12403,6 +12777,8 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + domain-browser@4.22.0: {} + domelementtype@2.3.0: {} domhandler@5.0.3: @@ -12441,6 +12817,16 @@ snapshots: electron-to-chromium@1.5.250: {} + elliptic@6.6.1: + dependencies: + bn.js: 4.12.2 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + emittery@0.13.1: {} emoji-regex@10.4.0: {} @@ -12784,6 +13170,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -12800,6 +13188,11 @@ snapshots: eventsource@2.0.2: {} + evp_bytestokey@1.0.3: + dependencies: + md5.js: 1.3.5 + safe-buffer: 5.2.1 + exec-async@2.2.0: {} execa@5.1.1: @@ -13396,6 +13789,23 @@ snapshots: dependencies: has-symbols: 1.1.0 + hash-base@3.0.5: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + + hash-base@3.1.2: + dependencies: + inherits: 2.0.4 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -13412,6 +13822,12 @@ snapshots: dependencies: hermes-estree: 0.32.0 + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -13447,6 +13863,8 @@ snapshots: http2-client@1.3.5: {} + https-browserify@1.0.0: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -13606,6 +14024,11 @@ snapshots: is-map@2.0.3: {} + is-nan@1.3.2: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + is-negative-zero@2.0.3: {} is-npm@6.1.0: {} @@ -13698,6 +14121,8 @@ snapshots: isobject@3.0.1: {} + isomorphic-timers-promises@1.0.1: {} + isomorphic-ws@5.0.0(ws@8.17.1): dependencies: ws: 8.17.1 @@ -14387,6 +14812,12 @@ snapshots: math-intrinsics@1.1.0: {} + md5.js@1.3.5: + dependencies: + hash-base: 3.0.5 + inherits: 2.0.4 + safe-buffer: 5.2.1 + md5@2.3.0: dependencies: charenc: 0.0.2 @@ -14775,6 +15206,11 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + miller-rabin@4.0.1: + dependencies: + bn.js: 4.12.2 + brorand: 1.1.0 + mime-db@1.52.0: {} mime-types@2.1.35: @@ -14793,6 +15229,8 @@ snapshots: minimalistic-assert@1.0.1: {} + minimalistic-crypto-utils@1.0.1: {} + minimatch@10.0.3: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -14933,6 +15371,36 @@ snapshots: node-releases@2.0.27: {} + node-stdlib-browser@1.3.1: + dependencies: + assert: 2.1.0 + browser-resolve: 2.0.0 + browserify-zlib: 0.2.0 + buffer: 5.7.1 + console-browserify: 1.2.0 + constants-browserify: 1.0.0 + create-require: 1.1.1 + crypto-browserify: 3.12.1 + domain-browser: 4.22.0 + events: 3.3.0 + https-browserify: 1.0.0 + isomorphic-timers-promises: 1.0.1 + os-browserify: 0.3.0 + path-browserify: 1.0.1 + pkg-dir: 5.0.0 + process: 0.11.10 + punycode: 1.4.1 + querystring-es3: 0.2.1 + readable-stream: 3.6.2 + stream-browserify: 3.0.0 + stream-http: 3.2.0 + string_decoder: 1.3.0 + timers-browserify: 2.0.12 + tty-browserify: 0.0.1 + url: 0.11.4 + util: 0.12.5 + vm-browserify: 1.1.2 + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -15022,6 +15490,11 @@ snapshots: object-inspect@1.13.4: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + object-keys@1.1.1: {} object.assign@4.1.7: @@ -15112,6 +15585,8 @@ snapshots: strip-ansi: 5.2.0 wcwidth: 1.0.1 + os-browserify@0.3.0: {} + os-shim@0.1.3: {} outdent@0.5.0: {} @@ -15165,6 +15640,14 @@ snapshots: dependencies: callsites: 3.1.0 + parse-asn1@5.1.9: + dependencies: + asn1.js: 4.10.1 + browserify-aes: 1.2.0 + evp_bytestokey: 1.0.3 + pbkdf2: 3.1.5 + safe-buffer: 5.2.1 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.27.1 @@ -15188,6 +15671,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -15212,6 +15697,15 @@ snapshots: pathe@2.0.3: {} + pbkdf2@3.1.5: + dependencies: + create-hash: 1.2.0 + create-hmac: 1.1.7 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + to-buffer: 1.2.2 + perfect-debounce@2.0.0: {} picocolors@1.1.1: {} @@ -15250,6 +15744,10 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-dir@5.0.0: + dependencies: + find-up: 5.0.0 + pkg-prebuilds@1.0.0: dependencies: yargs: 17.7.2 @@ -15368,11 +15866,22 @@ snapshots: proxy-from-env@1.1.0: {} + public-encrypt@4.0.3: + dependencies: + bn.js: 4.12.2 + browserify-rsa: 4.1.1 + create-hash: 1.2.0 + parse-asn1: 5.1.9 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + punycode@1.4.1: {} + punycode@2.3.1: {} pupa@3.3.0: @@ -15402,6 +15911,8 @@ snapshots: split-on-first: 1.1.0 strict-uri-encode: 2.0.0 + querystring-es3@0.2.1: {} + queue-microtask@1.2.3: {} queue@6.0.2: @@ -15412,6 +15923,15 @@ snapshots: quick-lru@4.0.1: {} + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + randomfill@1.0.4: + dependencies: + randombytes: 2.1.0 + safe-buffer: 5.2.1 + range-parser@1.2.1: {} rc9@2.1.2: @@ -15856,6 +16376,11 @@ snapshots: glob: 11.0.3 package-json-from-dist: 1.0.1 + ripemd160@2.0.3: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + rollup@4.46.4: dependencies: '@types/estree': 1.0.8 @@ -16028,6 +16553,12 @@ snapshots: sf-symbols-typescript@2.1.0: {} + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + shallowequal@1.1.0: {} shebang-command@2.0.0: @@ -16214,8 +16745,20 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-buffers@2.2.0: {} + stream-http@3.2.0: + dependencies: + builtin-status-codes: 3.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + xtend: 4.0.2 + strict-uri-encode@2.0.0: {} string-length@4.0.2: @@ -16466,6 +17009,10 @@ snapshots: through@2.3.8: {} + timers-browserify@2.0.12: + dependencies: + setimmediate: 1.0.5 + tiny-uid@1.1.2: {} tinybench@2.9.0: {} @@ -16485,6 +17032,12 @@ snapshots: tmpl@1.0.5: {} + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -16581,6 +17134,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tty-browserify@0.0.1: {} + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -16779,7 +17334,7 @@ snapshots: update-notifier@7.3.1: dependencies: boxen: 8.0.1 - chalk: 5.4.1 + chalk: 5.6.2 configstore: 7.1.0 is-in-ci: 1.0.0 is-installed-globally: 1.0.0 @@ -16795,6 +17350,11 @@ snapshots: url-polyfill@1.1.14: {} + url@0.11.4: + dependencies: + punycode: 1.4.1 + qs: 6.14.0 + usb@2.9.0: dependencies: '@types/w3c-web-usb': 1.0.12 @@ -16872,6 +17432,14 @@ snapshots: vite-bundle-analyzer@1.2.3: {} + vite-plugin-node-polyfills@0.25.0(rollup@4.46.4)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)): + dependencies: + '@rollup/plugin-inject': 5.0.5(rollup@4.46.4) + node-stdlib-browser: 1.3.1 + vite: 7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + transitivePeerDependencies: + - rollup + vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.9 @@ -16923,6 +17491,23 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 + vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.46.4 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.1 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.1 + vitest@4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.8 @@ -16964,6 +17549,8 @@ snapshots: vlq@1.0.1: {} + vm-browserify@1.1.2: {} + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -17179,6 +17766,8 @@ snapshots: xmlbuilder@15.1.1: {} + xtend@4.0.2: {} + y18n@4.0.3: {} y18n@5.0.8: {} From b5b095ee896cabef0c6c03351ec738c365fa3e8a Mon Sep 17 00:00:00 2001 From: Ilyar Date: Wed, 21 Jan 2026 10:52:00 +0100 Subject: [PATCH 04/15] feat(staking): extract on-chain logic into the contract --- .../staking/tonstakers/TonStakersContract.ts | 180 ++++++++++++++++++ .../tonstakers/TonStakersStakingProvider.ts | 151 +++------------ 2 files changed, 202 insertions(+), 129 deletions(-) create mode 100644 packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.ts diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.ts new file mode 100644 index 000000000..e1c1d8189 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.ts @@ -0,0 +1,180 @@ +/** + * 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 { Address, beginCell } from '@ton/core'; + +import type { Base64String, TransactionRequestMessage, UserFriendlyAddress } from '../../../api/models'; +import type { ApiClient } from '../../../types/toncenter/ApiClient'; +import { CONTRACT } from './constants'; +import { ParseStack } from '../../../utils'; + +/** + * Low–level helper for interacting with Tonstakers staking contracts. + * + * This class encapsulates all contract-specific TL‑B payload building + * and read‑only getter calls. High–level staking flows should use + * `TonStakersStakingProvider` which composes this class. + */ +export class TonStakersContract { + readonly address: string; + + private readonly apiClient: ApiClient; + + constructor(address: string, apiClient: ApiClient) { + this.address = address; + this.apiClient = apiClient; + } + + /** + * Build stake message payload. + * + * TL‑B: deposit#47d54391 query_id:uint64 = InternalMsgBody; + */ + buildStakePayload(queryId: bigint = 1n): Base64String { + const cell = beginCell() + .storeUint(CONTRACT.PAYLOAD_STAKE, 32) + .storeUint(queryId, 64) + .storeUint(CONTRACT.PARTNER_CODE, 64) + .endCell(); + + return cell.toBoc().toString('base64') as Base64String; + } + + /** + * Build unstake message payload to be sent to user's tsTON jetton wallet. + * + * Internal body: + * - op: burn#595f07bc (see TonstakersBurnPayload specification) + * - query_id: uint64 + * - amount: Coins + * - response_destination: MsgAddress (user address) + * - custom_payload: Maybe ^Cell (TonstakersBurnPayload) + */ + buildUnstakePayload(params: { + amount: bigint; + userAddress: UserFriendlyAddress; + waitTillRoundEnd: boolean; + fillOrKill: boolean; + queryId?: bigint; + }): Base64String { + const { amount, userAddress, waitTillRoundEnd, fillOrKill, queryId = 0n } = params; + + const burnPayloadCell = beginCell() + .storeBit(waitTillRoundEnd ? 1 : 0) + .storeBit(fillOrKill ? 1 : 0) + .endCell(); + + const cell = beginCell() + .storeUint(CONTRACT.PAYLOAD_UNSTAKE, 32) + .storeUint(queryId, 64) + .storeCoins(amount) + .storeAddress(Address.parse(userAddress)) + .storeMaybeRef(burnPayloadCell) + .endCell(); + + return cell.toBoc().toString('base64') as Base64String; + } + + /** + * Resolve tsTON jetton wallet address for a given owner address. + * + * This uses on‑chain getter `get_pool_full_data` on the staking contract + * to locate the jetton minter, then calls `get_wallet_address` on it. + */ + async getJettonWalletAddress(userAddress: UserFriendlyAddress): Promise { + // 1. Resolve jetton minter from pool data + const poolInfoResult = await this.apiClient.runGetMethod(this.address, 'get_pool_full_data'); + + let jettonMinterAddress: string | undefined; + + if (poolInfoResult.stack && poolInfoResult.stack.length > 0) { + const parsedStack = ParseStack(poolInfoResult.stack); + for (const item of parsedStack) { + if (item.type === 'cell') { + try { + const slice = item.cell.beginParse(); + const addr = slice.loadAddress(); + if (addr && addr.hash && addr.hash.length > 0 && !addr.equals(Address.parse(this.address))) { + jettonMinterAddress = addr.toString(); + break; + } + } catch { + // Ignore parse errors and continue scanning stack + } + } + } + } + + if (!jettonMinterAddress) { + throw new Error('Jetton minter address not found in pool data'); + } + + // 2. Resolve jetton wallet address using minter's get_wallet_address + const addressCell = beginCell().storeAddress(Address.parse(userAddress)).endCell().toBoc().toString('base64'); + + const result = await this.apiClient.runGetMethod(jettonMinterAddress, 'get_wallet_address', [ + { type: 'cell', value: addressCell }, + ]); + + if (result.stack && result.stack.length > 0) { + const parsedStack = ParseStack(result.stack); + if (parsedStack.length > 0 && parsedStack[0].type === 'cell') { + const addressSlice = parsedStack[0].cell.beginParse(); + const address = addressSlice.loadAddress(); + return address.toString() as UserFriendlyAddress; + } + } + + throw new Error('Failed to get jetton wallet address from minter'); + } + + /** + * Read tsTON balance for user from jetton wallet contract. + */ + async getStakedBalance(userAddress: UserFriendlyAddress): Promise { + const jettonWalletAddress = await this.getJettonWalletAddress(userAddress); + const result = await this.apiClient.runGetMethod(jettonWalletAddress, 'get_wallet_data'); + + if (result.stack && result.stack.length > 0) { + const parsedStack = ParseStack(result.stack); + if (parsedStack.length > 0 && parsedStack[0].type === 'int') { + return parsedStack[0].value; + } + } + + return 0n; + } + + /** + * Helper to construct a TransactionRequestMessage for unstake flow. + * + * Note: fee amount is not applied here and should be added by caller. + */ + async buildUnstakeMessage(params: { + amount: bigint; + userAddress: UserFriendlyAddress; + waitTillRoundEnd: boolean; + fillOrKill: boolean; + }): Promise { + const { amount, userAddress, waitTillRoundEnd, fillOrKill } = params; + + const jettonWalletAddress = await this.getJettonWalletAddress(userAddress); + const payload = this.buildUnstakePayload({ + amount, + userAddress, + waitTillRoundEnd, + fillOrKill, + }); + + return { + address: jettonWalletAddress, + amount: CONTRACT.UNSTAKE_FEE_RES.toString(), + payload, + }; + } +} diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts index 29f78cbf2..43de4a1f9 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -6,14 +6,7 @@ * */ -import { Address, beginCell } from '@ton/core'; - -import type { - TransactionRequest, - UserFriendlyAddress, - TransactionRequestMessage, - Base64String, -} from '../../../api/models'; +import type { TransactionRequest, UserFriendlyAddress } from '../../../api/models'; import { Network } from '../../../api/models'; import { globalLogger } from '../../../core/Logger'; import type { NetworkManager } from '../../../core/NetworkManager'; @@ -31,7 +24,7 @@ import { StakingError, StakingErrorCode } from '../errors'; import { StakingQuoteDirection, UnstakeMode } from '../types'; import type { TonStakersProviderConfig } from './types'; import { CONTRACT } from './constants'; -import { ParseStack } from '../../../utils'; +import { TonStakersContract } from './TonStakersContract'; const log = globalLogger.createChild('TonStakersStakingProvider'); @@ -67,6 +60,13 @@ export class TonStakersStakingProvider extends StakingProvider { return networkConfig.contractAddress; } + private getContract(network?: Network): TonStakersContract { + const targetNetwork = network ?? Network.mainnet(); + const apiClient = this.getApiClient(targetNetwork); + const contractAddress = this.getStakingContractAddress(targetNetwork); + return new TonStakersContract(contractAddress, apiClient); + } + async getQuote(params: StakingQuoteParams): Promise { log.debug('TonStakers quote requested', { direction: params.direction, @@ -104,18 +104,13 @@ export class TonStakersStakingProvider extends StakingProvider { const amount = BigInt(params.amount); const totalAmount = amount + CONTRACT.STAKE_FEE_RES; - const payload = beginCell() - .storeUint(CONTRACT.PAYLOAD_STAKE, 32) - .storeUint(1, 64) - .storeUint(CONTRACT.PARTNER_CODE, 64) - .endCell() - .toBoc() - .toString('base64'); + const contract = this.getContract(network); + const payload = contract.buildStakePayload(1n); - const message: TransactionRequestMessage = { + const message = { address: contractAddress, amount: totalAmount.toString(), - payload: payload as Base64String, + payload, }; return { @@ -134,29 +129,13 @@ export class TonStakersStakingProvider extends StakingProvider { const waitTillRoundEnd = unstakeMode === UnstakeMode.Delayed && params.maxDelayHours !== undefined; const fillOrKill = unstakeMode === UnstakeMode.Instant; - // Get jetton wallet address - const jettonWalletAddress = await this.getJettonWalletAddress(params.userAddress, network); - - const payload = beginCell() - .storeUint(CONTRACT.PAYLOAD_UNSTAKE, 32) - .storeUint(0, 64) - .storeCoins(amount) - .storeAddress(Address.parse(params.userAddress)) - .storeMaybeRef( - beginCell() - .storeUint(waitTillRoundEnd ? 1 : 0, 1) - .storeUint(fillOrKill ? 1 : 0, 1) - .endCell(), - ) - .endCell() - .toBoc() - .toString('base64'); - - const message: TransactionRequestMessage = { - address: jettonWalletAddress, - amount: CONTRACT.UNSTAKE_FEE_RES.toString(), - payload: payload as Base64String, - }; + const contract = this.getContract(network); + const message = await contract.buildUnstakeMessage({ + amount, + userAddress: params.userAddress, + waitTillRoundEnd, + fillOrKill, + }); return { messages: [message], @@ -182,16 +161,8 @@ export class TonStakersStakingProvider extends StakingProvider { let instantUnstakeAvailable = 0n; try { - const jettonWalletAddress = await this.getJettonWalletAddress(userAddress, network); - const result = await apiClient.runGetMethod(jettonWalletAddress, 'get_wallet_data'); - - // Parse balance from stack (first item is balance) - if (result.stack && result.stack.length > 0) { - const parsedStack = ParseStack(result.stack); - if (parsedStack.length > 0 && parsedStack[0].type === 'int') { - stakedBalance = parsedStack[0].value; - } - } + const contract = this.getContract(network); + stakedBalance = await contract.getStakedBalance(userAddress); } catch (error) { log.warn('Failed to get staked balance', { error }); } @@ -223,82 +194,4 @@ export class TonStakersStakingProvider extends StakingProvider { provider: 'tonstakers', }); } - - private async getJettonWalletAddress( - userAddress: UserFriendlyAddress, - network?: Network, - ): Promise { - try { - const targetNetwork = network ?? Network.mainnet(); - const contractAddress = this.getStakingContractAddress(targetNetwork); - const apiClient = this.getApiClient(targetNetwork); - - // 1. Get pool info (get_pool_full_data) to find jetton minter - // runGetMethod is used instead of fetch to avoid external API dependencies - const poolInfoResult = await apiClient.runGetMethod(contractAddress, 'get_pool_full_data'); - - let jettonMinterAddress: string | undefined; - - if (poolInfoResult.stack && poolInfoResult.stack.length > 0) { - const parsedStack = ParseStack(poolInfoResult.stack); - // Look for an address in the stack which is likely the jetton minter - // Based on observation, it is usually one of the addresses in the stack - for (const item of parsedStack) { - if (item.type === 'cell') { - try { - const slice = item.cell.beginParse(); - const addr = slice.loadAddress(); - // Basic check if it looks like a valid address (not null address) - if ( - addr && - addr.hash && - addr.hash.length > 0 && - !addr.equals(Address.parse(contractAddress)) - ) { - jettonMinterAddress = addr.toString(); - break; // Take the first valid address found - } - } catch { - // Ignore parse errors - } - } - } - } - - if (!jettonMinterAddress) { - throw new Error('Jetton minter address not found in pool data'); - } - - // 2. Get jetton wallet address using API client via get_wallet_address method on minter - const addressCell = beginCell() - .storeAddress(Address.parse(userAddress)) - .endCell() - .toBoc() - .toString('base64'); - - const result = await apiClient.runGetMethod(jettonMinterAddress, 'get_wallet_address', [ - { type: 'cell', value: addressCell }, - ]); - - // Parse result from stack - if (result.stack && result.stack.length > 0) { - const parsedStack = ParseStack(result.stack); - if (parsedStack.length > 0 && parsedStack[0].type === 'cell') { - // Extract address from cell - const addressSlice = parsedStack[0].cell.beginParse(); - const address = addressSlice.loadAddress(); - return address.toString() as UserFriendlyAddress; - } - } - - throw new Error('Failed to get jetton wallet address from minter'); - } catch (error) { - log.error('Failed to get jetton wallet address', { error, userAddress, network }); - throw new StakingError('Failed to get jetton wallet address', StakingErrorCode.InvalidParams, { - error, - userAddress, - network, - }); - } - } } From 563c5e508e65d57b9ab4193b1c82061a8e74991d Mon Sep 17 00:00:00 2001 From: Ilyar Date: Mon, 2 Feb 2026 10:02:24 +0100 Subject: [PATCH 05/15] feat(staking): enhance unstaking functionality and add test --- demo/examples/staking.ts | 254 ++++------- eslint.config.js | 1 + packages/walletkit/README.md | 103 +++++ .../walletkit/src/core/ApiClientToncenter.ts | 30 +- packages/walletkit/src/defi/staking/index.ts | 4 +- .../defi/staking/tonstakers/PoolContract.ts | 399 ++++++++++++++++++ .../staking/tonstakers/StakingCache.spec.ts | 98 +++++ .../defi/staking/tonstakers/StakingCache.ts | 43 ++ .../tonstakers/TonStakersContract.spec.ts | 164 +++++++ .../staking/tonstakers/TonStakersContract.ts | 180 -------- .../TonStakersStakingProvider.spec.ts | 232 ++++++++++ .../tonstakers/TonStakersStakingProvider.ts | 242 ++++++++++- .../src/defi/staking/tonstakers/constants.ts | 5 + .../src/defi/staking/tonstakers/types.ts | 7 + packages/walletkit/src/defi/staking/types.ts | 7 + packages/walletkit/src/index.ts | 3 + packages/walletkit/src/utils/tvmStack.ts | 5 + .../walletkit/src/validation/address.spec.ts | 74 ++++ .../walletkit/src/validation/wallet.spec.ts | 85 ++++ 19 files changed, 1570 insertions(+), 366 deletions(-) create mode 100644 packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/StakingCache.spec.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/StakingCache.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts delete mode 100644 packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts create mode 100644 packages/walletkit/src/validation/address.spec.ts create mode 100644 packages/walletkit/src/validation/wallet.spec.ts diff --git a/demo/examples/staking.ts b/demo/examples/staking.ts index bfa99d8ca..374f118d7 100644 --- a/demo/examples/staking.ts +++ b/demo/examples/staking.ts @@ -50,7 +50,7 @@ let networkError: HTMLElement; let deleteDataBtn: HTMLButtonElement; const stakingError = document.getElementById('staking-error')!; -const stakingSuccess = document.getElementById('staking-success')!; + const balanceTon = document.getElementById('balance-ton')!; const balanceAvailable = document.getElementById('balance-available')!; const balanceStaked = document.getElementById('balance-staked')!; @@ -202,14 +202,6 @@ function showError(element: HTMLElement, message: string) { }, 5000); } -function showSuccess(message: string) { - stakingSuccess.textContent = message; - stakingSuccess.style.display = 'block'; - setTimeout(() => { - stakingSuccess.style.display = 'none'; - }, 3000); -} - function formatBalance(nanoValue: string | bigint): string { const value = parseFloat(fromNano(nanoValue.toString())); return value.toFixed(2); @@ -657,39 +649,54 @@ async function handleUnstake(amount: bigint, mode: 'instant' | 'delayed', waitTi return; } - if (!wallet || !jettonWalletAddress) { - showError(stakingError, 'Wallet not initialized'); + if (!wallet || !stakingManager) { + showError(stakingError, 'Wallet or staking manager not initialized'); return; } try { - const fillOrKill = mode === 'instant'; - const payload = beginCell() - .storeUint(CONTRACT.PAYLOAD_UNSTAKE, 32) - .storeUint(0, 64) - .storeCoins(amount) - .storeAddress(Address.parse(wallet.getAddress())) - .storeMaybeRef( - beginCell() - .storeUint(waitTillRoundEnd ? 1 : 0, 1) - .storeUint(fillOrKill ? 1 : 0, 1) - .endCell(), - ) - .endCell() - .toBoc() - .toString('base64') as Base64String; + const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - const transaction = await wallet.createTransferTonTransaction({ - recipientAddress: jettonWalletAddress.toString(), - transferAmount: CONTRACT.UNSTAKE_FEE_RES.toString(), - payload, + let unstakeMode: 'instant' | 'delayed' | 'bestRate'; + if (mode === 'instant') { + unstakeMode = 'instant'; + } else if (waitTillRoundEnd) { + unstakeMode = 'bestRate'; + } else { + unstakeMode = 'delayed'; + } + + showTxStatus('pending', 'Building unstake transaction...', `Mode: ${unstakeMode}`); + + const txRequest = await stakingManager.unstake({ + amount: amount.toString(), + userAddress: wallet.getAddress(), + network, + unstakeMode, }); - await wallet.sendTransaction(transaction); - showSuccess('Unstaking transaction sent'); - setTimeout(() => handleRefreshBalances(), 2000); + showTxStatus('pending', 'Sending transaction...', 'Please wait...'); + + if (txRequest.messages && txRequest.messages.length > 0) { + const msg = txRequest.messages[0]; + const transaction = await wallet.createTransferTonTransaction({ + recipientAddress: msg.address, + transferAmount: msg.amount, + payload: msg.payload, + }); + await wallet.sendTransaction(transaction); + } + + showTxStatus('success', 'Unstaking transaction sent!', `Unstaked ${fromNano(amount)} tsTON`); + setTimeout(async () => { + await handleRefreshBalances(); + setTimeout(() => hideTxStatus(), 3000); + }, 5000); } catch (error) { - showError(stakingError, `Unstaking error: ${error instanceof Error ? error.message : String(error)}`); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Unstaking error:', error); + showTxStatus('error', 'Unstake failed', errorMessage); + showError(stakingError, `Unstaking error: ${errorMessage}`); } } @@ -702,6 +709,8 @@ async function handleRefreshBalances() { console.log('Refreshing balances...'); try { + const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); + // Get TON balance const tonBalance = await wallet.getBalance(); console.log('TON balance:', fromNano(tonBalance)); @@ -714,71 +723,26 @@ async function handleRefreshBalances() { : 0n; balanceAvailable.textContent = formatBalance(available); - // Get staked balance (tsTON) using TonAPI directly - try { - const isTestnet = currentNetwork === 'testnet'; - const tonApiUrl = isTestnet ? 'https://testnet.tonapi.io' : 'https://tonapi.io'; - const walletAddr = currentNetwork === 'mainnet' ? mainnetAddress : testnetAddress; - - console.log('Getting tsTON balance from TonAPI for:', walletAddr); - - // Get jettons for the wallet - const jettonsResponse = await fetch(`${tonApiUrl}/v2/accounts/${encodeURIComponent(walletAddr)}/jettons`); - if (jettonsResponse.ok) { - const jettonsData = await jettonsResponse.json(); - console.log('Jettons data:', jettonsData); - - // Find tsTON jetton (look for the staking pool jetton) - let stakedAmount = '0'; - if (jettonsData.balances && Array.isArray(jettonsData.balances)) { - for (const jetton of jettonsData.balances) { - // Check if this is the tsTON jetton by looking at the name or symbol - const name = jetton.jetton?.name?.toLowerCase() || ''; - const symbol = jetton.jetton?.symbol?.toLowerCase() || ''; - if ( - name.includes('tstok') || - name.includes('tston') || - symbol === 'tston' || - symbol.includes('ts') - ) { - stakedAmount = jetton.balance || '0'; - console.log('Found tsTON jetton:', jetton); - break; - } - } - } - stakedBalanceNano = BigInt(stakedAmount); - balanceStaked.textContent = formatBalance(stakedAmount); - } else { - console.warn('TonAPI jettons request failed:', jettonsResponse.status); - stakedBalanceNano = 0n; - balanceStaked.textContent = '0'; - } - } catch (error) { - console.warn('Failed to get staked balance from TonAPI:', error); - - // Fallback to stakingManager - if (stakingManager && wallet) { - try { - const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - const stakingBalance = await stakingManager.getBalance(wallet.getAddress(), network); - console.log('Staked balance from manager:', stakingBalance); - stakedBalanceNano = BigInt(stakingBalance.stakedBalance); - balanceStaked.textContent = formatBalance(stakingBalance.stakedBalance); - } catch (err) { - console.warn('Failed to get staked balance from manager:', err); - stakedBalanceNano = 0n; - balanceStaked.textContent = '0'; - } - } else { + // Get staked balance via StakingManager (uses Toncenter, not TonAPI) + if (stakingManager) { + try { + const stakingBalance = await stakingManager.getBalance(wallet.getAddress(), network); + console.log('Staked balance from manager:', stakingBalance); + stakedBalanceNano = BigInt(stakingBalance.stakedBalance); + balanceStaked.textContent = formatBalance(stakingBalance.stakedBalance); + } catch (err) { + console.warn('Failed to get staked balance from manager:', err); stakedBalanceNano = 0n; balanceStaked.textContent = '0'; } + } else { + stakedBalanceNano = 0n; + balanceStaked.textContent = '0'; } updateUnstakeButtonsState(); - // Get pool info using TonAPI for TVL and Stakers + // Get pool info using StakingManager await refreshPoolInfo(); } catch (error) { console.error('Error in handleRefreshBalances:', error); @@ -787,48 +751,29 @@ async function handleRefreshBalances() { async function refreshPoolInfo() { const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - const isTestnet = currentNetwork === 'testnet'; - const tonApiUrl = isTestnet ? 'https://testnet.tonapi.io' : 'https://tonapi.io'; - const contractAddress = getStakingContractAddress(); - console.log('Refreshing pool info from TonAPI:', tonApiUrl, contractAddress); + console.log('Refreshing pool info via StakingManager'); try { - // Get APY from staking manager if (stakingManager) { try { const stakingInfo = await stakingManager.getStakingInfo(network); console.log('Staking info:', stakingInfo); poolApy.textContent = `${(stakingInfo.apy * 100).toFixed(2)}%`; + + // TVL is instant liquidity in this case + if (stakingInfo.instantUnstakeAvailable) { + poolTvl.textContent = formatBalance(stakingInfo.instantUnstakeAvailable); + } } catch (error) { - console.warn('Failed to get APY:', error); + console.warn('Failed to get staking info:', error); poolApy.textContent = '-'; + poolTvl.textContent = '-'; } } - // Get TVL and Stakers from TonAPI - try { - const poolInfoResponse = await fetch(`${tonApiUrl}/v2/staking/pool/${contractAddress}`); - if (poolInfoResponse.ok) { - const poolInfo = await poolInfoResponse.json(); - console.log('Pool info from TonAPI:', poolInfo); - - if (poolInfo.pool) { - // TVL - if (poolInfo.pool.total_amount) { - poolTvl.textContent = formatBalance(poolInfo.pool.total_amount); - } - // Stakers count - if (poolInfo.pool.current_nominators !== undefined) { - poolStakers.textContent = poolInfo.pool.current_nominators.toString(); - } - } - } else { - console.warn('TonAPI pool info request failed:', poolInfoResponse.status); - } - } catch (error) { - console.warn('Failed to get TVL/Stakers from TonAPI:', error); - } + // Note: Stakers count is not available via Toncenter API + poolStakers.textContent = '-'; } catch (error) { console.error('Error in refreshPoolInfo:', error); } @@ -837,49 +782,32 @@ async function refreshPoolInfo() { async function handleGetRounds() { console.log('Getting rounds info...'); - try { - const isTestnet = currentNetwork === 'testnet'; - const tonApiUrl = isTestnet ? 'https://testnet.tonapi.io' : 'https://tonapi.io'; - const contractAddress = getStakingContractAddress(); - - console.log('Fetching pool data from TonAPI:', tonApiUrl, contractAddress); - - // Get pool info from TonAPI staking endpoint - const response = await fetch(`${tonApiUrl}/v2/staking/pool/${contractAddress}`); - - if (!response.ok) { - throw new Error(`TonAPI request failed: ${response.status}`); - } - - const data = await response.json(); - console.log('Pool staking data:', data); - - // Get cycle info from pool data - if (data.pool) { - const cycleStart = data.pool.cycle_start; - const cycleEnd = data.pool.cycle_end; - const cycleLength = data.pool.cycle_length; - - if (cycleStart && cycleEnd) { - const startDate = new Date(cycleStart * 1000); - const endDate = new Date(cycleEnd * 1000); - const now = new Date(); - const remaining = endDate.getTime() - now.getTime(); - const hoursRemaining = Math.max(0, Math.floor(remaining / (1000 * 60 * 60))); - const minutesRemaining = Math.max(0, Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))); - - roundsInfo.innerHTML = ` -
Current Round
-
Start: ${startDate.toLocaleString()}
-
End: ${endDate.toLocaleString()}
-
Remaining: ${hoursRemaining}h ${minutesRemaining}m
- ${cycleLength ? `
Cycle Length: ${Math.floor(cycleLength / 3600)}h
` : ''} - `; - return; - } - } + if (!stakingManager) { + showError(stakingError, 'Staking manager not initialized'); + return; + } - roundsInfo.textContent = 'Unable to get rounds info from pool'; + try { + const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); + const provider = stakingManager.getProvider('tonstakers') as TonStakersStakingProvider; + const roundInfo = await provider.getRoundInfo(network); + + console.log('Round info:', roundInfo); + + const startDate = new Date(roundInfo.cycle_start * 1000); + const endDate = new Date(roundInfo.cycle_end * 1000); + const now = new Date(); + const remaining = endDate.getTime() - now.getTime(); + const hoursRemaining = Math.max(0, Math.floor(remaining / (1000 * 60 * 60))); + const minutesRemaining = Math.max(0, Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))); + + roundsInfo.innerHTML = ` +
Current Round (Estimated)
+
Start: ${startDate.toLocaleString()}
+
End: ${endDate.toLocaleString()}
+
Remaining: ${hoursRemaining}h ${minutesRemaining}m
+ ${roundInfo.cycle_length ? `
Cycle Length: ${Math.floor(roundInfo.cycle_length / 3600)}h
` : ''} + `; } catch (error) { console.error('Error getting rounds info:', error); showError(stakingError, `Error getting rounds info: ${error instanceof Error ? error.message : String(error)}`); diff --git a/eslint.config.js b/eslint.config.js index 480bbf53b..024baaddf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,7 @@ module.exports = [ '**/Packages/TONWalletKit/*', '**/TONWalletApp/TONWalletApp/*', '**/androidkit/**', + '**/analytics/swagger/generated.ts', ], }, { diff --git a/packages/walletkit/README.md b/packages/walletkit/README.md index 66c932145..d908af762 100644 --- a/packages/walletkit/README.md +++ b/packages/walletkit/README.md @@ -14,6 +14,7 @@ A production-ready wallet-side integration layer for TON Connect, designed for b - 🎨 **Previews for actions** - Transaction emulation with money flow analysis - 🪙 **Asset Support** - TON, Jettons, NFTs with metadata - 🔄 **Token Swaps** - Multi-DEX swap aggregation +- 📈 **Liquid Staking** - Tonstakers liquid staking integration **Live Demo**: [https://walletkit-demo-wallet.vercel.app/](https://walletkit-demo-wallet.vercel.app/) @@ -24,6 +25,7 @@ A production-ready wallet-side integration layer for TON Connect, designed for b - **[Browser Extension Build](https://github.com/ton-connect/kit/blob/main/apps/demo-wallet/EXTENSION.md)** - How to build and load the demo wallet as a Chrome extension - **[JS Bridge Usage](/packages/walletkit/examples/js-bridge-usage.md)** - Implementing TonConnect JS Bridge for browser extension wallets - **[Token Swaps](/packages/walletkit/src/defi/swap/README.md)** - Multi-DEX swap integration with custom provider support +- **[Liquid Staking](#liquid-staking)** - Tonstakers liquid staking with 3 unstake modes - **[iOS WalletKit](https://github.com/ton-connect/kit-ios)** - Swift Package providing TON wallet capabilities for iOS and macOS - **[Android WalletKit](https://github.com/ton-connect/kit-android)** - Kotlin/Java Package providing TON wallet capabilities for Android @@ -460,6 +462,107 @@ The store slices [walletCoreSlice.ts](https://github.com/ton-connect/kit/blob/ma - Wire `onConnectRequest` and `onTransactionRequest` to open modals - Approve or reject requests using the kit methods +## Liquid Staking + +TonWalletKit includes built-in support for Tonstakers liquid staking protocol. Stake TON to receive tsTON tokens and earn staking rewards. + +### Setup Staking + +```ts +import { + StakingManager, + TonStakersStakingProvider, + UnstakeMode, + Network, +} from '@ton/walletkit'; +import { toNano, fromNano } from '@ton/core'; + +// Create staking manager +const stakingManager = new StakingManager(); + +// Register Tonstakers provider +const stakingProvider = new TonStakersStakingProvider( + kit.getNetworkManager(), + kit.getEventEmitter() +); +stakingManager.registerProvider('tonstakers', stakingProvider); +``` + +### Get Staking Info + +```ts +// Get current APY and pool info +const info = await stakingManager.getStakingInfo(Network.mainnet()); +console.log(`APY: ${(info.apy * 100).toFixed(2)}%`); +console.log(`Instant liquidity: ${fromNano(info.instantUnstakeAvailable)} TON`); + +// Get user balance +const balance = await stakingManager.getBalance(wallet.getAddress(), Network.mainnet()); +console.log(`Staked: ${fromNano(balance.stakedBalance)} tsTON`); +console.log(`Available to stake: ${fromNano(balance.availableBalance)} TON`); +``` + +### Stake TON + +```ts +const stakeTx = await stakingManager.stake({ + amount: toNano('10').toString(), + userAddress: wallet.getAddress(), + network: Network.mainnet() +}); + +// Build and send transaction +const tx = await wallet.createTransferTonTransaction({ + recipientAddress: stakeTx.messages[0].address, + transferAmount: stakeTx.messages[0].amount, + payload: stakeTx.messages[0].payload +}); +await wallet.sendTransaction(tx); +``` + +### Unstake tsTON + +Three unstake modes are supported: + +```ts +// Delayed (default) - Funds released at end of round (~18 hours) +const delayedTx = await stakingManager.unstake({ + amount: toNano('5').toString(), + userAddress: wallet.getAddress(), + network: Network.mainnet(), + unstakeMode: UnstakeMode.Delayed +}); + +// Instant - Immediate withdrawal if pool has liquidity +const instantTx = await stakingManager.unstake({ + amount: toNano('5').toString(), + userAddress: wallet.getAddress(), + network: Network.mainnet(), + unstakeMode: UnstakeMode.Instant +}); + +// Best Rate - Wait for best exchange rate at round end +const bestRateTx = await stakingManager.unstake({ + amount: toNano('5').toString(), + userAddress: wallet.getAddress(), + network: Network.mainnet(), + unstakeMode: UnstakeMode.BestRate +}); +``` + +### Get Quote + +```ts +import { StakingQuoteDirection } from '@ton/walletkit'; + +const quote = await stakingManager.getQuote({ + direction: StakingQuoteDirection.Stake, + amount: toNano('10').toString(), + network: Network.mainnet() +}); +console.log(`APY: ${(quote.apy * 100).toFixed(2)}%`); +``` + ## Resources - [TON Connect Protocol](https://github.com/ton-blockchain/ton-connect) - Official TON Connect protocol specification diff --git a/packages/walletkit/src/core/ApiClientToncenter.ts b/packages/walletkit/src/core/ApiClientToncenter.ts index 95fa5f8b2..259082604 100644 --- a/packages/walletkit/src/core/ApiClientToncenter.ts +++ b/packages/walletkit/src/core/ApiClientToncenter.ts @@ -228,15 +228,35 @@ export class ApiClientToncenter implements ApiClient { headers.set('accept', 'application/json'); if (this.apiKey) headers.set('x-api-key', this.apiKey); props = { ...props, headers }; + + const maxRetries = 3; + let attempt = 0; + + while (attempt < maxRetries) { + const response = await this.doRequest(url, props); + if (response.status === 429) { + attempt++; + const delay = attempt * 1000; // 1s, 2s, 3s + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + if (!response.ok) { + throw await this.buildError(response); + } + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + const text = await (response as globalThis.Response).text(); + throw new TonClientError('Unexpected non-JSON response', response.status, text.slice(0, 200)); + } + const json = await response.json(); + return json as Promise; + } + + // If we exhausted retries for 429, make one last attempt or just throw const response = await this.doRequest(url, props); if (!response.ok) { throw await this.buildError(response); } - const contentType = response.headers.get('content-type') || ''; - if (!contentType.includes('application/json')) { - const text = await (response as globalThis.Response).text(); - throw new TonClientError('Unexpected non-JSON response', response.status, text.slice(0, 200)); - } const json = await response.json(); return json as Promise; } diff --git a/packages/walletkit/src/defi/staking/index.ts b/packages/walletkit/src/defi/staking/index.ts index 2d7b82261..8f90a2274 100644 --- a/packages/walletkit/src/defi/staking/index.ts +++ b/packages/walletkit/src/defi/staking/index.ts @@ -12,5 +12,7 @@ export { StakingError } from './errors'; export type { StakingErrorCode } from './errors'; export type * from './types'; export { TonStakersStakingProvider } from './tonstakers/TonStakersStakingProvider'; -export type { TonStakersProviderConfig } from './tonstakers/types'; +export { PoolContract } from './tonstakers/PoolContract'; +export { StakingCache } from './tonstakers/StakingCache'; +export type { TonStakersProviderConfig, PoolFullData, TonStakersPoolInfo } from './tonstakers/types'; export { CONTRACT, BLOCKCHAIN, TIMING } from './tonstakers/constants'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts b/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts new file mode 100644 index 000000000..baf565c43 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts @@ -0,0 +1,399 @@ +/** + * 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 { Cell, DictionaryValue } from '@ton/core'; +import { Address, beginCell, Dictionary } from '@ton/core'; + +import type { + Base64String, + Hex, + TokenAmount, + TransactionRequestMessage, + UserFriendlyAddress, +} from '../../../api/models'; +import type { ApiClient } from '../../../types/toncenter/ApiClient'; +import { CONTRACT, TIMING } from './constants'; +import { asAddressFriendly, asHex, asMaybeAddressFriendly, ReaderStack, SerializeStack } from '../../../utils'; +import type { RoundInfo } from '../types'; + +const SHARE_BASIS = 2 ** 24; // 24 bit + +export enum PoolState { + Normal = 0, + RepaymentOnly = 1, +} + +export type BorrowerDescription = { + borrowed: TokenAmount; + accountedInterest: TokenAmount; +}; + +export const BorrowerDescriptionValue: DictionaryValue = { + serialize: (src, builder) => { + builder.storeCoins(BigInt(src.borrowed)); + builder.storeCoins(BigInt(src.accountedInterest)); + }, + parse: (src) => { + return { + borrowed: src.loadCoins().toString(), + accountedInterest: src.loadCoins().toString(), + }; + }, +}; + +export interface PoolRoundData { + borrowers: Dictionary; + roundId: number; + activeBorrowers: bigint; + borrowed: bigint; + expected: bigint; + returned: bigint; + profit: bigint; +} + +export type UpdateAfter = Date | 'completed'; + +function asUpdateAfter(value: number): UpdateAfter { + const COMPLETED_FLAG = 0xffffffffffff; // 281474976710655 + if (value === COMPLETED_FLAG) { + return 'completed'; + } + return new Date(value * 1000); +} + +export interface PoolFullData { + state: PoolState; + halted: boolean; + totalBalance: TokenAmount; + interestRatePercent: number; + optimisticDepositWithdrawals: boolean; + depositsOpen: boolean; + savedValidatorSetHash: Hex; + + prevRound: PoolRoundData; + currentRound: PoolRoundData; + minLoan: TokenAmount; + maxLoan: TokenAmount; + governanceFeePercent: number; + + jettonMinter: UserFriendlyAddress; + supply: TokenAmount; + + depositPayout: UserFriendlyAddress | null; + requestedForDeposit: TokenAmount; + withdrawalPayout: UserFriendlyAddress | null; + requestedForWithdrawal: TokenAmount; + + sudoer: UserFriendlyAddress; + sudoerSetAt: Date; + governor: UserFriendlyAddress; + governorUpdateAfter: UpdateAfter; + interestManager: UserFriendlyAddress; + halter: UserFriendlyAddress; + approver: UserFriendlyAddress; + + controllerCode: Cell; + poolJettonWalletCode: Cell; + payoutMinterCode: Cell; + + projectedTotalBalance: TokenAmount; + projectedPoolSupply: TokenAmount; +} + +export interface PoolSimpleData { + state: PoolState; + halted: boolean; + totalBalance: TokenAmount; + supply: TokenAmount; + interestRatePercent: number; +} + +function parseBorrowers(data: Cell | null, workChain = 0): Dictionary { + const list = Dictionary.empty(Dictionary.Keys.Address(), BorrowerDescriptionValue); + if (data) { + const raw = Dictionary.loadDirect(Dictionary.Keys.BigUint(256), BorrowerDescriptionValue, data.asSlice()); + for (const hash of raw.keys()) { + list.set( + Address.parse(`${workChain}:${hash.toString(16).padStart(64, '0')}`), + raw.get(hash) as BorrowerDescription, + ); + } + } + return list; +} + +export class PoolContract { + readonly address: UserFriendlyAddress; + + private readonly client: ApiClient; + + constructor(address: string | UserFriendlyAddress, client: ApiClient) { + this.address = asAddressFriendly(address); + this.client = client; + } + + /** + * Get contract code version (git commit hash). + * + * Returns the git commit hash from the liquid staking contract repository: + * https://github.com/ton-blockchain/liquid-staking-contract + */ + async getCodeVersion(): Promise { + const data = await this.client.runGetMethod(this.address, 'get_code_version'); + const stack = ReaderStack(data.stack); + const version = stack.readBigNumber().toString(16).padStart(40, '0'); + return `https://github.com/ton-blockchain/liquid-staking-contract/tree/${version}`; + } + + async getPoolFullData(): Promise { + const data = await this.client.runGetMethod(this.address, 'get_pool_full_data'); + const stack = ReaderStack(data.stack); + const state = stack.readNumber() as PoolState; + const halted = stack.readBoolean(); + const totalBalance = stack.readBigNumber().toString(); + const interestRatePercent = (stack.readNumber() / SHARE_BASIS) * 100; + const optimisticDepositWithdrawals = stack.readBoolean(); + const depositsOpen = stack.readBoolean(); + const savedValidatorSetHash = asHex(`0x${stack.readBigNumber().toString(16).padStart(64, '0')}`); + const workChain = Address.parse(this.address).workChain; + const prev = stack.readTuple(); + const prevRound = { + borrowers: parseBorrowers(prev.readCellOpt(), workChain), + roundId: prev.readNumber(), + activeBorrowers: prev.readBigNumber(), + borrowed: prev.readBigNumber(), + expected: prev.readBigNumber(), + returned: prev.readBigNumber(), + profit: prev.readBigNumber(), + }; + + const current = stack.readTuple(); + const currentRound = { + borrowers: parseBorrowers(current.readCellOpt(), workChain), + roundId: current.readNumber(), + activeBorrowers: current.readBigNumber(), + borrowed: current.readBigNumber(), + expected: current.readBigNumber(), + returned: current.readBigNumber(), + profit: current.readBigNumber(), + }; + + const minLoan = stack.readBigNumber().toString(); + const maxLoan = stack.readBigNumber().toString(); + const governanceFeePercent = (stack.readNumber() / SHARE_BASIS) * 100; + + const jettonMinter = asAddressFriendly(stack.readAddress()); + const supply = stack.readBigNumber().toString(); + + const depositPayout = asMaybeAddressFriendly(stack.readAddressOpt()?.toString()); + const requestedForDeposit = stack.readBigNumber().toString(); + + const withdrawalPayout = asMaybeAddressFriendly(stack.readAddressOpt()?.toString()); + const requestedForWithdrawal = stack.readBigNumber().toString(); + + const sudoer = asAddressFriendly(stack.readAddress()); + const sudoerSetAt = new Date(stack.readNumber() * 1000); + const governor = asAddressFriendly(stack.readAddress()); + const governorUpdateAfter = asUpdateAfter(stack.readNumber()); + const interestManager = asAddressFriendly(stack.readAddress()); + const halter = asAddressFriendly(stack.readAddress()); + const approver = asAddressFriendly(stack.readAddress()); + + const controllerCode = stack.readCell(); + const poolJettonWalletCode = stack.readCell(); + const payoutMinterCode = stack.readCell(); + + const projectedTotalBalance = stack.readBigNumber().toString(); + const projectedPoolSupply = stack.readBigNumber().toString(); + + return { + state, + halted, + totalBalance, + interestRatePercent, + optimisticDepositWithdrawals, + depositsOpen, + savedValidatorSetHash, + prevRound, + currentRound, + + minLoan, + maxLoan, + governanceFeePercent, + + jettonMinter, + supply, + + depositPayout, + requestedForDeposit, + withdrawalPayout, + requestedForWithdrawal, + + sudoer, + sudoerSetAt, + governor, + governorUpdateAfter, + interestManager, + halter, + approver, + + controllerCode, + poolJettonWalletCode, + payoutMinterCode, + + projectedTotalBalance, + projectedPoolSupply, + }; + } + + async getPoolData(): Promise { + const data = await this.client.runGetMethod(this.address, 'get_pool_data'); + const stack = ReaderStack(data.stack); + const state = stack.readNumber() as PoolState; + const halted = stack.readBoolean(); + const totalBalance = stack.readBigNumber().toString(); + const supply = stack.readBigNumber().toString(); + const interestRatePercent = (stack.readNumber() / SHARE_BASIS) * 100; + + return { + state, + halted, + totalBalance, + supply, + interestRatePercent, + }; + } + + async getJettonWalletAddress(userAddress: UserFriendlyAddress): Promise { + const { jettonMinter } = await this.getPoolFullData(); + const data = await this.client.runGetMethod( + jettonMinter, + 'get_wallet_address', + SerializeStack([{ type: 'slice', cell: beginCell().storeAddress(Address.parse(userAddress)).endCell() }]), + ); + const stack = ReaderStack(data.stack); + return asAddressFriendly(stack.readAddress()); + } + + async getStakedBalance(userAddress: UserFriendlyAddress): Promise { + const jettonWalletAddress = await this.getJettonWalletAddress(userAddress); + const data = await this.client.runGetMethod(jettonWalletAddress, 'get_wallet_data'); + const stack = ReaderStack(data.stack); + return stack.readBigNumber().toString(); + } + + /** + * Build stake message payload. + * + * TL‑B: deposit#47d54391 query_id:uint64 = InternalMsgBody; + */ + buildStakePayload(queryId: bigint = 1n): Base64String { + const cell = beginCell() + .storeUint(CONTRACT.PAYLOAD_STAKE, 32) + .storeUint(queryId, 64) + .storeUint(CONTRACT.PARTNER_CODE, 64) + .endCell(); + + return cell.toBoc().toString('base64') as Base64String; + } + + /** + * Build unstake message payload to be sent to user's tsTON jetton wallet. + * + * Internal body: + * - op: burn#595f07bc (see TonstakersBurnPayload specification) + * - query_id: uint64 + * - amount: Coins + * - response_destination: MsgAddress (user address) + * - custom_payload: Maybe ^Cell (TonstakersBurnPayload) + */ + buildUnstakePayload(params: { + amount: bigint; + userAddress: UserFriendlyAddress; + waitTillRoundEnd: boolean; + fillOrKill: boolean; + queryId?: bigint; + }): Base64String { + const { amount, userAddress, waitTillRoundEnd, fillOrKill, queryId = 0n } = params; + + const burnPayloadCell = beginCell() + .storeBit(waitTillRoundEnd ? 1 : 0) + .storeBit(fillOrKill ? 1 : 0) + .endCell(); + + const cell = beginCell() + .storeUint(CONTRACT.PAYLOAD_UNSTAKE, 32) + .storeUint(queryId, 64) + .storeCoins(amount) + .storeAddress(Address.parse(userAddress)) + .storeMaybeRef(burnPayloadCell) + .endCell(); + + return cell.toBoc().toString('base64') as Base64String; + } + + /** + * Helper to construct a TransactionRequestMessage for unstake flow. + * + * Note: fee amount is not applied here and should be added by caller. + */ + async buildUnstakeMessage(params: { + amount: bigint; + userAddress: UserFriendlyAddress; + waitTillRoundEnd: boolean; + fillOrKill: boolean; + }): Promise { + const { amount, userAddress, waitTillRoundEnd, fillOrKill } = params; + + const jettonWalletAddress = await this.getJettonWalletAddress(userAddress); + const payload = this.buildUnstakePayload({ + amount, + userAddress, + waitTillRoundEnd, + fillOrKill, + }); + + return { + address: jettonWalletAddress, + amount: CONTRACT.UNSTAKE_FEE_RES.toString(), + payload, + }; + } + + calculateApy(interestRate: number): number { + const cyclesPerYear = TIMING.CYCLES_PER_YEAR; + const protocolFee = TIMING.PROTOCOL_FEE; + return interestRate * cyclesPerYear * (1 - protocolFee); + } + + /** + * Get staking contract balance (instant liquidity available). + */ + async getPoolBalance(): Promise { + const balance = await this.client.getBalance(this.address); + return BigInt(balance); + } + + /** + * Get round timestamps from pool data. + * Note: Toncenter doesn't provide cycle_start/cycle_end directly, + * so we estimate based on cycle length (~18 hours). + */ + async getRoundInfo(): Promise { + const cycleLengthSeconds = TIMING.CYCLE_LENGTH_HOURS * 3600; + const now = Math.floor(Date.now() / 1000); + const cycle_end = now + Math.floor(cycleLengthSeconds / 2); + const cycle_start = cycle_end - cycleLengthSeconds; + + return { + cycle_start, + cycle_end, + cycle_length: cycleLengthSeconds, + }; + } +} diff --git a/packages/walletkit/src/defi/staking/tonstakers/StakingCache.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/StakingCache.spec.ts new file mode 100644 index 000000000..61d55e19b --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/StakingCache.spec.ts @@ -0,0 +1,98 @@ +/** + * 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 { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { StakingCache } from './StakingCache'; + +describe('StakingCache', () => { + let cache: StakingCache; + + beforeEach(() => { + cache = new StakingCache(); + }); + + describe('get', () => { + it('should return cached value if exists', async () => { + const fetcher = vi.fn().mockResolvedValue('value1'); + + const result1 = await cache.get('key1', fetcher); + const result2 = await cache.get('key1', fetcher); + + expect(result1).toBe('value1'); + expect(result2).toBe('value1'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('should call fetcher if value not cached', async () => { + const fetcher = vi.fn().mockResolvedValue('fetched-value'); + + const result = await cache.get('new-key', fetcher); + + expect(result).toBe('fetched-value'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('should cache fetched value', async () => { + const fetcher1 = vi.fn().mockResolvedValue('first'); + const fetcher2 = vi.fn().mockResolvedValue('second'); + + await cache.get('key', fetcher1); + const result = await cache.get('key', fetcher2); + + expect(result).toBe('first'); + expect(fetcher2).not.toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('should clear all cached values', async () => { + const fetcher1 = vi.fn().mockResolvedValue('value1'); + const fetcher2 = vi.fn().mockResolvedValue('value2'); + + await cache.get('key1', fetcher1); + await cache.get('key2', fetcher2); + + cache.clear(); + + const newFetcher1 = vi.fn().mockResolvedValue('new-value1'); + const newFetcher2 = vi.fn().mockResolvedValue('new-value2'); + + const result1 = await cache.get('key1', newFetcher1); + const result2 = await cache.get('key2', newFetcher2); + + expect(result1).toBe('new-value1'); + expect(result2).toBe('new-value2'); + expect(newFetcher1).toHaveBeenCalledTimes(1); + expect(newFetcher2).toHaveBeenCalledTimes(1); + }); + }); + + describe('invalidate', () => { + it('should remove specific key', async () => { + const fetcher1 = vi.fn().mockResolvedValue('value1'); + const fetcher2 = vi.fn().mockResolvedValue('value2'); + + await cache.get('key1', fetcher1); + await cache.get('key2', fetcher2); + + cache.invalidate('key1'); + + const newFetcher1 = vi.fn().mockResolvedValue('new-value1'); + const newFetcher2 = vi.fn().mockResolvedValue('new-value2'); + + const result1 = await cache.get('key1', newFetcher1); + const result2 = await cache.get('key2', newFetcher2); + + expect(result1).toBe('new-value1'); + expect(result2).toBe('value2'); + expect(newFetcher1).toHaveBeenCalledTimes(1); + expect(newFetcher2).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/walletkit/src/defi/staking/tonstakers/StakingCache.ts b/packages/walletkit/src/defi/staking/tonstakers/StakingCache.ts new file mode 100644 index 000000000..f06a20d9e --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/StakingCache.ts @@ -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 { LRUCache } from 'lru-cache'; + +import { TIMING } from './constants'; + +export class StakingCache { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private cache: LRUCache; + private readonly defaultTtl: number; + + constructor(maxSize: number = 100, defaultTtl: number = TIMING.CACHE_TIMEOUT) { + this.defaultTtl = defaultTtl; + this.cache = new LRUCache({ + max: maxSize, + }); + } + + async get(key: string, fetcher: () => Promise, ttl: number = this.defaultTtl): Promise { + const cached = this.cache.get(key); + if (cached !== undefined) { + return cached as T; + } + + const value = await fetcher(); + this.cache.set(key, value, { ttl }); + return value; + } + + clear(): void { + this.cache.clear(); + } + + invalidate(key: string): void { + this.cache.delete(key); + } +} diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts new file mode 100644 index 000000000..2e21ba091 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts @@ -0,0 +1,164 @@ +/** + * 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 { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Cell } from '@ton/core'; + +import { PoolContract } from './PoolContract'; +import { CONTRACT, TIMING } from './constants'; + +const mockApiClient = { + runGetMethod: vi.fn(), + getBalance: vi.fn(), +}; + +describe('TonStakersContract', () => { + let contract: PoolContract; + + beforeEach(() => { + vi.clearAllMocks(); + contract = new PoolContract(CONTRACT.STAKING_CONTRACT_ADDRESS, mockApiClient as never); + }); + + describe('buildStakePayload', () => { + it('should create correct Cell with op code, query_id, and partner code', () => { + const queryId = 123n; + const payload = contract.buildStakePayload(queryId); + + const cell = Cell.fromBase64(payload); + const slice = cell.beginParse(); + + expect(slice.loadUint(32)).toBe(CONTRACT.PAYLOAD_STAKE); + expect(slice.loadUintBig(64)).toBe(queryId); + expect(slice.loadUintBig(64)).toBe(BigInt(CONTRACT.PARTNER_CODE)); + }); + + it('should use default query_id of 1n when not provided', () => { + const payload = contract.buildStakePayload(); + + const cell = Cell.fromBase64(payload); + const slice = cell.beginParse(); + + slice.loadUint(32); + expect(slice.loadUintBig(64)).toBe(1n); + }); + }); + + describe('buildUnstakePayload', () => { + const baseParams = { + amount: 1000000000n, + userAddress: 'EQDtFpEwcFAEcRe5mLVh2N6C0x-_hJEM7W61_JLnSF74p4q2', + }; + + it('should build Delayed unstake payload (waitTillRoundEnd=false, fillOrKill=false)', () => { + const payload = contract.buildUnstakePayload({ + ...baseParams, + waitTillRoundEnd: false, + fillOrKill: false, + }); + + const cell = Cell.fromBase64(payload); + const slice = cell.beginParse(); + + expect(slice.loadUint(32)).toBe(CONTRACT.PAYLOAD_UNSTAKE); + expect(slice.loadUintBig(64)).toBe(0n); + expect(slice.loadCoins()).toBe(baseParams.amount); + slice.loadAddress(); + + const burnPayloadRef = slice.loadMaybeRef(); + expect(burnPayloadRef).not.toBeNull(); + const burnSlice = burnPayloadRef!.beginParse(); + expect(burnSlice.loadBit()).toBe(false); + expect(burnSlice.loadBit()).toBe(false); + }); + + it('should build Instant unstake payload (waitTillRoundEnd=false, fillOrKill=true)', () => { + const payload = contract.buildUnstakePayload({ + ...baseParams, + waitTillRoundEnd: false, + fillOrKill: true, + }); + + const cell = Cell.fromBase64(payload); + const slice = cell.beginParse(); + + slice.loadUint(32); + slice.loadUintBig(64); + slice.loadCoins(); + slice.loadAddress(); + + const burnPayloadRef = slice.loadMaybeRef(); + expect(burnPayloadRef).not.toBeNull(); + const burnSlice = burnPayloadRef!.beginParse(); + expect(burnSlice.loadBit()).toBe(false); + expect(burnSlice.loadBit()).toBe(true); + }); + + it('should build BestRate unstake payload (waitTillRoundEnd=true, fillOrKill=false)', () => { + const payload = contract.buildUnstakePayload({ + ...baseParams, + waitTillRoundEnd: true, + fillOrKill: false, + }); + + const cell = Cell.fromBase64(payload); + const slice = cell.beginParse(); + + slice.loadUint(32); + slice.loadUintBig(64); + slice.loadCoins(); + slice.loadAddress(); + + const burnPayloadRef = slice.loadMaybeRef(); + expect(burnPayloadRef).not.toBeNull(); + const burnSlice = burnPayloadRef!.beginParse(); + expect(burnSlice.loadBit()).toBe(true); + expect(burnSlice.loadBit()).toBe(false); + }); + + it('should use custom query_id when provided', () => { + const customQueryId = 999n; + const payload = contract.buildUnstakePayload({ + ...baseParams, + waitTillRoundEnd: false, + fillOrKill: false, + queryId: customQueryId, + }); + + const cell = Cell.fromBase64(payload); + const slice = cell.beginParse(); + + slice.loadUint(32); + expect(slice.loadUintBig(64)).toBe(customQueryId); + }); + }); + + describe('calculateApy', () => { + it('should calculate APY from interest rate', () => { + const interestRate = 0.001; + const apy = contract.calculateApy(interestRate); + + const expectedApy = interestRate * TIMING.CYCLES_PER_YEAR * (1 - TIMING.PROTOCOL_FEE); + + expect(apy).toBeCloseTo(expectedApy, 10); + }); + + it('should return 0 for zero interest rate', () => { + const apy = contract.calculateApy(0); + expect(apy).toBe(0); + }); + + it('should handle large interest rates', () => { + const largeInterestRate = 0.5; + const apy = contract.calculateApy(largeInterestRate); + + expect(apy).toBeGreaterThan(0); + expect(Number.isFinite(apy)).toBe(true); + }); + }); +}); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.ts deleted file mode 100644 index e1c1d8189..000000000 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.ts +++ /dev/null @@ -1,180 +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 { Address, beginCell } from '@ton/core'; - -import type { Base64String, TransactionRequestMessage, UserFriendlyAddress } from '../../../api/models'; -import type { ApiClient } from '../../../types/toncenter/ApiClient'; -import { CONTRACT } from './constants'; -import { ParseStack } from '../../../utils'; - -/** - * Low–level helper for interacting with Tonstakers staking contracts. - * - * This class encapsulates all contract-specific TL‑B payload building - * and read‑only getter calls. High–level staking flows should use - * `TonStakersStakingProvider` which composes this class. - */ -export class TonStakersContract { - readonly address: string; - - private readonly apiClient: ApiClient; - - constructor(address: string, apiClient: ApiClient) { - this.address = address; - this.apiClient = apiClient; - } - - /** - * Build stake message payload. - * - * TL‑B: deposit#47d54391 query_id:uint64 = InternalMsgBody; - */ - buildStakePayload(queryId: bigint = 1n): Base64String { - const cell = beginCell() - .storeUint(CONTRACT.PAYLOAD_STAKE, 32) - .storeUint(queryId, 64) - .storeUint(CONTRACT.PARTNER_CODE, 64) - .endCell(); - - return cell.toBoc().toString('base64') as Base64String; - } - - /** - * Build unstake message payload to be sent to user's tsTON jetton wallet. - * - * Internal body: - * - op: burn#595f07bc (see TonstakersBurnPayload specification) - * - query_id: uint64 - * - amount: Coins - * - response_destination: MsgAddress (user address) - * - custom_payload: Maybe ^Cell (TonstakersBurnPayload) - */ - buildUnstakePayload(params: { - amount: bigint; - userAddress: UserFriendlyAddress; - waitTillRoundEnd: boolean; - fillOrKill: boolean; - queryId?: bigint; - }): Base64String { - const { amount, userAddress, waitTillRoundEnd, fillOrKill, queryId = 0n } = params; - - const burnPayloadCell = beginCell() - .storeBit(waitTillRoundEnd ? 1 : 0) - .storeBit(fillOrKill ? 1 : 0) - .endCell(); - - const cell = beginCell() - .storeUint(CONTRACT.PAYLOAD_UNSTAKE, 32) - .storeUint(queryId, 64) - .storeCoins(amount) - .storeAddress(Address.parse(userAddress)) - .storeMaybeRef(burnPayloadCell) - .endCell(); - - return cell.toBoc().toString('base64') as Base64String; - } - - /** - * Resolve tsTON jetton wallet address for a given owner address. - * - * This uses on‑chain getter `get_pool_full_data` on the staking contract - * to locate the jetton minter, then calls `get_wallet_address` on it. - */ - async getJettonWalletAddress(userAddress: UserFriendlyAddress): Promise { - // 1. Resolve jetton minter from pool data - const poolInfoResult = await this.apiClient.runGetMethod(this.address, 'get_pool_full_data'); - - let jettonMinterAddress: string | undefined; - - if (poolInfoResult.stack && poolInfoResult.stack.length > 0) { - const parsedStack = ParseStack(poolInfoResult.stack); - for (const item of parsedStack) { - if (item.type === 'cell') { - try { - const slice = item.cell.beginParse(); - const addr = slice.loadAddress(); - if (addr && addr.hash && addr.hash.length > 0 && !addr.equals(Address.parse(this.address))) { - jettonMinterAddress = addr.toString(); - break; - } - } catch { - // Ignore parse errors and continue scanning stack - } - } - } - } - - if (!jettonMinterAddress) { - throw new Error('Jetton minter address not found in pool data'); - } - - // 2. Resolve jetton wallet address using minter's get_wallet_address - const addressCell = beginCell().storeAddress(Address.parse(userAddress)).endCell().toBoc().toString('base64'); - - const result = await this.apiClient.runGetMethod(jettonMinterAddress, 'get_wallet_address', [ - { type: 'cell', value: addressCell }, - ]); - - if (result.stack && result.stack.length > 0) { - const parsedStack = ParseStack(result.stack); - if (parsedStack.length > 0 && parsedStack[0].type === 'cell') { - const addressSlice = parsedStack[0].cell.beginParse(); - const address = addressSlice.loadAddress(); - return address.toString() as UserFriendlyAddress; - } - } - - throw new Error('Failed to get jetton wallet address from minter'); - } - - /** - * Read tsTON balance for user from jetton wallet contract. - */ - async getStakedBalance(userAddress: UserFriendlyAddress): Promise { - const jettonWalletAddress = await this.getJettonWalletAddress(userAddress); - const result = await this.apiClient.runGetMethod(jettonWalletAddress, 'get_wallet_data'); - - if (result.stack && result.stack.length > 0) { - const parsedStack = ParseStack(result.stack); - if (parsedStack.length > 0 && parsedStack[0].type === 'int') { - return parsedStack[0].value; - } - } - - return 0n; - } - - /** - * Helper to construct a TransactionRequestMessage for unstake flow. - * - * Note: fee amount is not applied here and should be added by caller. - */ - async buildUnstakeMessage(params: { - amount: bigint; - userAddress: UserFriendlyAddress; - waitTillRoundEnd: boolean; - fillOrKill: boolean; - }): Promise { - const { amount, userAddress, waitTillRoundEnd, fillOrKill } = params; - - const jettonWalletAddress = await this.getJettonWalletAddress(userAddress); - const payload = this.buildUnstakePayload({ - amount, - userAddress, - waitTillRoundEnd, - fillOrKill, - }); - - return { - address: jettonWalletAddress, - amount: CONTRACT.UNSTAKE_FEE_RES.toString(), - payload, - }; - } -} diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts new file mode 100644 index 000000000..0f9e99f20 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts @@ -0,0 +1,232 @@ +/** + * 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 { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { MockInstance } from 'vitest'; + +import { TonStakersStakingProvider } from './TonStakersStakingProvider'; +import { PoolContract } from './PoolContract'; +import { StakingQuoteDirection, UnstakeMode } from '../types'; +import { CONTRACT } from './constants'; +import { Network } from '../../../api/models'; + +const mockApiClient = { + runGetMethod: vi.fn(), + getBalance: vi.fn(), +}; + +const mockNetworkManager = { + getClient: vi.fn(() => mockApiClient), +}; + +const mockEventEmitter = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), +}; + +describe('TonStakersStakingProvider', () => { + let provider: TonStakersStakingProvider; + const testUserAddress = 'EQDtFpEwcFAEcRe5mLVh2N6C0x-_hJEM7W61_JLnSF74p4q2'; + + let buildStakePayloadSpy: MockInstance; + let buildUnstakeMessageSpy: MockInstance; + let getPoolDataSpy: MockInstance; + let calculateApySpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + + buildStakePayloadSpy = vi + .spyOn(PoolContract.prototype, 'buildStakePayload') + .mockReturnValue('mock-stake-payload'); + + buildUnstakeMessageSpy = vi.spyOn(PoolContract.prototype, 'buildUnstakeMessage').mockResolvedValue({ + address: 'EQMockJettonWallet', + amount: CONTRACT.UNSTAKE_FEE_RES.toString(), + payload: 'mock-unstake-payload', + }); + + getPoolDataSpy = vi.spyOn(PoolContract.prototype, 'getPoolData').mockResolvedValue({ + state: 0, + halted: false, + totalBalance: '1000000000000', + supply: '900000000000', + interestRatePercent: 0.1, + }); + + calculateApySpy = vi.spyOn(PoolContract.prototype, 'calculateApy').mockReturnValue(0.05); + + vi.spyOn(PoolContract.prototype, 'getPoolBalance').mockResolvedValue(500000000000n); + + provider = new TonStakersStakingProvider(mockNetworkManager as never, mockEventEmitter as never); + }); + + describe('getQuote', () => { + it('should return correct quote with APY for stake direction', async () => { + const amount = '1000000000'; + const quote = await provider.getQuote({ + direction: StakingQuoteDirection.Stake, + amount, + userAddress: testUserAddress, + network: Network.mainnet(), + }); + + expect(quote.direction).toBe(StakingQuoteDirection.Stake); + expect(quote.amountIn).toBe(amount); + expect(quote.amountOut).toBe(amount); + expect(quote.provider).toBe('tonstakers'); + expect(quote.apy).toBe(0.05); + expect(getPoolDataSpy).toHaveBeenCalled(); + expect(calculateApySpy).toHaveBeenCalledWith(0.1); + }); + + it('should return correct quote with unstakeMode for unstake direction', async () => { + const amount = '1000000000'; + const quote = await provider.getQuote({ + direction: StakingQuoteDirection.Unstake, + amount, + userAddress: testUserAddress, + network: Network.mainnet(), + unstakeMode: UnstakeMode.Instant, + }); + + expect(quote.direction).toBe(StakingQuoteDirection.Unstake); + expect(quote.amountIn).toBe(amount); + expect(quote.amountOut).toBe(amount); + expect(quote.provider).toBe('tonstakers'); + expect(quote.unstakeMode).toBe(UnstakeMode.Instant); + }); + + it('should default to Delayed unstakeMode when not specified', async () => { + const quote = await provider.getQuote({ + direction: StakingQuoteDirection.Unstake, + amount: '1000000000', + network: Network.mainnet(), + }); + + expect(quote.unstakeMode).toBe(UnstakeMode.Delayed); + }); + }); + + describe('stake', () => { + it('should build correct transaction with stake payload', async () => { + const amount = '1000000000'; + const tx = await provider.stake({ + amount, + userAddress: testUserAddress, + network: Network.mainnet(), + }); + + expect(tx.fromAddress).toBe(testUserAddress); + expect(tx.network).toEqual(Network.mainnet()); + expect(tx.messages).toHaveLength(1); + + const message = tx.messages[0]; + expect(message.address).toBe(CONTRACT.STAKING_CONTRACT_ADDRESS); + expect(message.payload).toBe('mock-stake-payload'); + + const expectedAmount = BigInt(amount) + CONTRACT.STAKE_FEE_RES; + expect(message.amount).toBe(expectedAmount.toString()); + + expect(buildStakePayloadSpy).toHaveBeenCalledWith(1n); + }); + }); + + describe('unstake', () => { + it('should build correct transaction for Delayed mode', async () => { + const tx = await provider.unstake({ + amount: '1000000000', + userAddress: testUserAddress, + network: Network.mainnet(), + unstakeMode: UnstakeMode.Delayed, + }); + + expect(tx.fromAddress).toBe(testUserAddress); + expect(tx.messages).toHaveLength(1); + expect(tx.messages[0].address).toBe('EQMockJettonWallet'); + expect(tx.messages[0].payload).toBe('mock-unstake-payload'); + + expect(buildUnstakeMessageSpy).toHaveBeenCalledWith({ + amount: 1000000000n, + userAddress: testUserAddress, + waitTillRoundEnd: false, + fillOrKill: false, + }); + }); + + it('should build correct transaction for Instant mode', async () => { + await provider.unstake({ + amount: '1000000000', + userAddress: testUserAddress, + network: Network.mainnet(), + unstakeMode: UnstakeMode.Instant, + }); + + expect(buildUnstakeMessageSpy).toHaveBeenCalledWith({ + amount: 1000000000n, + userAddress: testUserAddress, + waitTillRoundEnd: false, + fillOrKill: true, + }); + }); + + it('should build correct transaction for BestRate mode', async () => { + await provider.unstake({ + amount: '1000000000', + userAddress: testUserAddress, + network: Network.mainnet(), + unstakeMode: UnstakeMode.BestRate, + }); + + expect(buildUnstakeMessageSpy).toHaveBeenCalledWith({ + amount: 1000000000n, + userAddress: testUserAddress, + waitTillRoundEnd: true, + fillOrKill: false, + }); + }); + + it('should default to Delayed mode when unstakeMode not specified', async () => { + await provider.unstake({ + amount: '1000000000', + userAddress: testUserAddress, + network: Network.mainnet(), + }); + + expect(buildUnstakeMessageSpy).toHaveBeenCalledWith({ + amount: 1000000000n, + userAddress: testUserAddress, + waitTillRoundEnd: false, + fillOrKill: false, + }); + }); + }); + + describe('unstake mode flags', () => { + it.each([ + { mode: UnstakeMode.Delayed, waitTillRoundEnd: false, fillOrKill: false }, + { mode: UnstakeMode.Instant, waitTillRoundEnd: false, fillOrKill: true }, + { mode: UnstakeMode.BestRate, waitTillRoundEnd: true, fillOrKill: false }, + ])('should set correct flags for $mode mode', async ({ mode, waitTillRoundEnd, fillOrKill }) => { + await provider.unstake({ + amount: '1000000000', + userAddress: testUserAddress, + network: Network.mainnet(), + unstakeMode: mode, + }); + + expect(buildUnstakeMessageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + waitTillRoundEnd, + fillOrKill, + }), + ); + }); + }); +}); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts index 43de4a1f9..c57e5c5c2 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -19,18 +19,68 @@ import type { StakingInfo, StakingQuoteParams, StakingQuote, + RoundInfo, } from '../types'; import { StakingError, StakingErrorCode } from '../errors'; import { StakingQuoteDirection, UnstakeMode } from '../types'; import type { TonStakersProviderConfig } from './types'; -import { CONTRACT } from './constants'; -import { TonStakersContract } from './TonStakersContract'; +import { CONTRACT, TIMING } from './constants'; +import type { PoolFullData } from './PoolContract'; +import { PoolContract } from './PoolContract'; +import { StakingCache } from './StakingCache'; const log = globalLogger.createChild('TonStakersStakingProvider'); +/** + * TonStakersStakingProvider - Staking provider for the Tonstakers liquid staking protocol. + * + * This provider implements all staking operations using ONLY Toncenter API + * (no TonAPI dependency). It supports: + * - Stake: Deposit TON to receive tsTON liquid staking tokens + * - Unstake: Burn tsTON to withdraw TON with 3 modes: + * - Delayed: Standard withdrawal at end of round (~18 hours) + * - Instant: Immediate withdrawal if liquidity available + * - BestRate: Wait for best exchange rate at round end + * + * @example + * ```typescript + * const stakingManager = new StakingManager(); + * const provider = new TonStakersStakingProvider( + * walletKit.getNetworkManager(), + * walletKit.getEventEmitter() + * ); + * stakingManager.registerProvider('tonstakers', provider); + * + * // Get staking info with APY + * const info = await stakingManager.getStakingInfo(Network.mainnet()); + * + * // Stake TON + * const stakeTx = await stakingManager.stake({ + * amount: toNano('10').toString(), + * userAddress: wallet.getAddress(), + * network: Network.mainnet() + * }); + * + * // Unstake with instant mode + * const unstakeTx = await stakingManager.unstake({ + * amount: toNano('5').toString(), + * userAddress: wallet.getAddress(), + * network: Network.mainnet(), + * unstakeMode: UnstakeMode.Instant + * }); + * ``` + */ export class TonStakersStakingProvider extends StakingProvider { protected config: TonStakersProviderConfig; + private cache: StakingCache; + /** + * Create a new TonStakersStakingProvider instance. + * + * @param networkManager - Network manager for API client access + * @param eventEmitter - Event emitter for staking events + * @param config - Optional configuration with custom contract addresses per network + */ constructor(networkManager: NetworkManager, eventEmitter: EventEmitter, config: TonStakersProviderConfig = {}) { super(networkManager, eventEmitter); this.config = { @@ -42,6 +92,7 @@ export class TonStakersStakingProvider extends StakingProvider { }, ...config, }; + this.cache = new StakingCache(); log.info('TonStakersStakingProvider initialized'); } @@ -60,13 +111,19 @@ export class TonStakersStakingProvider extends StakingProvider { return networkConfig.contractAddress; } - private getContract(network?: Network): TonStakersContract { + private getContract(network?: Network): PoolContract { const targetNetwork = network ?? Network.mainnet(); const apiClient = this.getApiClient(targetNetwork); const contractAddress = this.getStakingContractAddress(targetNetwork); - return new TonStakersContract(contractAddress, apiClient); + return new PoolContract(contractAddress, apiClient); } + /** + * Get a quote for staking or unstaking operations. + * + * @param params - Quote parameters including direction, amount, and optional unstake mode + * @returns Quote with expected amounts and current APY (for stake direction) + */ async getQuote(params: StakingQuoteParams): Promise { log.debug('TonStakers quote requested', { direction: params.direction, @@ -96,6 +153,16 @@ export class TonStakersStakingProvider extends StakingProvider { } } + /** + * Build a transaction for staking TON. + * + * The stake operation sends TON to the Tonstakers pool contract + * and receives tsTON liquid staking tokens in return. + * A fee reserve of 1 TON is automatically added to the amount. + * + * @param params - Stake parameters including amount and user address + * @returns Transaction request ready to be signed and sent + */ async stake(params: StakeParams): Promise { log.debug('TonStakers stake requested', { params }); @@ -120,14 +187,42 @@ export class TonStakersStakingProvider extends StakingProvider { }; } + /** + * Build a transaction for unstaking tsTON. + * + * Supports three unstake modes: + * - **Delayed** (default): Standard withdrawal, funds released at end of round (~18 hours) + * - **Instant**: Immediate withdrawal if pool has sufficient liquidity (fillOrKill) + * - **BestRate**: Wait until round end for best exchange rate + * + * @param params - Unstake parameters including amount, user address, and optional mode + * @returns Transaction request ready to be signed and sent + */ async unstake(params: UnstakeParams): Promise { log.debug('TonStakers unstake requested', { amount: params.amount, userAddress: params.userAddress }); const network = params.network; const amount = BigInt(params.amount); const unstakeMode = params.unstakeMode || UnstakeMode.Delayed; - const waitTillRoundEnd = unstakeMode === UnstakeMode.Delayed && params.maxDelayHours !== undefined; - const fillOrKill = unstakeMode === UnstakeMode.Instant; + + let waitTillRoundEnd = false; + let fillOrKill = false; + + switch (unstakeMode) { + case UnstakeMode.Instant: + waitTillRoundEnd = false; + fillOrKill = true; + break; + case UnstakeMode.BestRate: + waitTillRoundEnd = true; + fillOrKill = false; + break; + case UnstakeMode.Delayed: + default: + waitTillRoundEnd = false; + fillOrKill = false; + break; + } const contract = this.getContract(network); const message = await contract.buildUnstakeMessage({ @@ -144,6 +239,18 @@ export class TonStakersStakingProvider extends StakingProvider { }; } + /** + * Get staking balance information for a user. + * + * Returns: + * - stakedBalance: Amount of tsTON tokens held + * - availableBalance: TON available for staking (minus fee reserve) + * - instantUnstakeAvailable: Pool liquidity for instant unstaking + * + * @param userAddress - User wallet address + * @param network - Network to query (defaults to mainnet) + * @returns Balance information including staked and available amounts + */ async getBalance(userAddress: UserFriendlyAddress, network?: Network): Promise { log.debug('TonStakers balance requested', { userAddress, network }); @@ -156,19 +263,25 @@ export class TonStakersStakingProvider extends StakingProvider { ? BigInt(tonBalance) - CONTRACT.RECOMMENDED_FEE_RESERVE : 0n; - // Get staked balance (tsTON) - let stakedBalance = 0n; + let stakedBalance = '0'; let instantUnstakeAvailable = 0n; + const contract = this.getContract(targetNetwork); + try { - const contract = this.getContract(network); stakedBalance = await contract.getStakedBalance(userAddress); } catch (error) { log.warn('Failed to get staked balance', { error }); } + try { + instantUnstakeAvailable = await contract.getPoolBalance(); + } catch (error) { + log.warn('Failed to get instant unstake liquidity', { error }); + } + return { - stakedBalance: stakedBalance.toString(), + stakedBalance: stakedBalance, availableBalance: availableBalance.toString(), instantUnstakeAvailable: instantUnstakeAvailable.toString(), provider: 'tonstakers', @@ -183,15 +296,110 @@ export class TonStakersStakingProvider extends StakingProvider { } } + /** + * Get staking pool information including APY and liquidity. + * + * APY is calculated from on-chain data using the formula: + * (interest_rate / 2^24) * cycles_per_year * (1 - protocol_fee) + * + * Results are cached for 30 seconds to reduce API calls. + * + * @param network - Network to query (defaults to mainnet) + * @returns Staking info with APY and available instant liquidity + */ async getStakingInfo(network?: Network): Promise { log.debug('TonStakers info requested', { network }); - // Note: APY and other dynamic info is not easily available on-chain without historical data. - // We return default values here to avoid dependency on external APIs like tonapi.io - return Promise.resolve({ - apy: 0, - instantUnstakeAvailable: '0', - provider: 'tonstakers', - }); + const targetNetwork = network ?? Network.mainnet(); + const cacheKey = `staking-info:${targetNetwork.chainId}`; + + try { + return await this.cache.get( + cacheKey, + async () => { + const contract = this.getContract(targetNetwork); + const poolData = await contract.getPoolData(); + const apy = contract.calculateApy(poolData.interestRatePercent); + const instantLiquidity = await contract.getPoolBalance(); + + return { + apy, + instantUnstakeAvailable: instantLiquidity.toString(), + provider: 'tonstakers', + }; + }, + TIMING.CACHE_TIMEOUT, + ); + } catch (error) { + log.warn('Failed to get staking info from on-chain', { error }); + return { + apy: 0, + instantUnstakeAvailable: '0', + provider: 'tonstakers', + }; + } + } + + /** + * Get full pool data from on-chain getter. + * Results are cached for 30 seconds. + * + * @param network - Network to query (defaults to mainnet) + * @returns Pool data including total_balance, supply, interest_rate + */ + async getPoolFullData(network?: Network): Promise { + const targetNetwork = network ?? Network.mainnet(); + const cacheKey = `pool-full-data:${targetNetwork.chainId}`; + + return this.cache.get( + cacheKey, + async () => { + const contract = this.getContract(targetNetwork); + return contract.getPoolFullData(); + }, + TIMING.CACHE_TIMEOUT, + ); + } + + /** + * Get current round timing information. + * Note: Returns estimated values based on ~18 hour cycle length. + * + * @param network - Network to query (defaults to mainnet) + * @returns Round info with cycle_start, cycle_end, and cycle_length + */ + async getRoundInfo(network?: Network): Promise { + const contract = this.getContract(network); + return contract.getRoundInfo(); + } + + /** + * Get instant unstake liquidity available in the pool. + * This is the amount that can be withdrawn instantly. + * Results are cached for 30 seconds. + * + * @param network - Network to query (defaults to mainnet) + * @returns Available liquidity in nanotons + */ + async getInstantLiquidity(network?: Network): Promise { + const targetNetwork = network ?? Network.mainnet(); + const cacheKey = `instant-liquidity:${targetNetwork.chainId}`; + + return this.cache.get( + cacheKey, + async () => { + const contract = this.getContract(targetNetwork); + return contract.getPoolBalance(); + }, + TIMING.CACHE_TIMEOUT, + ); + } + + /** + * Clear all cached data. + * Use this to force fresh data retrieval on next call. + */ + clearCache(): void { + this.cache.clear(); } } diff --git a/packages/walletkit/src/defi/staking/tonstakers/constants.ts b/packages/walletkit/src/defi/staking/tonstakers/constants.ts index 0b2dd9cea..0dca051d8 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/constants.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/constants.ts @@ -15,11 +15,16 @@ export const TIMING = { CACHE_TIMEOUT: 30000, ESTIMATED_TIME_BW_TX_S: 3, ESTIMATED_TIME_AFTER_ROUND_S: 10 * 60, + CYCLE_LENGTH_HOURS: 18, + CYCLES_PER_YEAR: (365.25 * 24) / 18, + PROTOCOL_FEE: 0.1, }; // Contract-related constants export const CONTRACT = { + // https://github.com/ton-blockchain/liquid-staking-contract/tree/35d676f6ac6e35e755ea3c4d7d7cf577627b1cf0 STAKING_CONTRACT_ADDRESS: 'EQCkWxfyhAkim3g2DjKQQg8T5P4g-Q1-K_jErGcDJZ4i-vqR', + // https://github.com/ton-blockchain/liquid-staking-contract/tree/77f13c850890517a6b490ef5f109c31b4fa783e7 STAKING_CONTRACT_ADDRESS_TESTNET: 'kQANFsYyYn-GSZ4oajUJmboDURZU-udMHf9JxzO4vYM_hFP3', PARTNER_CODE: 0x000000106796caef, PAYLOAD_UNSTAKE: 0x595f07bc, diff --git a/packages/walletkit/src/defi/staking/tonstakers/types.ts b/packages/walletkit/src/defi/staking/tonstakers/types.ts index 471a731f4..b0539bab0 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/types.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/types.ts @@ -11,3 +11,10 @@ export type TonStakersProviderConfig = { contractAddress: string; }; }; + +export interface TonStakersPoolInfo { + apy: number; + tvl: bigint; + instantLiquidity: bigint; +} +export type { PoolFullData } from './PoolContract'; diff --git a/packages/walletkit/src/defi/staking/types.ts b/packages/walletkit/src/defi/staking/types.ts index 841ac4782..c65c2c678 100644 --- a/packages/walletkit/src/defi/staking/types.ts +++ b/packages/walletkit/src/defi/staking/types.ts @@ -16,6 +16,13 @@ export enum StakingQuoteDirection { export enum UnstakeMode { Instant = 'instant', Delayed = 'delayed', + BestRate = 'bestRate', +} + +export interface RoundInfo { + cycle_start: number; + cycle_end: number; + cycle_length?: number; } /** diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 88dd2cd23..077d2074b 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -25,11 +25,14 @@ export { StakingProvider, StakingError, TonStakersStakingProvider, + PoolContract, + StakingCache, CONTRACT, BLOCKCHAIN, TIMING, } from './defi/staking'; export type * from './defi/staking/types'; +export type { TonStakersProviderConfig, PoolFullData, TonStakersPoolInfo } from './defi/staking/tonstakers/types'; export { EventEmitter } from './core/EventEmitter'; export type { EventListener } from './core/EventEmitter'; export { ApiClientToncenter } from './core/ApiClientToncenter'; diff --git a/packages/walletkit/src/utils/tvmStack.ts b/packages/walletkit/src/utils/tvmStack.ts index 95a07641a..e2a01e154 100644 --- a/packages/walletkit/src/utils/tvmStack.ts +++ b/packages/walletkit/src/utils/tvmStack.ts @@ -8,6 +8,7 @@ import type { TupleItem } from '@ton/core'; import { Cell } from '@ton/core'; +import { TupleReader } from '@ton/core'; export type RawStackItem = | { type: 'null' } @@ -46,6 +47,10 @@ export function ParseStack(list: RawStackItem[]): TupleItem[] { return stack; } +export function ReaderStack(list: RawStackItem[]): TupleReader { + return new TupleReader(ParseStack(list)); +} + // todo - add support for all types function SerializeStackItem(item: TupleItem): RawStackItem { switch (item.type) { diff --git a/packages/walletkit/src/validation/address.spec.ts b/packages/walletkit/src/validation/address.spec.ts new file mode 100644 index 000000000..43191fc7c --- /dev/null +++ b/packages/walletkit/src/validation/address.spec.ts @@ -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 { describe, it, expect } from 'vitest'; +import { CHAIN } from '@tonconnect/protocol'; + +import { validateTonAddress, detectAddressFormat, detectAddressNetwork } from './address'; + +describe('address validation', () => { + const validRaw = '0:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const validBounceable = 'EQCD39VS5jcptHL8vMjEXrzGaRcCVYto7HUn4bpAOg8xqB2N'; + const validNonBounceable = 'UQCD39VS5jcptHL8vMjEXrzGaRcCVYto7HUn4bpAOg8xqEBI'; + + describe('validateTonAddress', () => { + it('should validate correct raw address', () => { + const result = validateTonAddress(validRaw); + expect(result.isValid).toBe(true); + }); + + it('should validate correct bounceable address', () => { + const result = validateTonAddress(validBounceable); + expect(result.isValid).toBe(true); + }); + + it('should validate correct non-bounceable address', () => { + const result = validateTonAddress(validNonBounceable); + expect(result.isValid).toBe(true); + }); + + it('should fail for invalid address', () => { + const result = validateTonAddress('invalid-address'); + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should return errors for invalid type', () => { + // @ts-expect-error Testing invalid type input + const result = validateTonAddress(123); + expect(result.isValid).toBe(false); + }); + }); + + describe('detectAddressFormat', () => { + it('should detect raw', () => { + expect(detectAddressFormat(validRaw)).toBe('raw'); + }); + it('should detect bounceable', () => { + expect(detectAddressFormat(validBounceable)).toBe('bounceable'); + }); + it('should detect non-bounceable', () => { + expect(detectAddressFormat(validNonBounceable)).toBe('non-bounceable'); + }); + it('should detect unknown', () => { + expect(detectAddressFormat('invalid')).toBe('unknown'); + }); + }); + + describe('detectAddressNetwork', () => { + it('should return unknown for raw', () => { + expect(detectAddressNetwork(validRaw)).toBe('unknown'); + }); + + it('should detect mainnet', () => { + expect(detectAddressNetwork(validBounceable)).toBe(CHAIN.MAINNET); + }); + + // Add testnet address example if available/needed, but mainnet is enough coverage for logic + }); +}); diff --git a/packages/walletkit/src/validation/wallet.spec.ts b/packages/walletkit/src/validation/wallet.spec.ts new file mode 100644 index 000000000..2f7aadbd0 --- /dev/null +++ b/packages/walletkit/src/validation/wallet.spec.ts @@ -0,0 +1,85 @@ +/** + * 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 { describe, it, expect, vi } from 'vitest'; + +import { validatePublicKey, validateWalletVersion, validateWalletMethods, validateWallet } from './wallet'; +import type { Wallet } from '../api/interfaces'; + +describe('wallet validation', () => { + describe('validatePublicKey', () => { + it('should validate correct public key', () => { + const validKey = '0'.repeat(64); + const result = validatePublicKey(validKey); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should reject invalid public key', () => { + const invalidLength = '0'.repeat(63); + const r1 = validatePublicKey(invalidLength); + expect(r1.isValid).toBe(false); + expect(r1.errors).toContainEqual(expect.stringContaining('must be 64 characters long')); + + const invalidChars = 'x'.repeat(64); + const r2 = validatePublicKey(invalidChars); + expect(r2.isValid).toBe(false); + expect(r2.errors).toContainEqual(expect.stringContaining('hexadecimal characters')); + + const empty = ''; + const r3 = validatePublicKey(empty); + expect(r3.isValid).toBe(false); + }); + }); + + describe('validateWalletVersion', () => { + it('should validate correct versions', () => { + expect(validateWalletVersion('v3r1').isValid).toBe(true); + expect(validateWalletVersion('v4r2').isValid).toBe(true); + expect(validateWalletVersion('v5r1').isValid).toBe(true); + }); + + it('should reject invalid versions', () => { + expect(validateWalletVersion('v1r1').isValid).toBe(false); + expect(validateWalletVersion('invalid').isValid).toBe(false); + expect(validateWalletVersion('').isValid).toBe(false); + }); + }); + + describe('validateWalletMethods', () => { + it('should pass for valid wallet', async () => { + const mockWallet = { + getAddress: vi.fn().mockResolvedValue('valid-address'), + getBalance: vi.fn().mockResolvedValue('100'), + getStateInit: vi.fn().mockResolvedValue('state-init'), + } as unknown as Wallet; + + const result = await validateWalletMethods(mockWallet); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should fail if methods throw', async () => { + const mockWallet = { + getAddress: vi.fn().mockRejectedValue(new Error('fail')), + getBalance: vi.fn().mockResolvedValue('100'), + } as unknown as Wallet; + + const result = await validateWalletMethods(mockWallet); + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('getAddress() failed')); + }); + }); + + describe('validateWallet', () => { + it('should return valid result (placeholder)', () => { + const result = validateWallet({} as Wallet); + expect(result.isValid).toBe(true); + }); + }); +}); From bdb0109e68b8fe2f59309aa024207155dbfd5888 Mon Sep 17 00:00:00 2001 From: Ilyar Date: Tue, 3 Feb 2026 13:46:00 +0100 Subject: [PATCH 06/15] feat(staking): refactor unstaking logic and introduce new staking example --- demo/examples/staking.ts | 95 +------------- packages/walletkit/examples/ton-staking.ts | 123 ++++++++++++++++++ .../src/defi/staking/StakingManager.ts | 27 +--- packages/walletkit/src/defi/staking/index.ts | 3 +- .../defi/staking/tonstakers/PoolContract.ts | 15 ++- .../tonstakers/TonStakersContract.spec.ts | 2 +- .../TonStakersStakingProvider.spec.ts | 5 +- .../src/defi/staking/tonstakers/constants.ts | 7 - packages/walletkit/src/index.ts | 3 +- 9 files changed, 156 insertions(+), 124 deletions(-) create mode 100644 packages/walletkit/examples/ton-staking.ts diff --git a/demo/examples/staking.ts b/demo/examples/staking.ts index 374f118d7..84074806d 100644 --- a/demo/examples/staking.ts +++ b/demo/examples/staking.ts @@ -17,6 +17,7 @@ import { createDeviceInfo, createWalletManifest, ParseStack, + UnstakeMode, } from '@ton/walletkit'; import type { Wallet, ApiClient } from '@ton/walletkit'; import { Address, beginCell, toNano, fromNano } from '@ton/core'; @@ -57,7 +58,6 @@ const balanceStaked = document.getElementById('balance-staked')!; const poolApy = document.getElementById('pool-apy')!; const poolTvl = document.getElementById('pool-tvl')!; const poolStakers = document.getElementById('pool-stakers')!; -const withdrawalsList = document.getElementById('withdrawals-list')!; const roundsInfo = document.getElementById('rounds-info')!; // State @@ -657,18 +657,18 @@ async function handleUnstake(amount: bigint, mode: 'instant' | 'delayed', waitTi try { const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - let unstakeMode: 'instant' | 'delayed' | 'bestRate'; + let unstakeMode: UnstakeMode; if (mode === 'instant') { - unstakeMode = 'instant'; + unstakeMode = UnstakeMode.Instant; } else if (waitTillRoundEnd) { - unstakeMode = 'bestRate'; + unstakeMode = UnstakeMode.BestRate; } else { - unstakeMode = 'delayed'; + unstakeMode = UnstakeMode.Delayed; } showTxStatus('pending', 'Building unstake transaction...', `Mode: ${unstakeMode}`); - const txRequest = await stakingManager.unstake({ + const request = await stakingManager.unstake({ amount: amount.toString(), userAddress: wallet.getAddress(), network, @@ -677,15 +677,7 @@ async function handleUnstake(amount: bigint, mode: 'instant' | 'delayed', waitTi showTxStatus('pending', 'Sending transaction...', 'Please wait...'); - if (txRequest.messages && txRequest.messages.length > 0) { - const msg = txRequest.messages[0]; - const transaction = await wallet.createTransferTonTransaction({ - recipientAddress: msg.address, - transferAmount: msg.amount, - payload: msg.payload, - }); - await wallet.sendTransaction(transaction); - } + await wallet.sendTransaction(request); showTxStatus('success', 'Unstaking transaction sent!', `Unstaked ${fromNano(amount)} tsTON`); setTimeout(async () => { @@ -814,79 +806,6 @@ async function handleGetRounds() { } } -async function _handleGetWithdrawals() { - if (!wallet) { - showError(stakingError, 'Wallet not initialized'); - return; - } - - try { - // Fetch withdrawal payouts - const response = await fetch('https://api.tonstakers.com/api/v1/pool/withdrawal_payout'); - const data = await response.json(); - const activeCollections = data.data?.active_collections || []; - - if (activeCollections.length === 0) { - withdrawalsList.innerHTML = '

No active withdrawals

'; - return; - } - - const userAddress = wallet.getAddress(); - let hasWithdrawals = false; - - withdrawalsList.innerHTML = ''; - - for (const collection of activeCollections) { - try { - if (!kit) continue; - - const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - const apiClient = kit.getApiClient(network); - - // Use apiClient to get NFT items by owner - const nftData = await apiClient.nftItemsByOwner({ - ownerAddress: userAddress, - collectionAddress: collection.withdrawal_payout, - limit: 100, - offset: 0, - }); - - interface NftItem { - owner?: { address?: string }; - metadata?: { name?: string }; - } - - const userNfts = (nftData.nft_items as NftItem[] | undefined) || []; - - for (const nft of userNfts) { - hasWithdrawals = true; - const withdrawalDiv = document.createElement('div'); - withdrawalDiv.className = 'withdrawal-item'; - const tsTONAmount = nft.metadata?.name?.match(/[\d.]+/)?.[0] || '0'; - withdrawalDiv.innerHTML = ` - Withdrawal ${tsTONAmount} tsTON
- Collection: ${collection.withdrawal_payout}
- Round ends: ${new Date(collection.cycle_end * 1000).toLocaleString()} - `; - withdrawalsList.appendChild(withdrawalDiv); - } - } catch (error) { - console.warn('Error fetching withdrawal NFTs:', error); - // Error handled silently - withdrawal will be skipped - } - } - - if (!hasWithdrawals) { - withdrawalsList.innerHTML = '

No active withdrawals

'; - } - } catch (error) { - showError( - stakingError, - `Error getting withdrawals list: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); diff --git a/packages/walletkit/examples/ton-staking.ts b/packages/walletkit/examples/ton-staking.ts new file mode 100644 index 000000000..46c28590d --- /dev/null +++ b/packages/walletkit/examples/ton-staking.ts @@ -0,0 +1,123 @@ +/** + * 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. + * + */ + +// npx tsx examples/ton-staking-local.ts +import 'dotenv/config'; + +import { fromNano, toNano } from '@ton/core'; + +import { CONTRACT, EventEmitter, NetworkManager, StakingManager, TonStakersStakingProvider, UnstakeMode } from '../src'; +import type { TransactionRequest } from '../src'; +import type { WalletSigner, NetworkAdapters } from '../src'; +import { ApiClientToncenter, Network, Signer, WalletV5R1Adapter, wrapWalletInterface } from '../src'; +import { PoolContract } from '../src'; +import { globalLogger, LogLevel } from '../src/core/Logger'; + +globalLogger.configure({ level: LogLevel.NONE }); + +// eslint-disable-next-line no-console +const logInfo = console.log; +// eslint-disable-next-line no-console +const logError = console.error; + +const mnemonic = process.env[`WALLET_MNEMONIC`]!.trim().split(' '); + +async function testEnvironment(signer: WalletSigner, network: Network) { + const testnet = Network.testnet().chainId == network.chainId; + const client = new ApiClientToncenter({ + apiKey: testnet ? process.env[`API_KEY_TESTNET`]! : process.env[`API_KEY_MAINNET`]!, + network, + }); + const wallet = await wrapWalletInterface( + await WalletV5R1Adapter.create(signer, { client, network: Network.mainnet() }), + ); + const userAddress = wallet.getAddress({ testnet }); + return { client, wallet, userAddress, testnet }; +} + +async function testPool(signer: WalletSigner, network: Network) { + const { client, userAddress, testnet } = await testEnvironment(signer, network); + const poolContract = testnet ? CONTRACT.STAKING_CONTRACT_ADDRESS_TESTNET : CONTRACT.STAKING_CONTRACT_ADDRESS; + const pool = new PoolContract(poolContract, client); + logInfo(await pool.getCodeVersion()); + const { supply, jettonMinter, minLoan } = await pool.getPoolFullData(); + const stakedBalance = await pool.getStakedBalance(userAddress); + logInfo({ + supply: fromNano(supply), + jettonMinter, + minLoan: fromNano(minLoan), + stakedBalance: fromNano(stakedBalance), + }); +} + +async function testStakingManager(signer: WalletSigner, network: Network) { + const { client, wallet, userAddress } = await testEnvironment(signer, network); + const stakingManager = new StakingManager(); + const stakingProvider = new TonStakersStakingProvider( + new NetworkManager({ networks: { [network.chainId]: client } as NetworkAdapters }), + new EventEmitter(), + {}, + ); + stakingManager.registerProvider('tonstakers', stakingProvider); + stakingManager.setDefaultProvider('tonstakers'); + let balance = await stakingManager.getBalance(userAddress, network); + logInfo({ + balance, + }); + let request: TransactionRequest | undefined; + if (balance.stakedBalance != '0') { + request = await stakingManager.unstake({ + userAddress, + amount: balance.stakedBalance, + network, + unstakeMode: UnstakeMode.Instant, + }); + } else { + request = await stakingManager.stake({ + userAddress, + amount: toNano(1).toString(), + network, + }); + } + if (request) { + logInfo({ + messages: request.messages, + }); + const preview = await wallet.getTransactionPreview(request); + if (preview.error) { + logInfo({ preview }); + } else { + const transaction = await wallet.sendTransaction(request); + logInfo({ transaction }); + } + balance = await stakingManager.getBalance(userAddress, network); + logInfo({ + balance, + }); + } +} + +async function main() { + const signer = await Signer.fromMnemonic(mnemonic); + + await testPool(signer, Network.mainnet()); + await testPool(signer, Network.testnet()); + + await testStakingManager(signer, Network.mainnet()); + await testStakingManager(signer, Network.testnet()); +} + +main().catch((error) => { + if (error instanceof Error) { + logError(error.message); + logError(error.stack); + } else { + logError('Unknown error:', error); + } + process.exit(1); +}); diff --git a/packages/walletkit/src/defi/staking/StakingManager.ts b/packages/walletkit/src/defi/staking/StakingManager.ts index be8e3ae31..57303fe0f 100644 --- a/packages/walletkit/src/defi/staking/StakingManager.ts +++ b/packages/walletkit/src/defi/staking/StakingManager.ts @@ -36,19 +36,10 @@ export class StakingManager extends DefiManager implem * @param provider - Optional provider name to use */ async getQuote(params: StakingQuoteParams, provider?: string): Promise { - log.debug('Getting staking quote', { - direction: params.direction, - amount: params.amount, - provider: provider || this.defaultProvider, - }); - + log.debug('Getting staking quote', params); try { const quote = await this.getProvider(provider).getQuote(params); - log.debug('Received staking quote', { - direction: quote.direction, - amountIn: quote.amountIn, - amountOut: quote.amountOut, - }); + log.debug('Received staking quote', quote); return quote; } catch (error) { throw this.createError('Failed to get staking quote', StakingErrorCode.InvalidParams, { error, params }); @@ -61,12 +52,7 @@ export class StakingManager extends DefiManager implem * @param provider - Optional provider name to use */ async stake(params: StakeParams, provider?: string): Promise { - log.debug('Building staking transaction', { - userAddress: params.userAddress, - amount: params.amount, - provider: provider || this.defaultProvider, - }); - + log.debug('Building staking transaction', params); try { return await this.getProvider(provider).stake(params); } catch (error) { @@ -83,12 +69,7 @@ export class StakingManager extends DefiManager implem * @param provider - Optional provider name to use */ async unstake(params: UnstakeParams, provider?: string): Promise { - log.debug('Building unstaking transaction', { - userAddress: params.userAddress, - amount: params.amount, - provider: provider || this.defaultProvider, - }); - + log.debug('Building unstaking transaction', params); try { return await this.getProvider(provider).unstake(params); } catch (error) { diff --git a/packages/walletkit/src/defi/staking/index.ts b/packages/walletkit/src/defi/staking/index.ts index 8f90a2274..71879a326 100644 --- a/packages/walletkit/src/defi/staking/index.ts +++ b/packages/walletkit/src/defi/staking/index.ts @@ -10,9 +10,10 @@ export { StakingProvider } from './StakingProvider'; export { StakingManager } from './StakingManager'; export { StakingError } from './errors'; export type { StakingErrorCode } from './errors'; +export { StakingQuoteDirection, UnstakeMode } from './types'; export type * from './types'; export { TonStakersStakingProvider } from './tonstakers/TonStakersStakingProvider'; export { PoolContract } from './tonstakers/PoolContract'; export { StakingCache } from './tonstakers/StakingCache'; export type { TonStakersProviderConfig, PoolFullData, TonStakersPoolInfo } from './tonstakers/types'; -export { CONTRACT, BLOCKCHAIN, TIMING } from './tonstakers/constants'; +export { CONTRACT, TIMING } from './tonstakers/constants'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts b/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts index baf565c43..ae5d089f8 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts @@ -287,12 +287,25 @@ export class PoolContract { return stack.readBigNumber().toString(); } + async getControllerAddress(id: number, validator: UserFriendlyAddress) { + const data = await this.client.runGetMethod( + this.address, + 'get_controller_address', + SerializeStack([ + { type: 'int', value: BigInt(id) }, + { type: 'slice', cell: beginCell().storeAddress(Address.parse(validator)).endCell() }, + ]), + ); + const stack = ReaderStack(data.stack); + return stack.readAddress(); + } + /** * Build stake message payload. * * TL‑B: deposit#47d54391 query_id:uint64 = InternalMsgBody; */ - buildStakePayload(queryId: bigint = 1n): Base64String { + buildStakePayload(queryId: bigint = 0n): Base64String { const cell = beginCell() .storeUint(CONTRACT.PAYLOAD_STAKE, 32) .storeUint(queryId, 64) diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts index 2e21ba091..8d598bff1 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts @@ -45,7 +45,7 @@ describe('TonStakersContract', () => { const slice = cell.beginParse(); slice.loadUint(32); - expect(slice.loadUintBig(64)).toBe(1n); + expect(slice.loadUintBig(64)).toBe(0n); }); }); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts index 0f9e99f20..26b04c63a 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts @@ -14,6 +14,7 @@ import { PoolContract } from './PoolContract'; import { StakingQuoteDirection, UnstakeMode } from '../types'; import { CONTRACT } from './constants'; import { Network } from '../../../api/models'; +import type { Base64String } from '../../../api/models'; const mockApiClient = { runGetMethod: vi.fn(), @@ -44,12 +45,12 @@ describe('TonStakersStakingProvider', () => { buildStakePayloadSpy = vi .spyOn(PoolContract.prototype, 'buildStakePayload') - .mockReturnValue('mock-stake-payload'); + .mockReturnValue('mock-stake-payload' as Base64String); buildUnstakeMessageSpy = vi.spyOn(PoolContract.prototype, 'buildUnstakeMessage').mockResolvedValue({ address: 'EQMockJettonWallet', amount: CONTRACT.UNSTAKE_FEE_RES.toString(), - payload: 'mock-unstake-payload', + payload: 'mock-unstake-payload' as Base64String, }); getPoolDataSpy = vi.spyOn(PoolContract.prototype, 'getPoolData').mockResolvedValue({ diff --git a/packages/walletkit/src/defi/staking/tonstakers/constants.ts b/packages/walletkit/src/defi/staking/tonstakers/constants.ts index 0dca051d8..f09a7b54a 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/constants.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/constants.ts @@ -33,10 +33,3 @@ export const CONTRACT = { UNSTAKE_FEE_RES: toNano('1.05'), RECOMMENDED_FEE_RESERVE: toNano('1.1'), }; - -// Blockchain identifiers exposed as part of the public staking API. -// Currently not used internally but kept for library consumers. -export const BLOCKCHAIN = { - MAINNET: 'mainnet', - TESTNET: 'testnet', -} as const; diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 077d2074b..e44132be8 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -28,8 +28,9 @@ export { PoolContract, StakingCache, CONTRACT, - BLOCKCHAIN, TIMING, + UnstakeMode, + StakingQuoteDirection, } from './defi/staking'; export type * from './defi/staking/types'; export type { TonStakersProviderConfig, PoolFullData, TonStakersPoolInfo } from './defi/staking/tonstakers/types'; From 49b1a6698407ade9820a829218263bc6440a4cd4 Mon Sep 17 00:00:00 2001 From: "V. K." Date: Wed, 11 Mar 2026 17:35:55 +0400 Subject: [PATCH 07/15] feat(staking): rename methods --- .../src/defi/staking/StakingManager.ts | 12 ++-- .../src/defi/staking/StakingProvider.ts | 26 ++------ .../TonStakersStakingProvider.spec.ts | 12 ++-- .../tonstakers/TonStakersStakingProvider.ts | 64 +++++++++---------- packages/walletkit/src/defi/staking/types.ts | 12 ++-- 5 files changed, 56 insertions(+), 70 deletions(-) diff --git a/packages/walletkit/src/defi/staking/StakingManager.ts b/packages/walletkit/src/defi/staking/StakingManager.ts index 2bd10f186..69eba1ef1 100644 --- a/packages/walletkit/src/defi/staking/StakingManager.ts +++ b/packages/walletkit/src/defi/staking/StakingManager.ts @@ -12,7 +12,7 @@ import type { StakeParams, UnstakeParams, StakingBalance, - StakingInfo, + StakingProviderInfo, StakingProviderInterface, StakingQuoteParams, StakingQuote, @@ -54,7 +54,7 @@ export class StakingManager extends DefiManager implem async stake(params: StakeParams, providerId?: string): Promise { log.debug('Building staking transaction', params); try { - return await this.getProvider(providerId).stake(params); + return await this.getProvider(providerId).buildStakeTransaction(params); } catch (error) { throw this.createError('Failed to build staking transaction', StakingErrorCode.InvalidParams, { error, @@ -71,7 +71,7 @@ export class StakingManager extends DefiManager implem async unstake(params: UnstakeParams, providerId?: string): Promise { log.debug('Building unstaking transaction', params); try { - return await this.getProvider(providerId).unstake(params); + return await this.getProvider(providerId).buildUnstakeTransaction(params); } catch (error) { throw this.createError('Failed to build unstaking transaction', StakingErrorCode.InvalidParams, { error, @@ -98,7 +98,7 @@ export class StakingManager extends DefiManager implem }); try { - return await this.getProvider(providerId).getBalance(userAddress, network); + return await this.getProvider(providerId).getStakedBalance(userAddress, network); } catch (error) { throw this.createError('Failed to get staking balance', StakingErrorCode.InvalidParams, { error, @@ -113,14 +113,14 @@ export class StakingManager extends DefiManager implem * @param network - Network to query * @param providerId - Optional provider id to use */ - async getStakingInfo(network?: Network, providerId?: string): Promise { + async getStakingProviderInfo(network?: Network, providerId?: string): Promise { log.debug('Getting staking info', { network, provider: providerId || this.defaultProviderId, }); try { - return await this.getProvider(providerId).getStakingInfo(network); + return await this.getProvider(providerId).getStakingProviderInfo(network); } catch (error) { throw this.createError('Failed to get staking info', StakingErrorCode.InvalidParams, { error, network }); } diff --git a/packages/walletkit/src/defi/staking/StakingProvider.ts b/packages/walletkit/src/defi/staking/StakingProvider.ts index 6b2a374fa..0f747c569 100644 --- a/packages/walletkit/src/defi/staking/StakingProvider.ts +++ b/packages/walletkit/src/defi/staking/StakingProvider.ts @@ -9,12 +9,11 @@ import type { ApiClient } from '../../types/toncenter/ApiClient'; import type { Network, TransactionRequest, UserFriendlyAddress } from '../../api/models'; import type { NetworkManager } from '../../core/NetworkManager'; -import type { EventEmitter } from '../../core/EventEmitter'; import type { StakeParams, UnstakeParams, StakingBalance, - StakingInfo, + StakingProviderInfo, StakingProviderInterface, StakingQuoteParams, StakingQuote, @@ -31,12 +30,10 @@ export abstract class StakingProvider implements StakingProviderInterface { readonly providerId: string; protected networkManager: NetworkManager; - protected eventEmitter: EventEmitter; - constructor(providerId: string, networkManager: NetworkManager, eventEmitter: EventEmitter) { + constructor(providerId: string, networkManager: NetworkManager) { this.providerId = providerId; this.networkManager = networkManager; - this.eventEmitter = eventEmitter; } /** @@ -50,27 +47,27 @@ export abstract class StakingProvider implements StakingProviderInterface { * @param params - Staking parameters including amount and user address * @returns Promise resolving to transaction request ready to be signed */ - abstract stake(params: StakeParams): Promise; + abstract buildStakeTransaction(params: StakeParams): Promise; /** * Build a transaction for unstaking * @param params - Unstaking parameters including amount and user address * @returns Promise resolving to transaction request ready to be signed */ - abstract unstake(params: UnstakeParams): Promise; + abstract buildUnstakeTransaction(params: UnstakeParams): Promise; /** - * Get staking balance for a user + * Get staked balance for a user * @param userAddress - User address to fetch balance for * @param network - Optional network to use for balance query */ - abstract getBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; + abstract getStakedBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; /** * Get staking information for a network * @param network - Optional network to fetch info for */ - abstract getStakingInfo(network?: Network): Promise; + abstract getStakingProviderInfo(network?: Network): Promise; /** * Get API client for a specific network @@ -80,13 +77,4 @@ export abstract class StakingProvider implements StakingProviderInterface { protected getApiClient(network: Network): ApiClient { return this.networkManager.getClient(network); } - - /** - * Emit an event through the event emitter - * @param event - Event name - * @param data - Event data - */ - protected emitEvent(event: string, data: unknown): void { - this.eventEmitter.emit(event, data); - } } diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts index 26b04c63a..1b6606250 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts @@ -118,7 +118,7 @@ describe('TonStakersStakingProvider', () => { describe('stake', () => { it('should build correct transaction with stake payload', async () => { const amount = '1000000000'; - const tx = await provider.stake({ + const tx = await provider.buildStakeTransaction({ amount, userAddress: testUserAddress, network: Network.mainnet(), @@ -141,7 +141,7 @@ describe('TonStakersStakingProvider', () => { describe('unstake', () => { it('should build correct transaction for Delayed mode', async () => { - const tx = await provider.unstake({ + const tx = await provider.buildUnstakeTransaction({ amount: '1000000000', userAddress: testUserAddress, network: Network.mainnet(), @@ -162,7 +162,7 @@ describe('TonStakersStakingProvider', () => { }); it('should build correct transaction for Instant mode', async () => { - await provider.unstake({ + await provider.buildUnstakeTransaction({ amount: '1000000000', userAddress: testUserAddress, network: Network.mainnet(), @@ -178,7 +178,7 @@ describe('TonStakersStakingProvider', () => { }); it('should build correct transaction for BestRate mode', async () => { - await provider.unstake({ + await provider.buildUnstakeTransaction({ amount: '1000000000', userAddress: testUserAddress, network: Network.mainnet(), @@ -194,7 +194,7 @@ describe('TonStakersStakingProvider', () => { }); it('should default to Delayed mode when unstakeMode not specified', async () => { - await provider.unstake({ + await provider.buildUnstakeTransaction({ amount: '1000000000', userAddress: testUserAddress, network: Network.mainnet(), @@ -215,7 +215,7 @@ describe('TonStakersStakingProvider', () => { { mode: UnstakeMode.Instant, waitTillRoundEnd: false, fillOrKill: true }, { mode: UnstakeMode.BestRate, waitTillRoundEnd: true, fillOrKill: false }, ])('should set correct flags for $mode mode', async ({ mode, waitTillRoundEnd, fillOrKill }) => { - await provider.unstake({ + await provider.buildUnstakeTransaction({ amount: '1000000000', userAddress: testUserAddress, network: Network.mainnet(), diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts index 066644536..7ead61008 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -10,13 +10,12 @@ import type { TransactionRequest, UserFriendlyAddress } from '../../../api/model import { Network } from '../../../api/models'; import { globalLogger } from '../../../core/Logger'; import type { NetworkManager } from '../../../core/NetworkManager'; -import type { EventEmitter } from '../../../core/EventEmitter'; import { StakingProvider } from '../StakingProvider'; import type { StakeParams, UnstakeParams, StakingBalance, - StakingInfo, + StakingProviderInfo, StakingQuoteParams, StakingQuote, RoundInfo, @@ -52,7 +51,7 @@ const log = globalLogger.createChild('TonStakersStakingProvider'); * stakingManager.registerProvider('tonstakers', provider); * * // Get staking info with APY - * const info = await stakingManager.getStakingInfo(Network.mainnet()); + * const info = await stakingManager.getStakingProviderInfo(Network.mainnet()); * * // Stake TON * const stakeTx = await stakingManager.stake({ @@ -78,11 +77,10 @@ export class TonStakersStakingProvider extends StakingProvider { * Create a new TonStakersStakingProvider instance. * * @param networkManager - Network manager for API client access - * @param eventEmitter - Event emitter for staking events * @param config - Optional configuration with custom contract addresses per network */ - constructor(networkManager: NetworkManager, eventEmitter: EventEmitter, config: TonStakersProviderConfig = {}) { - super('tonstakers', networkManager, eventEmitter); + constructor(networkManager: NetworkManager, config: TonStakersProviderConfig = {}) { + super('tonstakers', networkManager); this.config = { [Network.mainnet().chainId]: { contractAddress: CONTRACT.STAKING_CONTRACT_ADDRESS, @@ -96,28 +94,6 @@ export class TonStakersStakingProvider extends StakingProvider { log.info('TonStakersStakingProvider initialized'); } - private getStakingContractAddress(network?: Network): string { - const targetNetwork = network ?? Network.mainnet(); - const networkConfig = this.config[targetNetwork.chainId]; - - if (!networkConfig || !networkConfig.contractAddress) { - throw new StakingError( - 'Staking contract address is not configured for the selected network', - StakingErrorCode.InvalidParams, - { network: targetNetwork }, - ); - } - - return networkConfig.contractAddress; - } - - private getContract(network?: Network): PoolContract { - const targetNetwork = network ?? Network.mainnet(); - const apiClient = this.getApiClient(targetNetwork); - const contractAddress = this.getStakingContractAddress(targetNetwork); - return new PoolContract(contractAddress, apiClient); - } - /** * Get a quote for staking or unstaking operations. * @@ -131,7 +107,7 @@ export class TonStakersStakingProvider extends StakingProvider { userAddress: params.userAddress, }); - const stakingInfo = await this.getStakingInfo(params.network); + const stakingInfo = await this.getStakingProviderInfo(params.network); if (params.direction === StakingQuoteDirection.Stake) { return { @@ -163,7 +139,7 @@ export class TonStakersStakingProvider extends StakingProvider { * @param params - Stake parameters including amount and user address * @returns Transaction request ready to be signed and sent */ - async stake(params: StakeParams): Promise { + async buildStakeTransaction(params: StakeParams): Promise { log.debug('TonStakers stake requested', { params }); const network = params.network; @@ -198,7 +174,7 @@ export class TonStakersStakingProvider extends StakingProvider { * @param params - Unstake parameters including amount, user address, and optional mode * @returns Transaction request ready to be signed and sent */ - async unstake(params: UnstakeParams): Promise { + async buildUnstakeTransaction(params: UnstakeParams): Promise { log.debug('TonStakers unstake requested', { amount: params.amount, userAddress: params.userAddress }); const network = params.network; @@ -251,7 +227,7 @@ export class TonStakersStakingProvider extends StakingProvider { * @param network - Network to query (defaults to mainnet) * @returns Balance information including staked and available amounts */ - async getBalance(userAddress: UserFriendlyAddress, network?: Network): Promise { + async getStakedBalance(userAddress: UserFriendlyAddress, network?: Network): Promise { log.debug('TonStakers balance requested', { userAddress, network }); try { @@ -307,7 +283,7 @@ export class TonStakersStakingProvider extends StakingProvider { * @param network - Network to query (defaults to mainnet) * @returns Staking info with APY and available instant liquidity */ - async getStakingInfo(network?: Network): Promise { + async getStakingProviderInfo(network?: Network): Promise { log.debug('TonStakers info requested', { network }); const targetNetwork = network ?? Network.mainnet(); @@ -402,4 +378,26 @@ export class TonStakersStakingProvider extends StakingProvider { clearCache(): void { this.cache.clear(); } + + private getStakingContractAddress(network?: Network): string { + const targetNetwork = network ?? Network.mainnet(); + const networkConfig = this.config[targetNetwork.chainId]; + + if (!networkConfig || !networkConfig.contractAddress) { + throw new StakingError( + 'Staking contract address is not configured for the selected network', + StakingErrorCode.InvalidParams, + { network: targetNetwork }, + ); + } + + return networkConfig.contractAddress; + } + + private getContract(network?: Network): PoolContract { + const targetNetwork = network ?? Network.mainnet(); + const apiClient = this.getApiClient(targetNetwork); + const contractAddress = this.getStakingContractAddress(targetNetwork); + return new PoolContract(contractAddress, apiClient); + } } diff --git a/packages/walletkit/src/defi/staking/types.ts b/packages/walletkit/src/defi/staking/types.ts index 2ea487381..45ef271b9 100644 --- a/packages/walletkit/src/defi/staking/types.ts +++ b/packages/walletkit/src/defi/staking/types.ts @@ -97,7 +97,7 @@ export interface StakingBalance { /** * Staking information for a provider */ -export interface StakingInfo { +export interface StakingProviderInfo { apy: number; instantUnstakeAvailable?: TokenAmount; providerId: string; @@ -111,7 +111,7 @@ export interface StakingAPI { stake(params: StakeParams, providerId?: string): Promise; unstake(params: UnstakeParams, providerId?: string): Promise; getBalance(userAddress: UserFriendlyAddress, network?: Network, providerId?: string): Promise; - getStakingInfo(network?: Network, providerId?: string): Promise; + getStakingProviderInfo(network?: Network, providerId?: string): Promise; } /** @@ -119,10 +119,10 @@ export interface StakingAPI { */ export interface StakingProviderInterface extends DefiProvider { getQuote(params: StakingQuoteParams): Promise; - stake(params: StakeParams): Promise; - unstake(params: UnstakeParams): Promise; - getBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; - getStakingInfo(network?: Network): Promise; + buildStakeTransaction(params: StakeParams): Promise; + buildUnstakeTransaction(params: UnstakeParams): Promise; + getStakedBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; + getStakingProviderInfo(network?: Network): Promise; } /** From da22ebac787d6838396115e5984d05b0c2a34e22 Mon Sep 17 00:00:00 2001 From: "V. K." Date: Thu, 12 Mar 2026 11:05:45 +0400 Subject: [PATCH 08/15] feat(staking): delete unused code --- .../walletkit/src/clients/BaseApiClient.ts | 40 +-- packages/walletkit/src/defi/staking/index.ts | 3 +- .../defi/staking/tonstakers/PoolContract.ts | 309 ++---------------- .../defi/staking/tonstakers/StakingCache.ts | 4 +- .../tonstakers/TonStakersContract.spec.ts | 26 +- .../TonStakersStakingProvider.spec.ts | 43 ++- .../tonstakers/TonStakersStakingProvider.ts | 134 +++----- .../src/defi/staking/tonstakers/constants.ts | 12 +- .../src/defi/staking/tonstakers/types.ts | 16 +- packages/walletkit/src/defi/staking/types.ts | 23 -- packages/walletkit/src/index.ts | 4 +- 11 files changed, 136 insertions(+), 478 deletions(-) diff --git a/packages/walletkit/src/clients/BaseApiClient.ts b/packages/walletkit/src/clients/BaseApiClient.ts index 20f7a0785..f9966ed92 100644 --- a/packages/walletkit/src/clients/BaseApiClient.ts +++ b/packages/walletkit/src/clients/BaseApiClient.ts @@ -37,24 +37,7 @@ export abstract class BaseApiClient { protected abstract appendAuthHeaders(headers: Headers): void; - private async doRequest(url: URL, init: globalThis.RequestInit = {}): Promise { - const fetchFn = this.fetchApi; - - if (!this.timeout || this.timeout <= 0) { - return fetchFn(url, init); - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); - - try { - return await fetchFn(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeoutId); - } - } - - protected async fetch(url: URL, props: globalThis.RequestInit = {}): Promise { + async fetch(url: URL, props: globalThis.RequestInit = {}): Promise { const headers = new Headers(props.headers); headers.set('accept', 'application/json'); this.appendAuthHeaders(headers); @@ -72,11 +55,11 @@ export abstract class BaseApiClient { return json as Promise; } - protected async getJson(path: string, query?: Record): Promise { + async getJson(path: string, query?: Record): Promise { return this.fetch(this.buildUrl(path, query), { method: 'GET' }); } - protected async postJson(path: string, props: unknown): Promise { + async postJson(path: string, props: unknown): Promise { return this.fetch(this.buildUrl(path), { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -113,4 +96,21 @@ export abstract class BaseApiClient { } return new TonClientError(`HTTP ${response.status}: ${message}`, code, detail); } + + private async doRequest(url: URL, init: globalThis.RequestInit = {}): Promise { + const fetchFn = this.fetchApi; + + if (!this.timeout || this.timeout <= 0) { + return fetchFn(url, init); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + return await fetchFn(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + } } diff --git a/packages/walletkit/src/defi/staking/index.ts b/packages/walletkit/src/defi/staking/index.ts index 71879a326..6b49b1f6a 100644 --- a/packages/walletkit/src/defi/staking/index.ts +++ b/packages/walletkit/src/defi/staking/index.ts @@ -15,5 +15,4 @@ export type * from './types'; export { TonStakersStakingProvider } from './tonstakers/TonStakersStakingProvider'; export { PoolContract } from './tonstakers/PoolContract'; export { StakingCache } from './tonstakers/StakingCache'; -export type { TonStakersProviderConfig, PoolFullData, TonStakersPoolInfo } from './tonstakers/types'; -export { CONTRACT, TIMING } from './tonstakers/constants'; +export type { TonStakersProviderConfig } from './tonstakers/types'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts b/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts index ae5d089f8..8979efb43 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/PoolContract.ts @@ -6,126 +6,13 @@ * */ -import type { Cell, DictionaryValue } from '@ton/core'; -import { Address, beginCell, Dictionary } from '@ton/core'; +import { Address, beginCell } from '@ton/core'; -import type { - Base64String, - Hex, - TokenAmount, - TransactionRequestMessage, - UserFriendlyAddress, -} from '../../../api/models'; +import type { Base64String, TokenAmount, TransactionRequestMessage, UserFriendlyAddress } from '../../../api/models'; import type { ApiClient } from '../../../types/toncenter/ApiClient'; -import { CONTRACT, TIMING } from './constants'; -import { asAddressFriendly, asHex, asMaybeAddressFriendly, ReaderStack, SerializeStack } from '../../../utils'; -import type { RoundInfo } from '../types'; - -const SHARE_BASIS = 2 ** 24; // 24 bit - -export enum PoolState { - Normal = 0, - RepaymentOnly = 1, -} - -export type BorrowerDescription = { - borrowed: TokenAmount; - accountedInterest: TokenAmount; -}; - -export const BorrowerDescriptionValue: DictionaryValue = { - serialize: (src, builder) => { - builder.storeCoins(BigInt(src.borrowed)); - builder.storeCoins(BigInt(src.accountedInterest)); - }, - parse: (src) => { - return { - borrowed: src.loadCoins().toString(), - accountedInterest: src.loadCoins().toString(), - }; - }, -}; - -export interface PoolRoundData { - borrowers: Dictionary; - roundId: number; - activeBorrowers: bigint; - borrowed: bigint; - expected: bigint; - returned: bigint; - profit: bigint; -} - -export type UpdateAfter = Date | 'completed'; - -function asUpdateAfter(value: number): UpdateAfter { - const COMPLETED_FLAG = 0xffffffffffff; // 281474976710655 - if (value === COMPLETED_FLAG) { - return 'completed'; - } - return new Date(value * 1000); -} - -export interface PoolFullData { - state: PoolState; - halted: boolean; - totalBalance: TokenAmount; - interestRatePercent: number; - optimisticDepositWithdrawals: boolean; - depositsOpen: boolean; - savedValidatorSetHash: Hex; - - prevRound: PoolRoundData; - currentRound: PoolRoundData; - minLoan: TokenAmount; - maxLoan: TokenAmount; - governanceFeePercent: number; - - jettonMinter: UserFriendlyAddress; - supply: TokenAmount; - - depositPayout: UserFriendlyAddress | null; - requestedForDeposit: TokenAmount; - withdrawalPayout: UserFriendlyAddress | null; - requestedForWithdrawal: TokenAmount; - - sudoer: UserFriendlyAddress; - sudoerSetAt: Date; - governor: UserFriendlyAddress; - governorUpdateAfter: UpdateAfter; - interestManager: UserFriendlyAddress; - halter: UserFriendlyAddress; - approver: UserFriendlyAddress; - - controllerCode: Cell; - poolJettonWalletCode: Cell; - payoutMinterCode: Cell; - - projectedTotalBalance: TokenAmount; - projectedPoolSupply: TokenAmount; -} - -export interface PoolSimpleData { - state: PoolState; - halted: boolean; - totalBalance: TokenAmount; - supply: TokenAmount; - interestRatePercent: number; -} - -function parseBorrowers(data: Cell | null, workChain = 0): Dictionary { - const list = Dictionary.empty(Dictionary.Keys.Address(), BorrowerDescriptionValue); - if (data) { - const raw = Dictionary.loadDirect(Dictionary.Keys.BigUint(256), BorrowerDescriptionValue, data.asSlice()); - for (const hash of raw.keys()) { - list.set( - Address.parse(`${workChain}:${hash.toString(16).padStart(64, '0')}`), - raw.get(hash) as BorrowerDescription, - ); - } - } - return list; -} +import { CONTRACT } from './constants'; +import { asAddressFriendly, ReaderStack, SerializeStack } from '../../../utils'; +import { formatUnits } from '../../../utils/units'; export class PoolContract { readonly address: UserFriendlyAddress; @@ -137,140 +24,21 @@ export class PoolContract { this.client = client; } - /** - * Get contract code version (git commit hash). - * - * Returns the git commit hash from the liquid staking contract repository: - * https://github.com/ton-blockchain/liquid-staking-contract - */ - async getCodeVersion(): Promise { - const data = await this.client.runGetMethod(this.address, 'get_code_version'); - const stack = ReaderStack(data.stack); - const version = stack.readBigNumber().toString(16).padStart(40, '0'); - return `https://github.com/ton-blockchain/liquid-staking-contract/tree/${version}`; - } - - async getPoolFullData(): Promise { + async getJettonMinter(): Promise { const data = await this.client.runGetMethod(this.address, 'get_pool_full_data'); const stack = ReaderStack(data.stack); - const state = stack.readNumber() as PoolState; - const halted = stack.readBoolean(); - const totalBalance = stack.readBigNumber().toString(); - const interestRatePercent = (stack.readNumber() / SHARE_BASIS) * 100; - const optimisticDepositWithdrawals = stack.readBoolean(); - const depositsOpen = stack.readBoolean(); - const savedValidatorSetHash = asHex(`0x${stack.readBigNumber().toString(16).padStart(64, '0')}`); - const workChain = Address.parse(this.address).workChain; - const prev = stack.readTuple(); - const prevRound = { - borrowers: parseBorrowers(prev.readCellOpt(), workChain), - roundId: prev.readNumber(), - activeBorrowers: prev.readBigNumber(), - borrowed: prev.readBigNumber(), - expected: prev.readBigNumber(), - returned: prev.readBigNumber(), - profit: prev.readBigNumber(), - }; - - const current = stack.readTuple(); - const currentRound = { - borrowers: parseBorrowers(current.readCellOpt(), workChain), - roundId: current.readNumber(), - activeBorrowers: current.readBigNumber(), - borrowed: current.readBigNumber(), - expected: current.readBigNumber(), - returned: current.readBigNumber(), - profit: current.readBigNumber(), - }; - - const minLoan = stack.readBigNumber().toString(); - const maxLoan = stack.readBigNumber().toString(); - const governanceFeePercent = (stack.readNumber() / SHARE_BASIS) * 100; - const jettonMinter = asAddressFriendly(stack.readAddress()); - const supply = stack.readBigNumber().toString(); + // Skip all fields until jettonMinter + // 0: state, 1: halted, 2: totalBalance, 3: interestRatePercent + // 4: optimisticDepositWithdrawals, 5: depositsOpen, 6: savedValidatorSetHash + // 7: prevRound, 8: currentRound, 9: minLoan, 10: maxLoan, 11: governanceFeePercent + stack.skip(12); - const depositPayout = asMaybeAddressFriendly(stack.readAddressOpt()?.toString()); - const requestedForDeposit = stack.readBigNumber().toString(); - - const withdrawalPayout = asMaybeAddressFriendly(stack.readAddressOpt()?.toString()); - const requestedForWithdrawal = stack.readBigNumber().toString(); - - const sudoer = asAddressFriendly(stack.readAddress()); - const sudoerSetAt = new Date(stack.readNumber() * 1000); - const governor = asAddressFriendly(stack.readAddress()); - const governorUpdateAfter = asUpdateAfter(stack.readNumber()); - const interestManager = asAddressFriendly(stack.readAddress()); - const halter = asAddressFriendly(stack.readAddress()); - const approver = asAddressFriendly(stack.readAddress()); - - const controllerCode = stack.readCell(); - const poolJettonWalletCode = stack.readCell(); - const payoutMinterCode = stack.readCell(); - - const projectedTotalBalance = stack.readBigNumber().toString(); - const projectedPoolSupply = stack.readBigNumber().toString(); - - return { - state, - halted, - totalBalance, - interestRatePercent, - optimisticDepositWithdrawals, - depositsOpen, - savedValidatorSetHash, - prevRound, - currentRound, - - minLoan, - maxLoan, - governanceFeePercent, - - jettonMinter, - supply, - - depositPayout, - requestedForDeposit, - withdrawalPayout, - requestedForWithdrawal, - - sudoer, - sudoerSetAt, - governor, - governorUpdateAfter, - interestManager, - halter, - approver, - - controllerCode, - poolJettonWalletCode, - payoutMinterCode, - - projectedTotalBalance, - projectedPoolSupply, - }; - } - - async getPoolData(): Promise { - const data = await this.client.runGetMethod(this.address, 'get_pool_data'); - const stack = ReaderStack(data.stack); - const state = stack.readNumber() as PoolState; - const halted = stack.readBoolean(); - const totalBalance = stack.readBigNumber().toString(); - const supply = stack.readBigNumber().toString(); - const interestRatePercent = (stack.readNumber() / SHARE_BASIS) * 100; - - return { - state, - halted, - totalBalance, - supply, - interestRatePercent, - }; + return asAddressFriendly(stack.readAddress()); } async getJettonWalletAddress(userAddress: UserFriendlyAddress): Promise { - const { jettonMinter } = await this.getPoolFullData(); + const jettonMinter = await this.getJettonMinter(); const data = await this.client.runGetMethod( jettonMinter, 'get_wallet_address', @@ -287,22 +55,8 @@ export class PoolContract { return stack.readBigNumber().toString(); } - async getControllerAddress(id: number, validator: UserFriendlyAddress) { - const data = await this.client.runGetMethod( - this.address, - 'get_controller_address', - SerializeStack([ - { type: 'int', value: BigInt(id) }, - { type: 'slice', cell: beginCell().storeAddress(Address.parse(validator)).endCell() }, - ]), - ); - const stack = ReaderStack(data.stack); - return stack.readAddress(); - } - /** * Build stake message payload. - * * TL‑B: deposit#47d54391 query_id:uint64 = InternalMsgBody; */ buildStakePayload(queryId: bigint = 0n): Base64String { @@ -352,7 +106,6 @@ export class PoolContract { /** * Helper to construct a TransactionRequestMessage for unstake flow. - * * Note: fee amount is not applied here and should be added by caller. */ async buildUnstakeMessage(params: { @@ -378,12 +131,6 @@ export class PoolContract { }; } - calculateApy(interestRate: number): number { - const cyclesPerYear = TIMING.CYCLES_PER_YEAR; - const protocolFee = TIMING.PROTOCOL_FEE; - return interestRate * cyclesPerYear * (1 - protocolFee); - } - /** * Get staking contract balance (instant liquidity available). */ @@ -393,20 +140,28 @@ export class PoolContract { } /** - * Get round timestamps from pool data. - * Note: Toncenter doesn't provide cycle_start/cycle_end directly, - * so we estimate based on cycle length (~18 hours). + * Get current and projected exchange rates for tsTON/TON. */ - async getRoundInfo(): Promise { - const cycleLengthSeconds = TIMING.CYCLE_LENGTH_HOURS * 3600; - const now = Math.floor(Date.now() / 1000); - const cycle_end = now + Math.floor(cycleLengthSeconds / 2); - const cycle_start = cycle_end - cycleLengthSeconds; + async getRates(): Promise<{ tsTONTON: number; tsTONTONProjected: number }> { + const data = await this.client.runGetMethod(this.address, 'get_pool_full_data'); + const stack = ReaderStack(data.stack); + + stack.skip(2); // Skip state, halted + const totalBalance = Number(formatUnits(stack.readBigNumber(), 9)); + + stack.skip(10); // Skip up to minter + const supply = Number(formatUnits(stack.readBigNumber(), 9)); + + stack.skip(14); // Skip to projected balance + const projectedBalance = Number(formatUnits(stack.readBigNumber(), 9)); + const projectedSupply = Number(formatUnits(stack.readBigNumber(), 9)); + + const tsTONTON = supply > 0 ? totalBalance / supply : 1; + const tsTONTONProjected = projectedSupply > 0 ? projectedBalance / projectedSupply : 1; return { - cycle_start, - cycle_end, - cycle_length: cycleLengthSeconds, + tsTONTON, + tsTONTONProjected, }; } } diff --git a/packages/walletkit/src/defi/staking/tonstakers/StakingCache.ts b/packages/walletkit/src/defi/staking/tonstakers/StakingCache.ts index f06a20d9e..e72e74755 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/StakingCache.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/StakingCache.ts @@ -8,14 +8,14 @@ import { LRUCache } from 'lru-cache'; -import { TIMING } from './constants'; +import { CACHE_TIMEOUT } from './constants'; export class StakingCache { // eslint-disable-next-line @typescript-eslint/no-explicit-any private cache: LRUCache; private readonly defaultTtl: number; - constructor(maxSize: number = 100, defaultTtl: number = TIMING.CACHE_TIMEOUT) { + constructor(maxSize: number = 100, defaultTtl: number = CACHE_TIMEOUT) { this.defaultTtl = defaultTtl; this.cache = new LRUCache({ max: maxSize, diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts index 8d598bff1..aa4d68e25 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts @@ -10,7 +10,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Cell } from '@ton/core'; import { PoolContract } from './PoolContract'; -import { CONTRACT, TIMING } from './constants'; +import { CONTRACT } from './constants'; const mockApiClient = { runGetMethod: vi.fn(), @@ -137,28 +137,4 @@ describe('TonStakersContract', () => { expect(slice.loadUintBig(64)).toBe(customQueryId); }); }); - - describe('calculateApy', () => { - it('should calculate APY from interest rate', () => { - const interestRate = 0.001; - const apy = contract.calculateApy(interestRate); - - const expectedApy = interestRate * TIMING.CYCLES_PER_YEAR * (1 - TIMING.PROTOCOL_FEE); - - expect(apy).toBeCloseTo(expectedApy, 10); - }); - - it('should return 0 for zero interest rate', () => { - const apy = contract.calculateApy(0); - expect(apy).toBe(0); - }); - - it('should handle large interest rates', () => { - const largeInterestRate = 0.5; - const apy = contract.calculateApy(largeInterestRate); - - expect(apy).toBeGreaterThan(0); - expect(Number.isFinite(apy)).toBe(true); - }); - }); }); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts index 1b6606250..e5d4d2873 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts @@ -37,8 +37,7 @@ describe('TonStakersStakingProvider', () => { let buildStakePayloadSpy: MockInstance; let buildUnstakeMessageSpy: MockInstance; - let getPoolDataSpy: MockInstance; - let calculateApySpy: MockInstance; + let getApyFromTonApiSpy: MockInstance; beforeEach(() => { vi.clearAllMocks(); @@ -52,20 +51,16 @@ describe('TonStakersStakingProvider', () => { amount: CONTRACT.UNSTAKE_FEE_RES.toString(), payload: 'mock-unstake-payload' as Base64String, }); - - getPoolDataSpy = vi.spyOn(PoolContract.prototype, 'getPoolData').mockResolvedValue({ - state: 0, - halted: false, - totalBalance: '1000000000000', - supply: '900000000000', - interestRatePercent: 0.1, - }); - - calculateApySpy = vi.spyOn(PoolContract.prototype, 'calculateApy').mockReturnValue(0.05); - vi.spyOn(PoolContract.prototype, 'getPoolBalance').mockResolvedValue(500000000000n); + vi.spyOn(PoolContract.prototype, 'getRates').mockResolvedValue({ + tsTONTON: 1.05, + tsTONTONProjected: 1.1, + }); provider = new TonStakersStakingProvider(mockNetworkManager as never, mockEventEmitter as never); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getApyFromTonApiSpy = vi.spyOn(provider as any, 'getApyFromTonApi').mockResolvedValue(0.05); }); describe('getQuote', () => { @@ -80,11 +75,11 @@ describe('TonStakersStakingProvider', () => { expect(quote.direction).toBe(StakingQuoteDirection.Stake); expect(quote.amountIn).toBe(amount); - expect(quote.amountOut).toBe(amount); + // amountOut = 1000000000 / 1.1 = 909090909 + expect(quote.amountOut).toBe('909090909'); expect(quote.provider).toBe('tonstakers'); expect(quote.apy).toBe(0.05); - expect(getPoolDataSpy).toHaveBeenCalled(); - expect(calculateApySpy).toHaveBeenCalledWith(0.1); + expect(getApyFromTonApiSpy).toHaveBeenCalled(); }); it('should return correct quote with unstakeMode for unstake direction', async () => { @@ -99,7 +94,8 @@ describe('TonStakersStakingProvider', () => { expect(quote.direction).toBe(StakingQuoteDirection.Unstake); expect(quote.amountIn).toBe(amount); - expect(quote.amountOut).toBe(amount); + // amountOut = 1000000000 * 1.05 = 1050000000 + expect(quote.amountOut).toBe('1050000000'); expect(quote.provider).toBe('tonstakers'); expect(quote.unstakeMode).toBe(UnstakeMode.Instant); }); @@ -230,4 +226,17 @@ describe('TonStakersStakingProvider', () => { ); }); }); + + describe('getStakingProviderInfo', () => { + it('should return simplified info with APY and liquidity', async () => { + const info = await provider.getStakingProviderInfo(Network.mainnet()); + + expect(info.apy).toBe(0.05); + expect(info.instantUnstakeAvailable).toBe('500000000000'); + expect(info.providerId).toBe('tonstakers'); + // Ensure exchange rates are NOT in the response + expect(info).not.toHaveProperty('tsTONTON'); + expect(info).not.toHaveProperty('tsTONTONProjected'); + }); + }); }); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts index 7ead61008..dc17ec560 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -18,23 +18,22 @@ import type { StakingProviderInfo, StakingQuoteParams, StakingQuote, - RoundInfo, } from '../types'; import { StakingError, StakingErrorCode } from '../errors'; import { StakingQuoteDirection, UnstakeMode } from '../types'; import type { TonStakersProviderConfig } from './types'; -import { CONTRACT, TIMING } from './constants'; -import type { PoolFullData } from './PoolContract'; +import { CONTRACT } from './constants'; import { PoolContract } from './PoolContract'; import { StakingCache } from './StakingCache'; +import { ApiClientTonApi } from '../../../clients/tonapi/ApiClientTonApi'; +import { formatUnits, parseUnits } from '../../../utils/units'; const log = globalLogger.createChild('TonStakersStakingProvider'); /** * TonStakersStakingProvider - Staking provider for the Tonstakers liquid staking protocol. * - * This provider implements all staking operations using ONLY Toncenter API - * (no TonAPI dependency). It supports: + * This provider implements all staking operations. It supports: * - Stake: Deposit TON to receive tsTON liquid staking tokens * - Unstake: Burn tsTON to withdraw TON with 3 modes: * - Delayed: Standard withdrawal at end of round (~18 hours) @@ -108,21 +107,35 @@ export class TonStakersStakingProvider extends StakingProvider { }); const stakingInfo = await this.getStakingProviderInfo(params.network); + const contract = this.getContract(params.network); + const rates = await contract.getRates(); if (params.direction === StakingQuoteDirection.Stake) { + // User deposits TON, receives tsTON: tsTON = TON / rate + const amountInTokens = Number(formatUnits(params.amount, 9)); + const amountOutTokens = amountInTokens / rates.tsTONTONProjected; + const amountOut = parseUnits(amountOutTokens.toFixed(9), 9).toString(); + return { direction: StakingQuoteDirection.Stake, amountIn: params.amount, - amountOut: params.amount, // 1:1 for staking + amountOut, provider: 'tonstakers', apy: stakingInfo.apy, }; } else { - // For unstaking, amount is the same (1:1) + // User burns tsTON, receives TON: TON = tsTON * rate + const amountInTokens = Number(formatUnits(params.amount, 9)); + const amountOutTokens = + params.unstakeMode === UnstakeMode.Instant + ? amountInTokens * rates.tsTONTON + : amountInTokens * rates.tsTONTONProjected; + const amountOut = parseUnits(amountOutTokens.toFixed(9), 9).toString(); + return { direction: StakingQuoteDirection.Unstake, amountIn: params.amount, - amountOut: params.amount, + amountOut, provider: 'tonstakers', unstakeMode: params.unstakeMode || UnstakeMode.Delayed, }; @@ -274,10 +287,7 @@ export class TonStakersStakingProvider extends StakingProvider { /** * Get staking pool information including APY and liquidity. - * - * APY is calculated from on-chain data using the formula: - * (interest_rate / 2^24) * cycles_per_year * (1 - protocol_fee) - * + * APY is fetched from TonAPI. * Results are cached for 30 seconds to reduce API calls. * * @param network - Network to query (defaults to mainnet) @@ -289,86 +299,17 @@ export class TonStakersStakingProvider extends StakingProvider { const targetNetwork = network ?? Network.mainnet(); const cacheKey = `staking-info:${targetNetwork.chainId}`; - try { - return await this.cache.get( - cacheKey, - async () => { - const contract = this.getContract(targetNetwork); - const poolData = await contract.getPoolData(); - const apy = contract.calculateApy(poolData.interestRatePercent); - const instantLiquidity = await contract.getPoolBalance(); - - return { - apy, - instantUnstakeAvailable: instantLiquidity.toString(), - providerId: 'tonstakers', - }; - }, - TIMING.CACHE_TIMEOUT, - ); - } catch (error) { - log.warn('Failed to get staking info from on-chain', { error }); + return await this.cache.get(cacheKey, async () => { + const contract = this.getContract(targetNetwork); + const instantLiquidity = await contract.getPoolBalance(); + const apy = await this.getApyFromTonApi(targetNetwork); + return { - apy: 0, - instantUnstakeAvailable: '0', + apy, + instantUnstakeAvailable: instantLiquidity.toString(), providerId: 'tonstakers', }; - } - } - - /** - * Get full pool data from on-chain getter. - * Results are cached for 30 seconds. - * - * @param network - Network to query (defaults to mainnet) - * @returns Pool data including total_balance, supply, interest_rate - */ - async getPoolFullData(network?: Network): Promise { - const targetNetwork = network ?? Network.mainnet(); - const cacheKey = `pool-full-data:${targetNetwork.chainId}`; - - return this.cache.get( - cacheKey, - async () => { - const contract = this.getContract(targetNetwork); - return contract.getPoolFullData(); - }, - TIMING.CACHE_TIMEOUT, - ); - } - - /** - * Get current round timing information. - * Note: Returns estimated values based on ~18 hour cycle length. - * - * @param network - Network to query (defaults to mainnet) - * @returns Round info with cycle_start, cycle_end, and cycle_length - */ - async getRoundInfo(network?: Network): Promise { - const contract = this.getContract(network); - return contract.getRoundInfo(); - } - - /** - * Get instant unstake liquidity available in the pool. - * This is the amount that can be withdrawn instantly. - * Results are cached for 30 seconds. - * - * @param network - Network to query (defaults to mainnet) - * @returns Available liquidity in nanotons - */ - async getInstantLiquidity(network?: Network): Promise { - const targetNetwork = network ?? Network.mainnet(); - const cacheKey = `instant-liquidity:${targetNetwork.chainId}`; - - return this.cache.get( - cacheKey, - async () => { - const contract = this.getContract(targetNetwork); - return contract.getPoolBalance(); - }, - TIMING.CACHE_TIMEOUT, - ); + }); } /** @@ -400,4 +341,19 @@ export class TonStakersStakingProvider extends StakingProvider { const contractAddress = this.getStakingContractAddress(targetNetwork); return new PoolContract(contractAddress, apiClient); } + + private async getApyFromTonApi(network: Network): Promise { + const networkConfig = this.config[network.chainId]; + const token = networkConfig?.tonApiToken; + const address = this.getStakingContractAddress(network); + const client = new ApiClientTonApi({ network, apiKey: token }); + + const poolInfo = await client.getJson<{ pool: { apy: number } }>(`/v2/staking/pool/${address}`); + + if (!poolInfo?.pool?.apy) { + throw new Error('Invalid APY data from TonAPI'); + } + + return Number(poolInfo.pool.apy) / 100; + } } diff --git a/packages/walletkit/src/defi/staking/tonstakers/constants.ts b/packages/walletkit/src/defi/staking/tonstakers/constants.ts index f09a7b54a..4c608a8e5 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/constants.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/constants.ts @@ -8,17 +8,7 @@ import { toNano } from '@ton/core'; -// Timing-related constants -export const TIMING = { - DEFAULT_INTERVAL: 5000, - TIMEOUT: 600000, - CACHE_TIMEOUT: 30000, - ESTIMATED_TIME_BW_TX_S: 3, - ESTIMATED_TIME_AFTER_ROUND_S: 10 * 60, - CYCLE_LENGTH_HOURS: 18, - CYCLES_PER_YEAR: (365.25 * 24) / 18, - PROTOCOL_FEE: 0.1, -}; +export const CACHE_TIMEOUT = 30000; // Contract-related constants export const CONTRACT = { diff --git a/packages/walletkit/src/defi/staking/tonstakers/types.ts b/packages/walletkit/src/defi/staking/tonstakers/types.ts index b0539bab0..9932577f3 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/types.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/types.ts @@ -6,15 +6,13 @@ * */ -export type TonStakersProviderConfig = { - [key: string]: { +export interface TonStakersProviderConfig { + [chainId: string]: { contractAddress: string; + /** + * Optional TonAPI token used exclusively for fetching historical APY. + * The provider is fully functional without this token. + */ + tonApiToken?: string; }; -}; - -export interface TonStakersPoolInfo { - apy: number; - tvl: bigint; - instantLiquidity: bigint; } -export type { PoolFullData } from './PoolContract'; diff --git a/packages/walletkit/src/defi/staking/types.ts b/packages/walletkit/src/defi/staking/types.ts index 45ef271b9..d63c1f9f9 100644 --- a/packages/walletkit/src/defi/staking/types.ts +++ b/packages/walletkit/src/defi/staking/types.ts @@ -20,12 +20,6 @@ export enum UnstakeMode { BestRate = 'bestRate', } -export interface RoundInfo { - cycle_start: number; - cycle_end: number; - cycle_length?: number; -} - /** * Parameters for requesting a staking quote */ @@ -52,14 +46,6 @@ export interface StakingQuote { metadata?: unknown; } -/** - * Parameters for building a market swap transaction for st-tokens - */ -export interface StakingMarketSwapParams { - quote: StakingQuote; - userAddress: UserFriendlyAddress; -} - /** * Parameters for staking TON */ @@ -124,12 +110,3 @@ export interface StakingProviderInterface extends DefiProvider { getStakedBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; getStakingProviderInfo(network?: Network): Promise; } - -/** - * Optional interface for market providers that exchange st-tokens. - * This should remain separate from staking provider logic. - */ -export interface StakingMarketProviderInterface { - getQuote(params: StakingQuoteParams): Promise; - buildSwapTransaction(params: StakingMarketSwapParams): Promise; -} diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 325a0f3df..3a225b463 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -27,13 +27,11 @@ export { TonStakersStakingProvider, PoolContract, StakingCache, - CONTRACT, - TIMING, UnstakeMode, StakingQuoteDirection, } from './defi/staking'; export type * from './defi/staking/types'; -export type { TonStakersProviderConfig, PoolFullData, TonStakersPoolInfo } from './defi/staking/tonstakers/types'; +export type { TonStakersProviderConfig } from './defi/staking/tonstakers/types'; export { EventEmitter } from './core/EventEmitter'; export type { EventListener } from './core/EventEmitter'; export { ApiClientToncenter } from './clients/toncenter'; From 6145ae181f9acb7f70db3f80713f99fcba7f6351 Mon Sep 17 00:00:00 2001 From: "V. K." Date: Thu, 12 Mar 2026 14:40:19 +0400 Subject: [PATCH 09/15] feat(staking): rework models, rename fields --- demo/examples/staking.ts | 814 ------------------ packages/walletkit/README.md | 111 --- packages/walletkit/examples/ton-staking.ts | 123 --- .../src/api/interfaces/StakingAPI.ts | 43 + .../walletkit/src/api/interfaces/index.ts | 4 +- packages/walletkit/src/api/models/index.ts | 10 + .../src/api/models/staking/StakeParams.ts | 30 + .../src/api/models/staking/StakingBalance.ts | 34 + .../api/models/staking/StakingProviderInfo.ts | 30 + .../src/api/models/staking/StakingQuote.ts | 69 ++ .../models/staking/StakingQuoteDirection.ts | 12 + .../api/models/staking/StakingQuoteParams.ts | 48 ++ .../src/api/models/staking/UnstakeMode.ts | 12 + .../src/api/models/staking/UnstakeParams.ts | 37 + .../src/defi/staking/StakingManager.ts | 11 +- .../src/defi/staking/StakingProvider.ts | 4 +- packages/walletkit/src/defi/staking/index.ts | 10 +- .../TonStakersStakingProvider.spec.ts | 127 ++- .../tonstakers/TonStakersStakingProvider.ts | 45 +- .../src/defi/staking/tonstakers/constants.ts | 13 +- .../src/defi/staking/tonstakers/index.ts | 12 + .../TonStakersProviderConfig.ts} | 4 +- .../defi/staking/tonstakers/models/index.ts | 9 + packages/walletkit/src/defi/staking/types.ts | 112 --- packages/walletkit/src/index.ts | 5 +- 25 files changed, 490 insertions(+), 1239 deletions(-) delete mode 100644 demo/examples/staking.ts delete mode 100644 packages/walletkit/examples/ton-staking.ts create mode 100644 packages/walletkit/src/api/interfaces/StakingAPI.ts create mode 100644 packages/walletkit/src/api/models/staking/StakeParams.ts create mode 100644 packages/walletkit/src/api/models/staking/StakingBalance.ts create mode 100644 packages/walletkit/src/api/models/staking/StakingProviderInfo.ts create mode 100644 packages/walletkit/src/api/models/staking/StakingQuote.ts create mode 100644 packages/walletkit/src/api/models/staking/StakingQuoteDirection.ts create mode 100644 packages/walletkit/src/api/models/staking/StakingQuoteParams.ts create mode 100644 packages/walletkit/src/api/models/staking/UnstakeMode.ts create mode 100644 packages/walletkit/src/api/models/staking/UnstakeParams.ts create mode 100644 packages/walletkit/src/defi/staking/tonstakers/index.ts rename packages/walletkit/src/defi/staking/tonstakers/{types.ts => models/TonStakersProviderConfig.ts} (79%) create mode 100644 packages/walletkit/src/defi/staking/tonstakers/models/index.ts delete mode 100644 packages/walletkit/src/defi/staking/types.ts diff --git a/demo/examples/staking.ts b/demo/examples/staking.ts deleted file mode 100644 index 84074806d..000000000 --- a/demo/examples/staking.ts +++ /dev/null @@ -1,814 +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 { - TonWalletKit, - Signer, - WalletV5R1Adapter, - Network, - LocalStorageAdapter, - StakingManager, - TonStakersStakingProvider, - createDeviceInfo, - createWalletManifest, - ParseStack, - UnstakeMode, -} from '@ton/walletkit'; -import type { Wallet, ApiClient } from '@ton/walletkit'; -import { Address, beginCell, toNano, fromNano } from '@ton/core'; -import { CONTRACT } from '@ton/walletkit'; -import type { Base64String } from '@ton/walletkit'; - -// Storage keys -const STORAGE_KEY_SEED = 'staking-demo:seed'; -const STORAGE_KEY_NETWORK = 'staking-demo:network'; -const STORAGE_KEY_WALLET_ADDRESS = 'staking-demo:wallet-address'; - -// Environment variables (Vite injects these as import.meta.env.VITE_*) -const ENV_WALLET_MNEMONIC = import.meta.env.VITE_WALLET_MNEMONIC || ''; -const ENV_TON_API_KEY_MAINNET = import.meta.env.VITE_TON_API_KEY_MAINNET || ''; -const ENV_TON_API_KEY_TESTNET = import.meta.env.VITE_TON_API_KEY_TESTNET || ''; - -// UI Elements - will be initialized in init() -let screens: { - seed: HTMLElement; - network: HTMLElement; - staking: HTMLElement; -}; -let seedInput: HTMLTextAreaElement; -let restoreWalletBtn: HTMLButtonElement; -let seedError: HTMLElement; -let networkMainnetCard: HTMLElement; -let networkTestnetCard: HTMLElement; -let walletAddressMainnet: HTMLElement; -let walletAddressTestnet: HTMLElement; -let networkError: HTMLElement; -let deleteDataBtn: HTMLButtonElement; - -const stakingError = document.getElementById('staking-error')!; - -const balanceTon = document.getElementById('balance-ton')!; -const balanceAvailable = document.getElementById('balance-available')!; -const balanceStaked = document.getElementById('balance-staked')!; -const poolApy = document.getElementById('pool-apy')!; -const poolTvl = document.getElementById('pool-tvl')!; -const poolStakers = document.getElementById('pool-stakers')!; -const roundsInfo = document.getElementById('rounds-info')!; - -// State -let kit: TonWalletKit | null = null; -let wallet: Wallet | null = null; -let stakingManager: StakingManager | null = null; -let currentNetwork: 'mainnet' | 'testnet' = 'mainnet'; -let jettonWalletAddress: Address | null = null; -let mainnetAddress: string = ''; -let testnetAddress: string = ''; - -// Additional UI elements -let currentNetworkLabel: HTMLElement; -let currentWalletAddress: HTMLElement; -let changeNetworkBtn: HTMLButtonElement; -let txStatusEl: HTMLElement; -let txStatusIcon: HTMLElement; -let txStatusText: HTMLElement; -let txStatusDetails: HTMLElement; -let stakeBtn: HTMLButtonElement; -let stakeMaxBtn: HTMLButtonElement; -let unstakeBtn: HTMLButtonElement; -let unstakeInstantBtn: HTMLButtonElement; -let unstakeBestRateBtn: HTMLButtonElement; - -let stakedBalanceNano = 0n; - -// Initialize app -async function init() { - console.log('Initializing app...'); - - // Initialize DOM elements - screens = { - seed: document.getElementById('screen-seed')!, - network: document.getElementById('screen-network')!, - staking: document.getElementById('screen-staking')!, - }; - - seedInput = document.getElementById('seed-input') as HTMLTextAreaElement; - restoreWalletBtn = document.getElementById('restore-wallet-btn') as HTMLButtonElement; - seedError = document.getElementById('seed-error')!; - networkMainnetCard = document.getElementById('network-mainnet')!; - networkTestnetCard = document.getElementById('network-testnet')!; - walletAddressMainnet = document.getElementById('wallet-address-mainnet')!; - walletAddressTestnet = document.getElementById('wallet-address-testnet')!; - networkError = document.getElementById('network-error')!; - deleteDataBtn = document.getElementById('delete-data-btn') as HTMLButtonElement; - - // Validate critical elements - if (!seedInput || !restoreWalletBtn || !seedError) { - console.error('Critical DOM elements not found:', { - seedInput: !!seedInput, - restoreWalletBtn: !!restoreWalletBtn, - seedError: !!seedError, - }); - alert('Error: Required DOM elements not found. Please check the HTML file.'); - return; - } - - console.log('DOM elements found, setting up event listeners...'); - - // Check if wallet is already restored - const savedSeed = localStorage.getItem(STORAGE_KEY_SEED); - const savedNetwork = localStorage.getItem(STORAGE_KEY_NETWORK) as 'mainnet' | 'testnet' | null; - - if (savedSeed) { - currentNetwork = savedNetwork || 'mainnet'; - await computeAddresses(savedSeed); - showScreen('network'); - } else { - showScreen('seed'); - } - - // Setup event listeners - - console.log('Adding click listener to restore button'); - restoreWalletBtn.addEventListener('click', (e) => { - console.log('Restore button clicked!', e); - handleRestoreWallet(); - }); - networkMainnetCard.addEventListener('click', () => selectNetworkAndContinue('mainnet')); - networkTestnetCard.addEventListener('click', () => selectNetworkAndContinue('testnet')); - deleteDataBtn.addEventListener('click', handleDeleteData); - - // Additional UI element references - currentNetworkLabel = document.getElementById('current-network-label')!; - currentWalletAddress = document.getElementById('current-wallet-address')!; - changeNetworkBtn = document.getElementById('btn-change-network') as HTMLButtonElement; - changeNetworkBtn.addEventListener('click', handleChangeNetwork); - - // Check for environment mnemonic - auto-restore if available - const envMnemonic = ENV_WALLET_MNEMONIC; - if (envMnemonic && !savedSeed) { - console.log('Using mnemonic from environment variable'); - seedInput.value = envMnemonic; - await handleRestoreWallet(); - } - - stakeBtn = document.getElementById('btn-stake') as HTMLButtonElement; - stakeMaxBtn = document.getElementById('btn-stake-max') as HTMLButtonElement; - txStatusEl = document.getElementById('tx-status')!; - txStatusIcon = document.getElementById('tx-status-icon')!; - txStatusText = document.getElementById('tx-status-text')!; - txStatusDetails = document.getElementById('tx-status-details')!; - - // Staking operations - stakeBtn.addEventListener('click', () => handleStake(toNano('1'))); - stakeMaxBtn.addEventListener('click', handleStakeMax); - - unstakeBtn = document.getElementById('btn-unstake')! as HTMLButtonElement; - unstakeInstantBtn = document.getElementById('btn-unstake-instant')! as HTMLButtonElement; - unstakeBestRateBtn = document.getElementById('btn-unstake-best-rate')! as HTMLButtonElement; - - unstakeBtn.addEventListener('click', () => { - const amount = stakedBalanceNano < toNano('1') ? stakedBalanceNano : toNano('1'); - handleUnstake(amount, 'delayed'); - }); - unstakeInstantBtn.addEventListener('click', () => { - const amount = stakedBalanceNano < toNano('1') ? stakedBalanceNano : toNano('1'); - handleUnstake(amount, 'instant'); - }); - unstakeBestRateBtn.addEventListener('click', () => { - const amount = stakedBalanceNano < toNano('1') ? stakedBalanceNano : toNano('1'); - handleUnstake(amount, 'delayed', true); - }); - document.getElementById('btn-refresh')!.addEventListener('click', handleRefreshBalances); - document.getElementById('btn-get-rounds')!.addEventListener('click', handleGetRounds); - // Initial button state - updateUnstakeButtonsState(); -} - -function showScreen(screenName: 'seed' | 'network' | 'staking') { - Object.values(screens).forEach((screen) => screen.classList.remove('active')); - screens[screenName].classList.add('active'); -} - -function showError(element: HTMLElement, message: string) { - element.textContent = message; - element.style.display = 'block'; - setTimeout(() => { - element.style.display = 'none'; - }, 5000); -} - -function formatBalance(nanoValue: string | bigint): string { - const value = parseFloat(fromNano(nanoValue.toString())); - return value.toFixed(2); -} - -function showTxStatus(status: 'pending' | 'success' | 'error', text: string, details?: string) { - txStatusEl.style.display = 'block'; - txStatusEl.className = 'tx-status ' + status; - - if (status === 'pending') { - txStatusIcon.textContent = '⏳'; - } else if (status === 'success') { - txStatusIcon.textContent = '✅'; - } else { - txStatusIcon.textContent = '❌'; - } - - txStatusText.textContent = text; - txStatusDetails.textContent = details || ''; -} - -function updateUnstakeButtonsState() { - const disabled = stakedBalanceNano <= 0n; - if (unstakeBtn) unstakeBtn.disabled = disabled; - if (unstakeInstantBtn) unstakeInstantBtn.disabled = disabled; - if (unstakeBestRateBtn) unstakeBestRateBtn.disabled = disabled; -} - -function hideTxStatus() { - txStatusEl.style.display = 'none'; -} - -async function handleRestoreWallet() { - const seedPhrase = seedInput.value.trim(); - const words = seedPhrase.split(/\s+/).filter((w) => w.length > 0); - - console.log('handleRestoreWallet called', { wordsCount: words.length }); - - if (words.length !== 12 && words.length !== 24) { - showError(seedError, 'Seed phrase must contain 12 or 24 words'); - return; - } - - try { - restoreWalletBtn.disabled = true; - restoreWalletBtn.textContent = 'Restoring...'; - - console.log('Getting wallet kit...'); - const walletKit = getKit(); - - console.log('Waiting for kit to be ready...'); - await walletKit.waitForReady(); - - console.log('Kit is ready, creating signer...'); - - const signer = await Signer.fromMnemonic(words, { type: 'ton' }); - - console.log('Signer created, creating wallet adapter...'); - const network = Network.mainnet(); - const walletAdapter = await WalletV5R1Adapter.create(signer, { - client: walletKit.getApiClient(network), - network, - }); - - console.log('Wallet adapter created, adding wallet...'); - const addedWallet = await walletKit.addWallet(walletAdapter); - if (!addedWallet) { - throw new Error('Failed to create wallet'); - } - wallet = addedWallet; - - const address = wallet.getAddress(); - - console.log('Wallet created successfully:', address); - - localStorage.setItem(STORAGE_KEY_SEED, seedPhrase); - localStorage.setItem(STORAGE_KEY_NETWORK, 'mainnet'); - - await computeAddresses(seedPhrase); - showScreen('network'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - showError(seedError, `Error restoring wallet: ${errorMessage}`); - // Log to console for debugging - - console.error('Wallet restoration error:', error); - } finally { - restoreWalletBtn.disabled = false; - restoreWalletBtn.textContent = 'Restore Wallet'; - } -} - -async function computeAddresses(seedPhrase: string) { - const words = seedPhrase.split(/\s+/); - const walletKit = getKit(); - await walletKit.waitForReady(); - - const signer = await Signer.fromMnemonic(words, { type: 'ton' }); - - // Create mainnet wallet and get address (bounceable format) - const mainnetNetwork = Network.mainnet(); - const mainnetAdapter = await WalletV5R1Adapter.create(signer, { - client: walletKit.getApiClient(mainnetNetwork), - network: mainnetNetwork, - }); - const mainnetRawAddress = mainnetAdapter.getAddress(); - mainnetAddress = Address.parse(mainnetRawAddress).toString({ bounceable: true, testOnly: false }); - - // Create testnet wallet and get address (bounceable format) - const testnetNetwork = Network.testnet(); - const testnetAdapter = await WalletV5R1Adapter.create(signer, { - client: walletKit.getApiClient(testnetNetwork), - network: testnetNetwork, - }); - const testnetRawAddress = testnetAdapter.getAddress({ testnet: true }); - testnetAddress = Address.parse(testnetRawAddress).toString({ bounceable: true, testOnly: true }); - - // Update UI - walletAddressMainnet.textContent = mainnetAddress; - walletAddressTestnet.textContent = testnetAddress; - - console.log('Addresses computed (bounceable):', { mainnetAddress, testnetAddress }); -} - -async function selectNetworkAndContinue(network: 'mainnet' | 'testnet') { - currentNetwork = network; - localStorage.setItem(STORAGE_KEY_NETWORK, network); - localStorage.setItem(STORAGE_KEY_WALLET_ADDRESS, network === 'mainnet' ? mainnetAddress : testnetAddress); - - try { - networkMainnetCard.style.opacity = '0.5'; - networkTestnetCard.style.opacity = '0.5'; - (network === 'mainnet' ? networkMainnetCard : networkTestnetCard).style.opacity = '1'; - - await initializeStaking(); - - // Update wallet info on staking screen - updateWalletInfoDisplay(); - - showScreen('staking'); - await handleRefreshBalances(); - } catch (error) { - showError(networkError, `Initialization error: ${error instanceof Error ? error.message : String(error)}`); - networkMainnetCard.style.opacity = '1'; - networkTestnetCard.style.opacity = '1'; - } -} - -function updateWalletInfoDisplay() { - currentNetworkLabel.textContent = currentNetwork === 'mainnet' ? 'Mainnet' : 'Testnet'; - currentWalletAddress.textContent = currentNetwork === 'mainnet' ? mainnetAddress : testnetAddress; -} - -function handleChangeNetwork() { - // Reset staking state and go back to network selection - wallet = null; - stakingManager = null; - jettonWalletAddress = null; - showScreen('network'); -} - -function handleDeleteData() { - if (confirm('Are you sure you want to delete all wallet data?')) { - localStorage.removeItem(STORAGE_KEY_SEED); - localStorage.removeItem(STORAGE_KEY_NETWORK); - localStorage.removeItem(STORAGE_KEY_WALLET_ADDRESS); - if (kit) { - kit.clearWallets(); - } - kit = null; - wallet = null; - stakingManager = null; - jettonWalletAddress = null; - mainnetAddress = ''; - testnetAddress = ''; - seedInput.value = ''; - showScreen('seed'); - } -} - -function getKit(): TonWalletKit { - if (!kit) { - kit = new TonWalletKit({ - deviceInfo: createDeviceInfo({ - platform: 'browser', - appName: 'StakingDemo', - appVersion: '1.0.0', - maxProtocolVersion: 2, - features: [ - { - name: 'SendTransaction', - maxMessages: 4, - extraCurrencySupported: false, - }, - ], - }), - walletManifest: createWalletManifest({ - name: 'staking_demo', - appName: 'Staking Demo', - imageUrl: 'https://ton.org/download/ton_symbol.png', - bridgeUrl: 'https://walletbot.me/tonconnect-bridge/bridge', - universalLink: window.location.origin, - aboutUrl: window.location.origin, - platforms: ['chrome'], - }), - storage: new LocalStorageAdapter({ prefix: 'staking-demo:' }), - networks: { - [Network.mainnet().chainId]: { - apiClient: { - url: 'https://toncenter.com', - ...(ENV_TON_API_KEY_MAINNET && { apiKey: ENV_TON_API_KEY_MAINNET }), - }, - }, - [Network.testnet().chainId]: { - apiClient: { - url: 'https://testnet.toncenter.com', - ...(ENV_TON_API_KEY_TESTNET && { apiKey: ENV_TON_API_KEY_TESTNET }), - }, - }, - }, - }); - } - return kit; -} - -async function initializeStaking() { - const savedSeed = localStorage.getItem(STORAGE_KEY_SEED); - if (!savedSeed) { - throw new Error('Seed phrase not found'); - } - - const walletKit = getKit(); - await walletKit.waitForReady(); - - const words = savedSeed.split(/\s+/); - const signer = await Signer.fromMnemonic(words, { type: 'ton' }); - const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - const walletAdapter = await WalletV5R1Adapter.create(signer, { - client: walletKit.getApiClient(network), - network, - }); - - const addedWallet = await walletKit.addWallet(walletAdapter); - if (!addedWallet) { - throw new Error('Failed to create wallet'); - } - wallet = addedWallet; - - // Initialize StakingManager - stakingManager = new StakingManager(); - - // Register TonStakersStakingProvider - const stakingProvider = new TonStakersStakingProvider( - walletKit.getNetworkManager(), - walletKit.getEventEmitter(), - {}, - ); - stakingManager.registerProvider('tonstakers', stakingProvider); - stakingManager.setDefaultProvider('tonstakers'); - - // Get jetton wallet address - await updateJettonWalletAddress(); -} - -async function updateJettonWalletAddress() { - if (!wallet || !kit) return; - - try { - const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - const contractAddress = getStakingContractAddress(); - const apiClient = kit.getApiClient(network); - - // Try to get jetton minter address from staking contract - // The staking contract should have a method to get liquid_jetton_master - // For now, we'll try to get it from get_pool_full_data or use a known address - // Since we can't use tonapi.io, we'll use wallet.getJettonWalletAddress - // which will call the jetton minter contract directly - - // Try to get jetton minter address from staking contract - // First, try to get it from contract state - await updateJettonWalletAddressFromContract(apiClient, contractAddress); - - // If that didn't work, try using stakingManager.getBalance which internally gets jetton wallet - if (!jettonWalletAddress && stakingManager) { - try { - const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - await stakingManager.getBalance(wallet.getAddress(), network); - // This will internally call getJettonWalletAddress, but we still need the address - // So we'll try the contract approach again with a different method - } catch { - // Ignore - } - } - } catch (error) { - console.warn('Error updating jetton wallet address:', error); - // Error handled silently - jetton wallet address will remain null - } -} - -async function updateJettonWalletAddressFromContract(apiClient: ApiClient, contractAddress: string) { - if (!wallet) return; - - try { - // Try to get jetton minter from contract using runGetMethod - // This is contract-specific and may not work for all contracts - const result = await apiClient.runGetMethod(contractAddress, 'get_pool_full_data'); - const parsedStack = ParseStack(result.stack); - - // Try to find address in stack items - for (const item of parsedStack) { - if (item.type === 'cell') { - try { - const slice = item.cell.beginParse(); - const addr = slice.loadMaybeAddress(); - if (addr) { - const jettonWalletAddr = await wallet.getJettonWalletAddress(addr.toString()); - jettonWalletAddress = Address.parse(jettonWalletAddr); - return; - } - } catch { - // Continue searching - } - } else if (item.type === 'tuple' && item.items) { - for (const tupleItem of item.items) { - if (tupleItem.type === 'cell') { - try { - const slice = tupleItem.cell.beginParse(); - const addr = slice.loadMaybeAddress(); - if (addr) { - const jettonWalletAddr = await wallet.getJettonWalletAddress(addr.toString()); - jettonWalletAddress = Address.parse(jettonWalletAddr); - return; - } - } catch { - // Continue searching - } - } - } - } - } - } catch (error) { - console.warn('Failed to get jetton wallet address from contract:', error); - } -} - -function getStakingContractAddress(): string { - return currentNetwork === 'mainnet' ? CONTRACT.STAKING_CONTRACT_ADDRESS : CONTRACT.STAKING_CONTRACT_ADDRESS_TESTNET; -} - -async function handleStake(amount: bigint) { - if (!wallet || !stakingManager) { - showError(stakingError, 'Wallet not initialized'); - return; - } - - // Disable buttons and show pending status - stakeBtn.disabled = true; - stakeMaxBtn.disabled = true; - const originalText = stakeBtn.textContent; - stakeBtn.textContent = 'Processing...'; - - showTxStatus('pending', 'Creating transaction...', `Staking ${fromNano(amount)} TON`); - - try { - const contractAddress = getStakingContractAddress(); - const totalAmount = amount + CONTRACT.STAKE_FEE_RES; - - console.log('Creating stake transaction:', { - contractAddress, - amount: fromNano(amount), - totalAmount: fromNano(totalAmount), - }); - - const payload = beginCell() - .storeUint(CONTRACT.PAYLOAD_STAKE, 32) - .storeUint(1, 64) - .storeUint(CONTRACT.PARTNER_CODE, 64) - .endCell() - .toBoc() - .toString('base64') as Base64String; - - showTxStatus('pending', 'Building transaction...', `To: ${contractAddress}`); - - const transaction = await wallet.createTransferTonTransaction({ - recipientAddress: contractAddress, - transferAmount: totalAmount.toString(), - payload, - }); - - console.log('Transaction created:', transaction); - showTxStatus('pending', 'Sending transaction...', 'Please wait...'); - - await wallet.sendTransaction(transaction); - - showTxStatus( - 'success', - 'Transaction sent!', - `Staked ${fromNano(amount)} TON successfully. Refreshing balances...`, - ); - console.log('Transaction sent successfully'); - - setTimeout(async () => { - await handleRefreshBalances(); - setTimeout(() => hideTxStatus(), 3000); - }, 5000); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('Staking error:', error); - showTxStatus('error', 'Transaction failed', errorMessage); - showError(stakingError, `Staking error: ${errorMessage}`); - } finally { - stakeBtn.disabled = false; - stakeMaxBtn.disabled = false; - stakeBtn.textContent = originalText; - } -} - -async function handleStakeMax() { - if (!wallet) { - showError(stakingError, 'Wallet not initialized'); - return; - } - - try { - const balance = BigInt(await wallet.getBalance()); - const available = balance > CONTRACT.RECOMMENDED_FEE_RESERVE ? balance - CONTRACT.RECOMMENDED_FEE_RESERVE : 0n; - if (available > 0n) { - await handleStake(available); - } else { - showError(stakingError, 'Insufficient funds for staking'); - } - } catch (error) { - showError(stakingError, `Error getting balance: ${error instanceof Error ? error.message : String(error)}`); - } -} - -async function handleUnstake(amount: bigint, mode: 'instant' | 'delayed', waitTillRoundEnd = false) { - if (stakedBalanceNano <= 0n) { - showError(stakingError, 'No staked balance available to unstake'); - return; - } - - if (amount > stakedBalanceNano) { - showError(stakingError, `Insufficient staked balance. Available: ${fromNano(stakedBalanceNano)}`); - return; - } - - if (!wallet || !stakingManager) { - showError(stakingError, 'Wallet or staking manager not initialized'); - return; - } - - try { - const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - - let unstakeMode: UnstakeMode; - if (mode === 'instant') { - unstakeMode = UnstakeMode.Instant; - } else if (waitTillRoundEnd) { - unstakeMode = UnstakeMode.BestRate; - } else { - unstakeMode = UnstakeMode.Delayed; - } - - showTxStatus('pending', 'Building unstake transaction...', `Mode: ${unstakeMode}`); - - const request = await stakingManager.unstake({ - amount: amount.toString(), - userAddress: wallet.getAddress(), - network, - unstakeMode, - }); - - showTxStatus('pending', 'Sending transaction...', 'Please wait...'); - - await wallet.sendTransaction(request); - - showTxStatus('success', 'Unstaking transaction sent!', `Unstaked ${fromNano(amount)} tsTON`); - setTimeout(async () => { - await handleRefreshBalances(); - setTimeout(() => hideTxStatus(), 3000); - }, 5000); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('Unstaking error:', error); - showTxStatus('error', 'Unstake failed', errorMessage); - showError(stakingError, `Unstaking error: ${errorMessage}`); - } -} - -async function handleRefreshBalances() { - if (!wallet) { - console.warn('handleRefreshBalances: wallet not initialized'); - return; - } - - console.log('Refreshing balances...'); - - try { - const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - - // Get TON balance - const tonBalance = await wallet.getBalance(); - console.log('TON balance:', fromNano(tonBalance)); - balanceTon.textContent = formatBalance(tonBalance); - - // Get available balance - const available = - BigInt(tonBalance) > CONTRACT.RECOMMENDED_FEE_RESERVE - ? BigInt(tonBalance) - CONTRACT.RECOMMENDED_FEE_RESERVE - : 0n; - balanceAvailable.textContent = formatBalance(available); - - // Get staked balance via StakingManager (uses Toncenter, not TonAPI) - if (stakingManager) { - try { - const stakingBalance = await stakingManager.getBalance(wallet.getAddress(), network); - console.log('Staked balance from manager:', stakingBalance); - stakedBalanceNano = BigInt(stakingBalance.stakedBalance); - balanceStaked.textContent = formatBalance(stakingBalance.stakedBalance); - } catch (err) { - console.warn('Failed to get staked balance from manager:', err); - stakedBalanceNano = 0n; - balanceStaked.textContent = '0'; - } - } else { - stakedBalanceNano = 0n; - balanceStaked.textContent = '0'; - } - - updateUnstakeButtonsState(); - - // Get pool info using StakingManager - await refreshPoolInfo(); - } catch (error) { - console.error('Error in handleRefreshBalances:', error); - } -} - -async function refreshPoolInfo() { - const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - - console.log('Refreshing pool info via StakingManager'); - - try { - if (stakingManager) { - try { - const stakingInfo = await stakingManager.getStakingInfo(network); - console.log('Staking info:', stakingInfo); - poolApy.textContent = `${(stakingInfo.apy * 100).toFixed(2)}%`; - - // TVL is instant liquidity in this case - if (stakingInfo.instantUnstakeAvailable) { - poolTvl.textContent = formatBalance(stakingInfo.instantUnstakeAvailable); - } - } catch (error) { - console.warn('Failed to get staking info:', error); - poolApy.textContent = '-'; - poolTvl.textContent = '-'; - } - } - - // Note: Stakers count is not available via Toncenter API - poolStakers.textContent = '-'; - } catch (error) { - console.error('Error in refreshPoolInfo:', error); - } -} - -async function handleGetRounds() { - console.log('Getting rounds info...'); - - if (!stakingManager) { - showError(stakingError, 'Staking manager not initialized'); - return; - } - - try { - const network = currentNetwork === 'mainnet' ? Network.mainnet() : Network.testnet(); - const provider = stakingManager.getProvider('tonstakers') as TonStakersStakingProvider; - const roundInfo = await provider.getRoundInfo(network); - - console.log('Round info:', roundInfo); - - const startDate = new Date(roundInfo.cycle_start * 1000); - const endDate = new Date(roundInfo.cycle_end * 1000); - const now = new Date(); - const remaining = endDate.getTime() - now.getTime(); - const hoursRemaining = Math.max(0, Math.floor(remaining / (1000 * 60 * 60))); - const minutesRemaining = Math.max(0, Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))); - - roundsInfo.innerHTML = ` -
Current Round (Estimated)
-
Start: ${startDate.toLocaleString()}
-
End: ${endDate.toLocaleString()}
-
Remaining: ${hoursRemaining}h ${minutesRemaining}m
- ${roundInfo.cycle_length ? `
Cycle Length: ${Math.floor(roundInfo.cycle_length / 3600)}h
` : ''} - `; - } catch (error) { - console.error('Error getting rounds info:', error); - showError(stakingError, `Error getting rounds info: ${error instanceof Error ? error.message : String(error)}`); - } -} - -// Initialize when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); -} else { - init(); -} diff --git a/packages/walletkit/README.md b/packages/walletkit/README.md index 7240e86f3..6b461544d 100644 --- a/packages/walletkit/README.md +++ b/packages/walletkit/README.md @@ -19,11 +19,6 @@ A production-ready wallet-side integration layer for TON Connect, designed for b - 🌉 **Bridge & JS Bridge** - HTTP bridge and browser extension support - 🎨 **Previews for actions** - Transaction emulation with money flow analysis - 🪙 **Asset Support** - TON, Jettons, NFTs with metadata -<<<<<<< HEAD -- 🔄 **Token Swaps** - Multi-DEX swap aggregation -- 📈 **Liquid Staking** - Tonstakers liquid staking integration -======= ->>>>>>> main **Live Demo**: [https://walletkit-demo-wallet.vercel.app/](https://walletkit-demo-wallet.vercel.app/) @@ -33,11 +28,6 @@ A production-ready wallet-side integration layer for TON Connect, designed for b - **[Browser Extension Build](https://github.com/ton-connect/kit/blob/main/apps/demo-wallet/EXTENSION.md)** - How to build and load the demo wallet as a Chrome extension - **[JS Bridge Usage](/packages/walletkit/examples/js-bridge-usage.md)** - Implementing TonConnect JS Bridge for browser extension wallets -<<<<<<< HEAD -- **[Token Swaps](/packages/walletkit/src/defi/swap/README.md)** - Multi-DEX swap integration with custom provider support -- **[Liquid Staking](#liquid-staking)** - Tonstakers liquid staking with 3 unstake modes -======= ->>>>>>> main - **[iOS WalletKit](https://github.com/ton-connect/kit-ios)** - Swift Package providing TON wallet capabilities for iOS and macOS - **[Android WalletKit](https://github.com/ton-connect/kit-android)** - Kotlin/Java Package providing TON wallet capabilities for Android @@ -480,107 +470,6 @@ The store slices [walletCoreSlice.ts](https://github.com/ton-connect/kit/blob/ma - Wire `onConnectRequest` and `onTransactionRequest` to open modals - Approve or reject requests using the kit methods -## Liquid Staking - -TonWalletKit includes built-in support for Tonstakers liquid staking protocol. Stake TON to receive tsTON tokens and earn staking rewards. - -### Setup Staking - -```ts -import { - StakingManager, - TonStakersStakingProvider, - UnstakeMode, - Network, -} from '@ton/walletkit'; -import { toNano, fromNano } from '@ton/core'; - -// Create staking manager -const stakingManager = new StakingManager(); - -// Register Tonstakers provider -const stakingProvider = new TonStakersStakingProvider( - kit.getNetworkManager(), - kit.getEventEmitter() -); -stakingManager.registerProvider('tonstakers', stakingProvider); -``` - -### Get Staking Info - -```ts -// Get current APY and pool info -const info = await stakingManager.getStakingInfo(Network.mainnet()); -console.log(`APY: ${(info.apy * 100).toFixed(2)}%`); -console.log(`Instant liquidity: ${fromNano(info.instantUnstakeAvailable)} TON`); - -// Get user balance -const balance = await stakingManager.getBalance(wallet.getAddress(), Network.mainnet()); -console.log(`Staked: ${fromNano(balance.stakedBalance)} tsTON`); -console.log(`Available to stake: ${fromNano(balance.availableBalance)} TON`); -``` - -### Stake TON - -```ts -const stakeTx = await stakingManager.stake({ - amount: toNano('10').toString(), - userAddress: wallet.getAddress(), - network: Network.mainnet() -}); - -// Build and send transaction -const tx = await wallet.createTransferTonTransaction({ - recipientAddress: stakeTx.messages[0].address, - transferAmount: stakeTx.messages[0].amount, - payload: stakeTx.messages[0].payload -}); -await wallet.sendTransaction(tx); -``` - -### Unstake tsTON - -Three unstake modes are supported: - -```ts -// Delayed (default) - Funds released at end of round (~18 hours) -const delayedTx = await stakingManager.unstake({ - amount: toNano('5').toString(), - userAddress: wallet.getAddress(), - network: Network.mainnet(), - unstakeMode: UnstakeMode.Delayed -}); - -// Instant - Immediate withdrawal if pool has liquidity -const instantTx = await stakingManager.unstake({ - amount: toNano('5').toString(), - userAddress: wallet.getAddress(), - network: Network.mainnet(), - unstakeMode: UnstakeMode.Instant -}); - -// Best Rate - Wait for best exchange rate at round end -const bestRateTx = await stakingManager.unstake({ - amount: toNano('5').toString(), - userAddress: wallet.getAddress(), - network: Network.mainnet(), - unstakeMode: UnstakeMode.BestRate -}); -``` - -### Get Quote - -```ts -import { StakingQuoteDirection } from '@ton/walletkit'; - -const quote = await stakingManager.getQuote({ - direction: StakingQuoteDirection.Stake, - amount: toNano('10').toString(), - network: Network.mainnet() -}); -console.log(`APY: ${(quote.apy * 100).toFixed(2)}%`); -``` - ## Resources - [TON Connect Protocol](https://github.com/ton-blockchain/ton-connect) - Official TON Connect protocol specification diff --git a/packages/walletkit/examples/ton-staking.ts b/packages/walletkit/examples/ton-staking.ts deleted file mode 100644 index 46c28590d..000000000 --- a/packages/walletkit/examples/ton-staking.ts +++ /dev/null @@ -1,123 +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. - * - */ - -// npx tsx examples/ton-staking-local.ts -import 'dotenv/config'; - -import { fromNano, toNano } from '@ton/core'; - -import { CONTRACT, EventEmitter, NetworkManager, StakingManager, TonStakersStakingProvider, UnstakeMode } from '../src'; -import type { TransactionRequest } from '../src'; -import type { WalletSigner, NetworkAdapters } from '../src'; -import { ApiClientToncenter, Network, Signer, WalletV5R1Adapter, wrapWalletInterface } from '../src'; -import { PoolContract } from '../src'; -import { globalLogger, LogLevel } from '../src/core/Logger'; - -globalLogger.configure({ level: LogLevel.NONE }); - -// eslint-disable-next-line no-console -const logInfo = console.log; -// eslint-disable-next-line no-console -const logError = console.error; - -const mnemonic = process.env[`WALLET_MNEMONIC`]!.trim().split(' '); - -async function testEnvironment(signer: WalletSigner, network: Network) { - const testnet = Network.testnet().chainId == network.chainId; - const client = new ApiClientToncenter({ - apiKey: testnet ? process.env[`API_KEY_TESTNET`]! : process.env[`API_KEY_MAINNET`]!, - network, - }); - const wallet = await wrapWalletInterface( - await WalletV5R1Adapter.create(signer, { client, network: Network.mainnet() }), - ); - const userAddress = wallet.getAddress({ testnet }); - return { client, wallet, userAddress, testnet }; -} - -async function testPool(signer: WalletSigner, network: Network) { - const { client, userAddress, testnet } = await testEnvironment(signer, network); - const poolContract = testnet ? CONTRACT.STAKING_CONTRACT_ADDRESS_TESTNET : CONTRACT.STAKING_CONTRACT_ADDRESS; - const pool = new PoolContract(poolContract, client); - logInfo(await pool.getCodeVersion()); - const { supply, jettonMinter, minLoan } = await pool.getPoolFullData(); - const stakedBalance = await pool.getStakedBalance(userAddress); - logInfo({ - supply: fromNano(supply), - jettonMinter, - minLoan: fromNano(minLoan), - stakedBalance: fromNano(stakedBalance), - }); -} - -async function testStakingManager(signer: WalletSigner, network: Network) { - const { client, wallet, userAddress } = await testEnvironment(signer, network); - const stakingManager = new StakingManager(); - const stakingProvider = new TonStakersStakingProvider( - new NetworkManager({ networks: { [network.chainId]: client } as NetworkAdapters }), - new EventEmitter(), - {}, - ); - stakingManager.registerProvider('tonstakers', stakingProvider); - stakingManager.setDefaultProvider('tonstakers'); - let balance = await stakingManager.getBalance(userAddress, network); - logInfo({ - balance, - }); - let request: TransactionRequest | undefined; - if (balance.stakedBalance != '0') { - request = await stakingManager.unstake({ - userAddress, - amount: balance.stakedBalance, - network, - unstakeMode: UnstakeMode.Instant, - }); - } else { - request = await stakingManager.stake({ - userAddress, - amount: toNano(1).toString(), - network, - }); - } - if (request) { - logInfo({ - messages: request.messages, - }); - const preview = await wallet.getTransactionPreview(request); - if (preview.error) { - logInfo({ preview }); - } else { - const transaction = await wallet.sendTransaction(request); - logInfo({ transaction }); - } - balance = await stakingManager.getBalance(userAddress, network); - logInfo({ - balance, - }); - } -} - -async function main() { - const signer = await Signer.fromMnemonic(mnemonic); - - await testPool(signer, Network.mainnet()); - await testPool(signer, Network.testnet()); - - await testStakingManager(signer, Network.mainnet()); - await testStakingManager(signer, Network.testnet()); -} - -main().catch((error) => { - if (error instanceof Error) { - logError(error.message); - logError(error.stack); - } else { - logError('Unknown error:', error); - } - process.exit(1); -}); diff --git a/packages/walletkit/src/api/interfaces/StakingAPI.ts b/packages/walletkit/src/api/interfaces/StakingAPI.ts new file mode 100644 index 000000000..0034a93b6 --- /dev/null +++ b/packages/walletkit/src/api/interfaces/StakingAPI.ts @@ -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 { DefiManagerAPI } from './DefiManagerAPI'; +import type { DefiProvider } from './DefiProvider'; +import type { + Network, + StakeParams, + StakingBalance, + StakingProviderInfo, + StakingQuote, + StakingQuoteParams, + UnstakeParams, + TransactionRequest, + UserFriendlyAddress, +} from '../models'; + +/** + * Staking API interface exposed by StakingManager + */ +export interface StakingAPI extends DefiManagerAPI { + getQuote(params: StakingQuoteParams, providerId?: string): Promise; + buildStakeTransaction(params: StakeParams, providerId?: string): Promise; + buildUnstakeTransaction(params: UnstakeParams, providerId?: string): Promise; + getStakedBalance(userAddress: UserFriendlyAddress, network?: Network, providerId?: string): Promise; + getStakingProviderInfo(network?: Network, providerId?: string): Promise; +} + +/** + * Interface that all staking providers must implement + */ +export interface StakingProviderInterface extends DefiProvider { + getQuote(params: StakingQuoteParams): Promise; + buildStakeTransaction(params: StakeParams): Promise; + buildUnstakeTransaction(params: UnstakeParams): Promise; + getStakedBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; + getStakingProviderInfo(network?: Network): Promise; +} diff --git a/packages/walletkit/src/api/interfaces/index.ts b/packages/walletkit/src/api/interfaces/index.ts index 49ebc0c94..da88c2b56 100644 --- a/packages/walletkit/src/api/interfaces/index.ts +++ b/packages/walletkit/src/api/interfaces/index.ts @@ -9,9 +9,11 @@ export type { Wallet, WalletTonInterface, WalletNftInterface, WalletJettonInterface } from './Wallet'; export type { WalletAdapter } from './WalletAdapter'; export type { WalletSigner, ISigner } from './WalletSigner'; + // Defi interfaces export type { DefiManagerAPI } from './DefiManagerAPI'; -export type { SwapAPI, SwapProviderInterface } from './SwapAPI'; export type { DefiProvider } from './DefiProvider'; +export type { SwapAPI, SwapProviderInterface } from './SwapAPI'; +export type { StakingAPI, StakingProviderInterface } from './StakingAPI'; export type { TONConnectSessionManager } from './TONConnectSessionManager'; diff --git a/packages/walletkit/src/api/models/index.ts b/packages/walletkit/src/api/models/index.ts index e038205db..eecec5dc0 100644 --- a/packages/walletkit/src/api/models/index.ts +++ b/packages/walletkit/src/api/models/index.ts @@ -76,6 +76,16 @@ export type { SwapQuote } from './swaps/SwapQuote'; export type { SwapQuoteParams } from './swaps/SwapQuoteParams'; export type { SwapParams } from './swaps/SwapParams'; +// Staking models +export type { StakeParams } from './staking/StakeParams'; +export type { StakingBalance } from './staking/StakingBalance'; +export type { StakingProviderInfo } from './staking/StakingProviderInfo'; +export type { StakingQuote } from './staking/StakingQuote'; +export type { StakingQuoteDirection } from './staking/StakingQuoteDirection'; +export type { StakingQuoteParams } from './staking/StakingQuoteParams'; +export type { UnstakeMode } from './staking/UnstakeMode'; +export type { UnstakeParams } from './staking/UnstakeParams'; + // Transaction models export * from './transactions/Transaction'; export type { TransactionAddressMetadata, TransactionAddressMetadataEntry } from './transactions/TransactionMetadata'; diff --git a/packages/walletkit/src/api/models/staking/StakeParams.ts b/packages/walletkit/src/api/models/staking/StakeParams.ts new file mode 100644 index 000000000..8a59e9f1d --- /dev/null +++ b/packages/walletkit/src/api/models/staking/StakeParams.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 type { UserFriendlyAddress } from '../core/Primitives'; +import type { StakingQuote } from './StakingQuote'; + +/** + * Parameters for staking TON + */ +export interface StakeParams { + /** + * The staking quote based on which the transaction is built + */ + quote: StakingQuote; + + /** + * Address of the user performing the staking + */ + userAddress: UserFriendlyAddress; + + /** + * Provider-specific options + */ + providerOptions?: TProviderOptions; +} diff --git a/packages/walletkit/src/api/models/staking/StakingBalance.ts b/packages/walletkit/src/api/models/staking/StakingBalance.ts new file mode 100644 index 000000000..a84437288 --- /dev/null +++ b/packages/walletkit/src/api/models/staking/StakingBalance.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 type { TokenAmount } from '../core/TokenAmount'; + +/** + * Staking balance information for a user + */ +export interface StakingBalance { + /** + * Amount currently staked + */ + stakedBalance: TokenAmount; + + /** + * Amount available to unstake + */ + availableBalance: TokenAmount; + + /** + * Amount available for instant unstake + */ + instantUnstakeAvailable: TokenAmount; + + /** + * Identifier of the staking provider + */ + providerId: string; +} diff --git a/packages/walletkit/src/api/models/staking/StakingProviderInfo.ts b/packages/walletkit/src/api/models/staking/StakingProviderInfo.ts new file mode 100644 index 000000000..c226de5b2 --- /dev/null +++ b/packages/walletkit/src/api/models/staking/StakingProviderInfo.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 type { TokenAmount } from '../core/TokenAmount'; + +/** + * Staking information for a provider + */ +export interface StakingProviderInfo { + /** + * Annual Percentage Yield in basis points (100 = 1%) + * @format int + */ + apy: number; + + /** + * Amount available for instant unstake + */ + instantUnstakeAvailable?: TokenAmount; + + /** + * Identifier of the staking provider + */ + providerId: string; +} diff --git a/packages/walletkit/src/api/models/staking/StakingQuote.ts b/packages/walletkit/src/api/models/staking/StakingQuote.ts new file mode 100644 index 000000000..b540d310a --- /dev/null +++ b/packages/walletkit/src/api/models/staking/StakingQuote.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 type { Network } from '../core/Network'; +import type { TokenAmount } from '../core/TokenAmount'; +import type { StakingQuoteDirection } from './StakingQuoteDirection'; +import type { UnstakeMode } from './UnstakeMode'; + +/** + * Staking quote response with pricing information + */ +export interface StakingQuote { + /** + * Direction of the quote (stake or unstake) + */ + direction: StakingQuoteDirection; + + /** + * Amount of tokens being provided + */ + amountIn: TokenAmount; + + /** + * Estimated amount of tokens to be received + */ + amountOut: TokenAmount; + + /** + * Network on which the staking will be executed + */ + network: Network; + + /** + * Identifier of the staking provider + */ + providerId: string; + + /** + * Annual Percentage Yield in basis points (100 = 1%) + * @format int + */ + apy?: number; + + /** + * Mode of unstaking (if applicable) + */ + unstakeMode?: UnstakeMode; + + /** + * Estimated delay in hours for unstaking + * @format int + */ + estimatedUnstakeDelayHours?: number; + + /** + * Amount available for instant unstake + */ + instantUnstakeAvailable?: TokenAmount; + + /** + * Provider-specific metadata for the quote + */ + metadata?: unknown; +} diff --git a/packages/walletkit/src/api/models/staking/StakingQuoteDirection.ts b/packages/walletkit/src/api/models/staking/StakingQuoteDirection.ts new file mode 100644 index 000000000..ca2625b77 --- /dev/null +++ b/packages/walletkit/src/api/models/staking/StakingQuoteDirection.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. + * + */ + +/** + * Direction of the staking quote + */ +export type StakingQuoteDirection = 'stake' | 'unstake'; diff --git a/packages/walletkit/src/api/models/staking/StakingQuoteParams.ts b/packages/walletkit/src/api/models/staking/StakingQuoteParams.ts new file mode 100644 index 000000000..4620deeee --- /dev/null +++ b/packages/walletkit/src/api/models/staking/StakingQuoteParams.ts @@ -0,0 +1,48 @@ +/** + * 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 { UserFriendlyAddress } from '../core/Primitives'; +import type { Network } from '../core/Network'; +import type { StakingQuoteDirection } from './StakingQuoteDirection'; +import type { UnstakeMode } from './UnstakeMode'; +import type { TokenAmount } from '../core/TokenAmount'; + +/** + * Parameters for getting a staking quote + */ +export interface StakingQuoteParams { + /** + * Direction of the quote (stake or unstake) + */ + direction: StakingQuoteDirection; + + /** + * Amount of tokens to stake or unstake + */ + amount: TokenAmount; + + /** + * Address of the user + */ + userAddress?: UserFriendlyAddress; + + /** + * Network on which the staking will be executed + */ + network?: Network; + + /** + * Requested mode of unstaking + */ + unstakeMode?: UnstakeMode; + + /** + * Provider-specific options + */ + providerOptions?: TProviderOptions; +} diff --git a/packages/walletkit/src/api/models/staking/UnstakeMode.ts b/packages/walletkit/src/api/models/staking/UnstakeMode.ts new file mode 100644 index 000000000..752a9612c --- /dev/null +++ b/packages/walletkit/src/api/models/staking/UnstakeMode.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. + * + */ + +/** + * Mode of unstaking + */ +export type UnstakeMode = 'instant' | 'delayed' | 'bestRate'; diff --git a/packages/walletkit/src/api/models/staking/UnstakeParams.ts b/packages/walletkit/src/api/models/staking/UnstakeParams.ts new file mode 100644 index 000000000..16ee795a2 --- /dev/null +++ b/packages/walletkit/src/api/models/staking/UnstakeParams.ts @@ -0,0 +1,37 @@ +/** + * 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 { UserFriendlyAddress } from '../core/Primitives'; +import type { StakingQuote } from './StakingQuote'; + +/** + * Parameters for unstaking TON + */ +export interface UnstakeParams { + /** + * The staking quote based on which the transaction is built + */ + quote: StakingQuote; + + /** + * Address of the user performing the unstaking + */ + userAddress: UserFriendlyAddress; + + /** + * Optional upper bound for delayed unstake waiting time. + * Providers can use this to decide between instant and delayed flows. + * @format int + */ + maxDelayHours?: number; + + /** + * Provider-specific options + */ + providerOptions?: TProviderOptions; +} diff --git a/packages/walletkit/src/defi/staking/StakingManager.ts b/packages/walletkit/src/defi/staking/StakingManager.ts index 69eba1ef1..f11cac1ba 100644 --- a/packages/walletkit/src/defi/staking/StakingManager.ts +++ b/packages/walletkit/src/defi/staking/StakingManager.ts @@ -8,15 +8,14 @@ import type { TransactionRequest, UserFriendlyAddress, Network } from '../../api/models'; import type { - StakingAPI, StakeParams, UnstakeParams, StakingBalance, StakingProviderInfo, - StakingProviderInterface, StakingQuoteParams, StakingQuote, -} from './types'; +} from '../../api/models'; +import type { StakingAPI, StakingProviderInterface } from '../../api/interfaces'; import { StakingError, StakingErrorCode } from './errors'; import { globalLogger } from '../../core/Logger'; import { DefiManager } from '../DefiManager'; @@ -51,7 +50,7 @@ export class StakingManager extends DefiManager implem * @param params - Staking parameters * @param providerId - Optional provider id to use */ - async stake(params: StakeParams, providerId?: string): Promise { + async buildStakeTransaction(params: StakeParams, providerId?: string): Promise { log.debug('Building staking transaction', params); try { return await this.getProvider(providerId).buildStakeTransaction(params); @@ -68,7 +67,7 @@ export class StakingManager extends DefiManager implem * @param params - Unstaking parameters * @param providerId - Optional provider id to use */ - async unstake(params: UnstakeParams, providerId?: string): Promise { + async buildUnstakeTransaction(params: UnstakeParams, providerId?: string): Promise { log.debug('Building unstaking transaction', params); try { return await this.getProvider(providerId).buildUnstakeTransaction(params); @@ -86,7 +85,7 @@ export class StakingManager extends DefiManager implem * @param network - Network to query * @param providerId - Optional provider id to use */ - async getBalance( + async getStakedBalance( userAddress: UserFriendlyAddress, network?: Network, providerId?: string, diff --git a/packages/walletkit/src/defi/staking/StakingProvider.ts b/packages/walletkit/src/defi/staking/StakingProvider.ts index 0f747c569..fe8bd8b8a 100644 --- a/packages/walletkit/src/defi/staking/StakingProvider.ts +++ b/packages/walletkit/src/defi/staking/StakingProvider.ts @@ -14,10 +14,10 @@ import type { UnstakeParams, StakingBalance, StakingProviderInfo, - StakingProviderInterface, StakingQuoteParams, StakingQuote, -} from './types'; +} from '../../api/models'; +import type { StakingProviderInterface } from '../../api/interfaces'; /** * Abstract base class for staking providers diff --git a/packages/walletkit/src/defi/staking/index.ts b/packages/walletkit/src/defi/staking/index.ts index 6b49b1f6a..16abb38d0 100644 --- a/packages/walletkit/src/defi/staking/index.ts +++ b/packages/walletkit/src/defi/staking/index.ts @@ -10,9 +10,7 @@ export { StakingProvider } from './StakingProvider'; export { StakingManager } from './StakingManager'; export { StakingError } from './errors'; export type { StakingErrorCode } from './errors'; -export { StakingQuoteDirection, UnstakeMode } from './types'; -export type * from './types'; -export { TonStakersStakingProvider } from './tonstakers/TonStakersStakingProvider'; -export { PoolContract } from './tonstakers/PoolContract'; -export { StakingCache } from './tonstakers/StakingCache'; -export type { TonStakersProviderConfig } from './tonstakers/types'; + +// TonStakers +export { TonStakersStakingProvider, PoolContract, StakingCache } from './tonstakers'; +export type { TonStakersProviderConfig } from './tonstakers'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts index e5d4d2873..8b339b923 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts @@ -11,10 +11,9 @@ import type { MockInstance } from 'vitest'; import { TonStakersStakingProvider } from './TonStakersStakingProvider'; import { PoolContract } from './PoolContract'; -import { StakingQuoteDirection, UnstakeMode } from '../types'; import { CONTRACT } from './constants'; import { Network } from '../../../api/models'; -import type { Base64String } from '../../../api/models'; +import type { Base64String, UnstakeMode } from '../../../api/models'; const mockApiClient = { runGetMethod: vi.fn(), @@ -67,17 +66,17 @@ describe('TonStakersStakingProvider', () => { it('should return correct quote with APY for stake direction', async () => { const amount = '1000000000'; const quote = await provider.getQuote({ - direction: StakingQuoteDirection.Stake, + direction: 'stake', amount, userAddress: testUserAddress, network: Network.mainnet(), }); - expect(quote.direction).toBe(StakingQuoteDirection.Stake); + expect(quote.direction).toBe('stake'); expect(quote.amountIn).toBe(amount); // amountOut = 1000000000 / 1.1 = 909090909 expect(quote.amountOut).toBe('909090909'); - expect(quote.provider).toBe('tonstakers'); + expect(quote.providerId).toBe('tonstakers'); expect(quote.apy).toBe(0.05); expect(getApyFromTonApiSpy).toHaveBeenCalled(); }); @@ -85,41 +84,47 @@ describe('TonStakersStakingProvider', () => { it('should return correct quote with unstakeMode for unstake direction', async () => { const amount = '1000000000'; const quote = await provider.getQuote({ - direction: StakingQuoteDirection.Unstake, + direction: 'unstake', amount, userAddress: testUserAddress, network: Network.mainnet(), - unstakeMode: UnstakeMode.Instant, + unstakeMode: 'instant', }); - expect(quote.direction).toBe(StakingQuoteDirection.Unstake); + expect(quote.direction).toBe('unstake'); expect(quote.amountIn).toBe(amount); // amountOut = 1000000000 * 1.05 = 1050000000 expect(quote.amountOut).toBe('1050000000'); - expect(quote.provider).toBe('tonstakers'); - expect(quote.unstakeMode).toBe(UnstakeMode.Instant); + expect(quote.providerId).toBe('tonstakers'); + expect(quote.unstakeMode).toBe('instant'); }); it('should default to Delayed unstakeMode when not specified', async () => { const quote = await provider.getQuote({ - direction: StakingQuoteDirection.Unstake, + direction: 'unstake', amount: '1000000000', network: Network.mainnet(), }); - expect(quote.unstakeMode).toBe(UnstakeMode.Delayed); + expect(quote.unstakeMode).toBe('delayed'); }); }); describe('stake', () => { it('should build correct transaction with stake payload', async () => { const amount = '1000000000'; - const tx = await provider.buildStakeTransaction({ + const quote = await provider.getQuote({ + direction: 'stake', amount, userAddress: testUserAddress, network: Network.mainnet(), }); + const tx = await provider.buildStakeTransaction({ + quote, + userAddress: testUserAddress, + }); + expect(tx.fromAddress).toBe(testUserAddress); expect(tx.network).toEqual(Network.mainnet()); expect(tx.messages).toHaveLength(1); @@ -137,11 +142,18 @@ describe('TonStakersStakingProvider', () => { describe('unstake', () => { it('should build correct transaction for Delayed mode', async () => { - const tx = await provider.buildUnstakeTransaction({ - amount: '1000000000', + const amount = '1000000000'; + const quote = await provider.getQuote({ + direction: 'unstake', + amount, userAddress: testUserAddress, network: Network.mainnet(), - unstakeMode: UnstakeMode.Delayed, + unstakeMode: 'delayed', + }); + + const tx = await provider.buildUnstakeTransaction({ + quote, + userAddress: testUserAddress, }); expect(tx.fromAddress).toBe(testUserAddress); @@ -158,11 +170,18 @@ describe('TonStakersStakingProvider', () => { }); it('should build correct transaction for Instant mode', async () => { - await provider.buildUnstakeTransaction({ - amount: '1000000000', + const amount = '1000000000'; + const quote = await provider.getQuote({ + direction: 'unstake', + amount, userAddress: testUserAddress, network: Network.mainnet(), - unstakeMode: UnstakeMode.Instant, + unstakeMode: 'instant', + }); + + await provider.buildUnstakeTransaction({ + quote, + userAddress: testUserAddress, }); expect(buildUnstakeMessageSpy).toHaveBeenCalledWith({ @@ -174,11 +193,18 @@ describe('TonStakersStakingProvider', () => { }); it('should build correct transaction for BestRate mode', async () => { - await provider.buildUnstakeTransaction({ - amount: '1000000000', + const amount = '1000000000'; + const quote = await provider.getQuote({ + direction: 'unstake', + amount, userAddress: testUserAddress, network: Network.mainnet(), - unstakeMode: UnstakeMode.BestRate, + unstakeMode: 'bestRate', + }); + + await provider.buildUnstakeTransaction({ + quote, + userAddress: testUserAddress, }); expect(buildUnstakeMessageSpy).toHaveBeenCalledWith({ @@ -189,13 +215,20 @@ describe('TonStakersStakingProvider', () => { }); }); - it('should default to Delayed mode when unstakeMode not specified', async () => { - await provider.buildUnstakeTransaction({ - amount: '1000000000', + it('should default to Delayed mode when unstakeMode not specified in quote', async () => { + const amount = '1000000000'; + const quote = await provider.getQuote({ + direction: 'unstake', + amount, userAddress: testUserAddress, network: Network.mainnet(), }); + await provider.buildUnstakeTransaction({ + quote, + userAddress: testUserAddress, + }); + expect(buildUnstakeMessageSpy).toHaveBeenCalledWith({ amount: 1000000000n, userAddress: testUserAddress, @@ -207,15 +240,22 @@ describe('TonStakersStakingProvider', () => { describe('unstake mode flags', () => { it.each([ - { mode: UnstakeMode.Delayed, waitTillRoundEnd: false, fillOrKill: false }, - { mode: UnstakeMode.Instant, waitTillRoundEnd: false, fillOrKill: true }, - { mode: UnstakeMode.BestRate, waitTillRoundEnd: true, fillOrKill: false }, + { mode: 'delayed', waitTillRoundEnd: false, fillOrKill: false }, + { mode: 'instant', waitTillRoundEnd: false, fillOrKill: true }, + { mode: 'bestRate', waitTillRoundEnd: true, fillOrKill: false }, ])('should set correct flags for $mode mode', async ({ mode, waitTillRoundEnd, fillOrKill }) => { - await provider.buildUnstakeTransaction({ - amount: '1000000000', + const amount = '1000000000'; + const quote = await provider.getQuote({ + direction: 'unstake', + amount, userAddress: testUserAddress, network: Network.mainnet(), - unstakeMode: mode, + unstakeMode: mode as UnstakeMode, + }); + + await provider.buildUnstakeTransaction({ + quote, + userAddress: testUserAddress, }); expect(buildUnstakeMessageSpy).toHaveBeenCalledWith( @@ -239,4 +279,29 @@ describe('TonStakersStakingProvider', () => { expect(info).not.toHaveProperty('tsTONTONProjected'); }); }); + + describe('getStakedBalance', () => { + it('should return user balance and provider info', async () => { + mockApiClient.getBalance.mockResolvedValue('2000000000'); // 2 TON + vi.spyOn(PoolContract.prototype, 'getStakedBalance').mockResolvedValue('1000000000'); // 1 tsTON + + const balance = await provider.getStakedBalance(testUserAddress, Network.mainnet()); + + expect(balance.stakedBalance).toBe('1000000000'); + // availableBalance = 2 TON - 1.1 TON reserve = 0.9 TON + expect(balance.availableBalance).toBe('900000000'); + expect(balance.instantUnstakeAvailable).toBe('500000000000'); + expect(balance.providerId).toBe('tonstakers'); + expect(mockApiClient.getBalance).toHaveBeenCalledWith(testUserAddress); + }); + + it('should return 0 available balance if TON balance is below reserve', async () => { + mockApiClient.getBalance.mockResolvedValue('500000000'); // 0.5 TON + vi.spyOn(PoolContract.prototype, 'getStakedBalance').mockResolvedValue('0'); + + const balance = await provider.getStakedBalance(testUserAddress, Network.mainnet()); + + expect(balance.availableBalance).toBe('0'); + }); + }); }); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts index dc17ec560..08432a98e 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -18,10 +18,9 @@ import type { StakingProviderInfo, StakingQuoteParams, StakingQuote, -} from '../types'; +} from '../../../api/models'; import { StakingError, StakingErrorCode } from '../errors'; -import { StakingQuoteDirection, UnstakeMode } from '../types'; -import type { TonStakersProviderConfig } from './types'; +import type { TonStakersProviderConfig } from './models/TonStakersProviderConfig'; import { CONTRACT } from './constants'; import { PoolContract } from './PoolContract'; import { StakingCache } from './StakingCache'; @@ -64,7 +63,7 @@ const log = globalLogger.createChild('TonStakersStakingProvider'); * amount: toNano('5').toString(), * userAddress: wallet.getAddress(), * network: Network.mainnet(), - * unstakeMode: UnstakeMode.Instant + * unstakeMode: 'instant' * }); * ``` */ @@ -110,34 +109,36 @@ export class TonStakersStakingProvider extends StakingProvider { const contract = this.getContract(params.network); const rates = await contract.getRates(); - if (params.direction === StakingQuoteDirection.Stake) { + if (params.direction === 'stake') { // User deposits TON, receives tsTON: tsTON = TON / rate const amountInTokens = Number(formatUnits(params.amount, 9)); const amountOutTokens = amountInTokens / rates.tsTONTONProjected; const amountOut = parseUnits(amountOutTokens.toFixed(9), 9).toString(); return { - direction: StakingQuoteDirection.Stake, + direction: 'stake', amountIn: params.amount, amountOut, - provider: 'tonstakers', + network: params.network || Network.mainnet(), + providerId: 'tonstakers', apy: stakingInfo.apy, }; } else { // User burns tsTON, receives TON: TON = tsTON * rate const amountInTokens = Number(formatUnits(params.amount, 9)); const amountOutTokens = - params.unstakeMode === UnstakeMode.Instant + params.unstakeMode === 'instant' ? amountInTokens * rates.tsTONTON : amountInTokens * rates.tsTONTONProjected; const amountOut = parseUnits(amountOutTokens.toFixed(9), 9).toString(); return { - direction: StakingQuoteDirection.Unstake, + direction: 'unstake', amountIn: params.amount, amountOut, - provider: 'tonstakers', - unstakeMode: params.unstakeMode || UnstakeMode.Delayed, + network: params.network || Network.mainnet(), + providerId: 'tonstakers', + unstakeMode: params.unstakeMode || 'delayed', }; } } @@ -149,15 +150,15 @@ export class TonStakersStakingProvider extends StakingProvider { * and receives tsTON liquid staking tokens in return. * A fee reserve of 1 TON is automatically added to the amount. * - * @param params - Stake parameters including amount and user address + * @param params - Stake parameters including quote and user address * @returns Transaction request ready to be signed and sent */ async buildStakeTransaction(params: StakeParams): Promise { log.debug('TonStakers stake requested', { params }); - const network = params.network; + const network = params.quote.network; const contractAddress = this.getStakingContractAddress(network); - const amount = BigInt(params.amount); + const amount = BigInt(params.quote.amountIn); const totalAmount = amount + CONTRACT.STAKE_FEE_RES; const contract = this.getContract(network); @@ -184,29 +185,29 @@ export class TonStakersStakingProvider extends StakingProvider { * - **Instant**: Immediate withdrawal if pool has sufficient liquidity (fillOrKill) * - **BestRate**: Wait until round end for best exchange rate * - * @param params - Unstake parameters including amount, user address, and optional mode + * @param params - Unstake parameters including quote and user address * @returns Transaction request ready to be signed and sent */ async buildUnstakeTransaction(params: UnstakeParams): Promise { - log.debug('TonStakers unstake requested', { amount: params.amount, userAddress: params.userAddress }); + log.debug('TonStakers unstake requested', { amount: params.quote.amountIn, userAddress: params.userAddress }); - const network = params.network; - const amount = BigInt(params.amount); - const unstakeMode = params.unstakeMode || UnstakeMode.Delayed; + const network = params.quote.network; + const amount = BigInt(params.quote.amountIn); + const unstakeMode = params.quote.unstakeMode || 'delayed'; let waitTillRoundEnd = false; let fillOrKill = false; switch (unstakeMode) { - case UnstakeMode.Instant: + case 'instant': waitTillRoundEnd = false; fillOrKill = true; break; - case UnstakeMode.BestRate: + case 'bestRate': waitTillRoundEnd = true; fillOrKill = false; break; - case UnstakeMode.Delayed: + case 'delayed': default: waitTillRoundEnd = false; fillOrKill = false; diff --git a/packages/walletkit/src/defi/staking/tonstakers/constants.ts b/packages/walletkit/src/defi/staking/tonstakers/constants.ts index 4c608a8e5..d5e14f63f 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/constants.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/constants.ts @@ -6,20 +6,21 @@ * */ -import { toNano } from '@ton/core'; +import type { UserFriendlyAddress } from '../../../api/models'; +import { parseUnits } from '../../../utils/units'; export const CACHE_TIMEOUT = 30000; // Contract-related constants export const CONTRACT = { // https://github.com/ton-blockchain/liquid-staking-contract/tree/35d676f6ac6e35e755ea3c4d7d7cf577627b1cf0 - STAKING_CONTRACT_ADDRESS: 'EQCkWxfyhAkim3g2DjKQQg8T5P4g-Q1-K_jErGcDJZ4i-vqR', + STAKING_CONTRACT_ADDRESS: 'EQCkWxfyhAkim3g2DjKQQg8T5P4g-Q1-K_jErGcDJZ4i-vqR' as UserFriendlyAddress, // https://github.com/ton-blockchain/liquid-staking-contract/tree/77f13c850890517a6b490ef5f109c31b4fa783e7 - STAKING_CONTRACT_ADDRESS_TESTNET: 'kQANFsYyYn-GSZ4oajUJmboDURZU-udMHf9JxzO4vYM_hFP3', + STAKING_CONTRACT_ADDRESS_TESTNET: 'kQANFsYyYn-GSZ4oajUJmboDURZU-udMHf9JxzO4vYM_hFP3' as UserFriendlyAddress, PARTNER_CODE: 0x000000106796caef, PAYLOAD_UNSTAKE: 0x595f07bc, PAYLOAD_STAKE: 0x47d54391, - STAKE_FEE_RES: toNano('1'), - UNSTAKE_FEE_RES: toNano('1.05'), - RECOMMENDED_FEE_RESERVE: toNano('1.1'), + STAKE_FEE_RES: parseUnits('1', 9), + UNSTAKE_FEE_RES: parseUnits('1.05', 9), + RECOMMENDED_FEE_RESERVE: parseUnits('1.1', 9), }; diff --git a/packages/walletkit/src/defi/staking/tonstakers/index.ts b/packages/walletkit/src/defi/staking/tonstakers/index.ts new file mode 100644 index 000000000..4d1968a97 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/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 { TonStakersStakingProvider } from './TonStakersStakingProvider'; +export { PoolContract } from './PoolContract'; +export { StakingCache } from './StakingCache'; +export type { TonStakersProviderConfig } from './models/TonStakersProviderConfig'; diff --git a/packages/walletkit/src/defi/staking/tonstakers/types.ts b/packages/walletkit/src/defi/staking/tonstakers/models/TonStakersProviderConfig.ts similarity index 79% rename from packages/walletkit/src/defi/staking/tonstakers/types.ts rename to packages/walletkit/src/defi/staking/tonstakers/models/TonStakersProviderConfig.ts index 9932577f3..65a2be4c3 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/types.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/models/TonStakersProviderConfig.ts @@ -6,9 +6,11 @@ * */ +import type { UserFriendlyAddress } from '../../../../api/models'; + export interface TonStakersProviderConfig { [chainId: string]: { - contractAddress: string; + contractAddress: UserFriendlyAddress; /** * Optional TonAPI token used exclusively for fetching historical APY. * The provider is fully functional without this token. diff --git a/packages/walletkit/src/defi/staking/tonstakers/models/index.ts b/packages/walletkit/src/defi/staking/tonstakers/models/index.ts new file mode 100644 index 000000000..5c9626354 --- /dev/null +++ b/packages/walletkit/src/defi/staking/tonstakers/models/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 type { TonStakersProviderConfig } from './TonStakersProviderConfig'; diff --git a/packages/walletkit/src/defi/staking/types.ts b/packages/walletkit/src/defi/staking/types.ts deleted file mode 100644 index d63c1f9f9..000000000 --- a/packages/walletkit/src/defi/staking/types.ts +++ /dev/null @@ -1,112 +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 { DefiProvider } from '../../api/interfaces'; -import type { Network, TokenAmount, TransactionRequest, UserFriendlyAddress } from '../../api/models'; - -export enum StakingQuoteDirection { - Stake = 'stake', - Unstake = 'unstake', -} - -export enum UnstakeMode { - Instant = 'instant', - Delayed = 'delayed', - BestRate = 'bestRate', -} - -/** - * Parameters for requesting a staking quote - */ -export interface StakingQuoteParams { - direction: StakingQuoteDirection; - amount: TokenAmount; - userAddress?: UserFriendlyAddress; - network?: Network; - unstakeMode?: UnstakeMode; -} - -/** - * Staking quote response with pricing information - */ -export interface StakingQuote { - direction: StakingQuoteDirection; - amountIn: TokenAmount; - amountOut: TokenAmount; - provider: string; - apy?: number; - unstakeMode?: UnstakeMode; - estimatedUnstakeDelayHours?: number; - instantUnstakeAvailable?: TokenAmount; - metadata?: unknown; -} - -/** - * Parameters for staking TON - */ -export interface StakeParams { - amount: TokenAmount; - userAddress: UserFriendlyAddress; - network?: Network; -} - -/** - * Parameters for unstaking TON - */ -export interface UnstakeParams { - amount: TokenAmount; - userAddress: UserFriendlyAddress; - network?: Network; - unstakeMode?: UnstakeMode; - /** - * Optional upper bound for delayed unstake waiting time. - * Providers can use this to decide between instant and delayed flows. - */ - maxDelayHours?: number; -} - -/** - * Staking balance information for a user - */ -export interface StakingBalance { - stakedBalance: TokenAmount; - availableBalance: TokenAmount; - instantUnstakeAvailable: TokenAmount; - providerId: string; -} - -/** - * Staking information for a provider - */ -export interface StakingProviderInfo { - apy: number; - instantUnstakeAvailable?: TokenAmount; - providerId: string; -} - -/** - * Staking API interface exposed by StakingManager - */ -export interface StakingAPI { - getQuote(params: StakingQuoteParams, providerId?: string): Promise; - stake(params: StakeParams, providerId?: string): Promise; - unstake(params: UnstakeParams, providerId?: string): Promise; - getBalance(userAddress: UserFriendlyAddress, network?: Network, providerId?: string): Promise; - getStakingProviderInfo(network?: Network, providerId?: string): Promise; -} - -/** - * Interface that all staking providers must implement - */ -export interface StakingProviderInterface extends DefiProvider { - getQuote(params: StakingQuoteParams): Promise; - buildStakeTransaction(params: StakeParams): Promise; - buildUnstakeTransaction(params: UnstakeParams): Promise; - getStakedBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; - getStakingProviderInfo(network?: Network): Promise; -} diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 3a225b463..420697017 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -27,11 +27,8 @@ export { TonStakersStakingProvider, PoolContract, StakingCache, - UnstakeMode, - StakingQuoteDirection, } from './defi/staking'; -export type * from './defi/staking/types'; -export type { TonStakersProviderConfig } from './defi/staking/tonstakers/types'; +export type { TonStakersProviderConfig } from './defi/staking/tonstakers/models/TonStakersProviderConfig'; export { EventEmitter } from './core/EventEmitter'; export type { EventListener } from './core/EventEmitter'; export { ApiClientToncenter } from './clients/toncenter'; From e22447866dd858c8efdba1848729e67fb0ca31ee Mon Sep 17 00:00:00 2001 From: "V. K." Date: Thu, 12 Mar 2026 22:55:24 +0400 Subject: [PATCH 10/15] feat(staking): add ui in demo-wallet --- apps/demo-wallet/src/components/AppRouter.tsx | 9 + .../src/components/staking/StakingInfo.tsx | 56 ++++ .../components/staking/StakingInterface.tsx | 163 ++++++++++++ .../src/components/swap/SwapInterface.tsx | 4 +- apps/demo-wallet/src/pages/Staking.tsx | 65 +++++ .../demo-wallet/src/pages/WalletDashboard.tsx | 6 +- apps/demo-wallet/src/pages/index.ts | 1 + demo/wallet-core/src/hooks/useWalletStore.ts | 30 ++- demo/wallet-core/src/index.ts | 2 + .../src/store/createWalletStore.ts | 4 + .../src/store/slices/stakingSlice.ts | 244 ++++++++++++++++++ .../wallet-core/src/store/slices/swapSlice.ts | 4 +- .../src/store/slices/walletCoreSlice.ts | 12 +- demo/wallet-core/src/types/store.ts | 48 +++- .../src/api/models/staking/StakingBalance.ts | 5 - packages/walletkit/src/core/TonWalletKit.ts | 6 - .../src/defi/staking/StakingProvider.ts | 17 +- .../tonstakers/TonStakersStakingProvider.ts | 80 +++--- .../src/defi/staking/tonstakers/constants.ts | 12 +- .../models/TonStakersProviderConfig.ts | 4 +- packages/walletkit/src/types/kit.ts | 5 +- 21 files changed, 683 insertions(+), 94 deletions(-) create mode 100644 apps/demo-wallet/src/components/staking/StakingInfo.tsx create mode 100644 apps/demo-wallet/src/components/staking/StakingInterface.tsx create mode 100644 apps/demo-wallet/src/pages/Staking.tsx create mode 100644 demo/wallet-core/src/store/slices/stakingSlice.ts diff --git a/apps/demo-wallet/src/components/AppRouter.tsx b/apps/demo-wallet/src/components/AppRouter.tsx index 97e2b89d8..632f166de 100644 --- a/apps/demo-wallet/src/components/AppRouter.tsx +++ b/apps/demo-wallet/src/components/AppRouter.tsx @@ -21,6 +21,7 @@ import { TracePage, TransactionDetail, Swap, + Staking, } from '../pages'; import { useWalletDataUpdater } from '@/hooks/useWalletDataUpdater'; @@ -119,6 +120,14 @@ export const AppRouter: React.FC = () => { } /> + + + + } + /> { + const { stakedBalance, providerInfo } = useStaking(); + + return ( +
+ +
+
+

Balance

+

+ {stakedBalance?.stakedBalance ? formatUnits(stakedBalance?.stakedBalance, 9) : '0.00'} tsTON +

+
+
+
+ + +
+
+ Provider + Tonstakers +
+
+ APY + + {providerInfo?.apy ? `${providerInfo.apy.toFixed(2)}%` : '--'} + +
+
+ Instant Unstake Available + + {providerInfo?.instantUnstakeAvailable + ? Number(formatUnits(providerInfo?.instantUnstakeAvailable, 9)).toFixed(4) + : '0.00'}{' '} + TON + +
+
+
+
+ ); +}; diff --git a/apps/demo-wallet/src/components/staking/StakingInterface.tsx b/apps/demo-wallet/src/components/staking/StakingInterface.tsx new file mode 100644 index 000000000..674d52f96 --- /dev/null +++ b/apps/demo-wallet/src/components/staking/StakingInterface.tsx @@ -0,0 +1,163 @@ +/** + * 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, ChangeEvent } from 'react'; +import { useState } from 'react'; +import { formatUnits, useStaking } from '@demo/wallet-core'; +import { useNavigate } from 'react-router-dom'; + +import { Card } from '../Card'; +import { Button } from '../Button'; + +import { cn } from '@/lib/utils'; + +export const StakingInterface: FC = () => { + const { + amount, + currentQuote, + isLoadingQuote, + isStaking, + isUnstaking, + error, + setStakingAmount: setAmount, + getStakingQuote: getQuote, + stake, + unstake, + validateStakingInputs, + } = useStaking(); + + const [tab, setTab] = useState<'stake' | 'unstake'>('stake'); + + const navigate = useNavigate(); + + const handleAmountChange = (e: ChangeEvent) => { + setAmount(e.target.value); + }; + + const handleGetQuote = async () => { + await getQuote({ + amount, + direction: tab === 'stake' ? 'stake' : 'unstake', + }); + }; + + const handleAction = async () => { + if (!currentQuote) return; + + if (tab === 'stake') { + await stake({ quote: currentQuote }); + } else { + await unstake({ quote: currentQuote }); + } + + navigate('/wallet', { + state: { message: `${tab === 'stake' ? 'Staked' : 'Unstaked'} successfully!` }, + }); + }; + + const validationError = validateStakingInputs(); + const canGetQuote = !validationError && amount && parseFloat(amount) > 0; + + return ( + +
+ + +
+ +
+
+ +
+ +
+ {tab === 'stake' ? 'TON' : 'tsTON'} +
+
+ {validationError && amount !== '' &&

{validationError}

} +
+ + {currentQuote && ( +
+
+ You will receive + + {formatUnits(currentQuote.amountOut, 9)} {tab === 'stake' ? 'tsTON' : 'TON'} + +
+
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!currentQuote ? ( + + ) : ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/apps/demo-wallet/src/components/swap/SwapInterface.tsx b/apps/demo-wallet/src/components/swap/SwapInterface.tsx index 817485416..7840223f9 100644 --- a/apps/demo-wallet/src/components/swap/SwapInterface.tsx +++ b/apps/demo-wallet/src/components/swap/SwapInterface.tsx @@ -44,12 +44,12 @@ export const SwapInterface: FC = ({ className }) => { slippageBps, setFromToken, setToToken, - setAmount, + setSwapAmount: setAmount, setIsReverseSwap, setDestinationAddress, setSlippageBps, swapTokens, - getQuote, + getSwapQuote: getQuote, executeSwap, } = useSwap(); diff --git a/apps/demo-wallet/src/pages/Staking.tsx b/apps/demo-wallet/src/pages/Staking.tsx new file mode 100644 index 000000000..a2f157502 --- /dev/null +++ b/apps/demo-wallet/src/pages/Staking.tsx @@ -0,0 +1,65 @@ +/** + * 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 { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useStaking, useWallet } from '@demo/wallet-core'; + +import { Layout, Button } from '../components'; +import { StakingInterface } from '../components/staking/StakingInterface'; +import { StakingInfo } from '../components/staking/StakingInfo'; + +export const Staking: FC = () => { + const navigate = useNavigate(); + const { address } = useWallet(); + const { clearStaking, loadStakingData } = useStaking(); + + useEffect(() => { + if (address) { + loadStakingData(address); + } + return () => clearStaking(); + }, [address, loadStakingData, clearStaking]); + + return ( + +
+ +
+ +
+ + +
+ + {/* Warning */} +
+
+
+ + + +
+
+

+ Staking involves locking your TON to earn rewards. Please note that unstaking may take some + time depending on the pool cycle. +

+
+
+
+
+ ); +}; diff --git a/apps/demo-wallet/src/pages/WalletDashboard.tsx b/apps/demo-wallet/src/pages/WalletDashboard.tsx index 8a3f1fd48..f44a45ed5 100644 --- a/apps/demo-wallet/src/pages/WalletDashboard.tsx +++ b/apps/demo-wallet/src/pages/WalletDashboard.tsx @@ -267,7 +267,7 @@ export const WalletDashboard: React.FC = () => { )} -
+
@@ -275,6 +275,10 @@ export const WalletDashboard: React.FC = () => { + +
diff --git a/apps/demo-wallet/src/pages/index.ts b/apps/demo-wallet/src/pages/index.ts index 6bda77071..fb0c7a821 100644 --- a/apps/demo-wallet/src/pages/index.ts +++ b/apps/demo-wallet/src/pages/index.ts @@ -14,3 +14,4 @@ export * from './SendTransaction'; export * from './TracePage'; export * from './TransactionDetail'; export * from './Swap'; +export * from './Staking'; diff --git a/demo/wallet-core/src/hooks/useWalletStore.ts b/demo/wallet-core/src/hooks/useWalletStore.ts index d3d6bd174..65941c321 100644 --- a/demo/wallet-core/src/hooks/useWalletStore.ts +++ b/demo/wallet-core/src/hooks/useWalletStore.ts @@ -217,15 +217,41 @@ export const useSwap = () => { isReverseSwap: state.swap.isReverseSwap, setFromToken: state.setFromToken, setToToken: state.setToToken, - setAmount: state.setAmount, + setSwapAmount: state.setSwapAmount, setDestinationAddress: state.setDestinationAddress, setSlippageBps: state.setSlippageBps, setIsReverseSwap: state.setIsReverseSwap, swapTokens: state.swapTokens, - getQuote: state.getQuote, + getSwapQuote: state.getSwapQuote, executeSwap: state.executeSwap, clearSwap: state.clearSwap, validateSwapInputs: state.validateSwapInputs, })), ); }; +/** + * Hook for Staking + */ +export const useStaking = () => { + return useWalletStore( + useShallow((state) => ({ + amount: state.staking.amount, + providerId: state.staking.providerId, + currentQuote: state.staking.currentQuote, + isLoadingQuote: state.staking.isLoadingQuote, + isStaking: state.staking.isStaking, + isUnstaking: state.staking.isUnstaking, + error: state.staking.error, + stakedBalance: state.staking.stakedBalance, + providerInfo: state.staking.providerInfo, + setStakingAmount: state.setStakingAmount, + setStakingProviderId: state.setStakingProviderId, + getStakingQuote: state.getStakingQuote, + stake: state.stake, + unstake: state.unstake, + loadStakingData: state.loadStakingData, + clearStaking: state.clearStaking, + validateStakingInputs: state.validateStakingInputs, + })), + ); +}; diff --git a/demo/wallet-core/src/index.ts b/demo/wallet-core/src/index.ts index 23cc7cd79..2e1f8fdd9 100644 --- a/demo/wallet-core/src/index.ts +++ b/demo/wallet-core/src/index.ts @@ -30,6 +30,7 @@ export { useNfts, useJettons, useSwap, + useStaking, } from './hooks/useWalletStore'; export { useFormattedTonBalance, useFormattedAmount } from './hooks/useFormattedBalance'; export { useWalletInitialization } from './hooks/useWalletInitialization'; @@ -45,6 +46,7 @@ export type { JettonsSlice, NftsSlice, SwapSlice, + StakingSlice, } from './types/store'; export type { diff --git a/demo/wallet-core/src/store/createWalletStore.ts b/demo/wallet-core/src/store/createWalletStore.ts index 13d92b411..93698dd05 100644 --- a/demo/wallet-core/src/store/createWalletStore.ts +++ b/demo/wallet-core/src/store/createWalletStore.ts @@ -17,6 +17,7 @@ import { createTonConnectSlice } from './slices/tonConnectSlice'; import { createJettonsSlice } from './slices/jettonsSlice'; import { createNftsSlice } from './slices/nftsSlice'; import { createSwapSlice } from './slices/swapSlice'; +import { createStakingSlice } from './slices/stakingSlice'; import type { AppState } from '../types/store'; import type { StorageAdapter } from '../adapters/storage/types'; import type { WalletKitConfig } from '../types/wallet'; @@ -141,6 +142,9 @@ export function createWalletStore(options: CreateWalletStoreOptions = {}) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore ...createSwapSlice(...a), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...createStakingSlice(...a), // eslint-disable-next-line @typescript-eslint/no-explicit-any })) as unknown as any, { diff --git a/demo/wallet-core/src/store/slices/stakingSlice.ts b/demo/wallet-core/src/store/slices/stakingSlice.ts new file mode 100644 index 000000000..d6cb19381 --- /dev/null +++ b/demo/wallet-core/src/store/slices/stakingSlice.ts @@ -0,0 +1,244 @@ +/** + * 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 { parseUnits } from '@ton/walletkit'; +import type { StakeParams, StakingQuoteParams, UnstakeParams } from '@ton/walletkit'; + +import { createComponentLogger } from '../../utils/logger'; +import type { SetState, StakingSliceCreator } from '../../types/store'; + +const log = createComponentLogger('StakingSlice'); + +export const createStakingSlice: StakingSliceCreator = (set: SetState, get) => ({ + staking: { + amount: '', + providerId: 'tonstakers', + currentQuote: null, + isLoadingQuote: false, + isStaking: false, + isUnstaking: false, + error: null, + stakedBalance: null, + providerInfo: null, + }, + + setStakingAmount: (amount: string) => { + set((state) => { + if (amount === '' || /^\d*\.?\d*$/.test(amount)) { + state.staking.amount = amount; + state.staking.currentQuote = null; + state.staking.error = null; + } + }); + }, + + setStakingProviderId: (providerId: string) => { + set((state) => { + state.staking.providerId = providerId; + state.staking.currentQuote = null; + state.staking.error = null; + }); + }, + + validateStakingInputs: () => { + const state = get(); + const { amount } = state.staking; + + if (!amount || amount === '') { + return 'Please enter an amount'; + } + + const amountToValidate = parseFloat(amount); + if (isNaN(amountToValidate)) { + return 'Please enter a valid number'; + } + + if (amountToValidate <= 0) { + return 'Amount must be greater than 0'; + } + + const tonBalanceStr = state.walletManagement.balance; + if (!tonBalanceStr) { + return 'Insufficient balance'; + } + + return null; + }, + + getStakingQuote: async (params: Omit) => { + const state = get(); + const { providerId } = state.staking; + + if (!state.walletCore.walletKit) { + set((state) => { + state.staking.error = 'WalletKit not initialized'; + }); + return; + } + + const network = state.walletManagement.currentWallet?.getNetwork(); + if (!network) { + set((state) => { + state.staking.error = 'No active wallet'; + }); + return; + } + + set((state) => { + state.staking.isLoadingQuote = true; + state.staking.error = null; + }); + + try { + const amount = parseUnits(params.amount, 9).toString(); + + const quote = await state.walletCore.walletKit.staking.getQuote( + { + ...params, + amount, + network, + }, + providerId, + ); + + set((state) => { + state.staking.currentQuote = quote; + state.staking.isLoadingQuote = false; + }); + } catch (error) { + log.error('Failed to get staking quote:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to get quote'; + set((state) => { + state.staking.isLoadingQuote = false; + state.staking.error = errorMessage; + }); + } + }, + + stake: async (params: Omit) => { + const state = get(); + const { providerId } = state.staking; + const userAddress = state.walletManagement.address; + + if (!state.walletCore.walletKit || !state.walletManagement.currentWallet || !userAddress) { + set((state) => { + state.staking.error = 'Wallet not ready'; + }); + return; + } + + set((state) => { + state.staking.isStaking = true; + state.staking.error = null; + }); + + try { + const transaction = await state.walletCore.walletKit.staking.buildStakeTransaction( + { + ...params, + userAddress, + }, + providerId, + ); + + await state.walletCore.walletKit.handleNewTransaction(state.walletManagement.currentWallet, transaction); + + set((state) => { + state.staking.isStaking = false; + state.staking.amount = ''; + state.staking.currentQuote = null; + }); + } catch (error) { + log.error('Failed to stake:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to stake'; + set((state) => { + state.staking.isStaking = false; + state.staking.error = errorMessage; + }); + } + }, + + unstake: async (params: Omit) => { + const state = get(); + const { providerId } = state.staking; + const userAddress = state.walletManagement.address; + + if (!state.walletCore.walletKit || !state.walletManagement.currentWallet || !userAddress) { + set((state) => { + state.staking.error = 'Wallet not ready'; + }); + return; + } + + set((state) => { + state.staking.isUnstaking = true; + state.staking.error = null; + }); + + try { + const transaction = await state.walletCore.walletKit.staking.buildUnstakeTransaction( + { + ...params, + userAddress, + }, + providerId, + ); + + await state.walletCore.walletKit.handleNewTransaction(state.walletManagement.currentWallet, transaction); + + set((state) => { + state.staking.isUnstaking = false; + state.staking.amount = ''; + state.staking.currentQuote = null; + }); + } catch (error) { + log.error('Failed to unstake:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to unstake'; + set((state) => { + state.staking.isUnstaking = false; + state.staking.error = errorMessage; + }); + } + }, + + loadStakingData: async (userAddress: string) => { + const state = get(); + const { providerId } = state.staking; + + if (!state.walletCore.walletKit) return; + + const network = state.walletManagement.currentWallet?.getNetwork(); + + try { + const [balance, info] = await Promise.all([ + state.walletCore.walletKit.staking.getStakedBalance(userAddress, network, providerId), + state.walletCore.walletKit.staking.getStakingProviderInfo(network, providerId), + ]); + + set((state) => { + state.staking.stakedBalance = balance; + state.staking.providerInfo = info; + }); + } catch (error) { + log.error('Failed to load staking data:', error); + } + }, + + clearStaking: () => { + set((state) => { + state.staking.amount = ''; + state.staking.currentQuote = null; + state.staking.isLoadingQuote = false; + state.staking.isStaking = false; + state.staking.isUnstaking = false; + state.staking.error = null; + state.staking.stakedBalance = null; + state.staking.providerInfo = null; + }); + }, +}); diff --git a/demo/wallet-core/src/store/slices/swapSlice.ts b/demo/wallet-core/src/store/slices/swapSlice.ts index 3debacd36..7bd667437 100644 --- a/demo/wallet-core/src/store/slices/swapSlice.ts +++ b/demo/wallet-core/src/store/slices/swapSlice.ts @@ -51,7 +51,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ }); }, - setAmount: (amount: string) => { + setSwapAmount: (amount: string) => { set((state) => { // Allow empty string or valid number input if (amount === '' || /^\d*\.?\d*$/.test(amount)) { @@ -155,7 +155,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ return null; }, - getQuote: async () => { + getSwapQuote: async () => { const state = get(); const { fromToken, toToken, amount, isReverseSwap, slippageBps } = state.swap; diff --git a/demo/wallet-core/src/store/slices/walletCoreSlice.ts b/demo/wallet-core/src/store/slices/walletCoreSlice.ts index f2b4e1e42..40af80d7f 100644 --- a/demo/wallet-core/src/store/slices/walletCoreSlice.ts +++ b/demo/wallet-core/src/store/slices/walletCoreSlice.ts @@ -9,6 +9,7 @@ import { TonWalletKit, Network, createDeviceInfo, createWalletManifest, ApiClientTonApi } from '@ton/walletkit'; import type { ITonWalletKit } from '@ton/walletkit'; import { OmnistonSwapProvider } from '@ton/walletkit/swap/omniston'; +import { TonStakersStakingProvider } from '@ton/walletkit'; import { createComponentLogger } from '../../utils/logger'; import { isExtension } from '../../utils/isExtension'; @@ -34,7 +35,6 @@ function createWalletKitInstance(walletKitConfig?: WalletKitConfig): ITonWalletK jsBridgeTransport: walletKitConfig?.jsBridgeTransport, }, - // TODO: Tetra networks: { [Network.mainnet().chainId]: { apiClient: { @@ -74,6 +74,16 @@ function createWalletKitInstance(walletKitConfig?: WalletKitConfig): ITonWalletK }) as ITonWalletKit; walletKit.swap.registerProvider(new OmnistonSwapProvider()); + walletKit.staking.registerProvider( + new TonStakersStakingProvider({ + [Network.mainnet().chainId]: { + apiClient: walletKit.getApiClient(Network.mainnet()), + }, + [Network.testnet().chainId]: { + apiClient: walletKit.getApiClient(Network.testnet()), + }, + }), + ); log.info(`WalletKit initialized with network: ${isExtension() ? 'extension' : 'web'}`); return walletKit; diff --git a/demo/wallet-core/src/types/store.ts b/demo/wallet-core/src/types/store.ts index f68774daa..964020bb9 100644 --- a/demo/wallet-core/src/types/store.ts +++ b/demo/wallet-core/src/types/store.ts @@ -21,6 +21,12 @@ import type { WalletAdapter, SwapQuote, SwapToken, + StakingQuote, + StakingQuoteParams, + StakingBalance, + StakingProviderInfo, + StakeParams, + UnstakeParams, } from '@ton/walletkit'; import type { @@ -217,17 +223,43 @@ export interface SwapState { isReverseSwap: boolean; } +// Staking slice interface +export interface StakingState { + amount: string; + providerId: string; + currentQuote: StakingQuote | null; + isLoadingQuote: boolean; + isStaking: boolean; + isUnstaking: boolean; + error: string | null; + stakedBalance: StakingBalance | null; + providerInfo: StakingProviderInfo | null; +} + +export interface StakingSlice { + staking: StakingState; + + setStakingAmount: (amount: string) => void; + setStakingProviderId: (providerId: string) => void; + getStakingQuote: (params: Omit) => Promise; + stake: (params: Omit) => Promise; + unstake: (params: Omit) => Promise; + loadStakingData: (userAddress: string) => Promise; + clearStaking: () => void; + validateStakingInputs: () => string | null; +} + export interface SwapSlice { swap: SwapState; setFromToken: (token: SwapToken) => void; setToToken: (token: SwapToken) => void; - setAmount: (amount: string) => void; + setSwapAmount: (amount: string) => void; setDestinationAddress: (address: string) => void; setIsReverseSwap: (isReverseSwap: boolean) => void; setSlippageBps: (slippage: number) => void; swapTokens: () => void; - getQuote: () => Promise; + getSwapQuote: () => Promise; executeSwap: () => Promise; clearSwap: () => void; validateSwapInputs: () => string | null; @@ -235,7 +267,15 @@ export interface SwapSlice { // Combined app state export interface AppState - extends AuthSlice, WalletCoreSlice, WalletManagementSlice, TonConnectSlice, JettonsSlice, NftsSlice, SwapSlice { + extends + AuthSlice, + WalletCoreSlice, + WalletManagementSlice, + TonConnectSlice, + JettonsSlice, + NftsSlice, + SwapSlice, + StakingSlice { isHydrated: boolean; } @@ -259,6 +299,8 @@ export type NftsSliceCreator = StateCreator; export type SwapSliceCreator = StateCreator; +export type StakingSliceCreator = StateCreator; + // Migration types export interface MigrationState { version: number; diff --git a/packages/walletkit/src/api/models/staking/StakingBalance.ts b/packages/walletkit/src/api/models/staking/StakingBalance.ts index a84437288..b597731d6 100644 --- a/packages/walletkit/src/api/models/staking/StakingBalance.ts +++ b/packages/walletkit/src/api/models/staking/StakingBalance.ts @@ -17,11 +17,6 @@ export interface StakingBalance { */ stakedBalance: TokenAmount; - /** - * Amount available to unstake - */ - availableBalance: TokenAmount; - /** * Amount available for instant unstake */ diff --git a/packages/walletkit/src/core/TonWalletKit.ts b/packages/walletkit/src/core/TonWalletKit.ts index 9ae86c9b9..47d181e8e 100644 --- a/packages/walletkit/src/core/TonWalletKit.ts +++ b/packages/walletkit/src/core/TonWalletKit.ts @@ -710,12 +710,6 @@ export class TonWalletKit implements ITonWalletKit { * @throws WalletKitError if no client is configured for the network */ getApiClient(network: Network): ApiClient { - if (!this.isInitialized) { - throw new WalletKitError( - ERROR_CODES.INITIALIZATION_ERROR, - 'TonWalletKit not yet initialized - call initialize() first', - ); - } return this.networkManager.getClient(network); } diff --git a/packages/walletkit/src/defi/staking/StakingProvider.ts b/packages/walletkit/src/defi/staking/StakingProvider.ts index fe8bd8b8a..0a015da85 100644 --- a/packages/walletkit/src/defi/staking/StakingProvider.ts +++ b/packages/walletkit/src/defi/staking/StakingProvider.ts @@ -6,9 +6,7 @@ * */ -import type { ApiClient } from '../../types/toncenter/ApiClient'; import type { Network, TransactionRequest, UserFriendlyAddress } from '../../api/models'; -import type { NetworkManager } from '../../core/NetworkManager'; import type { StakeParams, UnstakeParams, @@ -18,6 +16,7 @@ import type { StakingQuote, } from '../../api/models'; import type { StakingProviderInterface } from '../../api/interfaces'; +import { NetworkManager } from '../../core/NetworkManager'; /** * Abstract base class for staking providers @@ -29,11 +28,8 @@ export abstract class StakingProvider implements StakingProviderInterface { readonly type = 'swap'; readonly providerId: string; - protected networkManager: NetworkManager; - - constructor(providerId: string, networkManager: NetworkManager) { + constructor(providerId: string) { this.providerId = providerId; - this.networkManager = networkManager; } /** @@ -68,13 +64,4 @@ export abstract class StakingProvider implements StakingProviderInterface { * @param network - Optional network to fetch info for */ abstract getStakingProviderInfo(network?: Network): Promise; - - /** - * Get API client for a specific network - * @param network - The network to get client for - * @returns API client instance - */ - protected getApiClient(network: Network): ApiClient { - return this.networkManager.getClient(network); - } } diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts index 08432a98e..a5717a699 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -9,7 +9,6 @@ import type { TransactionRequest, UserFriendlyAddress } from '../../../api/models'; import { Network } from '../../../api/models'; import { globalLogger } from '../../../core/Logger'; -import type { NetworkManager } from '../../../core/NetworkManager'; import { StakingProvider } from '../StakingProvider'; import type { StakeParams, @@ -21,11 +20,12 @@ import type { } from '../../../api/models'; import { StakingError, StakingErrorCode } from '../errors'; import type { TonStakersProviderConfig } from './models/TonStakersProviderConfig'; -import { CONTRACT } from './constants'; +import { CONTRACT, STAKING_CONTRACT_ADDRESS } from './constants'; import { PoolContract } from './PoolContract'; import { StakingCache } from './StakingCache'; import { ApiClientTonApi } from '../../../clients/tonapi/ApiClientTonApi'; import { formatUnits, parseUnits } from '../../../utils/units'; +import type { ApiClient } from '../../../types/toncenter/ApiClient'; const log = globalLogger.createChild('TonStakersStakingProvider'); @@ -38,34 +38,6 @@ const log = globalLogger.createChild('TonStakersStakingProvider'); * - Delayed: Standard withdrawal at end of round (~18 hours) * - Instant: Immediate withdrawal if liquidity available * - BestRate: Wait for best exchange rate at round end - * - * @example - * ```typescript - * const stakingManager = new StakingManager(); - * const provider = new TonStakersStakingProvider( - * walletKit.getNetworkManager(), - * walletKit.getEventEmitter() - * ); - * stakingManager.registerProvider('tonstakers', provider); - * - * // Get staking info with APY - * const info = await stakingManager.getStakingProviderInfo(Network.mainnet()); - * - * // Stake TON - * const stakeTx = await stakingManager.stake({ - * amount: toNano('10').toString(), - * userAddress: wallet.getAddress(), - * network: Network.mainnet() - * }); - * - * // Unstake with instant mode - * const unstakeTx = await stakingManager.unstake({ - * amount: toNano('5').toString(), - * userAddress: wallet.getAddress(), - * network: Network.mainnet(), - * unstakeMode: 'instant' - * }); - * ``` */ export class TonStakersStakingProvider extends StakingProvider { protected config: TonStakersProviderConfig; @@ -74,20 +46,24 @@ export class TonStakersStakingProvider extends StakingProvider { /** * Create a new TonStakersStakingProvider instance. * - * @param networkManager - Network manager for API client access * @param config - Optional configuration with custom contract addresses per network */ - constructor(networkManager: NetworkManager, config: TonStakersProviderConfig = {}) { - super('tonstakers', networkManager); - this.config = { - [Network.mainnet().chainId]: { - contractAddress: CONTRACT.STAKING_CONTRACT_ADDRESS, - }, - [Network.testnet().chainId]: { - contractAddress: CONTRACT.STAKING_CONTRACT_ADDRESS_TESTNET, - }, - ...config, - }; + constructor(config: TonStakersProviderConfig = {}) { + super('tonstakers'); + + this.config = Object.entries(config).reduce((acc, [chainId, configByNetwork]) => { + if (!configByNetwork.contractAddress && !STAKING_CONTRACT_ADDRESS[chainId]) { + throw new Error(`Contract address not found for chain ${chainId}, provide it in config`); + } + + acc[chainId] = { + ...configByNetwork, + contractAddress: configByNetwork.contractAddress || STAKING_CONTRACT_ADDRESS[chainId], + }; + + return acc; + }, {} as TonStakersProviderConfig); + this.cache = new StakingCache(); log.info('TonStakersStakingProvider initialized'); } @@ -246,12 +222,6 @@ export class TonStakersStakingProvider extends StakingProvider { try { const targetNetwork = network ?? Network.mainnet(); - const apiClient = this.getApiClient(targetNetwork); - const tonBalance = await apiClient.getBalance(userAddress); - const availableBalance = - BigInt(tonBalance) > CONTRACT.RECOMMENDED_FEE_RESERVE - ? BigInt(tonBalance) - CONTRACT.RECOMMENDED_FEE_RESERVE - : 0n; let stakedBalance = '0'; let instantUnstakeAvailable = 0n; @@ -271,8 +241,7 @@ export class TonStakersStakingProvider extends StakingProvider { } return { - stakedBalance: stakedBalance, - availableBalance: availableBalance.toString(), + stakedBalance, // in tsTON instantUnstakeAvailable: instantUnstakeAvailable.toString(), providerId: 'tonstakers', }; @@ -343,6 +312,15 @@ export class TonStakersStakingProvider extends StakingProvider { return new PoolContract(contractAddress, apiClient); } + private getApiClient(network?: Network): ApiClient { + const targetNetwork = network ?? Network.mainnet(); + const apiClient = this.config[targetNetwork.chainId].apiClient; + if (!apiClient) { + throw new Error(`API client not found for chain ${targetNetwork.chainId}`); + } + return apiClient; + } + private async getApyFromTonApi(network: Network): Promise { const networkConfig = this.config[network.chainId]; const token = networkConfig?.tonApiToken; @@ -355,6 +333,6 @@ export class TonStakersStakingProvider extends StakingProvider { throw new Error('Invalid APY data from TonAPI'); } - return Number(poolInfo.pool.apy) / 100; + return Number(poolInfo.pool.apy); } } diff --git a/packages/walletkit/src/defi/staking/tonstakers/constants.ts b/packages/walletkit/src/defi/staking/tonstakers/constants.ts index d5e14f63f..da2607a09 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/constants.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/constants.ts @@ -6,17 +6,21 @@ * */ +import { Network } from '../../../api/models'; import type { UserFriendlyAddress } from '../../../api/models'; import { parseUnits } from '../../../utils/units'; export const CACHE_TIMEOUT = 30000; -// Contract-related constants -export const CONTRACT = { +export const STAKING_CONTRACT_ADDRESS = { // https://github.com/ton-blockchain/liquid-staking-contract/tree/35d676f6ac6e35e755ea3c4d7d7cf577627b1cf0 - STAKING_CONTRACT_ADDRESS: 'EQCkWxfyhAkim3g2DjKQQg8T5P4g-Q1-K_jErGcDJZ4i-vqR' as UserFriendlyAddress, + [Network.mainnet().chainId]: 'EQCkWxfyhAkim3g2DjKQQg8T5P4g-Q1-K_jErGcDJZ4i-vqR' as UserFriendlyAddress, // https://github.com/ton-blockchain/liquid-staking-contract/tree/77f13c850890517a6b490ef5f109c31b4fa783e7 - STAKING_CONTRACT_ADDRESS_TESTNET: 'kQANFsYyYn-GSZ4oajUJmboDURZU-udMHf9JxzO4vYM_hFP3' as UserFriendlyAddress, + [Network.testnet().chainId]: 'kQANFsYyYn-GSZ4oajUJmboDURZU-udMHf9JxzO4vYM_hFP3' as UserFriendlyAddress, +}; + +// Contract-related constants +export const CONTRACT = { PARTNER_CODE: 0x000000106796caef, PAYLOAD_UNSTAKE: 0x595f07bc, PAYLOAD_STAKE: 0x47d54391, diff --git a/packages/walletkit/src/defi/staking/tonstakers/models/TonStakersProviderConfig.ts b/packages/walletkit/src/defi/staking/tonstakers/models/TonStakersProviderConfig.ts index 65a2be4c3..26d706083 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/models/TonStakersProviderConfig.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/models/TonStakersProviderConfig.ts @@ -7,10 +7,12 @@ */ import type { UserFriendlyAddress } from '../../../../api/models'; +import type { ApiClient } from '../../../../types/toncenter/ApiClient'; export interface TonStakersProviderConfig { [chainId: string]: { - contractAddress: UserFriendlyAddress; + apiClient: ApiClient; + contractAddress?: UserFriendlyAddress; /** * Optional TonAPI token used exclusively for fetching historical APY. * The provider is fully functional without this token. diff --git a/packages/walletkit/src/types/kit.ts b/packages/walletkit/src/types/kit.ts index 6159043e2..dbf72abee 100644 --- a/packages/walletkit/src/types/kit.ts +++ b/packages/walletkit/src/types/kit.ts @@ -27,7 +27,7 @@ import type { SendTransactionApprovalResponse, ConnectionApprovalResponse, } from '../api/models'; -import type { SwapAPI } from '../api/interfaces'; +import type { SwapAPI, StakingAPI } from '../api/interfaces'; /** * Main TonWalletKit interface @@ -148,4 +148,7 @@ export interface ITonWalletKit { /** Jettons API access */ swap: SwapAPI; + + /** Staking API access */ + staking: StakingAPI; } From 5bc7674dfc3c5adcc4109ce30199e0f79c38b78e Mon Sep 17 00:00:00 2001 From: "V. K." Date: Fri, 13 Mar 2026 10:16:41 +0400 Subject: [PATCH 11/15] feat(staking): tonstakers in separate entrypoint --- apps/demo-wallet/src/pages/WalletDashboard.tsx | 6 +++--- .../wallet-core/src/store/slices/walletCoreSlice.ts | 2 +- packages/walletkit/package.json | 13 +++++++++++++ packages/walletkit/src/defi/staking/index.ts | 4 ---- packages/walletkit/src/index.ts | 10 +--------- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/demo-wallet/src/pages/WalletDashboard.tsx b/apps/demo-wallet/src/pages/WalletDashboard.tsx index f44a45ed5..23a011d86 100644 --- a/apps/demo-wallet/src/pages/WalletDashboard.tsx +++ b/apps/demo-wallet/src/pages/WalletDashboard.tsx @@ -171,9 +171,9 @@ export const WalletDashboard: React.FC = () => { viewBox="0 0 24 24" fill="none" stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" > diff --git a/demo/wallet-core/src/store/slices/walletCoreSlice.ts b/demo/wallet-core/src/store/slices/walletCoreSlice.ts index 40af80d7f..cda8d6778 100644 --- a/demo/wallet-core/src/store/slices/walletCoreSlice.ts +++ b/demo/wallet-core/src/store/slices/walletCoreSlice.ts @@ -9,7 +9,7 @@ import { TonWalletKit, Network, createDeviceInfo, createWalletManifest, ApiClientTonApi } from '@ton/walletkit'; import type { ITonWalletKit } from '@ton/walletkit'; import { OmnistonSwapProvider } from '@ton/walletkit/swap/omniston'; -import { TonStakersStakingProvider } from '@ton/walletkit'; +import { TonStakersStakingProvider } from '@ton/walletkit/staking/tonstakers'; import { createComponentLogger } from '../../utils/logger'; import { isExtension } from '../../utils/isExtension'; diff --git a/packages/walletkit/package.json b/packages/walletkit/package.json index 26a8d3618..a4dad92b6 100644 --- a/packages/walletkit/package.json +++ b/packages/walletkit/package.json @@ -50,6 +50,16 @@ "types": "./dist/cjs/defi/swap/dedust/index.d.ts", "default": "./dist/cjs/defi/swap/dedust/index.js" } + }, + "./staking/tonstakers": { + "import": { + "types": "./dist/esm/defi/staking/tonstakers/index.d.ts", + "default": "./dist/esm/defi/staking/tonstakers/index.js" + }, + "require": { + "types": "./dist/cjs/defi/staking/tonstakers/index.d.ts", + "default": "./dist/cjs/defi/staking/tonstakers/index.js" + } } }, "typesVersions": { @@ -62,6 +72,9 @@ ], "swap/dedust": [ "./dist/cjs/defi/swap/dedust/index.d.ts" + ], + "staking/tonstakers": [ + "./dist/cjs/defi/staking/tonstakers/index.d.ts" ] } }, diff --git a/packages/walletkit/src/defi/staking/index.ts b/packages/walletkit/src/defi/staking/index.ts index 16abb38d0..b9a085c2a 100644 --- a/packages/walletkit/src/defi/staking/index.ts +++ b/packages/walletkit/src/defi/staking/index.ts @@ -10,7 +10,3 @@ export { StakingProvider } from './StakingProvider'; export { StakingManager } from './StakingManager'; export { StakingError } from './errors'; export type { StakingErrorCode } from './errors'; - -// TonStakers -export { TonStakersStakingProvider, PoolContract, StakingCache } from './tonstakers'; -export type { TonStakersProviderConfig } from './tonstakers'; diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 420697017..5d449299c 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -20,15 +20,7 @@ export { RequestProcessor } from './core/RequestProcessor'; export { Initializer } from './core/Initializer'; export { JettonsManager } from './core/JettonsManager'; export { SwapManager, SwapProvider, SwapError } from './defi/swap'; -export { - StakingManager, - StakingProvider, - StakingError, - TonStakersStakingProvider, - PoolContract, - StakingCache, -} from './defi/staking'; -export type { TonStakersProviderConfig } from './defi/staking/tonstakers/models/TonStakersProviderConfig'; +export { StakingManager, StakingProvider, StakingError } from './defi/staking'; export { EventEmitter } from './core/EventEmitter'; export type { EventListener } from './core/EventEmitter'; export { ApiClientToncenter } from './clients/toncenter'; From 7ca03c6e2b93eb0c344ee1a2131da2f6fb7da89d Mon Sep 17 00:00:00 2001 From: "V. K." Date: Fri, 13 Mar 2026 12:08:18 +0400 Subject: [PATCH 12/15] feat(staking): add actions/queries/hooks for staking --- .../appkit-minter/src/core/configs/app-kit.ts | 48 ++++++----- .../staking/components/stake-button.tsx | 76 ++++++++++++++++++ .../src/features/staking/index.ts | 9 +++ apps/appkit-minter/src/pages/minter-page.tsx | 16 +++- .../src/components/staking/StakingInfo.tsx | 5 +- .../components/staking/StakingInterface.tsx | 4 +- .../appkit/actions/network/get-api-client.ts | 20 +++++ .../appkit/actions/staking/staking-actions.ts | 55 +++++++++++++ .../src/appkit/hooks/staking/use-staking.tsx | 29 +++++++ .../examples/src/appkit/staking/tonstakers.ts | 63 +++++++++++++++ .../src/store/slices/stakingSlice.ts | 12 +-- packages/appkit-react/README.md | 28 +++++++ .../hooks/use-build-stake-transaction.ts | 33 ++++++++ .../hooks/use-build-unstake-transaction.ts | 33 ++++++++ .../staking/hooks/use-staked-balance.ts | 30 +++++++ .../hooks/use-staking-provider-info.ts | 35 ++++++++ .../staking/hooks/use-staking-providers.ts | 35 ++++++++ .../staking/hooks/use-staking-quote.ts | 30 +++++++ .../src/features/staking/index.ts | 33 ++++++++ packages/appkit-react/src/index.ts | 1 + packages/appkit/docs/actions.md | 69 ++++++++++++++++ packages/appkit/docs/staking.md | 72 +++++++++++++++++ packages/appkit/package.json | 19 ++++- packages/appkit/src/actions/index.ts | 30 +++++++ .../src/actions/network/get-api-client.ts | 22 ++++++ .../staking/build-stake-transaction.ts | 28 +++++++ .../staking/build-unstake-transaction.ts | 28 +++++++ .../src/actions/staking/get-staked-balance.ts | 34 ++++++++ .../actions/staking/get-staking-manager.ts | 20 +++++ .../staking/get-staking-provider-info.ts | 29 +++++++ .../actions/staking/get-staking-providers.ts | 18 +++++ .../src/actions/staking/get-staking-quote.ts | 30 +++++++ .../src/core/app-kit/services/app-kit.ts | 10 ++- packages/appkit/src/queries/index.ts | 43 ++++++++++ .../staking/build-stake-transaction.ts | 79 +++++++++++++++++++ .../staking/build-unstake-transaction.ts | 79 +++++++++++++++++++ .../src/queries/staking/get-staked-balance.ts | 60 ++++++++++++++ .../staking/get-staking-provider-info.ts | 63 +++++++++++++++ .../queries/staking/get-staking-providers.ts | 52 ++++++++++++ .../src/queries/staking/get-staking-quote.ts | 60 ++++++++++++++ packages/appkit/src/staking/index.ts | 23 ++++++ .../appkit/src/staking/tonstakers/index.ts | 9 +++ packages/appkit/src/types/provider.ts | 4 +- .../src/api/interfaces/StakingAPI.ts | 72 +++++++++++++++++ .../src/defi/staking/StakingProvider.ts | 3 +- .../tonstakers/TonStakersContract.spec.ts | 5 +- .../TonStakersStakingProvider.spec.ts | 68 ++++++---------- .../tonstakers/TonStakersStakingProvider.ts | 18 ++--- template/packages/appkit-react/README.md | 12 +++ template/packages/appkit/docs/actions.md | 38 +++++++++ template/packages/appkit/docs/staking.md | 29 +++++++ 51 files changed, 1619 insertions(+), 102 deletions(-) create mode 100644 apps/appkit-minter/src/features/staking/components/stake-button.tsx create mode 100644 apps/appkit-minter/src/features/staking/index.ts create mode 100644 demo/examples/src/appkit/actions/network/get-api-client.ts create mode 100644 demo/examples/src/appkit/actions/staking/staking-actions.ts create mode 100644 demo/examples/src/appkit/hooks/staking/use-staking.tsx create mode 100644 demo/examples/src/appkit/staking/tonstakers.ts create mode 100644 packages/appkit-react/src/features/staking/hooks/use-build-stake-transaction.ts create mode 100644 packages/appkit-react/src/features/staking/hooks/use-build-unstake-transaction.ts create mode 100644 packages/appkit-react/src/features/staking/hooks/use-staked-balance.ts create mode 100644 packages/appkit-react/src/features/staking/hooks/use-staking-provider-info.ts create mode 100644 packages/appkit-react/src/features/staking/hooks/use-staking-providers.ts create mode 100644 packages/appkit-react/src/features/staking/hooks/use-staking-quote.ts create mode 100644 packages/appkit-react/src/features/staking/index.ts create mode 100644 packages/appkit/docs/staking.md create mode 100644 packages/appkit/src/actions/network/get-api-client.ts create mode 100644 packages/appkit/src/actions/staking/build-stake-transaction.ts create mode 100644 packages/appkit/src/actions/staking/build-unstake-transaction.ts create mode 100644 packages/appkit/src/actions/staking/get-staked-balance.ts create mode 100644 packages/appkit/src/actions/staking/get-staking-manager.ts create mode 100644 packages/appkit/src/actions/staking/get-staking-provider-info.ts create mode 100644 packages/appkit/src/actions/staking/get-staking-providers.ts create mode 100644 packages/appkit/src/actions/staking/get-staking-quote.ts create mode 100644 packages/appkit/src/queries/staking/build-stake-transaction.ts create mode 100644 packages/appkit/src/queries/staking/build-unstake-transaction.ts create mode 100644 packages/appkit/src/queries/staking/get-staked-balance.ts create mode 100644 packages/appkit/src/queries/staking/get-staking-provider-info.ts create mode 100644 packages/appkit/src/queries/staking/get-staking-providers.ts create mode 100644 packages/appkit/src/queries/staking/get-staking-quote.ts create mode 100644 packages/appkit/src/staking/index.ts create mode 100644 packages/appkit/src/staking/tonstakers/index.ts create mode 100644 template/packages/appkit/docs/staking.md diff --git a/apps/appkit-minter/src/core/configs/app-kit.ts b/apps/appkit-minter/src/core/configs/app-kit.ts index 780e910ce..5a2c9011a 100644 --- a/apps/appkit-minter/src/core/configs/app-kit.ts +++ b/apps/appkit-minter/src/core/configs/app-kit.ts @@ -7,32 +7,33 @@ */ import { AppKit, Network } from '@ton/appkit'; -import { TonConnectConnector, ApiClientTonApi } from '@ton/appkit'; +import { TonConnectConnector, ApiClientTonApi, ApiClientToncenter } from '@ton/appkit'; import { DeDustSwapProvider } from '@ton/appkit/swap/dedust'; import { OmnistonSwapProvider } from '@ton/appkit/swap/omniston'; +import { TonStakersStakingProvider } from '@ton/appkit/staking/tonstakers'; import { ENV_TON_API_KEY_TESTNET, ENV_TON_API_KEY_MAINNET } from '@/core/configs/env'; +const mainnetApiClient = new ApiClientToncenter({ + network: Network.mainnet(), + apiKey: ENV_TON_API_KEY_MAINNET, +}); + +const testnetApiClient = new ApiClientToncenter({ + network: Network.testnet(), + apiKey: ENV_TON_API_KEY_TESTNET, +}); + +const tetraApiClient = new ApiClientTonApi({ + network: Network.tetra(), + endpoint: 'https://tetra.tonapi.io', +}); + export const appKit = new AppKit({ networks: { - [Network.mainnet().chainId]: { - apiClient: { - url: 'https://toncenter.com', - key: ENV_TON_API_KEY_MAINNET, - }, - }, - [Network.testnet().chainId]: { - apiClient: { - url: 'https://testnet.toncenter.com', - key: ENV_TON_API_KEY_TESTNET, - }, - }, - [Network.tetra().chainId]: { - apiClient: new ApiClientTonApi({ - network: Network.tetra(), - endpoint: 'https://tetra.tonapi.io', - }), - }, + [Network.mainnet().chainId]: { apiClient: mainnetApiClient }, + [Network.testnet().chainId]: { apiClient: testnetApiClient }, + [Network.tetra().chainId]: { apiClient: tetraApiClient }, }, connectors: [ new TonConnectConnector({ @@ -41,5 +42,12 @@ export const appKit = new AppKit({ }, }), ], - providers: [new DeDustSwapProvider(), new OmnistonSwapProvider()], + providers: [ + new DeDustSwapProvider(), + new OmnistonSwapProvider(), + new TonStakersStakingProvider({ + [Network.mainnet().chainId]: { apiClient: mainnetApiClient }, + [Network.testnet().chainId]: { apiClient: testnetApiClient }, + }), + ], }); diff --git a/apps/appkit-minter/src/features/staking/components/stake-button.tsx b/apps/appkit-minter/src/features/staking/components/stake-button.tsx new file mode 100644 index 000000000..37685da34 --- /dev/null +++ b/apps/appkit-minter/src/features/staking/components/stake-button.tsx @@ -0,0 +1,76 @@ +/** + * 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 { FC } from 'react'; +import { + Transaction, + useStakingQuote, + useNetwork, + useAddress, + useBuildStakeTransaction, + useBuildUnstakeTransaction, +} from '@ton/appkit-react'; + +interface StakeButtonProps { + amount: string; + direction: 'stake' | 'unstake'; + providerId?: string; +} + +export const StakeButton: FC = ({ amount, direction, providerId }) => { + const network = useNetwork(); + const address = useAddress(); + + const { + data: quote, + isError, + isLoading, + } = useStakingQuote({ + amount, + direction, + network, + providerId, + }); + + const { mutateAsync: buildStakeTransaction } = useBuildStakeTransaction(); + const { mutateAsync: buildUnstakeTransaction } = useBuildUnstakeTransaction(); + + const handleTransaction = () => { + if (!quote || !address) { + return Promise.reject(new Error('Missing quote or address')); + } + + if (direction === 'stake') { + return buildStakeTransaction({ + quote, + userAddress: address, + }); + } + + return buildUnstakeTransaction({ + quote, + userAddress: address, + }); + }; + + const buttonText = useMemo(() => { + if (isLoading) { + return 'Fetching quote...'; + } + + if (isError || !quote) { + return 'Staking Unavailable'; + } + + const action = direction === 'stake' ? 'Stake' : 'Unstake'; + return `${action} ${quote.amountIn} ${direction === 'stake' ? 'TON' : 'tsTON'} -> ${quote.amountOut} ${direction === 'stake' ? 'tsTON' : 'TON'}`; + }, [isLoading, isError, quote, direction]); + + return ; +}; diff --git a/apps/appkit-minter/src/features/staking/index.ts b/apps/appkit-minter/src/features/staking/index.ts new file mode 100644 index 000000000..65768ca74 --- /dev/null +++ b/apps/appkit-minter/src/features/staking/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 './components/stake-button'; diff --git a/apps/appkit-minter/src/pages/minter-page.tsx b/apps/appkit-minter/src/pages/minter-page.tsx index bee756ac1..353490ed8 100644 --- a/apps/appkit-minter/src/pages/minter-page.tsx +++ b/apps/appkit-minter/src/pages/minter-page.tsx @@ -13,8 +13,9 @@ import { TokensCard } from '@/features/balances'; import { CardGenerator } from '@/features/mint'; import { NftsCard } from '@/features/nft'; import { WalletInfo } from '@/features/wallet'; -import { Layout } from '@/core/components'; +import { Card, Layout } from '@/core/components'; import { SwapButton } from '@/features/swap'; +import { StakeButton } from '@/features/staking'; import { SignMessageCard } from '@/features/signing'; export const MinterPage: React.FC = () => { @@ -35,8 +36,7 @@ export const MinterPage: React.FC = () => { -
-

Swap Demo

+
Default provider:
@@ -50,7 +50,15 @@ export const MinterPage: React.FC = () => {
-
+ + + +
+
Tonstakers:
+ + +
+
)} diff --git a/apps/demo-wallet/src/components/staking/StakingInfo.tsx b/apps/demo-wallet/src/components/staking/StakingInfo.tsx index afc72dfa4..614e05557 100644 --- a/apps/demo-wallet/src/components/staking/StakingInfo.tsx +++ b/apps/demo-wallet/src/components/staking/StakingInfo.tsx @@ -8,7 +8,6 @@ import type { FC } from 'react'; import { useStaking } from '@demo/wallet-core'; -import { formatUnits } from '@ton/walletkit'; import { Card } from '../Card'; @@ -22,7 +21,7 @@ export const StakingInfo: FC = () => {

Balance

- {stakedBalance?.stakedBalance ? formatUnits(stakedBalance?.stakedBalance, 9) : '0.00'} tsTON + {stakedBalance?.stakedBalance ? stakedBalance?.stakedBalance : '0.00'} tsTON

@@ -44,7 +43,7 @@ export const StakingInfo: FC = () => { Instant Unstake Available {providerInfo?.instantUnstakeAvailable - ? Number(formatUnits(providerInfo?.instantUnstakeAvailable, 9)).toFixed(4) + ? Number(providerInfo?.instantUnstakeAvailable).toFixed(4) : '0.00'}{' '} TON diff --git a/apps/demo-wallet/src/components/staking/StakingInterface.tsx b/apps/demo-wallet/src/components/staking/StakingInterface.tsx index 674d52f96..85688e02e 100644 --- a/apps/demo-wallet/src/components/staking/StakingInterface.tsx +++ b/apps/demo-wallet/src/components/staking/StakingInterface.tsx @@ -8,7 +8,7 @@ import type { FC, ChangeEvent } from 'react'; import { useState } from 'react'; -import { formatUnits, useStaking } from '@demo/wallet-core'; +import { useStaking } from '@demo/wallet-core'; import { useNavigate } from 'react-router-dom'; import { Card } from '../Card'; @@ -121,7 +121,7 @@ export const StakingInterface: FC = () => {
You will receive - {formatUnits(currentQuote.amountOut, 9)} {tab === 'stake' ? 'tsTON' : 'TON'} + {currentQuote.amountOut} {tab === 'stake' ? 'tsTON' : 'TON'}
diff --git a/demo/examples/src/appkit/actions/network/get-api-client.ts b/demo/examples/src/appkit/actions/network/get-api-client.ts new file mode 100644 index 000000000..b27379c8b --- /dev/null +++ b/demo/examples/src/appkit/actions/network/get-api-client.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 { getApiClient, Network } from '@ton/appkit'; +import type { AppKit } from '@ton/appkit'; + +export const getApiClientExample = (appKit: AppKit) => { + // SAMPLE_START: GET_API_CLIENT + const apiClient = getApiClient(appKit, { + network: Network.mainnet(), + }); + + console.log('API Client:', apiClient); + // SAMPLE_END: GET_API_CLIENT +}; diff --git a/demo/examples/src/appkit/actions/staking/staking-actions.ts b/demo/examples/src/appkit/actions/staking/staking-actions.ts new file mode 100644 index 000000000..998774e7f --- /dev/null +++ b/demo/examples/src/appkit/actions/staking/staking-actions.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 { AppKit } from '@ton/appkit'; +import { + getStakingQuote, + buildStakeTransaction, + getStakedBalance, + getStakingProviders, + getStakingProviderInfo, +} from '@ton/appkit'; + +export const stakingExample = async (appKit: AppKit) => { + const userAddress = 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c'; + + // SAMPLE_START: GET_STAKING_PROVIDERS + const providers = await getStakingProviders(appKit); + console.log('Available Staking Providers:', providers); + // SAMPLE_END: GET_STAKING_PROVIDERS + + // SAMPLE_START: GET_STAKING_PROVIDER_INFO + const providerInfo = await getStakingProviderInfo(appKit, { + providerId: 'tonstakers', + }); + console.log('Provider Info:', providerInfo); + // SAMPLE_END: GET_STAKING_PROVIDER_INFO + + // SAMPLE_START: GET_STAKING_QUOTE + const quote = await getStakingQuote(appKit, { + amount: '1000000000', + direction: 'stake', + }); + console.log('Staking Quote:', quote); + // SAMPLE_END: GET_STAKING_QUOTE + + // SAMPLE_START: BUILD_STAKE_TRANSACTION + const txRequest = await buildStakeTransaction(appKit, { + quote, + userAddress, + }); + console.log('Stake Transaction:', txRequest); + // SAMPLE_END: BUILD_STAKE_TRANSACTION + + // SAMPLE_START: GET_STAKED_BALANCE + const balance = await getStakedBalance(appKit, { + userAddress, + }); + console.log('Staked Balance:', balance); + // SAMPLE_END: GET_STAKED_BALANCE +}; diff --git a/demo/examples/src/appkit/hooks/staking/use-staking.tsx b/demo/examples/src/appkit/hooks/staking/use-staking.tsx new file mode 100644 index 000000000..4b879cdb2 --- /dev/null +++ b/demo/examples/src/appkit/hooks/staking/use-staking.tsx @@ -0,0 +1,29 @@ +/** + * 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 { useStakingQuote, useStakedBalance } from '@ton/appkit-react'; + +export const UseStakingExample = () => { + // SAMPLE_START: USE_STAKING + const { data: quote } = useStakingQuote({ + amount: '1000000000', + direction: 'stake', + }); + + const { data: balance } = useStakedBalance({ + userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + }); + + return ( +
+
Staking Quote: {quote?.amountOut}
+
Staked Balance: {balance?.stakedBalance}
+
+ ); + // SAMPLE_END: USE_STAKING +}; diff --git a/demo/examples/src/appkit/staking/tonstakers.ts b/demo/examples/src/appkit/staking/tonstakers.ts new file mode 100644 index 000000000..f2ccf5267 --- /dev/null +++ b/demo/examples/src/appkit/staking/tonstakers.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. + * + */ + +import { AppKit, Network, registerProvider, ApiClientToncenter, getApiClient } from '@ton/appkit'; +import { TonStakersStakingProvider } from '@ton/appkit/staking/tonstakers'; + +export const stakingProviderInitExample = async () => { + // SAMPLE_START: STAKING_PROVIDER_INIT + // Initialize AppKit with staking providers + const network = Network.mainnet(); + const toncenterApiClient = new ApiClientToncenter({ network }); + const appKit = new AppKit({ + networks: { + [network.chainId]: { + apiClient: toncenterApiClient, + }, + }, + providers: [ + new TonStakersStakingProvider({ + [network.chainId]: { + apiClient: toncenterApiClient, + }, + }), + ], + }); + // SAMPLE_END: STAKING_PROVIDER_INIT + + return appKit; +}; + +export const stakingProviderRegisterExample = async () => { + // SAMPLE_START: STAKING_PROVIDER_REGISTER + // 1. Initialize AppKit + const appKit = new AppKit({ + networks: { + [Network.mainnet().chainId]: { + apiClient: { + url: 'https://toncenter.com', + key: 'your-key', + }, + }, + }, + }); + + // 2. Register staking providers + const apiClient = getApiClient(appKit, { network: Network.mainnet() }); + registerProvider( + appKit, + new TonStakersStakingProvider({ + [Network.mainnet().chainId]: { + apiClient, + }, + }), + ); + // SAMPLE_END: STAKING_PROVIDER_REGISTER + + return appKit; +}; diff --git a/demo/wallet-core/src/store/slices/stakingSlice.ts b/demo/wallet-core/src/store/slices/stakingSlice.ts index d6cb19381..1913a401c 100644 --- a/demo/wallet-core/src/store/slices/stakingSlice.ts +++ b/demo/wallet-core/src/store/slices/stakingSlice.ts @@ -6,7 +6,6 @@ * */ -import { parseUnits } from '@ton/walletkit'; import type { StakeParams, StakingQuoteParams, UnstakeParams } from '@ton/walletkit'; import { createComponentLogger } from '../../utils/logger'; @@ -95,16 +94,7 @@ export const createStakingSlice: StakingSliceCreator = (set: SetState, get) => ( }); try { - const amount = parseUnits(params.amount, 9).toString(); - - const quote = await state.walletCore.walletKit.staking.getQuote( - { - ...params, - amount, - network, - }, - providerId, - ); + const quote = await state.walletCore.walletKit.staking.getQuote({ ...params, network }, providerId); set((state) => { state.staking.currentQuote = quote; diff --git a/packages/appkit-react/README.md b/packages/appkit-react/README.md index a89769a62..579fb3f4f 100644 --- a/packages/appkit-react/README.md +++ b/packages/appkit-react/README.md @@ -199,6 +199,34 @@ Use `useSwapQuote` to get a quote and `useBuildSwapTransaction` to build the tra See [Swap Hooks](./docs/hooks.md#swap) for usage examples. +## Staking + +AppKit supports staking through various providers (e.g., Tonstakers). The staking functionality is integrated into the core action and hook system. + +### Hooks + +Use `useStakingQuote` to get a staking/unstaking quote and `useBuildStakeTransaction` or `useBuildUnstakeTransaction` to build the transaction. + +[Read more about Staking](https://github.com/ton-connect/kit/tree/main/packages/appkit/docs/staking.md) + +```tsx +const { data: quote } = useStakingQuote({ + amount: '1000000000', + direction: 'stake', +}); + +const { data: balance } = useStakedBalance({ + userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', +}); + +return ( +
+
Staking Quote: {quote?.amountOut}
+
Staked Balance: {balance?.stakedBalance}
+
+); +``` + ## Migration from TonConnect UI `AppKitProvider` automatically bridges TonConnect if a `TonConnectConnector` is configured, so `@tonconnect/ui-react` hooks (like `useTonAddress`, `useTonWallet`, etc.) work out of the box inside `AppKitProvider`. diff --git a/packages/appkit-react/src/features/staking/hooks/use-build-stake-transaction.ts b/packages/appkit-react/src/features/staking/hooks/use-build-stake-transaction.ts new file mode 100644 index 000000000..fb77ccb81 --- /dev/null +++ b/packages/appkit-react/src/features/staking/hooks/use-build-stake-transaction.ts @@ -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 { UseMutationResult } from '@tanstack/react-query'; +import { buildStakeTransactionMutationOptions } from '@ton/appkit/queries'; +import type { + BuildStakeTransactionData, + BuildStakeTransactionErrorType, + BuildStakeTransactionVariables, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../../hooks/use-app-kit'; +import { useMutation } from '../../../libs/query'; + +export type UseBuildStakeTransactionReturnType = UseMutationResult< + BuildStakeTransactionData, + BuildStakeTransactionErrorType, + BuildStakeTransactionVariables, + context +>; + +/** + * Hook to build stake transaction + */ +export const useBuildStakeTransaction = (): UseBuildStakeTransactionReturnType => { + const appKit = useAppKit(); + return useMutation(buildStakeTransactionMutationOptions(appKit)); +}; diff --git a/packages/appkit-react/src/features/staking/hooks/use-build-unstake-transaction.ts b/packages/appkit-react/src/features/staking/hooks/use-build-unstake-transaction.ts new file mode 100644 index 000000000..928f5ab09 --- /dev/null +++ b/packages/appkit-react/src/features/staking/hooks/use-build-unstake-transaction.ts @@ -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 { UseMutationResult } from '@tanstack/react-query'; +import { buildUnstakeTransactionMutationOptions } from '@ton/appkit/queries'; +import type { + BuildUnstakeTransactionData, + BuildUnstakeTransactionErrorType, + BuildUnstakeTransactionVariables, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../../hooks/use-app-kit'; +import { useMutation } from '../../../libs/query'; + +export type UseBuildUnstakeTransactionReturnType = UseMutationResult< + BuildUnstakeTransactionData, + BuildUnstakeTransactionErrorType, + BuildUnstakeTransactionVariables, + context +>; + +/** + * Hook to build unstake transaction + */ +export const useBuildUnstakeTransaction = (): UseBuildUnstakeTransactionReturnType => { + const appKit = useAppKit(); + return useMutation(buildUnstakeTransactionMutationOptions(appKit)); +}; diff --git a/packages/appkit-react/src/features/staking/hooks/use-staked-balance.ts b/packages/appkit-react/src/features/staking/hooks/use-staked-balance.ts new file mode 100644 index 000000000..afb01b6a5 --- /dev/null +++ b/packages/appkit-react/src/features/staking/hooks/use-staked-balance.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 { getStakedBalanceQueryOptions } from '@ton/appkit/queries'; +import type { GetStakedBalanceData, GetStakedBalanceErrorType, GetStakedBalanceQueryConfig } from '@ton/appkit/queries'; + +import { useAppKit } from '../../../hooks/use-app-kit'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseStakedBalanceParameters = GetStakedBalanceQueryConfig; +export type UseStakedBalanceReturnType = UseQueryReturnType< + selectData, + GetStakedBalanceErrorType +>; + +/** + * Hook to get user's staked balance + */ +export const useStakedBalance = ( + parameters: UseStakedBalanceParameters = {}, +): UseStakedBalanceReturnType => { + const appKit = useAppKit(); + return useQuery(getStakedBalanceQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/staking/hooks/use-staking-provider-info.ts b/packages/appkit-react/src/features/staking/hooks/use-staking-provider-info.ts new file mode 100644 index 000000000..ed528d9b7 --- /dev/null +++ b/packages/appkit-react/src/features/staking/hooks/use-staking-provider-info.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 { getStakingProviderInfoQueryOptions } from '@ton/appkit/queries'; +import type { + GetStakingProviderInfoData, + GetStakingProviderInfoErrorType, + GetStakingProviderInfoQueryConfig, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../../hooks/use-app-kit'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseStakingProviderInfoParameters = + GetStakingProviderInfoQueryConfig; +export type UseStakingProviderInfoReturnType = UseQueryReturnType< + selectData, + GetStakingProviderInfoErrorType +>; + +/** + * Hook to get staking provider information + */ +export const useStakingProviderInfo = ( + parameters: UseStakingProviderInfoParameters = {}, +): UseStakingProviderInfoReturnType => { + const appKit = useAppKit(); + return useQuery(getStakingProviderInfoQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/staking/hooks/use-staking-providers.ts b/packages/appkit-react/src/features/staking/hooks/use-staking-providers.ts new file mode 100644 index 000000000..fc97cfdee --- /dev/null +++ b/packages/appkit-react/src/features/staking/hooks/use-staking-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 { getStakingProvidersQueryOptions } from '@ton/appkit/queries'; +import type { + GetStakingProvidersData, + GetStakingProvidersErrorType, + GetStakingProvidersQueryConfig, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../../hooks/use-app-kit'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseStakingProvidersParameters = + GetStakingProvidersQueryConfig; +export type UseStakingProvidersReturnType = UseQueryReturnType< + selectData, + GetStakingProvidersErrorType +>; + +/** + * Hook to get available staking provider IDs + */ +export const useStakingProviders = ( + parameters: UseStakingProvidersParameters = {}, +): UseStakingProvidersReturnType => { + const appKit = useAppKit(); + return useQuery(getStakingProvidersQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/staking/hooks/use-staking-quote.ts b/packages/appkit-react/src/features/staking/hooks/use-staking-quote.ts new file mode 100644 index 000000000..c011c67a4 --- /dev/null +++ b/packages/appkit-react/src/features/staking/hooks/use-staking-quote.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 { getStakingQuoteQueryOptions } from '@ton/appkit/queries'; +import type { GetStakingQuoteData, GetStakingQuoteErrorType, GetStakingQuoteQueryConfig } from '@ton/appkit/queries'; + +import { useAppKit } from '../../../hooks/use-app-kit'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseStakingQuoteParameters = GetStakingQuoteQueryConfig; +export type UseStakingQuoteReturnType = UseQueryReturnType< + selectData, + GetStakingQuoteErrorType +>; + +/** + * Hook to get staking/unstaking quote + */ +export const useStakingQuote = ( + parameters: UseStakingQuoteParameters = {}, +): UseStakingQuoteReturnType => { + const appKit = useAppKit(); + return useQuery(getStakingQuoteQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/staking/index.ts b/packages/appkit-react/src/features/staking/index.ts new file mode 100644 index 000000000..4208045cc --- /dev/null +++ b/packages/appkit-react/src/features/staking/index.ts @@ -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. + * + */ + +export { + useStakingProviders, + type UseStakingProvidersParameters, + type UseStakingProvidersReturnType, +} from './hooks/use-staking-providers'; +export { + useStakingQuote, + type UseStakingQuoteParameters, + type UseStakingQuoteReturnType, +} from './hooks/use-staking-quote'; +export { + useStakedBalance, + type UseStakedBalanceParameters, + type UseStakedBalanceReturnType, +} from './hooks/use-staked-balance'; +export { + useStakingProviderInfo, + type UseStakingProviderInfoParameters, + type UseStakingProviderInfoReturnType, +} from './hooks/use-staking-provider-info'; +export { useBuildStakeTransaction, type UseBuildStakeTransactionReturnType } from './hooks/use-build-stake-transaction'; +export { + useBuildUnstakeTransaction, + type UseBuildUnstakeTransactionReturnType, +} from './hooks/use-build-unstake-transaction'; diff --git a/packages/appkit-react/src/index.ts b/packages/appkit-react/src/index.ts index 823e8e4b3..e44576dce 100644 --- a/packages/appkit-react/src/index.ts +++ b/packages/appkit-react/src/index.ts @@ -27,3 +27,4 @@ export * from './features/transaction'; export * from './features/wallets'; export * from './features/swap'; export * from './features/signing'; +export * from './features/staking'; diff --git a/packages/appkit/docs/actions.md b/packages/appkit/docs/actions.md index 4c23d6756..ab25ef7ee 100644 --- a/packages/appkit/docs/actions.md +++ b/packages/appkit/docs/actions.md @@ -260,6 +260,18 @@ const networks = getNetworks(appKit); console.log('Configured networks:', networks); ``` +### `getApiClient` + +Get the API client for a specific network. + +```ts +const apiClient = getApiClient(appKit, { + network: Network.mainnet(), +}); + +console.log('API Client:', apiClient); +``` + ### `watchNetworks` Watch configured networks. @@ -438,6 +450,63 @@ const transactionResponse = await sendTransaction(appKit, transactionRequest); console.log('Swap Transaction:', transactionResponse); ``` +## Staking + +### `getStakingProviders` + +Get all available staking provider IDs. + +```ts +const providers = await getStakingProviders(appKit); +console.log('Available Staking Providers:', providers); +``` + +### `getStakingProviderInfo` + +Get information about a specific staking provider. + +```ts +const providerInfo = await getStakingProviderInfo(appKit, { + providerId: 'tonstakers', +}); +console.log('Provider Info:', providerInfo); +``` + +### `getStakingQuote` + +Get a staking or unstaking quote. + +```ts +const quote = await getStakingQuote(appKit, { + amount: '1000000000', + direction: 'stake', +}); +console.log('Staking Quote:', quote); +``` + +### `buildStakeTransaction` + +Build a stake transaction based on a quote. + +```ts +const txRequest = await buildStakeTransaction(appKit, { + quote, + userAddress, +}); +console.log('Stake Transaction:', txRequest); +``` + +### `getStakedBalance` + +Get the user's staked balance. + +```ts +const balance = await getStakedBalance(appKit, { + userAddress, +}); +console.log('Staked Balance:', balance); +``` + ## Transaction ### `createTransferTonTransaction` diff --git a/packages/appkit/docs/staking.md b/packages/appkit/docs/staking.md new file mode 100644 index 000000000..84dcef800 --- /dev/null +++ b/packages/appkit/docs/staking.md @@ -0,0 +1,72 @@ + + +# Staking + +AppKit supports staking through various providers. Available providers: + +- **TonStakersStakingProvider** – [Tonstakers](https://tonstakers.com) liquid staking protocol + +## Installation + +Staking providers are included in the `@ton/appkit` package. No extra dependencies are required. + +## Setup + +You can set up staking providers by passing them to the `AppKit` constructor. + +```ts +// Initialize AppKit with staking providers +const network = Network.mainnet(); +const toncenterApiClient = new ApiClientToncenter({ network }); +const appKit = new AppKit({ + networks: { + [network.chainId]: { + apiClient: toncenterApiClient, + }, + }, + providers: [ + new TonStakersStakingProvider({ + [network.chainId]: { + apiClient: toncenterApiClient, + }, + }), + ], +}); +``` + +### Register Dynamically + +Alternatively, you can register providers dynamically using `registerProvider`: + +```ts +// 1. Initialize AppKit +const appKit = new AppKit({ + networks: { + [Network.mainnet().chainId]: { + apiClient: { + url: 'https://toncenter.com', + key: 'your-key', + }, + }, + }, +}); + +// 2. Register staking providers +const apiClient = getApiClient(appKit, { network: Network.mainnet() }); +registerProvider( + appKit, + new TonStakersStakingProvider({ + [Network.mainnet().chainId]: { + apiClient, + }, + }), +); +``` + +## Configuration + +- **Tonstakers**: [Tonstakers documentation](https://docs.tonstakers.com) and [provider README](https://github.com/ton-connect/kit/blob/main/packages/walletkit/src/defi/staking/tonstakers/README.md) diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 3b422e2b3..57b776331 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -50,18 +50,31 @@ "types": "./dist/cjs/swap/dedust/index.d.ts", "default": "./dist/cjs/swap/dedust/index.js" } + }, + "./staking/tonstakers": { + "import": { + "types": "./dist/esm/staking/tonstakers/index.d.ts", + "default": "./dist/esm/staking/tonstakers/index.js" + }, + "require": { + "types": "./dist/cjs/staking/tonstakers/index.d.ts", + "default": "./dist/cjs/staking/tonstakers/index.js" + } } }, "typesVersions": { "*": { "queries": [ - "./dist/cjs/queries/index.d.ts" + "./dist/esm/queries/index.d.ts" ], "swap/omniston": [ - "./dist/cjs/swap/omniston/index.d.ts" + "./dist/esm/swap/omniston/index.d.ts" ], "swap/dedust": [ - "./dist/cjs/swap/dedust/index.d.ts" + "./dist/esm/swap/dedust/index.d.ts" + ], + "staking/tonstakers": [ + "./dist/esm/staking/tonstakers/index.d.ts" ] } }, diff --git a/packages/appkit/src/actions/index.ts b/packages/appkit/src/actions/index.ts index 6edc5f557..6400b1916 100644 --- a/packages/appkit/src/actions/index.ts +++ b/packages/appkit/src/actions/index.ts @@ -67,6 +67,7 @@ export { // Network export { getNetworks, type GetNetworksReturnType } from './network/get-networks'; export { getNetwork, type GetNetworkReturnType } from './network/get-network'; +export { getApiClient, type GetApiClientOptions, type GetApiClientReturnType } from './network/get-api-client'; export { watchNetworks, type WatchNetworksParameters, type WatchNetworksReturnType } from './network/watch-networks'; export { getDefaultNetwork, type GetDefaultNetworkReturnType } from './network/get-default-network'; export { @@ -107,6 +108,35 @@ export { type BuildSwapTransactionReturnType, } from './swap/build-swap-transaction'; +// Staking +export { getStakingManager, type GetStakingManagerReturnType } from './staking/get-staking-manager'; +export { getStakingProviders, type GetStakingProvidersReturnType } from './staking/get-staking-providers'; +export { + getStakingQuote, + type GetStakingQuoteOptions, + type GetStakingQuoteReturnType, +} from './staking/get-staking-quote'; +export { + buildStakeTransaction, + type BuildStakeTransactionOptions, + type BuildStakeTransactionReturnType, +} from './staking/build-stake-transaction'; +export { + buildUnstakeTransaction, + type BuildUnstakeTransactionOptions, + type BuildUnstakeTransactionReturnType, +} from './staking/build-unstake-transaction'; +export { + getStakedBalance, + type GetStakedBalanceOptions, + type GetStakedBalanceReturnType, +} from './staking/get-staked-balance'; +export { + getStakingProviderInfo, + type GetStakingProviderInfoOptions, + type GetStakingProviderInfoReturnType, +} from './staking/get-staking-provider-info'; + // Transactions export { sendTransaction, diff --git a/packages/appkit/src/actions/network/get-api-client.ts b/packages/appkit/src/actions/network/get-api-client.ts new file mode 100644 index 000000000..ff19b546e --- /dev/null +++ b/packages/appkit/src/actions/network/get-api-client.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 type { Network } from '../../types/network'; +import type { AppKit } from '../../core/app-kit'; +import type { ApiClient } from '../../core/network'; + +export type GetApiClientReturnType = ApiClient; + +export type GetApiClientOptions = { network: Network }; + +/** + * Get API client for a network + */ +export const getApiClient = (appKit: AppKit, options: GetApiClientOptions): GetApiClientReturnType => { + return appKit.networkManager.getClient(options.network); +}; diff --git a/packages/appkit/src/actions/staking/build-stake-transaction.ts b/packages/appkit/src/actions/staking/build-stake-transaction.ts new file mode 100644 index 000000000..6cadb0f48 --- /dev/null +++ b/packages/appkit/src/actions/staking/build-stake-transaction.ts @@ -0,0 +1,28 @@ +/** + * 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 { StakeParams } from '@ton/walletkit'; + +import type { TransactionRequest } from '../../types/transaction'; +import type { AppKit } from '../../core/app-kit'; + +export type BuildStakeTransactionOptions = StakeParams & { + providerId?: string; +}; + +export type BuildStakeTransactionReturnType = Promise; + +/** + * Build stake transaction + */ +export const buildStakeTransaction = async ( + appKit: AppKit, + options: BuildStakeTransactionOptions, +): BuildStakeTransactionReturnType => { + return appKit.stakingManager.buildStakeTransaction(options, options.providerId); +}; diff --git a/packages/appkit/src/actions/staking/build-unstake-transaction.ts b/packages/appkit/src/actions/staking/build-unstake-transaction.ts new file mode 100644 index 000000000..eb6e2c319 --- /dev/null +++ b/packages/appkit/src/actions/staking/build-unstake-transaction.ts @@ -0,0 +1,28 @@ +/** + * 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 { UnstakeParams } from '@ton/walletkit'; + +import type { TransactionRequest } from '../../types/transaction'; +import type { AppKit } from '../../core/app-kit'; + +export type BuildUnstakeTransactionOptions = UnstakeParams & { + providerId?: string; +}; + +export type BuildUnstakeTransactionReturnType = Promise; + +/** + * Build unstake transaction + */ +export const buildUnstakeTransaction = async ( + appKit: AppKit, + options: BuildUnstakeTransactionOptions, +): BuildUnstakeTransactionReturnType => { + return appKit.stakingManager.buildUnstakeTransaction(options, options.providerId); +}; diff --git a/packages/appkit/src/actions/staking/get-staked-balance.ts b/packages/appkit/src/actions/staking/get-staked-balance.ts new file mode 100644 index 000000000..f975dc764 --- /dev/null +++ b/packages/appkit/src/actions/staking/get-staked-balance.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 type { StakingBalance, UserFriendlyAddress, Network } from '@ton/walletkit'; + +import type { AppKit } from '../../core/app-kit'; +import { resolveNetwork } from '../../utils'; + +export type GetStakedBalanceOptions = { + userAddress: UserFriendlyAddress; + network?: Network; + providerId?: string; +}; + +export type GetStakedBalanceReturnType = Promise; + +/** + * Get staked balance + */ +export const getStakedBalance = async ( + appKit: AppKit, + options: GetStakedBalanceOptions, +): GetStakedBalanceReturnType => { + return appKit.stakingManager.getStakedBalance( + options.userAddress, + resolveNetwork(appKit, options.network), + options.providerId, + ); +}; diff --git a/packages/appkit/src/actions/staking/get-staking-manager.ts b/packages/appkit/src/actions/staking/get-staking-manager.ts new file mode 100644 index 000000000..d5243498f --- /dev/null +++ b/packages/appkit/src/actions/staking/get-staking-manager.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 { StakingManager } from '@ton/walletkit'; + +import type { AppKit } from '../../core/app-kit'; + +export type GetStakingManagerReturnType = StakingManager; + +/** + * Get staking manager instance + */ +export const getStakingManager = (appKit: AppKit): GetStakingManagerReturnType => { + return appKit.stakingManager; +}; diff --git a/packages/appkit/src/actions/staking/get-staking-provider-info.ts b/packages/appkit/src/actions/staking/get-staking-provider-info.ts new file mode 100644 index 000000000..eab2a1b7b --- /dev/null +++ b/packages/appkit/src/actions/staking/get-staking-provider-info.ts @@ -0,0 +1,29 @@ +/** + * 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 { StakingProviderInfo, Network } from '@ton/walletkit'; + +import { resolveNetwork } from '../../utils'; +import type { AppKit } from '../../core/app-kit'; + +export type GetStakingProviderInfoOptions = { + network?: Network; + providerId?: string; +}; + +export type GetStakingProviderInfoReturnType = Promise; + +/** + * Get staking provider info + */ +export const getStakingProviderInfo = async ( + appKit: AppKit, + options: GetStakingProviderInfoOptions = {}, +): GetStakingProviderInfoReturnType => { + return appKit.stakingManager.getStakingProviderInfo(resolveNetwork(appKit, options.network), options.providerId); +}; diff --git a/packages/appkit/src/actions/staking/get-staking-providers.ts b/packages/appkit/src/actions/staking/get-staking-providers.ts new file mode 100644 index 000000000..8720d52b0 --- /dev/null +++ b/packages/appkit/src/actions/staking/get-staking-providers.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. + * + */ + +import type { AppKit } from '../../core/app-kit'; + +export type GetStakingProvidersReturnType = string[]; + +/** + * Get available staking provider IDs + */ +export const getStakingProviders = (appKit: AppKit): GetStakingProvidersReturnType => { + return appKit.stakingManager.getRegisteredProviders(); +}; diff --git a/packages/appkit/src/actions/staking/get-staking-quote.ts b/packages/appkit/src/actions/staking/get-staking-quote.ts new file mode 100644 index 000000000..fa683764e --- /dev/null +++ b/packages/appkit/src/actions/staking/get-staking-quote.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 type { StakingQuote, StakingQuoteParams } from '@ton/walletkit'; + +import type { AppKit } from '../../core/app-kit'; +import { resolveNetwork } from '../../utils'; + +export type GetStakingQuoteOptions = StakingQuoteParams & { + providerId?: string; +}; + +export type GetStakingQuoteReturnType = Promise; + +/** + * Get staking quote + */ +export const getStakingQuote = async (appKit: AppKit, options: GetStakingQuoteOptions): GetStakingQuoteReturnType => { + const optionsWithNetwork = { + ...options, + network: resolveNetwork(appKit, options.network), + }; + + return appKit.stakingManager.getQuote(optionsWithNetwork, options.providerId); +}; 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 094d7bf32..772843426 100644 --- a/packages/appkit/src/core/app-kit/services/app-kit.ts +++ b/packages/appkit/src/core/app-kit/services/app-kit.ts @@ -6,7 +6,8 @@ * */ -import { SwapManager } from '@ton/walletkit'; +import { SwapManager, StakingManager } from '@ton/walletkit'; +import type { SwapProviderInterface, StakingProviderInterface } from '@ton/walletkit'; import type { Provider } from 'src/types/provider'; import type { AppKitConfig } from '../types/config'; @@ -28,6 +29,7 @@ export class AppKit { readonly connectors: Connector[] = []; readonly walletsManager: WalletsManager; readonly swapManager: SwapManager; + readonly stakingManager: StakingManager; readonly networkManager: AppKitNetworkManager; readonly config: AppKitConfig; @@ -47,6 +49,7 @@ export class AppKit { this.networkManager = new AppKitNetworkManager({ networks }, this.emitter); this.walletsManager = new WalletsManager(this.emitter); this.swapManager = new SwapManager(); + this.stakingManager = new StakingManager(); if (config.connectors) { config.connectors.forEach((connector) => { @@ -99,7 +102,10 @@ export class AppKit { registerProvider(provider: Provider): void { switch (provider.type) { case 'swap': - this.swapManager.registerProvider(provider); + this.swapManager.registerProvider(provider as SwapProviderInterface); + break; + case 'staking': + this.stakingManager.registerProvider(provider as StakingProviderInterface); break; default: throw new Error('Unknown provider type'); diff --git a/packages/appkit/src/queries/index.ts b/packages/appkit/src/queries/index.ts index 76d47c950..910922c51 100644 --- a/packages/appkit/src/queries/index.ts +++ b/packages/appkit/src/queries/index.ts @@ -154,6 +154,49 @@ export { type BuildSwapTransactionVariables, } from './swap/build-swap-transaction'; +// Staking +export { + getStakingProvidersQueryOptions, + type GetStakingProvidersData, + type GetStakingProvidersErrorType, + type GetStakingProvidersQueryConfig, +} from './staking/get-staking-providers'; +export { + getStakingQuoteQueryOptions, + type GetStakingQuoteQueryConfig, + type GetStakingQuoteQueryOptions, + type GetStakingQuoteData, + type GetStakingQuoteErrorType, + type GetStakingQuoteQueryFnData, + type GetStakingQuoteQueryKey, +} from './staking/get-staking-quote'; +export { + getStakedBalanceQueryOptions, + type GetStakedBalanceQueryConfig, + type GetStakedBalanceData, + type GetStakedBalanceErrorType, +} from './staking/get-staked-balance'; +export { + getStakingProviderInfoQueryOptions, + type GetStakingProviderInfoQueryConfig, + type GetStakingProviderInfoData, + type GetStakingProviderInfoErrorType, +} from './staking/get-staking-provider-info'; +export { + buildStakeTransactionMutationOptions, + type BuildStakeTransactionData, + type BuildStakeTransactionErrorType, + type BuildStakeTransactionMutationOptions, + type BuildStakeTransactionVariables, +} from './staking/build-stake-transaction'; +export { + buildUnstakeTransactionMutationOptions, + type BuildUnstakeTransactionData, + type BuildUnstakeTransactionErrorType, + type BuildUnstakeTransactionMutationOptions, + type BuildUnstakeTransactionVariables, +} from './staking/build-unstake-transaction'; + // Transaction export { transferTonMutationOptions, diff --git a/packages/appkit/src/queries/staking/build-stake-transaction.ts b/packages/appkit/src/queries/staking/build-stake-transaction.ts new file mode 100644 index 000000000..f98b06c29 --- /dev/null +++ b/packages/appkit/src/queries/staking/build-stake-transaction.ts @@ -0,0 +1,79 @@ +/** + * 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 { MutateOptions, MutationOptions } from '@tanstack/query-core'; + +import type { AppKit } from '../../core/app-kit'; +import type { MutationParameter } from '../../types/query'; +import type { Compute } from '../../types/utils'; +import { buildStakeTransaction } from '../../actions/staking/build-stake-transaction'; +import type { + BuildStakeTransactionOptions, + BuildStakeTransactionReturnType, +} from '../../actions/staking/build-stake-transaction'; + +export type BuildStakeTransactionMutationOptions = MutationParameter< + BuildStakeTransactionData, + BuildStakeTransactionErrorType, + BuildStakeTransactionVariables, + context +>; + +export const buildStakeTransactionMutationOptions = ( + appKit: AppKit, + options: BuildStakeTransactionMutationOptions = {}, +): BuildStakeTransactionMutationConfig => { + return { + ...options.mutation, + mutationFn(variables) { + return buildStakeTransaction(appKit, variables); + }, + mutationKey: ['buildStakeTransaction'], + }; +}; + +export type BuildStakeTransactionMutationConfig = MutationOptions< + BuildStakeTransactionData, + BuildStakeTransactionErrorType, + BuildStakeTransactionVariables, + context +>; + +export type BuildStakeTransactionData = Compute>; + +export type BuildStakeTransactionErrorType = Error; + +export type BuildStakeTransactionVariables = BuildStakeTransactionOptions; + +export type BuildStakeTransactionMutate = ( + variables: BuildStakeTransactionVariables, + options?: + | Compute< + MutateOptions< + BuildStakeTransactionData, + BuildStakeTransactionErrorType, + Compute, + context + > + > + | undefined, +) => void; + +export type BuildStakeTransactionMutateAsync = ( + variables: BuildStakeTransactionVariables, + options?: + | Compute< + MutateOptions< + BuildStakeTransactionData, + BuildStakeTransactionErrorType, + Compute, + context + > + > + | undefined, +) => Promise; diff --git a/packages/appkit/src/queries/staking/build-unstake-transaction.ts b/packages/appkit/src/queries/staking/build-unstake-transaction.ts new file mode 100644 index 000000000..e037e4dc5 --- /dev/null +++ b/packages/appkit/src/queries/staking/build-unstake-transaction.ts @@ -0,0 +1,79 @@ +/** + * 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 { MutateOptions, MutationOptions } from '@tanstack/query-core'; + +import type { AppKit } from '../../core/app-kit'; +import type { MutationParameter } from '../../types/query'; +import type { Compute } from '../../types/utils'; +import { buildUnstakeTransaction } from '../../actions/staking/build-unstake-transaction'; +import type { + BuildUnstakeTransactionOptions, + BuildUnstakeTransactionReturnType, +} from '../../actions/staking/build-unstake-transaction'; + +export type BuildUnstakeTransactionMutationOptions = MutationParameter< + BuildUnstakeTransactionData, + BuildUnstakeTransactionErrorType, + BuildUnstakeTransactionVariables, + context +>; + +export const buildUnstakeTransactionMutationOptions = ( + appKit: AppKit, + options: BuildUnstakeTransactionMutationOptions = {}, +): BuildUnstakeTransactionMutationConfig => { + return { + ...options.mutation, + mutationFn(variables) { + return buildUnstakeTransaction(appKit, variables); + }, + mutationKey: ['buildUnstakeTransaction'], + }; +}; + +export type BuildUnstakeTransactionMutationConfig = MutationOptions< + BuildUnstakeTransactionData, + BuildUnstakeTransactionErrorType, + BuildUnstakeTransactionVariables, + context +>; + +export type BuildUnstakeTransactionData = Compute>; + +export type BuildUnstakeTransactionErrorType = Error; + +export type BuildUnstakeTransactionVariables = BuildUnstakeTransactionOptions; + +export type BuildUnstakeTransactionMutate = ( + variables: BuildUnstakeTransactionVariables, + options?: + | Compute< + MutateOptions< + BuildUnstakeTransactionData, + BuildUnstakeTransactionErrorType, + Compute, + context + > + > + | undefined, +) => void; + +export type BuildUnstakeTransactionMutateAsync = ( + variables: BuildUnstakeTransactionVariables, + options?: + | Compute< + MutateOptions< + BuildUnstakeTransactionData, + BuildUnstakeTransactionErrorType, + Compute, + context + > + > + | undefined, +) => Promise; diff --git a/packages/appkit/src/queries/staking/get-staked-balance.ts b/packages/appkit/src/queries/staking/get-staked-balance.ts new file mode 100644 index 000000000..5eb642cfc --- /dev/null +++ b/packages/appkit/src/queries/staking/get-staked-balance.ts @@ -0,0 +1,60 @@ +/** + * 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 { getStakedBalance } from '../../actions/staking/get-staked-balance'; +import type { GetStakedBalanceOptions } from '../../actions/staking/get-staked-balance'; +import type { GetStakedBalanceReturnType } from '../../actions/staking/get-staked-balance'; +import type { AppKit } from '../../core/app-kit'; +import type { QueryOptions, QueryParameter } from '../../types/query'; +import type { Compute, ExactPartial } from '../../types/utils'; +import { filterQueryOptions } from '../../utils'; + +export type GetStakedBalanceErrorType = Error; + +export type GetStakedBalanceQueryConfig = Compute< + ExactPartial +> & + QueryParameter; + +export const getStakedBalanceQueryOptions = ( + appKit: AppKit, + options: GetStakedBalanceQueryConfig = {}, +): GetStakedBalanceQueryOptions => { + return { + ...options.query, + enabled: Boolean(options.userAddress && (options.query?.enabled ?? true)), + queryFn: async (context) => { + const [, parameters] = context.queryKey as [string, GetStakedBalanceOptions]; + if (!parameters.userAddress) { + throw new Error('userAddress is required'); + } + + return getStakedBalance(appKit, parameters); + }, + queryKey: getStakedBalanceQueryKey(options), + }; +}; + +export type GetStakedBalanceQueryFnData = Compute>; + +export type GetStakedBalanceData = GetStakedBalanceQueryFnData; + +export const getStakedBalanceQueryKey = ( + options: Compute> = {}, +): GetStakedBalanceQueryKey => { + return ['stakedBalance', filterQueryOptions(options as unknown as Record)] as const; +}; + +export type GetStakedBalanceQueryKey = readonly ['stakedBalance', Compute>]; + +export type GetStakedBalanceQueryOptions = QueryOptions< + GetStakedBalanceQueryFnData, + GetStakedBalanceErrorType, + selectData, + GetStakedBalanceQueryKey +>; diff --git a/packages/appkit/src/queries/staking/get-staking-provider-info.ts b/packages/appkit/src/queries/staking/get-staking-provider-info.ts new file mode 100644 index 000000000..2fd7d8545 --- /dev/null +++ b/packages/appkit/src/queries/staking/get-staking-provider-info.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. + * + */ + +import { getStakingProviderInfo } from '../../actions/staking/get-staking-provider-info'; +import type { GetStakingProviderInfoOptions } from '../../actions/staking/get-staking-provider-info'; +import type { GetStakingProviderInfoReturnType } from '../../actions/staking/get-staking-provider-info'; +import type { AppKit } from '../../core/app-kit'; +import type { QueryOptions, QueryParameter } from '../../types/query'; +import type { Compute, ExactPartial } from '../../types/utils'; +import { filterQueryOptions } from '../../utils'; + +export type GetStakingProviderInfoErrorType = Error; + +export type GetStakingProviderInfoQueryConfig = Compute< + ExactPartial +> & + QueryParameter< + GetStakingProviderInfoQueryFnData, + GetStakingProviderInfoErrorType, + selectData, + GetStakingProviderInfoQueryKey + >; + +export const getStakingProviderInfoQueryOptions = ( + appKit: AppKit, + options: GetStakingProviderInfoQueryConfig = {}, +): GetStakingProviderInfoQueryOptions => { + return { + ...options.query, + queryFn: async (context) => { + const [, parameters] = context.queryKey as [string, GetStakingProviderInfoOptions]; + return getStakingProviderInfo(appKit, parameters); + }, + queryKey: getStakingProviderInfoQueryKey(options), + }; +}; + +export type GetStakingProviderInfoQueryFnData = Compute>; + +export type GetStakingProviderInfoData = GetStakingProviderInfoQueryFnData; + +export const getStakingProviderInfoQueryKey = ( + options: Compute> = {}, +): GetStakingProviderInfoQueryKey => { + return ['stakingProviderInfo', filterQueryOptions(options as unknown as Record)] as const; +}; + +export type GetStakingProviderInfoQueryKey = readonly [ + 'stakingProviderInfo', + Compute>, +]; + +export type GetStakingProviderInfoQueryOptions = QueryOptions< + GetStakingProviderInfoQueryFnData, + GetStakingProviderInfoErrorType, + selectData, + GetStakingProviderInfoQueryKey +>; diff --git a/packages/appkit/src/queries/staking/get-staking-providers.ts b/packages/appkit/src/queries/staking/get-staking-providers.ts new file mode 100644 index 000000000..19c9846ad --- /dev/null +++ b/packages/appkit/src/queries/staking/get-staking-providers.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 { getStakingProviders } from '../../actions/staking/get-staking-providers'; +import type { GetStakingProvidersReturnType } from '../../actions/staking/get-staking-providers'; +import type { AppKit } from '../../core/app-kit'; +import type { QueryOptions, QueryParameter } from '../../types/query'; +import type { Compute } from '../../types/utils'; + +export type GetStakingProvidersErrorType = Error; + +export type GetStakingProvidersQueryConfig = QueryParameter< + GetStakingProvidersQueryFnData, + GetStakingProvidersErrorType, + selectData, + GetStakingProvidersQueryKey +>; + +export const getStakingProvidersQueryOptions = ( + appKit: AppKit, + options: GetStakingProvidersQueryConfig = {}, +): GetStakingProvidersQueryOptions => { + return { + ...options.query, + queryFn: async () => { + return getStakingProviders(appKit); + }, + queryKey: getStakingProvidersQueryKey(), + }; +}; + +export type GetStakingProvidersQueryFnData = Compute>; + +export type GetStakingProvidersData = GetStakingProvidersQueryFnData; + +export const getStakingProvidersQueryKey = (): GetStakingProvidersQueryKey => { + return ['stakingProviders'] as const; +}; + +export type GetStakingProvidersQueryKey = readonly ['stakingProviders']; + +export type GetStakingProvidersQueryOptions = QueryOptions< + GetStakingProvidersQueryFnData, + GetStakingProvidersErrorType, + selectData, + GetStakingProvidersQueryKey +>; diff --git a/packages/appkit/src/queries/staking/get-staking-quote.ts b/packages/appkit/src/queries/staking/get-staking-quote.ts new file mode 100644 index 000000000..363187089 --- /dev/null +++ b/packages/appkit/src/queries/staking/get-staking-quote.ts @@ -0,0 +1,60 @@ +/** + * 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 { getStakingQuote } from '../../actions/staking/get-staking-quote'; +import type { GetStakingQuoteOptions } from '../../actions/staking/get-staking-quote'; +import type { GetStakingQuoteReturnType } from '../../actions/staking/get-staking-quote'; +import type { AppKit } from '../../core/app-kit'; +import type { QueryOptions, QueryParameter } from '../../types/query'; +import type { Compute, ExactPartial } from '../../types/utils'; +import { filterQueryOptions } from '../../utils'; + +export type GetStakingQuoteErrorType = Error; + +export type GetStakingQuoteQueryConfig = Compute< + ExactPartial +> & + QueryParameter; + +export const getStakingQuoteQueryOptions = ( + appKit: AppKit, + options: GetStakingQuoteQueryConfig = {}, +): GetStakingQuoteQueryOptions => { + return { + ...options.query, + enabled: Boolean(options.amount && options.direction && (options.query?.enabled ?? true)), + queryFn: async (context) => { + const [, parameters] = context.queryKey as [string, GetStakingQuoteOptions]; + if (!parameters.amount || !parameters.direction) { + throw new Error('amount and direction are required'); + } + + return getStakingQuote(appKit, parameters); + }, + queryKey: getStakingQuoteQueryKey(options), + }; +}; + +export type GetStakingQuoteQueryFnData = Compute>; + +export type GetStakingQuoteData = GetStakingQuoteQueryFnData; + +export const getStakingQuoteQueryKey = ( + options: Compute> = {}, +): GetStakingQuoteQueryKey => { + return ['stakingQuote', filterQueryOptions(options as unknown as Record)] as const; +}; + +export type GetStakingQuoteQueryKey = readonly ['stakingQuote', Compute>]; + +export type GetStakingQuoteQueryOptions = QueryOptions< + GetStakingQuoteQueryFnData, + GetStakingQuoteErrorType, + selectData, + GetStakingQuoteQueryKey +>; diff --git a/packages/appkit/src/staking/index.ts b/packages/appkit/src/staking/index.ts new file mode 100644 index 000000000..ecea5a62d --- /dev/null +++ b/packages/appkit/src/staking/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 type { + StakeParams, + StakingAPI, + StakingQuote, + StakingQuoteParams, + StakingProvider, + StakingError, + StakingManager, + StakingBalance, + StakingProviderInfo, + StakingProviderInterface, + StakingQuoteDirection, + UnstakeMode, + UnstakeParams, +} from '@ton/walletkit'; diff --git a/packages/appkit/src/staking/tonstakers/index.ts b/packages/appkit/src/staking/tonstakers/index.ts new file mode 100644 index 000000000..9b56f3973 --- /dev/null +++ b/packages/appkit/src/staking/tonstakers/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/staking/tonstakers'; diff --git a/packages/appkit/src/types/provider.ts b/packages/appkit/src/types/provider.ts index b2cd46a28..51d5748df 100644 --- a/packages/appkit/src/types/provider.ts +++ b/packages/appkit/src/types/provider.ts @@ -6,9 +6,9 @@ * */ -import type { SwapProviderInterface } from '@ton/walletkit'; +import type { SwapProviderInterface, StakingProviderInterface } from '@ton/walletkit'; /** * Provider configuration */ -export type Provider = SwapProviderInterface; +export type Provider = SwapProviderInterface | StakingProviderInterface; diff --git a/packages/walletkit/src/api/interfaces/StakingAPI.ts b/packages/walletkit/src/api/interfaces/StakingAPI.ts index 0034a93b6..e5fc48e0b 100644 --- a/packages/walletkit/src/api/interfaces/StakingAPI.ts +++ b/packages/walletkit/src/api/interfaces/StakingAPI.ts @@ -24,10 +24,45 @@ import type { * Staking API interface exposed by StakingManager */ export interface StakingAPI extends DefiManagerAPI { + /** + * Get a quote for staking or unstaking + * @param params Quote parameters (amount, direction, etc.) + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns A promise that resolves to a StakingQuote + */ getQuote(params: StakingQuoteParams, providerId?: string): Promise; + + /** + * Build a transaction for staking + * @param params Staking parameters (quote, user address, etc.) + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns A promise that resolves to a TransactionRequest + */ buildStakeTransaction(params: StakeParams, providerId?: string): Promise; + + /** + * Build a transaction for unstaking + * @param params Unstaking parameters (quote, user address, etc.) + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns A promise that resolves to a TransactionRequest + */ buildUnstakeTransaction(params: UnstakeParams, providerId?: string): Promise; + + /** + * Get user's staked balance + * @param userAddress User address + * @param network Network to query (optional) + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns A promise that resolves to a StakingBalance + */ getStakedBalance(userAddress: UserFriendlyAddress, network?: Network, providerId?: string): Promise; + + /** + * Get staking provider information + * @param network Network to query (optional) + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns A promise that resolves to a StakingProviderInfo + */ getStakingProviderInfo(network?: Network, providerId?: string): Promise; } @@ -35,9 +70,46 @@ export interface StakingAPI extends DefiManagerAPI { * Interface that all staking providers must implement */ export interface StakingProviderInterface extends DefiProvider { + readonly type: 'staking'; + + /** + * Unique identifier for the provider + */ + readonly providerId: string; + + /** + * Get a quote for staking or unstaking + * @param params Quote parameters including provider-specific options + * @returns A promise that resolves to a StakingQuote + */ getQuote(params: StakingQuoteParams): Promise; + + /** + * Build a transaction for staking + * @param params Staking parameters including provider-specific options + * @returns A promise that resolves to a TransactionRequest + */ buildStakeTransaction(params: StakeParams): Promise; + + /** + * Build a transaction for unstaking + * @param params Unstaking parameters including provider-specific options + * @returns A promise that resolves to a TransactionRequest + */ buildUnstakeTransaction(params: UnstakeParams): Promise; + + /** + * Get user's staked balance + * @param userAddress User address + * @param network Network to query (optional) + * @returns A promise that resolves to a StakingBalance + */ getStakedBalance(userAddress: UserFriendlyAddress, network?: Network): Promise; + + /** + * Get staking provider information + * @param network Network to query (optional) + * @returns A promise that resolves to a StakingProviderInfo + */ getStakingProviderInfo(network?: Network): Promise; } diff --git a/packages/walletkit/src/defi/staking/StakingProvider.ts b/packages/walletkit/src/defi/staking/StakingProvider.ts index 0a015da85..3c5259d6e 100644 --- a/packages/walletkit/src/defi/staking/StakingProvider.ts +++ b/packages/walletkit/src/defi/staking/StakingProvider.ts @@ -16,7 +16,6 @@ import type { StakingQuote, } from '../../api/models'; import type { StakingProviderInterface } from '../../api/interfaces'; -import { NetworkManager } from '../../core/NetworkManager'; /** * Abstract base class for staking providers @@ -25,7 +24,7 @@ import { NetworkManager } from '../../core/NetworkManager'; * Users can extend this class to create custom staking providers. */ export abstract class StakingProvider implements StakingProviderInterface { - readonly type = 'swap'; + readonly type = 'staking'; readonly providerId: string; constructor(providerId: string) { diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts index aa4d68e25..f7cfb46fb 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersContract.spec.ts @@ -10,7 +10,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Cell } from '@ton/core'; import { PoolContract } from './PoolContract'; -import { CONTRACT } from './constants'; +import { CONTRACT, STAKING_CONTRACT_ADDRESS } from './constants'; +import { Network } from '../../../api/models'; const mockApiClient = { runGetMethod: vi.fn(), @@ -22,7 +23,7 @@ describe('TonStakersContract', () => { beforeEach(() => { vi.clearAllMocks(); - contract = new PoolContract(CONTRACT.STAKING_CONTRACT_ADDRESS, mockApiClient as never); + contract = new PoolContract(STAKING_CONTRACT_ADDRESS[Network.mainnet().chainId], mockApiClient as never); }); describe('buildStakePayload', () => { diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts index 8b339b923..82282deb9 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts @@ -13,23 +13,14 @@ import { TonStakersStakingProvider } from './TonStakersStakingProvider'; import { PoolContract } from './PoolContract'; import { CONTRACT } from './constants'; import { Network } from '../../../api/models'; -import type { Base64String, UnstakeMode } from '../../../api/models'; +import type { Base64String, UnstakeMode, UserFriendlyAddress } from '../../../api/models'; +import type { ApiClient } from '../../../types/toncenter/ApiClient'; const mockApiClient = { runGetMethod: vi.fn(), getBalance: vi.fn(), }; -const mockNetworkManager = { - getClient: vi.fn(() => mockApiClient), -}; - -const mockEventEmitter = { - emit: vi.fn(), - on: vi.fn(), - off: vi.fn(), -}; - describe('TonStakersStakingProvider', () => { let provider: TonStakersStakingProvider; const testUserAddress = 'EQDtFpEwcFAEcRe5mLVh2N6C0x-_hJEM7W61_JLnSF74p4q2'; @@ -56,7 +47,12 @@ describe('TonStakersStakingProvider', () => { tsTONTONProjected: 1.1, }); - provider = new TonStakersStakingProvider(mockNetworkManager as never, mockEventEmitter as never); + provider = new TonStakersStakingProvider({ + [Network.mainnet().chainId]: { + apiClient: mockApiClient as unknown as ApiClient, + contractAddress: 'EQCkWxfyhAkim3g2DjKQQg8T5P4g-Q1-K_jErGcDJZ4i-vqR' as UserFriendlyAddress, + }, + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any getApyFromTonApiSpy = vi.spyOn(provider as any, 'getApyFromTonApi').mockResolvedValue(0.05); @@ -64,7 +60,7 @@ describe('TonStakersStakingProvider', () => { describe('getQuote', () => { it('should return correct quote with APY for stake direction', async () => { - const amount = '1000000000'; + const amount = '1'; const quote = await provider.getQuote({ direction: 'stake', amount, @@ -74,15 +70,15 @@ describe('TonStakersStakingProvider', () => { expect(quote.direction).toBe('stake'); expect(quote.amountIn).toBe(amount); - // amountOut = 1000000000 / 1.1 = 909090909 - expect(quote.amountOut).toBe('909090909'); + // amountOut = 1 / 1.1 = 0.909090909 + expect(quote.amountOut).toBe('0.909090909'); expect(quote.providerId).toBe('tonstakers'); expect(quote.apy).toBe(0.05); expect(getApyFromTonApiSpy).toHaveBeenCalled(); }); it('should return correct quote with unstakeMode for unstake direction', async () => { - const amount = '1000000000'; + const amount = '1'; const quote = await provider.getQuote({ direction: 'unstake', amount, @@ -93,8 +89,8 @@ describe('TonStakersStakingProvider', () => { expect(quote.direction).toBe('unstake'); expect(quote.amountIn).toBe(amount); - // amountOut = 1000000000 * 1.05 = 1050000000 - expect(quote.amountOut).toBe('1050000000'); + // amountOut = 1 * 1.05 = 1.05 + expect(quote.amountOut).toBe('1.050000000'); expect(quote.providerId).toBe('tonstakers'); expect(quote.unstakeMode).toBe('instant'); }); @@ -102,7 +98,7 @@ describe('TonStakersStakingProvider', () => { it('should default to Delayed unstakeMode when not specified', async () => { const quote = await provider.getQuote({ direction: 'unstake', - amount: '1000000000', + amount: '1', network: Network.mainnet(), }); @@ -112,7 +108,7 @@ describe('TonStakersStakingProvider', () => { describe('stake', () => { it('should build correct transaction with stake payload', async () => { - const amount = '1000000000'; + const amount = '1'; const quote = await provider.getQuote({ direction: 'stake', amount, @@ -130,10 +126,10 @@ describe('TonStakersStakingProvider', () => { expect(tx.messages).toHaveLength(1); const message = tx.messages[0]; - expect(message.address).toBe(CONTRACT.STAKING_CONTRACT_ADDRESS); + expect(message.address).toBe('EQCkWxfyhAkim3g2DjKQQg8T5P4g-Q1-K_jErGcDJZ4i-vqR'); expect(message.payload).toBe('mock-stake-payload'); - const expectedAmount = BigInt(amount) + CONTRACT.STAKE_FEE_RES; + const expectedAmount = CONTRACT.STAKE_FEE_RES + 1000000000n; expect(message.amount).toBe(expectedAmount.toString()); expect(buildStakePayloadSpy).toHaveBeenCalledWith(1n); @@ -142,7 +138,7 @@ describe('TonStakersStakingProvider', () => { describe('unstake', () => { it('should build correct transaction for Delayed mode', async () => { - const amount = '1000000000'; + const amount = '1'; const quote = await provider.getQuote({ direction: 'unstake', amount, @@ -170,7 +166,7 @@ describe('TonStakersStakingProvider', () => { }); it('should build correct transaction for Instant mode', async () => { - const amount = '1000000000'; + const amount = '1'; const quote = await provider.getQuote({ direction: 'unstake', amount, @@ -193,7 +189,7 @@ describe('TonStakersStakingProvider', () => { }); it('should build correct transaction for BestRate mode', async () => { - const amount = '1000000000'; + const amount = '1'; const quote = await provider.getQuote({ direction: 'unstake', amount, @@ -216,7 +212,7 @@ describe('TonStakersStakingProvider', () => { }); it('should default to Delayed mode when unstakeMode not specified in quote', async () => { - const amount = '1000000000'; + const amount = '1'; const quote = await provider.getQuote({ direction: 'unstake', amount, @@ -244,7 +240,7 @@ describe('TonStakersStakingProvider', () => { { mode: 'instant', waitTillRoundEnd: false, fillOrKill: true }, { mode: 'bestRate', waitTillRoundEnd: true, fillOrKill: false }, ])('should set correct flags for $mode mode', async ({ mode, waitTillRoundEnd, fillOrKill }) => { - const amount = '1000000000'; + const amount = '1'; const quote = await provider.getQuote({ direction: 'unstake', amount, @@ -272,7 +268,7 @@ describe('TonStakersStakingProvider', () => { const info = await provider.getStakingProviderInfo(Network.mainnet()); expect(info.apy).toBe(0.05); - expect(info.instantUnstakeAvailable).toBe('500000000000'); + expect(info.instantUnstakeAvailable).toBe('500'); expect(info.providerId).toBe('tonstakers'); // Ensure exchange rates are NOT in the response expect(info).not.toHaveProperty('tsTONTON'); @@ -287,21 +283,9 @@ describe('TonStakersStakingProvider', () => { const balance = await provider.getStakedBalance(testUserAddress, Network.mainnet()); - expect(balance.stakedBalance).toBe('1000000000'); - // availableBalance = 2 TON - 1.1 TON reserve = 0.9 TON - expect(balance.availableBalance).toBe('900000000'); - expect(balance.instantUnstakeAvailable).toBe('500000000000'); + expect(balance.stakedBalance).toBe('1'); + expect(balance.instantUnstakeAvailable).toBe('500'); expect(balance.providerId).toBe('tonstakers'); - expect(mockApiClient.getBalance).toHaveBeenCalledWith(testUserAddress); - }); - - it('should return 0 available balance if TON balance is below reserve', async () => { - mockApiClient.getBalance.mockResolvedValue('500000000'); // 0.5 TON - vi.spyOn(PoolContract.prototype, 'getStakedBalance').mockResolvedValue('0'); - - const balance = await provider.getStakedBalance(testUserAddress, Network.mainnet()); - - expect(balance.availableBalance).toBe('0'); }); }); }); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts index a5717a699..24dba5b47 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -87,9 +87,9 @@ export class TonStakersStakingProvider extends StakingProvider { if (params.direction === 'stake') { // User deposits TON, receives tsTON: tsTON = TON / rate - const amountInTokens = Number(formatUnits(params.amount, 9)); + const amountInTokens = Number(params.amount); const amountOutTokens = amountInTokens / rates.tsTONTONProjected; - const amountOut = parseUnits(amountOutTokens.toFixed(9), 9).toString(); + const amountOut = amountOutTokens.toFixed(9); return { direction: 'stake', @@ -101,12 +101,12 @@ export class TonStakersStakingProvider extends StakingProvider { }; } else { // User burns tsTON, receives TON: TON = tsTON * rate - const amountInTokens = Number(formatUnits(params.amount, 9)); + const amountInTokens = Number(params.amount); const amountOutTokens = params.unstakeMode === 'instant' ? amountInTokens * rates.tsTONTON : amountInTokens * rates.tsTONTONProjected; - const amountOut = parseUnits(amountOutTokens.toFixed(9), 9).toString(); + const amountOut = amountOutTokens.toFixed(9); return { direction: 'unstake', @@ -134,7 +134,7 @@ export class TonStakersStakingProvider extends StakingProvider { const network = params.quote.network; const contractAddress = this.getStakingContractAddress(network); - const amount = BigInt(params.quote.amountIn); + const amount = parseUnits(params.quote.amountIn, 9); const totalAmount = amount + CONTRACT.STAKE_FEE_RES; const contract = this.getContract(network); @@ -168,7 +168,7 @@ export class TonStakersStakingProvider extends StakingProvider { log.debug('TonStakers unstake requested', { amount: params.quote.amountIn, userAddress: params.userAddress }); const network = params.quote.network; - const amount = BigInt(params.quote.amountIn); + const amount = parseUnits(params.quote.amountIn, 9); const unstakeMode = params.quote.unstakeMode || 'delayed'; let waitTillRoundEnd = false; @@ -241,8 +241,8 @@ export class TonStakersStakingProvider extends StakingProvider { } return { - stakedBalance, // in tsTON - instantUnstakeAvailable: instantUnstakeAvailable.toString(), + stakedBalance: formatUnits(stakedBalance, 9), // in tsTON tokens + instantUnstakeAvailable: formatUnits(instantUnstakeAvailable, 9), providerId: 'tonstakers', }; } catch (error) { @@ -276,7 +276,7 @@ export class TonStakersStakingProvider extends StakingProvider { return { apy, - instantUnstakeAvailable: instantLiquidity.toString(), + instantUnstakeAvailable: formatUnits(instantLiquidity, 9), providerId: 'tonstakers', }; }); diff --git a/template/packages/appkit-react/README.md b/template/packages/appkit-react/README.md index d0f547302..a893e1333 100644 --- a/template/packages/appkit-react/README.md +++ b/template/packages/appkit-react/README.md @@ -124,6 +124,18 @@ Use `useSwapQuote` to get a quote and `useBuildSwapTransaction` to build the tra See [Swap Hooks](./docs/hooks.md#swap) for usage examples. +## Staking + +AppKit supports staking through various providers (e.g., Tonstakers). The staking functionality is integrated into the core action and hook system. + +### Hooks + +Use `useStakingQuote` to get a staking/unstaking quote and `useBuildStakeTransaction` or `useBuildUnstakeTransaction` to build the transaction. + +[Read more about Staking](https://github.com/ton-connect/kit/tree/main/packages/appkit/docs/staking.md) + +%%demo/examples/src/appkit/hooks/staking#USE_STAKING%% + ## Migration from TonConnect UI `AppKitProvider` automatically bridges TonConnect if a `TonConnectConnector` is configured, so `@tonconnect/ui-react` hooks (like `useTonAddress`, `useTonWallet`, etc.) work out of the box inside `AppKitProvider`. diff --git a/template/packages/appkit/docs/actions.md b/template/packages/appkit/docs/actions.md index 7bdb1b26c..01204cd36 100644 --- a/template/packages/appkit/docs/actions.md +++ b/template/packages/appkit/docs/actions.md @@ -122,6 +122,12 @@ Get all configured networks. %%demo/examples/src/appkit/actions/network#GET_NETWORKS%% +### `getApiClient` + +Get the API client for a specific network. + +%%demo/examples/src/appkit/actions/network#GET_API_CLIENT%% + ### `watchNetworks` Watch configured networks. @@ -208,6 +214,38 @@ Build (assemble) a swap transaction based on a quote. After the transaction is b %%demo/examples/src/appkit/actions/swap#BUILD_SWAP_TRANSACTION%% +## Staking + +### `getStakingProviders` + +Get all available staking provider IDs. + +%%demo/examples/src/appkit/actions/staking#GET_STAKING_PROVIDERS%% + +### `getStakingProviderInfo` + +Get information about a specific staking provider. + +%%demo/examples/src/appkit/actions/staking#GET_STAKING_PROVIDER_INFO%% + +### `getStakingQuote` + +Get a staking or unstaking quote. + +%%demo/examples/src/appkit/actions/staking#GET_STAKING_QUOTE%% + +### `buildStakeTransaction` + +Build a stake transaction based on a quote. + +%%demo/examples/src/appkit/actions/staking#BUILD_STAKE_TRANSACTION%% + +### `getStakedBalance` + +Get the user's staked balance. + +%%demo/examples/src/appkit/actions/staking#GET_STAKED_BALANCE%% + ## Transaction ### `createTransferTonTransaction` diff --git a/template/packages/appkit/docs/staking.md b/template/packages/appkit/docs/staking.md new file mode 100644 index 000000000..57869c018 --- /dev/null +++ b/template/packages/appkit/docs/staking.md @@ -0,0 +1,29 @@ +--- +target: packages/appkit/docs/staking.md +--- + +# Staking + +AppKit supports staking through various providers. Available providers: + +- **TonStakersStakingProvider** – [Tonstakers](https://tonstakers.com) liquid staking protocol + +## Installation + +Staking providers are included in the `@ton/appkit` package. No extra dependencies are required. + +## Setup + +You can set up staking providers by passing them to the `AppKit` constructor. + +%%demo/examples/src/appkit/staking#STAKING_PROVIDER_INIT%% + +### Register Dynamically + +Alternatively, you can register providers dynamically using `registerProvider`: + +%%demo/examples/src/appkit/staking#STAKING_PROVIDER_REGISTER%% + +## Configuration + +- **Tonstakers**: [Tonstakers documentation](https://docs.tonstakers.com) and [provider README](https://github.com/ton-connect/kit/blob/main/packages/walletkit/src/defi/staking/tonstakers/README.md) From e1bc040de518b65d0d21e3ff865d3ecfd666739d Mon Sep 17 00:00:00 2001 From: "V. K." Date: Fri, 13 Mar 2026 14:38:56 +0400 Subject: [PATCH 13/15] feat(staking): add unstake mode selector --- .../components/staking/StakingInterface.tsx | 31 +++++++++++++++++++ demo/wallet-core/src/hooks/useWalletStore.ts | 2 ++ .../src/store/slices/stakingSlice.ts | 19 ++++++++++-- demo/wallet-core/src/types/store.ts | 3 ++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/demo-wallet/src/components/staking/StakingInterface.tsx b/apps/demo-wallet/src/components/staking/StakingInterface.tsx index 85688e02e..ec07babf6 100644 --- a/apps/demo-wallet/src/components/staking/StakingInterface.tsx +++ b/apps/demo-wallet/src/components/staking/StakingInterface.tsx @@ -24,7 +24,9 @@ export const StakingInterface: FC = () => { isStaking, isUnstaking, error, + unstakeMode, setStakingAmount: setAmount, + setUnstakeMode, getStakingQuote: getQuote, stake, unstake, @@ -116,6 +118,35 @@ export const StakingInterface: FC = () => { {validationError && amount !== '' &&

{validationError}

} + {tab === 'unstake' && ( +
+ +
+ {(['delayed', 'instant', 'bestRate'] as const).map((mode) => ( + + ))} +
+

+ {unstakeMode === 'delayed' && + 'Standard withdrawal. Immediate if liquid, or up to ~18h queue'} + {unstakeMode === 'instant' && 'Receive TON immediately'} + {unstakeMode === 'bestRate' && 'Wait for cycle end (~18h) for best rate'} +

+
+ )} + {currentQuote && (
diff --git a/demo/wallet-core/src/hooks/useWalletStore.ts b/demo/wallet-core/src/hooks/useWalletStore.ts index 65941c321..9d3c296e8 100644 --- a/demo/wallet-core/src/hooks/useWalletStore.ts +++ b/demo/wallet-core/src/hooks/useWalletStore.ts @@ -244,8 +244,10 @@ export const useStaking = () => { error: state.staking.error, stakedBalance: state.staking.stakedBalance, providerInfo: state.staking.providerInfo, + unstakeMode: state.staking.unstakeMode, setStakingAmount: state.setStakingAmount, setStakingProviderId: state.setStakingProviderId, + setUnstakeMode: state.setUnstakeMode, getStakingQuote: state.getStakingQuote, stake: state.stake, unstake: state.unstake, diff --git a/demo/wallet-core/src/store/slices/stakingSlice.ts b/demo/wallet-core/src/store/slices/stakingSlice.ts index 1913a401c..c71531ef5 100644 --- a/demo/wallet-core/src/store/slices/stakingSlice.ts +++ b/demo/wallet-core/src/store/slices/stakingSlice.ts @@ -6,7 +6,7 @@ * */ -import type { StakeParams, StakingQuoteParams, UnstakeParams } from '@ton/walletkit'; +import type { StakeParams, StakingQuoteParams, UnstakeParams, UnstakeMode } from '@ton/walletkit'; import { createComponentLogger } from '../../utils/logger'; import type { SetState, StakingSliceCreator } from '../../types/store'; @@ -22,6 +22,7 @@ export const createStakingSlice: StakingSliceCreator = (set: SetState, get) => ( isStaking: false, isUnstaking: false, error: null, + unstakeMode: 'delayed', stakedBalance: null, providerInfo: null, }, @@ -43,6 +44,13 @@ export const createStakingSlice: StakingSliceCreator = (set: SetState, get) => ( state.staking.error = null; }); }, + setUnstakeMode: (unstakeMode: UnstakeMode) => { + set((state) => { + state.staking.unstakeMode = unstakeMode; + state.staking.currentQuote = null; + state.staking.error = null; + }); + }, validateStakingInputs: () => { const state = get(); @@ -94,7 +102,14 @@ export const createStakingSlice: StakingSliceCreator = (set: SetState, get) => ( }); try { - const quote = await state.walletCore.walletKit.staking.getQuote({ ...params, network }, providerId); + const quote = await state.walletCore.walletKit.staking.getQuote( + { + ...params, + network, + unstakeMode: state.staking.unstakeMode, + }, + providerId, + ); set((state) => { state.staking.currentQuote = quote; diff --git a/demo/wallet-core/src/types/store.ts b/demo/wallet-core/src/types/store.ts index 964020bb9..ad0859692 100644 --- a/demo/wallet-core/src/types/store.ts +++ b/demo/wallet-core/src/types/store.ts @@ -27,6 +27,7 @@ import type { StakingProviderInfo, StakeParams, UnstakeParams, + UnstakeMode, } from '@ton/walletkit'; import type { @@ -232,6 +233,7 @@ export interface StakingState { isStaking: boolean; isUnstaking: boolean; error: string | null; + unstakeMode: UnstakeMode; stakedBalance: StakingBalance | null; providerInfo: StakingProviderInfo | null; } @@ -241,6 +243,7 @@ export interface StakingSlice { setStakingAmount: (amount: string) => void; setStakingProviderId: (providerId: string) => void; + setUnstakeMode: (mode: UnstakeMode) => void; getStakingQuote: (params: Omit) => Promise; stake: (params: Omit) => Promise; unstake: (params: Omit) => Promise; From 42e51f0d6c5b9d4e66deb247e7a73293ef99a56a Mon Sep 17 00:00:00 2001 From: "V. K." Date: Mon, 16 Mar 2026 10:10:11 +0400 Subject: [PATCH 14/15] feat(staking): add method to get all supported unstake modes --- .../walletkit/src/api/interfaces/StakingAPI.ts | 14 ++++++++++++++ .../walletkit/src/defi/staking/StakingManager.ts | 10 ++++++++++ .../walletkit/src/defi/staking/StakingProvider.ts | 7 +++++++ .../tonstakers/TonStakersStakingProvider.spec.ts | 7 +++++++ .../tonstakers/TonStakersStakingProvider.ts | 11 +++++++++++ 5 files changed, 49 insertions(+) diff --git a/packages/walletkit/src/api/interfaces/StakingAPI.ts b/packages/walletkit/src/api/interfaces/StakingAPI.ts index e5fc48e0b..402165146 100644 --- a/packages/walletkit/src/api/interfaces/StakingAPI.ts +++ b/packages/walletkit/src/api/interfaces/StakingAPI.ts @@ -18,6 +18,7 @@ import type { UnstakeParams, TransactionRequest, UserFriendlyAddress, + UnstakeMode, } from '../models'; /** @@ -64,6 +65,13 @@ export interface StakingAPI extends DefiManagerAPI { * @returns A promise that resolves to a StakingProviderInfo */ getStakingProviderInfo(network?: Network, providerId?: string): Promise; + + /** + * Get supported unstake modes + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns An array of supported unstake modes + */ + getSupportedUnstakeModes(providerId?: string): UnstakeMode[]; } /** @@ -112,4 +120,10 @@ export interface StakingProviderInterface extends DefiProvider { * @returns A promise that resolves to a StakingProviderInfo */ getStakingProviderInfo(network?: Network): Promise; + + /** + * Get supported unstake modes + * @returns An array of supported unstake modes + */ + getSupportedUnstakeModes(): UnstakeMode[]; } diff --git a/packages/walletkit/src/defi/staking/StakingManager.ts b/packages/walletkit/src/defi/staking/StakingManager.ts index f11cac1ba..f454039bf 100644 --- a/packages/walletkit/src/defi/staking/StakingManager.ts +++ b/packages/walletkit/src/defi/staking/StakingManager.ts @@ -14,6 +14,7 @@ import type { StakingProviderInfo, StakingQuoteParams, StakingQuote, + UnstakeMode, } from '../../api/models'; import type { StakingAPI, StakingProviderInterface } from '../../api/interfaces'; import { StakingError, StakingErrorCode } from './errors'; @@ -125,6 +126,15 @@ export class StakingManager extends DefiManager implem } } + /** + * Get supported unstake modes + * @param providerId Provider identifier (optional, uses default if not specified) + * @returns An array of supported unstake modes + */ + getSupportedUnstakeModes(providerId?: string): UnstakeMode[] { + return this.getProvider(providerId).getSupportedUnstakeModes(); + } + protected createError(message: string, code: string, details?: unknown): StakingError { const errorCode = Object.values(StakingErrorCode).includes(code as StakingErrorCode) ? (code as StakingErrorCode) diff --git a/packages/walletkit/src/defi/staking/StakingProvider.ts b/packages/walletkit/src/defi/staking/StakingProvider.ts index 3c5259d6e..83673dfb4 100644 --- a/packages/walletkit/src/defi/staking/StakingProvider.ts +++ b/packages/walletkit/src/defi/staking/StakingProvider.ts @@ -14,6 +14,7 @@ import type { StakingProviderInfo, StakingQuoteParams, StakingQuote, + UnstakeMode, } from '../../api/models'; import type { StakingProviderInterface } from '../../api/interfaces'; @@ -63,4 +64,10 @@ export abstract class StakingProvider implements StakingProviderInterface { * @param network - Optional network to fetch info for */ abstract getStakingProviderInfo(network?: Network): Promise; + + /** + * Get supported unstake modes + * @returns An array of supported unstake modes + */ + abstract getSupportedUnstakeModes(): UnstakeMode[]; } diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts index 82282deb9..e3cc8b30f 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.spec.ts @@ -288,4 +288,11 @@ describe('TonStakersStakingProvider', () => { expect(balance.providerId).toBe('tonstakers'); }); }); + + describe('getSupportedUnstakeModes', () => { + it('should return supported unstake modes', () => { + const modes = provider.getSupportedUnstakeModes(); + expect(modes).toEqual(['delayed', 'instant', 'bestRate']); + }); + }); }); diff --git a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts index 24dba5b47..d23801777 100644 --- a/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts +++ b/packages/walletkit/src/defi/staking/tonstakers/TonStakersStakingProvider.ts @@ -17,6 +17,7 @@ import type { StakingProviderInfo, StakingQuoteParams, StakingQuote, + UnstakeMode, } from '../../../api/models'; import { StakingError, StakingErrorCode } from '../errors'; import type { TonStakersProviderConfig } from './models/TonStakersProviderConfig'; @@ -40,6 +41,8 @@ const log = globalLogger.createChild('TonStakersStakingProvider'); * - BestRate: Wait for best exchange rate at round end */ export class TonStakersStakingProvider extends StakingProvider { + readonly supportedUnstakeModes: UnstakeMode[] = ['delayed', 'instant', 'bestRate']; + protected config: TonStakersProviderConfig; private cache: StakingCache; @@ -282,6 +285,14 @@ export class TonStakersStakingProvider extends StakingProvider { }); } + /** + * Get supported unstake modes + * @returns An array of supported unstake modes + */ + getSupportedUnstakeModes(): UnstakeMode[] { + return ['delayed', 'instant', 'bestRate']; + } + /** * Clear all cached data. * Use this to force fresh data retrieval on next call. From 4a060fb6d06f950fd9ab99ffb0edabe13e9c86c1 Mon Sep 17 00:00:00 2001 From: "V. K." Date: Tue, 17 Mar 2026 08:59:08 +0400 Subject: [PATCH 15/15] docs: add changeset --- .changeset/wet-peas-beam.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/wet-peas-beam.md diff --git a/.changeset/wet-peas-beam.md b/.changeset/wet-peas-beam.md new file mode 100644 index 000000000..0db222e96 --- /dev/null +++ b/.changeset/wet-peas-beam.md @@ -0,0 +1,7 @@ +--- +'@ton/appkit-react': patch +'@ton/walletkit': patch +'@ton/appkit': patch +--- + +Implemented staking infrastructure including \`StakingManager\` and \`TonStakersStakingProvider\` with support for multiple unstake modes (delayed, instant, best rate). Added core type updates and exported staking features from the package root.