Skip to content

Commit

Permalink
feat: Add Nebula AI chat and transaction execution API (#5948)
Browse files Browse the repository at this point in the history
  • Loading branch information
joaquim-verges authored Jan 15, 2025
1 parent d1c03b0 commit b10f306
Show file tree
Hide file tree
Showing 12 changed files with 458 additions and 174 deletions.
44 changes: 44 additions & 0 deletions .changeset/warm-dodos-destroy.md
Original file line number Diff line number Diff line change
@@ -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);
```
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const tagsToGroup = {
"@modules": "Modules",
"@client": "Client",
"@account": "Account",
"@nebula": "Nebula",
} as const;

type TagKey = keyof typeof tagsToGroup;
Expand Down Expand Up @@ -81,6 +82,7 @@ const sidebarGroupOrder: TagKey[] = [
"@utils",
"@others",
"@account",
"@nebula",
];

function findTag(
Expand Down
78 changes: 24 additions & 54 deletions packages/thirdweb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/thirdweb/scripts/typedoc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions packages/thirdweb/src/ai/chat.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions packages/thirdweb/src/ai/chat.ts
Original file line number Diff line number Diff line change
@@ -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<Output> {
return nebulaFetch("chat", input);
}
114 changes: 114 additions & 0 deletions packages/thirdweb/src/ai/common.ts
Original file line number Diff line number Diff line change
@@ -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<Output> {
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,
};
}
43 changes: 43 additions & 0 deletions packages/thirdweb/src/ai/execute.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading

0 comments on commit b10f306

Please sign in to comment.