diff --git a/packages/plugin-ton-connect/README.md b/packages/plugin-ton-connect/README.md new file mode 100644 index 00000000000..817de952152 --- /dev/null +++ b/packages/plugin-ton-connect/README.md @@ -0,0 +1,41 @@ +# Ton Connect Plugin for Eliza + +The plugin for Eliza enables connection to any TON wallet via the Ton Connect protocol. + +## Overview + +This plugin offers the following features: + +- Seamless connection to any supported TON wallets via Ton Connect. +- Support for multiple wallets and simultaneous connections. +- Display a list of connected wallets. +- Effortless disconnection from Ton Connect. +- A provider to select and manage active connections (e.g., execute transactions). + +## Installation + +```bash +npm install @elizaos/plugin-ton-connect +``` + +## Configuration + +The plugin requires the following environment variables: + +```env +TON_CONNECT_MANIFEST_URL=https://domain/ton-manifest.json +``` +How to create manifest read [here](https://docs.ton.org/v3/guidelines/ton-connect/guidelines/creating-manifest) + +## Usage + +Import and register the plugin in your Eliza configuration: + +```typescript +import { tonConnectPlugin } from "@elizaos/plugin-ton-connect"; + +export default { + plugins: [tonConnectPlugin], + // ... other configuration +}; +``` diff --git a/packages/plugin-ton-connect/package.json b/packages/plugin-ton-connect/package.json new file mode 100644 index 00000000000..36be002456a --- /dev/null +++ b/packages/plugin-ton-connect/package.json @@ -0,0 +1,34 @@ +{ + "name": "@elizaos/plugin-ton-connect", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "@elizaos/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "files": [ + "dist" + ], + "dependencies": { + "@elizaos/core": "workspace:*", + "@tonconnect/sdk": "^3.0.6", + "tsup": "8.3.5", + "qrcode": "^1.5.4" + }, + "scripts": { + "build": "tsup --format esm --dts", + "dev": "tsup --format esm --dts --watch", + "test": "vitest run", + "clean": "rm -rf dist", + "lint": "eslint --fix --cache ." + } +} diff --git a/packages/plugin-ton-connect/src/actions/disconnect.ts b/packages/plugin-ton-connect/src/actions/disconnect.ts new file mode 100644 index 00000000000..7f23dfc3f0f --- /dev/null +++ b/packages/plugin-ton-connect/src/actions/disconnect.ts @@ -0,0 +1,80 @@ +import { + type Action, + type IAgentRuntime, + type Memory, + type State, + type HandlerCallback, + elizaLogger, +} from "@elizaos/core"; +import {IStorage, Storg} from "../libs/storage.ts"; +import TonConnect from "@tonconnect/sdk"; + +export const disconnect: Action = { + name: "DISCONNECT_TON_WALLET", + similes: ["DISCONNECT_CONNECTED_TON_WALLET", "REMOVE_TON_CONNECTED"], + description: + "Disconnect from ton wallet by address", + + validate: async (runtime: IAgentRuntime, _message: Memory) => { + const regex = /\b[UV]Q[A-Za-z0-9_-]{46}\b/g; + return _message.content.text.match(regex)?.[0] ?? false + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State, + _options?: { [key: string]: unknown }, + callback?: HandlerCallback + ) => { + try { + const manifestUrl = runtime.getSetting("TON_CONNECT_MANIFEST_URL") ?? process.env.TON_CONNECT_MANIFEST_URL ?? null + if (!manifestUrl) { + callback({ + text: `Unable to proceed. Please provide a TON_CONNECT_MANIFEST_URL'`, + }); + return; + } + + const storage: IStorage = new Storg(runtime.cacheManager) + + const regex = /\b[UV]Q[A-Za-z0-9_-]{46}\b/g; + const mentionedAddress = message.content.text.match(regex)?.[0] ?? null; + + if (mentionedAddress) { + await storage.readFromCache(mentionedAddress) + const connector = new TonConnect({manifestUrl, storage}); + await connector.restoreConnection() + if (connector.connected) { + await connector.disconnect(); + } + await storage.deleteFromCache(mentionedAddress); + callback({text: `Address ${mentionedAddress} successfully disconnected`}); + return + } + + callback({text: 'Please provide address to disconnect'}); + + } catch (error) { + elizaLogger.error("Error in show ton connected action: ", error); + callback({ + text: "An error occurred while make ton connect url. Please try again later.", + error + }); + return; + } + }, + + examples: [ + [ + { + user: "user", + content: { + text: "let disconnect {{ADDRESS}}", + action: "DISCONNECT_TON_WALLET", + }, + } + ], + + ], +}; diff --git a/packages/plugin-ton-connect/src/actions/showConnected.ts b/packages/plugin-ton-connect/src/actions/showConnected.ts new file mode 100644 index 00000000000..bdef6719ba6 --- /dev/null +++ b/packages/plugin-ton-connect/src/actions/showConnected.ts @@ -0,0 +1,66 @@ +import { + type Action, + type IAgentRuntime, + type Memory, + type State, + type HandlerCallback, + elizaLogger, +} from "@elizaos/core"; +import {IStorage, Storg} from "../libs/storage.ts"; + +export const showConnected: Action = { + name: "SHOW_TON_CONNECTED_WALLETS", + similes: ["SHOW_CONNECTED_TON_WALLETS", "LIST_TON_WALLETS", "LIST_TON_CONNECTED_WALLETS"], + description: + "Use to show all ton connected wallets", + + validate: async (runtime: IAgentRuntime, _message: Memory) => { + return true + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State, + _options?: { [key: string]: unknown }, + callback?: HandlerCallback + ) => { + try { + const storage: IStorage = new Storg(runtime.cacheManager) + + const connected = await storage.getCachedAddressList() + + let lines = [ + 'Connected wallets:', + `────────────────`, + ] + + Object.keys(connected).map((address: string) => { + lines.push(`- ${address} (${connected[address]})`) + }); + + callback({text: lines.join("\n")}); + + } catch (error) { + elizaLogger.error("Error in show ton connected action: ", error); + callback({ + text: "An error occurred while make ton connect url. Please try again later.", + error + }); + return; + } + }, + + examples: [ + [ + { + user: "user", + content: { + text: "show all ton connected addresses", + action: "SHOW_TON_CONNECTED_WALLETS", + }, + } + ], + + ], +}; diff --git a/packages/plugin-ton-connect/src/actions/tonConnect.ts b/packages/plugin-ton-connect/src/actions/tonConnect.ts new file mode 100644 index 00000000000..cffe9cc1dd3 --- /dev/null +++ b/packages/plugin-ton-connect/src/actions/tonConnect.ts @@ -0,0 +1,129 @@ +import { + type Action, + type IAgentRuntime, + type Memory, + type State, + type HandlerCallback, + elizaLogger, +} from "@elizaos/core"; +import TonConnect, { + isWalletInfoCurrentlyEmbedded, + toUserFriendlyAddress, + Wallet, + WalletInfoCurrentlyEmbedded, + WalletInfoRemote +} from '@tonconnect/sdk'; +import {IStorage, Storg} from "../libs/storage.ts"; +import QRCode from 'qrcode'; + +export const tonConnect: Action = { + name: "TON_CONNECT", + similes: ["CONNECT_TON_WALLET", "USE_TON_CONNECT"], + description: + "Use Ton Connect protocol to connect to your wallet", + + validate: async (runtime: IAgentRuntime, _message: Memory) => { + return true; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State, + _options?: { [key: string]: unknown }, + callback?: HandlerCallback + ) => { + try { + const manifestUrl = runtime.getSetting("TON_CONNECT_MANIFEST_URL") ?? process.env.TON_CONNECT_MANIFEST_URL ?? null + if (!manifestUrl) { + callback({ + text: `Unable to proceed. Please provide a TON_CONNECT_MANIFEST_URL'`, + }); + return; + } + + const storage: IStorage = new Storg(runtime.cacheManager) + + const connector = new TonConnect({manifestUrl, storage}); + + // check if user wrote wallet to use for connect + const wallets = await connector.getWallets(); + const walletNames = wallets.map(wallet => wallet.name.toLowerCase()); + let mentionedWallet = walletNames.find(walletName => message.content.text.toLowerCase().includes(walletName)) + const tonKeeper = wallets.find(wallet => wallet.name.toLowerCase() === 'tonkeeper') as WalletInfoRemote; + + let walletConnectionSources: { universalLink: string, bridgeUrl: string } | { jsBridgeKey: string } = { + universalLink: tonKeeper.universalLink ?? 'https://app.tonkeeper.com/ton-connect', + bridgeUrl: tonKeeper.bridgeUrl ?? 'https://bridge.tonapi.io/bridge' + } + if (mentionedWallet) { + const wallet: WalletInfoRemote = wallets.find(wallet => wallet.name.toLowerCase() === mentionedWallet) as WalletInfoRemote; + walletConnectionSources = { + universalLink: wallet.universalLink, + bridgeUrl: wallet.bridgeUrl + } + } else { // here check embed wallet if not mentioned other + const embeddedWallet = wallets.find(isWalletInfoCurrentlyEmbedded) as WalletInfoCurrentlyEmbedded; + mentionedWallet = 'Tonkeeper' + if (embeddedWallet) { + mentionedWallet = embeddedWallet.name + walletConnectionSources = {jsBridgeKey: embeddedWallet.jsBridgeKey}; + } + } + + const universalLink = connector.connect(walletConnectionSources); + const qrcode = await QRCode.toDataURL(universalLink); + const text = `You select ${mentionedWallet} to connect. Please open url in browser or scan qrcode to complete connection. ` + universalLink; + if (qrcode) { + callback({ + text, + attachments: [ + { + id: crypto.randomUUID(), + url: qrcode, + title: 'Connection url', + source: 'qrcode', + contentType: "image/png", + } + ] + }); + } else { + callback({text}); + } + + connector.onStatusChange( + async (data: Wallet) => { + const userFriendlyAddress = toUserFriendlyAddress(data.account.address); + await storage.writeToCache(userFriendlyAddress, data.device.appName) + } + ); + + } catch (error) { + elizaLogger.error("Error in ton-connect action: ", error); + callback({ + text: "An error occurred while make ton connect url. Please try again later.", + error + }); + return; + } + }, + + examples: [ + [ + { + user: "user", + content: { + text: "let connect to ton wallet", + action: "TON_CONNECT", + }, + }, + { + user: "assistant", + content: { + text: "Please use following url to connect to your wallet: ", + }, + }, + ], + + ], +}; diff --git a/packages/plugin-ton-connect/src/index.ts b/packages/plugin-ton-connect/src/index.ts new file mode 100644 index 00000000000..86626e0c02f --- /dev/null +++ b/packages/plugin-ton-connect/src/index.ts @@ -0,0 +1,15 @@ +import type {Plugin} from "@elizaos/core"; +import {tonConnect} from "./actions/tonConnect.ts"; +import {showConnected} from "./actions/showConnected.ts"; +import {disconnect} from "./actions/disconnect.ts"; +import WalletProvider from "./providers/wallet.ts"; + +export const tonConnectPlugin: Plugin = { + name: "ton-connect", + description: "Ton Connect Plugin for Eliza", + actions: [tonConnect, showConnected, disconnect], + providers: [WalletProvider], + services: [], +}; + +export default tonConnectPlugin; diff --git a/packages/plugin-ton-connect/src/libs/storage.ts b/packages/plugin-ton-connect/src/libs/storage.ts new file mode 100644 index 00000000000..79317ad3ec3 --- /dev/null +++ b/packages/plugin-ton-connect/src/libs/storage.ts @@ -0,0 +1,100 @@ +import path from "path"; +import {ICacheManager} from "@elizaos/core"; + +export interface IStorage { + setItem(key: string, value: string): Promise; + + getItem(key: string): Promise; + + removeItem(key: string): Promise; + + readFromCache(address: string): Promise; + + writeToCache(address: string, wallet: string): Promise; + + deleteFromCache(address: string): Promise; + + getCachedAddressList(): Promise<{ [key: string]: string }>; +} + +/** + * Cache and storage manipulating. Partially required for @tonconnect/sdk + */ +export class Storg implements IStorage { + private storeData: any = {} + private cacheManager: ICacheManager + private cacheKey = "ton/connect"; + private listKey = path.join(this.cacheKey, 'list'); + + constructor(cacheManager: ICacheManager) { + this.cacheManager = cacheManager; + } + + public setItem(key: string, value: string): Promise { + this.storeData[key] = value; + return Promise.resolve(); + } + + public getItem(key: string): Promise { + const value = this.storeData[key]; + return Promise.resolve(value); + } + + public removeItem(key: string): Promise { + delete this.storeData[key]; + return Promise.resolve(); + } + + /** + * Used to load and utilize the selected connection. + * @param address + */ + public async readFromCache(address: string): Promise { + const data: string = await this.cacheManager.get( + path.join(this.cacheKey, address), + ) ?? '{}'; + return JSON.parse(data); + } + + /** + * Save connected wallet to re-use + * @param address + * @param wallet + */ + public async writeToCache(address: string, wallet: string): Promise { + await this.cacheManager.set(path.join(this.cacheKey, address), JSON.stringify({data: this.storeData, wallet})); + + let list: { [x: string]: string; } + try { + list = JSON.parse(await this.cacheManager.get(this.listKey) ?? '{}'); + } catch (error) { + list = {} + } + + if (!list?.[address]) { + list[address] = wallet; + } + + await this.cacheManager.set(this.listKey, JSON.stringify(list)); + } + + /** + * To delete connection from list after disconnect + * @param address + */ + public async deleteFromCache(address: string): Promise { + await this.cacheManager.delete(path.join(this.cacheKey, address)); + + let list = JSON.parse(await this.cacheManager.get(this.listKey) ?? '{}'); + delete list[address]; + await this.cacheManager.set(this.listKey, JSON.stringify(list)); + } + + /** + * Get all connected wallets by its address + */ + public async getCachedAddressList(): Promise<{ [key: string]: string }> { + const list = JSON.parse(await this.cacheManager.get(this.listKey) ?? '{}') + return list ?? {} + } +} diff --git a/packages/plugin-ton-connect/src/providers/wallet.ts b/packages/plugin-ton-connect/src/providers/wallet.ts new file mode 100644 index 00000000000..a59aa38294e --- /dev/null +++ b/packages/plugin-ton-connect/src/providers/wallet.ts @@ -0,0 +1,48 @@ +import type {IAgentRuntime, ICacheManager} from "@elizaos/core"; +import {IStorage, Storg} from "../libs/storage.ts"; +import TonConnect, {ITonConnect} from "@tonconnect/sdk"; + +export class WalletProvider { + private readonly cacheManager: ICacheManager; + private readonly runtime: IAgentRuntime; + + constructor(runtime: IAgentRuntime) { + this.runtime = runtime; + this.cacheManager = runtime.cacheManager; + } + + /** + * Get connected via TON Connect wallet provider + * @param address - If not provided use first from connected list + */ + async getWalletClient(address: string = null): Promise { + const storage: IStorage = new Storg(this.cacheManager) + const connected: { [key: string]: string } = await storage.getCachedAddressList() + let selected: string = Object.keys(connected)?.[0] ?? null; + if (address && connected?.[address]) { + selected = address + } + + if (!selected) { + throw new Error('No wallets connected') + } + + await storage.readFromCache(selected) + + const manifestUrl = this.runtime.getSetting("TON_CONNECT_MANIFEST_URL") ?? process.env.TON_CONNECT_MANIFEST_URL ?? null + if (!manifestUrl) { + throw new Error(`Unable to proceed. Please provide a TON_CONNECT_MANIFEST_URL'`); + } + + const connector = new TonConnect({manifestUrl, storage}); + await connector.restoreConnection() + + if (connector.connected) { + return connector + } + + throw new Error('No wallets connected') + } +} + +export default WalletProvider; diff --git a/packages/plugin-ton-connect/tsconfig.json b/packages/plugin-ton-connect/tsconfig.json new file mode 100644 index 00000000000..65ec37c9e63 --- /dev/null +++ b/packages/plugin-ton-connect/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../core/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src" + }, + "include": [ + "src" + ] +} diff --git a/packages/plugin-ton-connect/tsup.config.ts b/packages/plugin-ton-connect/tsup.config.ts new file mode 100644 index 00000000000..1a55f7a745f --- /dev/null +++ b/packages/plugin-ton-connect/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + sourcemap: true, + clean: true, + format: ["esm"], // Ensure you're targeting CommonJS + external: [], +});