From b10f306fba2140cf7a702d4fc5c55c316986a6b6 Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Wed, 15 Jan 2025 14:50:13 +1300 Subject: [PATCH] feat: Add Nebula AI chat and transaction execution API (#5948) --- .changeset/warm-dodos-destroy.md | 44 ++++ .../TDoc/utils/getSidebarLinkgroups.ts | 2 + packages/thirdweb/package.json | 78 ++---- packages/thirdweb/scripts/typedoc.mjs | 1 + packages/thirdweb/src/ai/chat.test.ts | 31 +++ packages/thirdweb/src/ai/chat.ts | 26 ++ packages/thirdweb/src/ai/common.ts | 114 ++++++++ packages/thirdweb/src/ai/execute.test.ts | 43 +++ packages/thirdweb/src/ai/execute.ts | 44 ++++ packages/thirdweb/src/ai/index.ts | 3 + packages/thirdweb/src/exports/ai.ts | 1 + packages/thirdweb/tsdoc.json | 245 +++++++++--------- 12 files changed, 458 insertions(+), 174 deletions(-) create mode 100644 .changeset/warm-dodos-destroy.md create mode 100644 packages/thirdweb/src/ai/chat.test.ts create mode 100644 packages/thirdweb/src/ai/chat.ts create mode 100644 packages/thirdweb/src/ai/common.ts create mode 100644 packages/thirdweb/src/ai/execute.test.ts create mode 100644 packages/thirdweb/src/ai/execute.ts create mode 100644 packages/thirdweb/src/ai/index.ts create mode 100644 packages/thirdweb/src/exports/ai.ts diff --git a/.changeset/warm-dodos-destroy.md b/.changeset/warm-dodos-destroy.md new file mode 100644 index 00000000000..40037c5adf7 --- /dev/null +++ b/.changeset/warm-dodos-destroy.md @@ -0,0 +1,44 @@ +--- +"thirdweb": minor +--- + +Introducing Nebula API + +You can now chat with Nebula and ask it to execute transactions with your wallet. + +Ask questions about real time blockchain data. + +```ts +import { Nebula } from "thirdweb/ai"; + +const response = await Nebula.chat({ + client: TEST_CLIENT, + prompt: + "What's the symbol of this contract: 0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8", + context: { + chains: [sepolia], + }, +}); + +console.log("chat response:", response.message); +``` + +Ask it to execute transactions with your wallet. + +```ts +import { Nebula } from "thirdweb/ai"; + +const wallet = createWallet("io.metamask"); +const account = await wallet.connect({ client }); + +const result = await Nebula.execute({ + client, + prompt: "send 0.0001 ETH to vitalik.eth", + account, + context: { + chains: [sepolia], + }, +}); + +console.log("executed transaction:", result.transactionHash); +``` diff --git a/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts b/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts index 45a66e64fdc..953a9e12f7b 100644 --- a/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts +++ b/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts @@ -42,6 +42,7 @@ const tagsToGroup = { "@modules": "Modules", "@client": "Client", "@account": "Account", + "@nebula": "Nebula", } as const; type TagKey = keyof typeof tagsToGroup; @@ -81,6 +82,7 @@ const sidebarGroupOrder: TagKey[] = [ "@utils", "@others", "@account", + "@nebula", ]; function findTag( diff --git a/packages/thirdweb/package.json b/packages/thirdweb/package.json index 4f007ba2461..3a3885eaa70 100644 --- a/packages/thirdweb/package.json +++ b/packages/thirdweb/package.json @@ -123,64 +123,34 @@ "import": "./dist/esm/exports/social.js", "default": "./dist/cjs/exports/social.js" }, + "./ai": { + "types": "./dist/types/exports/ai.d.ts", + "import": "./dist/esm/exports/ai.js", + "default": "./dist/cjs/exports/ai.js" + }, "./package.json": "./package.json" }, "typesVersions": { "*": { - "adapters/*": [ - "./dist/types/exports/adapters/*.d.ts" - ], - "auth": [ - "./dist/types/exports/auth.d.ts" - ], - "chains": [ - "./dist/types/exports/chains.d.ts" - ], - "contract": [ - "./dist/types/exports/contract.d.ts" - ], - "deploys": [ - "./dist/types/exports/deploys.d.ts" - ], - "event": [ - "./dist/types/exports/event.d.ts" - ], - "extensions/*": [ - "./dist/types/exports/extensions/*.d.ts" - ], - "pay": [ - "./dist/types/exports/pay.d.ts" - ], - "react": [ - "./dist/types/exports/react.d.ts" - ], - "react-native": [ - "./dist/types/exports/react-native.d.ts" - ], - "rpc": [ - "./dist/types/exports/rpc.d.ts" - ], - "storage": [ - "./dist/types/exports/storage.d.ts" - ], - "transaction": [ - "./dist/types/exports/transaction.d.ts" - ], - "utils": [ - "./dist/types/exports/utils.d.ts" - ], - "wallets": [ - "./dist/types/exports/wallets.d.ts" - ], - "wallets/*": [ - "./dist/types/exports/wallets/*.d.ts" - ], - "modules": [ - "./dist/types/exports/modules.d.ts" - ], - "social": [ - "./dist/types/exports/social.d.ts" - ] + "adapters/*": ["./dist/types/exports/adapters/*.d.ts"], + "auth": ["./dist/types/exports/auth.d.ts"], + "chains": ["./dist/types/exports/chains.d.ts"], + "contract": ["./dist/types/exports/contract.d.ts"], + "deploys": ["./dist/types/exports/deploys.d.ts"], + "event": ["./dist/types/exports/event.d.ts"], + "extensions/*": ["./dist/types/exports/extensions/*.d.ts"], + "pay": ["./dist/types/exports/pay.d.ts"], + "react": ["./dist/types/exports/react.d.ts"], + "react-native": ["./dist/types/exports/react-native.d.ts"], + "rpc": ["./dist/types/exports/rpc.d.ts"], + "storage": ["./dist/types/exports/storage.d.ts"], + "transaction": ["./dist/types/exports/transaction.d.ts"], + "utils": ["./dist/types/exports/utils.d.ts"], + "wallets": ["./dist/types/exports/wallets.d.ts"], + "wallets/*": ["./dist/types/exports/wallets/*.d.ts"], + "modules": ["./dist/types/exports/modules.d.ts"], + "social": ["./dist/types/exports/social.d.ts"], + "ai": ["./dist/types/exports/ai.d.ts"] } }, "browser": { diff --git a/packages/thirdweb/scripts/typedoc.mjs b/packages/thirdweb/scripts/typedoc.mjs index d3f5ad075c5..2d42ffd0108 100644 --- a/packages/thirdweb/scripts/typedoc.mjs +++ b/packages/thirdweb/scripts/typedoc.mjs @@ -9,6 +9,7 @@ const app = await Application.bootstrapWithPlugins({ "src/extensions/modules/**/index.ts", "src/adapters/eip1193/index.ts", "src/wallets/smart/presets/index.ts", + "src/ai/index.ts", ], exclude: [ "src/exports/*.native.ts", diff --git a/packages/thirdweb/src/ai/chat.test.ts b/packages/thirdweb/src/ai/chat.test.ts new file mode 100644 index 00000000000..973d38d79d1 --- /dev/null +++ b/packages/thirdweb/src/ai/chat.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "../../test/src/test-clients.js"; +import { TEST_ACCOUNT_A, TEST_ACCOUNT_B } from "../../test/src/test-wallets.js"; +import { sepolia } from "../chains/chain-definitions/sepolia.js"; +import * as Nebula from "./index.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("chat", () => { + it("should respond with a message", async () => { + const response = await Nebula.chat({ + client: TEST_CLIENT, + prompt: `What's the symbol of this contract: 0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8`, + context: { + chains: [sepolia], + }, + }); + expect(response.message).toContain("CAT"); + }); + + it("should respond with a transaction", async () => { + const response = await Nebula.chat({ + client: TEST_CLIENT, + prompt: `send 0.0001 ETH on sepolia to ${TEST_ACCOUNT_B.address}`, + account: TEST_ACCOUNT_A, + context: { + chains: [sepolia], + walletAddresses: [TEST_ACCOUNT_A.address], + }, + }); + expect(response.transactions.length).toBe(1); + }); +}); diff --git a/packages/thirdweb/src/ai/chat.ts b/packages/thirdweb/src/ai/chat.ts new file mode 100644 index 00000000000..4fe218bbddb --- /dev/null +++ b/packages/thirdweb/src/ai/chat.ts @@ -0,0 +1,26 @@ +import { type Input, type Output, nebulaFetch } from "./common.js"; + +/** + * Chat with Nebula. + * + * @param input - The input for the chat. + * @returns The chat response. + * @beta + * @nebula + * + * @example + * ```ts + * import { Nebula } from "thirdweb/ai"; + * + * const response = await Nebula.chat({ + * client: TEST_CLIENT, + * prompt: "What's the symbol of this contract: 0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8", + * context: { + * chains: [sepolia], + * }, + * }); + * ``` + */ +export async function chat(input: Input): Promise { + return nebulaFetch("chat", input); +} diff --git a/packages/thirdweb/src/ai/common.ts b/packages/thirdweb/src/ai/common.ts new file mode 100644 index 00000000000..23a35857fcc --- /dev/null +++ b/packages/thirdweb/src/ai/common.ts @@ -0,0 +1,114 @@ +import type { Chain } from "../chains/types.js"; +import { getCachedChain } from "../chains/utils.js"; +import type { ThirdwebClient } from "../client/client.js"; +import { + type PreparedTransaction, + prepareTransaction, +} from "../transaction/prepare-transaction.js"; +import type { Address } from "../utils/address.js"; +import { toBigInt } from "../utils/bigint.js"; +import type { Hex } from "../utils/encoding/hex.js"; +import { getClientFetch } from "../utils/fetch.js"; +import type { Account } from "../wallets/interfaces/wallet.js"; + +const NEBULA_API_URL = "https://nebula-api.thirdweb.com"; + +export type Input = { + client: ThirdwebClient; + prompt: string | string[]; + account?: Account; + context?: { + chains?: Chain[]; + walletAddresses?: string[]; + contractAddresses?: string[]; + }; + sessionId?: string; +}; + +export type Output = { + message: string; + sessionId: string; + transactions: PreparedTransaction[]; +}; + +type ApiResponse = { + message: string; + session_id: string; + actions?: { + type: "init" | "presence" | "sign_transaction"; + source: string; + data: string; + }[]; +}; + +export async function nebulaFetch( + mode: "execute" | "chat", + input: Input, +): Promise { + const fetch = getClientFetch(input.client); + const response = await fetch(`${NEBULA_API_URL}/${mode}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message: input.prompt, // TODO: support array of messages + session_id: input.sessionId, + ...(input.account + ? { + execute_config: { + mode: "client", + signer_wallet_address: input.account.address, + }, + } + : {}), + ...(input.context + ? { + context_filter: { + chain_ids: + input.context.chains?.map((c) => c.id.toString()) || [], + signer_wallet_address: input.context.walletAddresses || [], + contract_addresses: input.context.contractAddresses || [], + }, + } + : {}), + }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Nebula API error: ${error}`); + } + const data = (await response.json()) as ApiResponse; + + // parse transactions if present + let transactions: PreparedTransaction[] = []; + if (data.actions) { + transactions = data.actions + .map((action) => { + // only parse sign_transaction actions + if (action.type === "sign_transaction") { + const tx = JSON.parse(action.data) as { + chainId: number; + to: Address | undefined; + value: Hex; + data: Hex; + }; + return prepareTransaction({ + chain: getCachedChain(tx.chainId), + client: input.client, + to: tx.to, + value: tx.value ? toBigInt(tx.value) : undefined, + data: tx.data, + }); + } + return undefined; + }) + .filter((tx) => tx !== undefined); + } + + return { + message: data.message, + sessionId: data.session_id, + transactions, + }; +} diff --git a/packages/thirdweb/src/ai/execute.test.ts b/packages/thirdweb/src/ai/execute.test.ts new file mode 100644 index 00000000000..c23a8b2cf47 --- /dev/null +++ b/packages/thirdweb/src/ai/execute.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "../../test/src/test-clients.js"; +import { TEST_ACCOUNT_A, TEST_ACCOUNT_B } from "../../test/src/test-wallets.js"; +import { sepolia } from "../chains/chain-definitions/sepolia.js"; +import { getContract } from "../contract/contract.js"; +import * as Nebula from "./index.js"; + +describe("execute", () => { + it("should execute a tx", async () => { + await expect( + Nebula.execute({ + client: TEST_CLIENT, + prompt: `send 0.0001 ETH to ${TEST_ACCOUNT_B.address}`, + account: TEST_ACCOUNT_A, + context: { + chains: [sepolia], + walletAddresses: [TEST_ACCOUNT_A.address], + }, + }), + ).rejects.toThrow(/insufficient funds for gas/); // shows that the tx was sent + }); + + // TODO make this work reliably + it.skip("should execute a contract call", async () => { + const nftContract = getContract({ + client: TEST_CLIENT, + chain: sepolia, + address: "0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8", + }); + + const response = await Nebula.execute({ + client: TEST_CLIENT, + prompt: `approve 1 token of token id 0 to ${TEST_ACCOUNT_B.address} using the approve function`, + account: TEST_ACCOUNT_A, + context: { + chains: [nftContract.chain], + walletAddresses: [TEST_ACCOUNT_A.address], + contractAddresses: [nftContract.address], + }, + }); + expect(response.transactionHash).toBeDefined(); + }); +}); diff --git a/packages/thirdweb/src/ai/execute.ts b/packages/thirdweb/src/ai/execute.ts new file mode 100644 index 00000000000..62e08e75b3d --- /dev/null +++ b/packages/thirdweb/src/ai/execute.ts @@ -0,0 +1,44 @@ +import { sendTransaction } from "../transaction/actions/send-transaction.js"; +import type { SendTransactionResult } from "../transaction/types.js"; +import type { Account } from "../wallets/interfaces/wallet.js"; +import { type Input, nebulaFetch } from "./common.js"; + +/** + * Execute a transaction based on a prompt. + * + * @param input - The input for the transaction. + * @returns The transaction hash. + * @beta + * @nebula + * + * @example + * ```ts + * import { Nebula } from "thirdweb/ai"; + * + * const result = await Nebula.execute({ + * client: TEST_CLIENT, + * prompt: "send 0.0001 ETH to vitalik.eth", + * account: TEST_ACCOUNT_A, + * context: { + * chains: [sepolia], + * }, + * }); + * ``` + */ +export async function execute( + input: Input & { account: Account }, +): Promise { + const result = await nebulaFetch("execute", input); + // TODO: optionally only return the transaction without executing it? + if (result.transactions.length === 0) { + throw new Error(result.message); + } + const tx = result.transactions[0]; + if (!tx) { + throw new Error(result.message); + } + return sendTransaction({ + transaction: tx, + account: input.account, + }); +} diff --git a/packages/thirdweb/src/ai/index.ts b/packages/thirdweb/src/ai/index.ts new file mode 100644 index 00000000000..a735a029ab3 --- /dev/null +++ b/packages/thirdweb/src/ai/index.ts @@ -0,0 +1,3 @@ +export { chat } from "./chat.js"; +export { execute } from "./execute.js"; +export type { Input, Output } from "./common.js"; diff --git a/packages/thirdweb/src/exports/ai.ts b/packages/thirdweb/src/exports/ai.ts new file mode 100644 index 00000000000..ce015cfe5ca --- /dev/null +++ b/packages/thirdweb/src/exports/ai.ts @@ -0,0 +1 @@ +export * as Nebula from "../ai/index.js"; diff --git a/packages/thirdweb/tsdoc.json b/packages/thirdweb/tsdoc.json index bd52d169c78..d1eb8da758e 100644 --- a/packages/thirdweb/tsdoc.json +++ b/packages/thirdweb/tsdoc.json @@ -1,122 +1,127 @@ { - "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", - "tagDefinitions": [ - { - "tagName": "@example", - "syntaxKind": "block" - }, - { - "tagName": "@contract", - "syntaxKind": "block" - }, - { - "tagName": "@wallet", - "syntaxKind": "block" - }, - { - "tagName": "@extension", - "syntaxKind": "block" - }, - { - "tagName": "@rpc", - "syntaxKind": "block" - }, - { - "tagName": "@transaction", - "syntaxKind": "block" - }, - { - "tagName": "@connectWallet", - "syntaxKind": "block" - }, - { - "tagName": "@theme", - "syntaxKind": "block" - }, - { - "tagName": "@locale", - "syntaxKind": "block" - }, - { - "tagName": "@component", - "syntaxKind": "block" - }, - { - "tagName": "@walletConfig", - "syntaxKind": "block" - }, - { - "tagName": "@walletConnection", - "syntaxKind": "block" - }, - { - "tagName": "@walletUtils", - "syntaxKind": "block" - }, - { - "tagName": "@buyCrypto", - "syntaxKind": "block" - }, - { - "tagName": "@storage", - "syntaxKind": "block" - }, - { - "tagName": "@auth", - "syntaxKind": "block" - }, - { - "tagName": "@utils", - "syntaxKind": "block" - }, - { - "tagName": "@chain", - "syntaxKind": "block" - }, - { - "tagName": "@modules", - "syntaxKind": "block" - }, - { - "tagName": "@social", - "syntaxKind": "block" - }, - { - "tagName": "@client", - "syntaxKind": "block" - }, - { - "tagName": "@nft", - "syntaxKind": "block" - }, - { - "tagName": "@account", - "syntaxKind": "block" - } - ], - "supportForTags": { - "@contract": true, - "@wallet": true, - "@extension": true, - "@rpc": true, - "@transaction": true, - "@connectWallet": true, - "@theme": true, - "@locale": true, - "@component": true, - "@walletConfig": true, - "@walletConnection": true, - "@walletUtils": true, - "@buyCrypto": true, - "@storage": true, - "@auth": true, - "@utils": true, - "@chain": true, - "@modules": true, - "@social": true, - "@client": true, - "@nft": true, - "@account": true, - "@beta": true - } + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "tagDefinitions": [ + { + "tagName": "@example", + "syntaxKind": "block" + }, + { + "tagName": "@contract", + "syntaxKind": "block" + }, + { + "tagName": "@wallet", + "syntaxKind": "block" + }, + { + "tagName": "@extension", + "syntaxKind": "block" + }, + { + "tagName": "@rpc", + "syntaxKind": "block" + }, + { + "tagName": "@transaction", + "syntaxKind": "block" + }, + { + "tagName": "@connectWallet", + "syntaxKind": "block" + }, + { + "tagName": "@theme", + "syntaxKind": "block" + }, + { + "tagName": "@locale", + "syntaxKind": "block" + }, + { + "tagName": "@component", + "syntaxKind": "block" + }, + { + "tagName": "@walletConfig", + "syntaxKind": "block" + }, + { + "tagName": "@walletConnection", + "syntaxKind": "block" + }, + { + "tagName": "@walletUtils", + "syntaxKind": "block" + }, + { + "tagName": "@buyCrypto", + "syntaxKind": "block" + }, + { + "tagName": "@storage", + "syntaxKind": "block" + }, + { + "tagName": "@auth", + "syntaxKind": "block" + }, + { + "tagName": "@utils", + "syntaxKind": "block" + }, + { + "tagName": "@chain", + "syntaxKind": "block" + }, + { + "tagName": "@modules", + "syntaxKind": "block" + }, + { + "tagName": "@social", + "syntaxKind": "block" + }, + { + "tagName": "@client", + "syntaxKind": "block" + }, + { + "tagName": "@nft", + "syntaxKind": "block" + }, + { + "tagName": "@account", + "syntaxKind": "block" + }, + { + "tagName": "@nebula", + "syntaxKind": "block" + } + ], + "supportForTags": { + "@contract": true, + "@wallet": true, + "@extension": true, + "@rpc": true, + "@transaction": true, + "@connectWallet": true, + "@theme": true, + "@locale": true, + "@component": true, + "@walletConfig": true, + "@walletConnection": true, + "@walletUtils": true, + "@buyCrypto": true, + "@storage": true, + "@auth": true, + "@utils": true, + "@chain": true, + "@modules": true, + "@social": true, + "@client": true, + "@nft": true, + "@account": true, + "@beta": true, + "@nebula": true + } }