diff --git a/.changeset/sweet-years-tell.md b/.changeset/sweet-years-tell.md new file mode 100644 index 00000000..851548b2 --- /dev/null +++ b/.changeset/sweet-years-tell.md @@ -0,0 +1,5 @@ +--- +"@stakekit/widget": patch +--- + +feat: ton connector diff --git a/packages/widget/package.json b/packages/widget/package.json index 8756ccee..a57d732a 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -80,11 +80,13 @@ "@solana/wallet-adapter-react": "^0.15.39", "@solana/wallet-adapter-wallets": "^0.19.37", "@solana/web3.js": "^1.98.4", - "@stakekit/rainbowkit": "^2.2.10", - "@tanstack/react-query": "^5.90.9", "@stakekit/api-hooks": "0.0.113", "@stakekit/common": "^0.0.61", + "@stakekit/rainbowkit": "^2.2.10", + "@tanstack/react-query": "^5.90.9", "@tanstack/react-virtual": "^3.13.12", + "@ton/core": "^0.62.0", + "@tonconnect/ui": "^2.3.1", "@tronweb3/tronwallet-abstract-adapter": "^1.1.10", "@tronweb3/tronwallet-adapter-bitkeep": "^1.1.7", "@tronweb3/tronwallet-adapter-ledger": "^1.1.11", diff --git a/packages/widget/src/domain/types/transaction.ts b/packages/widget/src/domain/types/transaction.ts index 44df9df0..a33c46f5 100644 --- a/packages/widget/src/domain/types/transaction.ts +++ b/packages/widget/src/domain/types/transaction.ts @@ -112,11 +112,13 @@ export type DecodedSolanaTransaction = GetType< typeof unsignedSolanaTransactionCodec >; +export const unsignedTonTransactionTonConnectCodec = Codec.interface({ + seqno: bigintCodec, + message: string, +}); + export const unsignedTonTransactionCodec = oneOf([ - Codec.interface({ - seqno: bigintCodec, - message: string, - }), + unsignedTonTransactionTonConnectCodec, array( Codec.interface({ address: string, diff --git a/packages/widget/src/providers/misc/config.ts b/packages/widget/src/providers/misc/config.ts index 2dbcd56b..324208cc 100644 --- a/packages/widget/src/providers/misc/config.ts +++ b/packages/widget/src/providers/misc/config.ts @@ -21,12 +21,14 @@ const queryFn = async ({ solanaWallets, solanaConnection, variant, + tonConnectManifestUrl, }: { enabledNetworks: Set; forceWalletConnectOnly: boolean; solanaWallets: Wallet[]; solanaConnection: Connection; variant: VariantProps["variant"]; + tonConnectManifestUrl: string | undefined; }): Promise<{ miscChainsMap: Partial; miscChains: Chain[]; @@ -71,6 +73,11 @@ const queryFn = async ({ v.getCardanoConnectors() ) ), + MaybeAsync.liftMaybe(Maybe.fromFalsy(filteredMiscChainsMap.ton)).chain(() => + MaybeAsync(() => import("./ton-connector")).map((v) => + v.getTonConnectors({ tonConnectManifestUrl }) + ) + ), ]).then((connectors) => ({ miscChainsMap: filteredMiscChainsMap, miscChains, diff --git a/packages/widget/src/providers/misc/ton-connector-meta.ts b/packages/widget/src/providers/misc/ton-connector-meta.ts new file mode 100644 index 00000000..e1e50b36 --- /dev/null +++ b/packages/widget/src/providers/misc/ton-connector-meta.ts @@ -0,0 +1,21 @@ +import type { EitherAsync } from "purify-ts"; +import type { Connector } from "wagmi"; +import type { ConnectorWithFilteredChains } from "../../domain/types/connectors"; + +export const configMeta = { + type: "tonWallet", +} as const; + +export type ExtraProps = ConnectorWithFilteredChains & { + signTransaction: (tx: string) => EitherAsync; +}; + +type TonConnector = Connector & ExtraProps; + +export type StorageItem = { + "ton.disconnected": boolean; +}; + +export const isTonConnector = ( + connector: Connector +): connector is TonConnector => connector.type === "tonWallet"; diff --git a/packages/widget/src/providers/misc/ton-connector.ts b/packages/widget/src/providers/misc/ton-connector.ts new file mode 100644 index 00000000..c468f1bd --- /dev/null +++ b/packages/widget/src/providers/misc/ton-connector.ts @@ -0,0 +1,197 @@ +import { MiscNetworks } from "@stakekit/common"; +import type { WalletDetailsParams, WalletList } from "@stakekit/rainbowkit"; +import { + Cell, + type CommonMessageInfoRelaxedInternal, + loadMessageRelaxed, +} from "@ton/core"; +import { + TonConnectUI, + toUserFriendlyAddress, + type Wallet, +} from "@tonconnect/ui"; +import { Either, EitherAsync } from "purify-ts"; +import { BehaviorSubject } from "rxjs"; +import type { Address, Chain } from "viem"; +import { createConnector } from "wagmi"; +import { ton } from "../../domain/types/chains/misc"; +import { unsignedTonTransactionTonConnectCodec } from "../../domain/types/transaction"; +import { getNetworkLogo } from "../../utils"; +import { + configMeta, + type ExtraProps, + type StorageItem, +} from "./ton-connector-meta"; + +const createTonConnector = ( + walletDetailsParams: WalletDetailsParams, + manifestUrl: string | undefined +) => + createConnector((config) => { + const tonconnectUI = new TonConnectUI({ + manifestUrl: + manifestUrl ?? "https://dapp.stakek.it/tonconnect-manifest.json", + }); + + let deferred: { + resolve: (wallet: Wallet) => void; + reject: () => void; + } | null = null; + let connectedWallet: Wallet | null = null; + + tonconnectUI.onStatusChange((wallet) => { + connectedWallet = wallet; + if (wallet) { + deferred?.resolve(wallet); + } + }); + + tonconnectUI.onModalStateChange((state) => { + if (state.status === "closed") { + deferred?.reject(); + } + }); + + return { + ...walletDetailsParams, + id: "tonconnect", + name: "TonConnect", + type: configMeta.type, + signTransaction: (tx: string) => + EitherAsync(async ({ throwE, liftEither }) => { + if (!connectedWallet) { + return throwE(new Error("No wallet connected")); + } + + const parsedTx = await liftEither( + Either.encase(() => JSON.parse(tx)).chain((val) => + unsignedTonTransactionTonConnectCodec + .decode(val) + .mapLeft((e) => new Error(e)) + ) + ).then(({ message }) => + loadMessageRelaxed(Cell.fromBase64(message).beginParse()) + ); + + const info = parsedTx.info as CommonMessageInfoRelaxedInternal; + + const result = await tonconnectUI.sendTransaction({ + messages: [ + { + address: info.dest.toString(), + amount: info.value.coins.toString(), + payload: parsedTx.body.toBoc().toString("base64"), + }, + ], + validUntil: Date.now() + 1000 * 60 * 60 * 24, + }); + + const externalMessageCell = Cell.fromBase64(result.boc); + const txHash = externalMessageCell.hash().toString("hex"); + + return txHash; + }), + connect: async (args) => { + config.emitter.emit("message", { type: "connecting" }); + + config.storage?.removeItem("ton.disconnected"); + + const wallet: Wallet = + connectedWallet ?? + (await tonconnectUI + .openModal() + .then( + () => + new Promise((resolve, reject) => { + deferred = { resolve, reject }; + }) + ) + .then((wallet) => { + deferred = null; + return wallet; + })); + + const userFriendlyAddress = toUserFriendlyAddress( + wallet.account.address + ); + + return { + accounts: args?.withCapabilities + ? [ + { + address: userFriendlyAddress as Address, + capabilities: {}, + }, + ] + : [userFriendlyAddress as Address], + chainId: ton.id, + } as never; + }, + disconnect: async () => { + config.storage?.setItem("ton.disconnected", true); + await tonconnectUI.disconnect(); + connectedWallet = null; + }, + getAccounts: async () => { + await tonconnectUI.connectionRestored; + + if (!connectedWallet) throw new Error("No wallet connected"); + + return [ + toUserFriendlyAddress(connectedWallet.account.address) as Address, + ]; + }, + switchChain: async () => ton, + getChainId: async () => ton.id, + isAuthorized: async () => { + await tonconnectUI.connectionRestored; + + const isDisconnected = + await config.storage?.getItem("ton.disconnected"); + + if (isDisconnected) return false; + + return !!connectedWallet; + }, + onAccountsChanged: (accounts: string[]) => { + if (accounts.length === 0) { + config.emitter.emit("disconnect"); + } else { + config.emitter.emit("change", { accounts: accounts as Address[] }); + } + }, + onChainChanged: (chainId) => { + config.emitter.emit("change", { + chainId: chainId as unknown as number, + }); + }, + onDisconnect: () => { + config.emitter.emit("disconnect"); + }, + getProvider: async () => ({}), + $filteredChains: new BehaviorSubject([ton]).asObservable(), + }; + }); + +export const getTonConnectors = ({ + tonConnectManifestUrl, +}: { + tonConnectManifestUrl: string | undefined; +}): WalletList[number] => ({ + groupName: "Ton", + wallets: [ + () => ({ + id: "tonconnect", + name: "TonConnect", + iconUrl: getNetworkLogo(MiscNetworks.Ton), + iconBackground: "transparent", + chainGroup: { + id: "ton", + title: "Ton", + iconUrl: getNetworkLogo(MiscNetworks.Ton), + }, + createConnector: (walletDetailsParams) => + createTonConnector(walletDetailsParams, tonConnectManifestUrl), + }), + ], +}); diff --git a/packages/widget/src/providers/settings/types.ts b/packages/widget/src/providers/settings/types.ts index 98baaa50..fc70f0d7 100644 --- a/packages/widget/src/providers/settings/types.ts +++ b/packages/widget/src/providers/settings/types.ts @@ -71,6 +71,7 @@ export type SettingsProps = { showUSDeBanner?: boolean; preferredTokenYieldsPerNetwork?: PreferredTokenYieldsPerNetwork; portalContainer?: HTMLElement; + tonConnectManifestUrl?: string; }; export type SettingsContextType = SettingsProps & VariantProps; diff --git a/packages/widget/src/providers/sk-wallet/index.tsx b/packages/widget/src/providers/sk-wallet/index.tsx index c1120906..945f0a1f 100644 --- a/packages/widget/src/providers/sk-wallet/index.tsx +++ b/packages/widget/src/providers/sk-wallet/index.tsx @@ -46,6 +46,7 @@ import { isExternalProviderConnector } from "../external-provider"; import { isLedgerLiveConnector } from "../ledger/ledger-live-connector-meta"; import { isCardanoConnector } from "../misc/cardano-connector-meta"; import { isSolanaConnector } from "../misc/solana-connector-meta"; +import { isTonConnector } from "../misc/ton-connector-meta"; import { isTronConnector } from "../misc/tron-connector-meta"; import { isSafeConnector } from "../safe/safe-connector-meta"; import { isSubstrateConnector } from "../substrate/substrate-connector-meta"; @@ -356,6 +357,13 @@ export const SKWalletProvider = ({ children }: PropsWithChildren) => { .map((res) => ({ signedTx: res, broadcasted: false })); } + if (isTonConnector(conn)) { + return conn + .signTransaction(tx) + .mapLeft(() => new SendTransactionError()) + .map((res) => ({ signedTx: res, broadcasted: true })); + } + /** * Safe connector */ diff --git a/packages/widget/src/providers/wagmi/index.ts b/packages/widget/src/providers/wagmi/index.ts index 7ae89b44..d8edb078 100644 --- a/packages/widget/src/providers/wagmi/index.ts +++ b/packages/widget/src/providers/wagmi/index.ts @@ -75,6 +75,7 @@ const buildWagmiConfig = async (opts: { solanaWallets: SolanaWallet[]; solanaConnection: Connection; mapWalletListFn?: (val: WalletList) => WalletList; + tonConnectManifestUrl: string | undefined; }): Promise<{ evmConfig: GetEitherAsyncRight>; cosmosConfig: GetEitherAsyncRight>; @@ -103,6 +104,7 @@ const buildWagmiConfig = async (opts: { solanaWallets: opts.solanaWallets, solanaConnection: opts.solanaConnection, variant: opts.variant, + tonConnectManifestUrl: opts.tonConnectManifestUrl, }), getSubstrateConfig({ queryClient: opts.queryClient, @@ -375,6 +377,7 @@ export const useWagmiConfig = () => { chainIconMapping, variant, mapWalletListFn, + tonConnectManifestUrl, } = useSettings(); const solanaWallets = useSolanaWallet(); @@ -409,6 +412,7 @@ export const useWagmiConfig = () => { solanaWallets: solanaWallets.wallets, solanaConnection: solanaConnection.connection, mapWalletListFn, + tonConnectManifestUrl, }), }); diff --git a/packages/widget/tests/fixtures/index.ts b/packages/widget/tests/fixtures/index.ts index e985875e..4b375bc8 100644 --- a/packages/widget/tests/fixtures/index.ts +++ b/packages/widget/tests/fixtures/index.ts @@ -15,7 +15,7 @@ export const yieldFixture = (overrides?: Partial) => .map( (val) => ({ - ...getYieldV2ControllerGetYieldByIdResponseMock(), + ...val, rewardRate: apyFaker(), rewardType: "apy", apy: apyFaker(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fb04d79..a60b9976 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,12 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.12 version: 3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@ton/core': + specifier: ^0.62.0 + version: 0.62.0(@ton/crypto@3.3.0) + '@tonconnect/ui': + specifier: ^2.3.1 + version: 2.3.1(encoding@0.1.13) '@tronweb3/tronwallet-abstract-adapter': specifier: ^1.1.10 version: 1.1.10(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -4378,6 +4384,21 @@ packages: '@ton/crypto@3.3.0': resolution: {integrity: sha512-/A6CYGgA/H36OZ9BbTaGerKtzWp50rg67ZCH2oIjV1NcrBaCK9Z343M+CxedvM7Haf3f/Ee9EhxyeTp0GKMUpA==} + '@tonconnect/isomorphic-eventsource@0.0.2': + resolution: {integrity: sha512-B4UoIjPi0QkvIzZH5fV3BQLWrqSYABdrzZQSI9sJA9aA+iC0ohOzFwVVGXanlxeDAy1bcvPbb29f6sVUk0UnnQ==} + + '@tonconnect/isomorphic-fetch@0.0.3': + resolution: {integrity: sha512-jIg5nTrDwnite4fXao3dD83eCpTvInTjZon/rZZrIftIegh4XxyVb5G2mpMqXrVGk1e8SVXm3Kj5OtfMplQs0w==} + + '@tonconnect/protocol@2.3.0': + resolution: {integrity: sha512-OxrmcXF/EsSdGeASP9VpTRojuMtTV87DKYFLRq4ZJvF/Hirfm2cgcxzzj2uksEGm5IIR010UWo6b38RuokNwFQ==} + + '@tonconnect/sdk@3.3.1': + resolution: {integrity: sha512-lhXJu95VvbD36u5mMPg2sg+w4GQwkrYnHeJ8rVveu2N7UPwt0jvrEqKlvf7Ss1gh5RDtzs35SS3GbJlaIOAJNA==} + + '@tonconnect/ui@2.3.1': + resolution: {integrity: sha512-G1hKn5TrqFYztbR94EG8YuhGUpp7wY8PjC1Fu7/d8rFg7XnPGlUcGmuqH8+2lYrb9Ms0M7v6+5U6S4q4+yCQtQ==} + '@toruslabs/base-controllers@5.11.0': resolution: {integrity: sha512-5AsGOlpf3DRIsd6PzEemBoRq+o2OhgSFXj5LZD6gXcBlfe0OpF+ydJb7Q8rIt5wwpQLNJCs8psBUbqIv7ukD2w==} engines: {node: '>=18.x', npm: '>=9.x'} @@ -5816,6 +5837,9 @@ packages: resolution: {integrity: sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==} engines: {node: '>= 0.10'} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -9400,6 +9424,9 @@ packages: resolution: {integrity: sha512-qBwXXuDT3rA53kbNafGbT5r++BrhRgx3sAo0cHoDAeG9g1ItTmUMgltz3Hy7Hazy1ODqNpR+C7QwqL6DYB52yA==} hasBin: true + tweetnacl-util@0.15.1: + resolution: {integrity: sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==} + tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} @@ -16064,6 +16091,39 @@ snapshots: jssha: 3.2.0 tweetnacl: 1.0.3 + '@tonconnect/isomorphic-eventsource@0.0.2': + dependencies: + eventsource: 2.0.2 + + '@tonconnect/isomorphic-fetch@0.0.3(encoding@0.1.13)': + dependencies: + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + + '@tonconnect/protocol@2.3.0': + dependencies: + tweetnacl: 1.0.3 + tweetnacl-util: 0.15.1 + + '@tonconnect/sdk@3.3.1(encoding@0.1.13)': + dependencies: + '@tonconnect/isomorphic-eventsource': 0.0.2 + '@tonconnect/isomorphic-fetch': 0.0.3(encoding@0.1.13) + '@tonconnect/protocol': 2.3.0 + transitivePeerDependencies: + - encoding + + '@tonconnect/ui@2.3.1(encoding@0.1.13)': + dependencies: + '@tonconnect/sdk': 3.3.1(encoding@0.1.13) + classnames: 2.5.1 + csstype: 3.2.1 + deepmerge: 4.3.1 + ua-parser-js: 1.0.40 + transitivePeerDependencies: + - encoding + '@toruslabs/base-controllers@5.11.0(@babel/runtime@7.28.4)(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@babel/runtime': 7.28.4 @@ -19469,6 +19529,8 @@ snapshots: inherits: 2.0.4 safe-buffer: 5.2.1 + classnames@2.5.1: {} + cli-boxes@3.0.0: {} cli-cursor@5.0.0: @@ -23490,6 +23552,8 @@ snapshots: turbo-windows-64: 2.6.1 turbo-windows-arm64: 2.6.1 + tweetnacl-util@0.15.1: {} + tweetnacl@1.0.3: {} type-detect@4.0.8: {}