Skip to content

Commit 07a59a2

Browse files
feat: Add Nebula AI chat and transaction execution API
1 parent 0682f26 commit 07a59a2

File tree

9 files changed

+297
-0
lines changed

9 files changed

+297
-0
lines changed

.changeset/warm-dodos-destroy.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
Introducing Nebula API
6+
7+
You can now chat with Nebula and ask it to execute transactions with your wallet.
8+
9+
Ask questions about real time blockchain data.
10+
11+
```ts
12+
import { Nebula } from "thirdweb/ai";
13+
14+
const response = await Nebula.chat({
15+
client: TEST_CLIENT,
16+
prompt:
17+
"What's the symbol of this contract: 0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8",
18+
context: {
19+
chains: [sepolia],
20+
},
21+
});
22+
23+
console.log("chat response:", response.message);
24+
```
25+
26+
Ask it to execute transactions with your wallet.
27+
28+
```ts
29+
import { Nebula } from "thirdweb/ai";
30+
31+
const wallet = createWallet("io.metamask");
32+
const account = await wallet.connect({ client });
33+
34+
const result = await Nebula.execute({
35+
client,
36+
prompt: "send 0.0001 ETH to vitalik.eth",
37+
account,
38+
context: {
39+
chains: [sepolia],
40+
},
41+
});
42+
43+
console.log("executed transaction:", result.transactionHash);
44+
```

packages/thirdweb/scripts/typedoc.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const app = await Application.bootstrapWithPlugins({
99
"src/extensions/modules/**/index.ts",
1010
"src/adapters/eip1193/index.ts",
1111
"src/wallets/smart/presets/index.ts",
12+
"src/ai/index.ts",
1213
],
1314
exclude: [
1415
"src/exports/*.native.ts",

packages/thirdweb/src/ai/chat.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest";
2+
import { TEST_CLIENT } from "../../test/src/test-clients.js";
3+
import { TEST_ACCOUNT_A, TEST_ACCOUNT_B } from "../../test/src/test-wallets.js";
4+
import { sepolia } from "../chains/chain-definitions/sepolia.js";
5+
import * as Nebula from "./index.js";
6+
7+
describe.runIf(process.env.TW_SECRET_KEY)("chat", () => {
8+
it("should respond with a message", async () => {
9+
const response = await Nebula.chat({
10+
client: TEST_CLIENT,
11+
prompt: `What's the symbol of this contract: 0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8`,
12+
context: {
13+
chains: [sepolia],
14+
},
15+
});
16+
expect(response.message).toContain("CAT");
17+
});
18+
19+
it("should respond with a transaction", async () => {
20+
const response = await Nebula.chat({
21+
client: TEST_CLIENT,
22+
prompt: `send 0.0001 ETH on sepolia to ${TEST_ACCOUNT_B.address}`,
23+
account: TEST_ACCOUNT_A,
24+
context: {
25+
chains: [sepolia],
26+
walletAddresses: [TEST_ACCOUNT_A.address],
27+
},
28+
});
29+
expect(response.transactions.length).toBe(1);
30+
});
31+
});

packages/thirdweb/src/ai/chat.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { type Input, type Output, nebulaFetch } from "./common.js";
2+
3+
/**
4+
* Chat with Nebula.
5+
*
6+
* @param input - The input for the chat.
7+
* @returns The chat response.
8+
*
9+
* @example
10+
* ```ts
11+
* import { Nebula } from "thirdweb/ai";
12+
*
13+
* const response = await Nebula.chat({
14+
* client: TEST_CLIENT,
15+
* prompt: "What's the symbol of this contract: 0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8",
16+
* context: {
17+
* chains: [sepolia],
18+
* },
19+
* });
20+
* ```
21+
*/
22+
export async function chat(input: Input): Promise<Output> {
23+
return nebulaFetch("chat", input);
24+
}

packages/thirdweb/src/ai/common.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Chain } from "../chains/types.js";
2+
import { getCachedChain } from "../chains/utils.js";
3+
import type { ThirdwebClient } from "../client/client.js";
4+
import {
5+
type PreparedTransaction,
6+
prepareTransaction,
7+
} from "../transaction/prepare-transaction.js";
8+
import type { Address } from "../utils/address.js";
9+
import { toBigInt } from "../utils/bigint.js";
10+
import type { Hex } from "../utils/encoding/hex.js";
11+
import { getClientFetch } from "../utils/fetch.js";
12+
import type { Account } from "../wallets/interfaces/wallet.js";
13+
14+
const NEBULA_API_URL = "https://nebula-api.thirdweb.com";
15+
16+
export type Input = {
17+
client: ThirdwebClient;
18+
prompt: string | string[];
19+
account?: Account;
20+
context?: {
21+
chains?: Chain[];
22+
walletAddresses?: string[];
23+
contractAddresses?: string[];
24+
};
25+
sessionId?: string;
26+
};
27+
28+
export type Output = {
29+
message: string;
30+
sessionId: string;
31+
transactions: PreparedTransaction[];
32+
};
33+
34+
type ApiResponse = {
35+
message: string;
36+
session_id: string;
37+
actions?: {
38+
type: "init" | "presence" | "sign_transaction";
39+
source: string;
40+
data: string;
41+
}[];
42+
};
43+
44+
export async function nebulaFetch(
45+
mode: "execute" | "chat",
46+
input: Input,
47+
): Promise<Output> {
48+
const fetch = getClientFetch(input.client);
49+
const response = await fetch(`${NEBULA_API_URL}/${mode}`, {
50+
method: "POST",
51+
headers: {
52+
"Content-Type": "application/json",
53+
},
54+
body: JSON.stringify({
55+
message: input.prompt, // TODO: support array of messages
56+
session_id: input.sessionId,
57+
...(input.account
58+
? {
59+
execute_config: {
60+
mode: "client",
61+
signer_wallet_address: input.account.address,
62+
},
63+
}
64+
: {}),
65+
...(input.context
66+
? {
67+
context_filter: {
68+
chain_ids:
69+
input.context.chains?.map((c) => c.id.toString()) || [],
70+
signer_wallet_address: input.context.walletAddresses || [],
71+
contract_addresses: input.context.contractAddresses || [],
72+
},
73+
}
74+
: {}),
75+
}),
76+
});
77+
if (!response.ok) {
78+
const error = await response.text();
79+
throw new Error(`Nebula API error: ${error}`);
80+
}
81+
const data = (await response.json()) as ApiResponse;
82+
83+
// parse transactions if present
84+
let transactions: PreparedTransaction[] = [];
85+
if (data.actions) {
86+
transactions = data.actions.map((action) => {
87+
const tx = JSON.parse(action.data) as {
88+
chainId: number;
89+
to: Address | undefined;
90+
value: Hex;
91+
data: Hex;
92+
};
93+
return prepareTransaction({
94+
chain: getCachedChain(tx.chainId),
95+
client: input.client,
96+
to: tx.to,
97+
value: tx.value ? toBigInt(tx.value) : undefined,
98+
data: tx.data,
99+
});
100+
});
101+
}
102+
103+
return {
104+
message: data.message,
105+
sessionId: data.session_id,
106+
transactions,
107+
};
108+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from "vitest";
2+
import { TEST_CLIENT } from "../../test/src/test-clients.js";
3+
import { TEST_ACCOUNT_A, TEST_ACCOUNT_B } from "../../test/src/test-wallets.js";
4+
import { sepolia } from "../chains/chain-definitions/sepolia.js";
5+
import { getContract } from "../contract/contract.js";
6+
import * as Nebula from "./index.js";
7+
8+
describe("execute", () => {
9+
it("should execute a tx", async () => {
10+
await expect(
11+
Nebula.execute({
12+
client: TEST_CLIENT,
13+
prompt: `send 0.0001 ETH to ${TEST_ACCOUNT_B.address}`,
14+
account: TEST_ACCOUNT_A,
15+
context: {
16+
chains: [sepolia],
17+
walletAddresses: [TEST_ACCOUNT_A.address],
18+
},
19+
}),
20+
).rejects.toThrow(/insufficient funds for gas/); // shows that the tx was sent
21+
});
22+
23+
// TODO make this work reliably
24+
it.skip("should execute a contract call", async () => {
25+
const nftContract = getContract({
26+
client: TEST_CLIENT,
27+
chain: sepolia,
28+
address: "0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8",
29+
});
30+
31+
const response = await Nebula.execute({
32+
client: TEST_CLIENT,
33+
prompt: `approve 1 token of token id 0 to ${TEST_ACCOUNT_B.address} using the approve function`,
34+
account: TEST_ACCOUNT_A,
35+
context: {
36+
chains: [nftContract.chain],
37+
walletAddresses: [TEST_ACCOUNT_A.address],
38+
contractAddresses: [nftContract.address],
39+
},
40+
});
41+
expect(response.transactionHash).toBeDefined();
42+
});
43+
});

packages/thirdweb/src/ai/execute.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { sendTransaction } from "../transaction/actions/send-transaction.js";
2+
import type { SendTransactionResult } from "../transaction/types.js";
3+
import type { Account } from "../wallets/interfaces/wallet.js";
4+
import { type Input, nebulaFetch } from "./common.js";
5+
6+
/**
7+
* Execute a transaction based on a prompt.
8+
*
9+
* @param input - The input for the transaction.
10+
* @returns The transaction hash.
11+
*
12+
* @example
13+
* ```ts
14+
* import { Nebula } from "thirdweb/ai";
15+
*
16+
* const result = await Nebula.execute({
17+
* client: TEST_CLIENT,
18+
* prompt: "send 0.0001 ETH to vitalik.eth",
19+
* account: TEST_ACCOUNT_A,
20+
* context: {
21+
* chains: [sepolia],
22+
* },
23+
* });
24+
* ```
25+
*/
26+
export async function execute(
27+
input: Input & { account: Account },
28+
): Promise<SendTransactionResult> {
29+
const result = await nebulaFetch("execute", input);
30+
// TODO: optionally only return the transaction without executing it?
31+
if (result.transactions.length === 0) {
32+
throw new Error(result.message);
33+
}
34+
const tx = result.transactions[0];
35+
if (!tx) {
36+
throw new Error(result.message);
37+
}
38+
return sendTransaction({
39+
transaction: tx,
40+
account: input.account,
41+
});
42+
}

packages/thirdweb/src/ai/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { chat } from "./chat.js";
2+
export { execute } from "./execute.js";
3+
export type { Input, Output } from "./common.js";

packages/thirdweb/src/exports/ai.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * as Nebula from "../ai/index.js";

0 commit comments

Comments
 (0)