diff --git a/.changeset/serious-geese-chew.md b/.changeset/serious-geese-chew.md new file mode 100644 index 00000000000..604f177d1a7 --- /dev/null +++ b/.changeset/serious-geese-chew.md @@ -0,0 +1,36 @@ +--- +"thirdweb": minor +--- + +Introducing `engineAccount()` for backend usage + +You can now use `engineAccount()` on the backend to create an account that can send transactions via your engine instance. + +This lets you use the full catalog of thirdweb SDK functions and extensions on the backend, with the performance, reliability, and monitoring of your engine instance. + +```ts +// get your engine url, auth token, and wallet address from your engine instance on the dashboard +const engine = engineAccount({ + engineUrl: process.env.ENGINE_URL, + authToken: process.env.ENGINE_AUTH_TOKEN, + walletAddress: process.env.ENGINE_WALLET_ADDRESS, +}); + +// Now you can use engineAcc to send transactions, deploy contracts, etc. +// For example, you can prepare extension functions: +const tx = await claimTo({ + contract: getContract({ client, chain, address: "0x..." }), + to: "0x...", + tokenId: 0n, + quantity: 1n, +}); + +// And then send the transaction via engine +// this will automatically wait for the transaction to be mined and return the transaction hash +const result = await sendTransaction({ + account: engine, // forward the transaction to your engine instance + transaction: tx, +}); + +console.log(result.transactionHash); +``` diff --git a/packages/thirdweb/.env.example b/packages/thirdweb/.env.example index 5f14fd7abd3..afad0e5c207 100644 --- a/packages/thirdweb/.env.example +++ b/packages/thirdweb/.env.example @@ -2,7 +2,13 @@ # Note: Adding new env also requires defining it in vite.config.ts file # required +TW_SECRET_KEY= STORYBOOK_CLIENT_ID= # optional - for testing using a specific account -STORYBOOK_ACCOUNT_PRIVATE_KEY= \ No newline at end of file +STORYBOOK_ACCOUNT_PRIVATE_KEY= + +# optional - for testing using a specific engine +ENGINE_URL= +ENGINE_AUTH_TOKEN= +ENGINE_WALLET_ADDRESS= \ No newline at end of file diff --git a/packages/thirdweb/src/exports/wallets/engine.ts b/packages/thirdweb/src/exports/wallets/engine.ts new file mode 100644 index 00000000000..2176c16b7e0 --- /dev/null +++ b/packages/thirdweb/src/exports/wallets/engine.ts @@ -0,0 +1,4 @@ +export { + type EngineAccountOptions, + engineAccount, +} from "../../wallets/engine/index.js"; diff --git a/packages/thirdweb/src/wallets/engine/engine-account.test.ts b/packages/thirdweb/src/wallets/engine/engine-account.test.ts new file mode 100644 index 00000000000..1c6faeb26b6 --- /dev/null +++ b/packages/thirdweb/src/wallets/engine/engine-account.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "../../../test/src/test-clients.js"; +import { TEST_ACCOUNT_B } from "../../../test/src/test-wallets.js"; +import { typedData } from "../../../test/src/typed-data.js"; +import { sepolia } from "../../chains/chain-definitions/sepolia.js"; +import { getContract } from "../../contract/contract.js"; +import { claimTo } from "../../extensions/erc1155/drops/write/claimTo.js"; +import { sendTransaction } from "../../transaction/actions/send-transaction.js"; +import { engineAccount } from "./index.js"; + +describe.runIf( + process.env.TW_SECRET_KEY && + process.env.ENGINE_URL && + process.env.ENGINE_AUTH_TOKEN && + process.env.ENGINE_WALLET_ADDRESS, +)("Engine", () => { + const engineAcc = engineAccount({ + engineUrl: process.env.ENGINE_URL as string, + authToken: process.env.ENGINE_AUTH_TOKEN as string, + walletAddress: process.env.ENGINE_WALLET_ADDRESS as string, + chain: sepolia, + }); + + it("should sign a message", async () => { + const signature = await engineAcc.signMessage({ + message: "hello", + }); + expect(signature).toBeDefined(); + }); + + it("should sign typed data", async () => { + const signature = await engineAcc.signTypedData({ + ...typedData.basic, + }); + expect(signature).toBeDefined(); + }); + + it("should send a tx", async () => { + const tx = await sendTransaction({ + account: engineAcc, + transaction: { + client: TEST_CLIENT, + chain: sepolia, + to: TEST_ACCOUNT_B.address, + value: 0n, + }, + }); + expect(tx).toBeDefined(); + }); + + it("should send a extension tx", async () => { + const nftContract = getContract({ + client: TEST_CLIENT, + chain: sepolia, + address: "0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8", + }); + const claimTx = claimTo({ + contract: nftContract, + to: TEST_ACCOUNT_B.address, + tokenId: 0n, + quantity: 1n, + }); + const tx = await sendTransaction({ + account: engineAcc, + transaction: claimTx, + }); + expect(tx).toBeDefined(); + }); +}); diff --git a/packages/thirdweb/src/wallets/engine/index.ts b/packages/thirdweb/src/wallets/engine/index.ts new file mode 100644 index 00000000000..1d139859bc5 --- /dev/null +++ b/packages/thirdweb/src/wallets/engine/index.ts @@ -0,0 +1,198 @@ +import type { Chain } from "../../chains/types.js"; +import type { Hex } from "../../utils/encoding/hex.js"; +import { toHex } from "../../utils/encoding/hex.js"; +import type { Account, SendTransactionOption } from "../interfaces/wallet.js"; + +/** + * Options for creating an engine account. + */ +export type EngineAccountOptions = { + /** + * The URL of your engine instance. + */ + engineUrl: string; + /** + * The auth token to use with the engine instance. + */ + authToken: string; + /** + * The backend wallet to use for sending transactions inside engine. + */ + walletAddress: string; + /** + * The chain to use for signing messages and typed data (smart backend wallet only). + */ + chain?: Chain; +}; + +/** + * Creates an account that uses your engine backend wallet for sending transactions and signing messages. + * + * @param options - The options for the engine account. + * @returns An account that uses your engine backend wallet. + * + * @beta + * @wallet + * + * @example + * ```ts + * import { engineAccount } from "thirdweb/wallets/engine"; + * + * const engineAcc = engineAccount({ + * engineUrl: "https://engine.thirdweb.com", + * authToken: "your-auth-token", + * walletAddress: "0x...", + * }); + * + * // then use the account as you would any other account + * const transaction = claimTo({ + * contract, + * to: "0x...", + * quantity: 1n, + * }); + * const result = await sendTransaction({ transaction, account: engineAcc }); + * console.log("Transaction sent:", result.transactionHash); + * ``` + */ +export function engineAccount(options: EngineAccountOptions): Account { + const { engineUrl, authToken, walletAddress, chain } = options; + + // these are shared across all methods + const headers: HeadersInit = { + "x-backend-wallet-address": walletAddress, + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }; + + return { + address: walletAddress, + sendTransaction: async (transaction: SendTransactionOption) => { + const ENGINE_URL = new URL(engineUrl); + ENGINE_URL.pathname = `/backend-wallet/${transaction.chainId}/send-transaction`; + + const engineData: Record = { + // add to address if we have it (is optional to pass to engine) + toAddress: transaction.to || undefined, + // engine wants a hex string here so we serialize it + data: transaction.data || "0x", + // value is always required + value: toHex(transaction.value ?? 0n), + }; + + // TODO: gas overrides etc? + + const engineRes = await fetch(ENGINE_URL, { + method: "POST", + headers, + body: JSON.stringify(engineData), + }); + if (!engineRes.ok) { + const body = await engineRes.text(); + throw new Error( + `Engine request failed with status ${engineRes.status} - ${body}`, + ); + } + const engineJson = (await engineRes.json()) as { + result: { + queueId: string; + }; + }; + + // wait for the queueId to be processed + ENGINE_URL.pathname = `/transaction/status/${engineJson.result.queueId}`; + const startTime = Date.now(); + const TIMEOUT_IN_MS = 5 * 60 * 1000; // 5 minutes in milliseconds + + while (Date.now() - startTime < TIMEOUT_IN_MS) { + const queueRes = await fetch(ENGINE_URL, { + method: "GET", + headers, + }); + if (!queueRes.ok) { + const body = await queueRes.text(); + throw new Error( + `Engine request failed with status ${queueRes.status} - ${body}`, + ); + } + const queueJSON = (await queueRes.json()) as { + result: { + status: "queued" | "mined" | "cancelled" | "errored"; + transactionHash: Hex | null; + userOpHash: Hex | null; + errorMessage: string | null; + }; + }; + + if ( + queueJSON.result.status === "errored" && + queueJSON.result.errorMessage + ) { + throw new Error(queueJSON.result.errorMessage); + } + if (queueJSON.result.transactionHash) { + return { + transactionHash: queueJSON.result.transactionHash, + }; + } + // wait 1s before checking again + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error("Transaction timed out after 5 minutes"); + }, + signMessage: async ({ message }) => { + let engineMessage: string | Hex; + let isBytes = false; + if (typeof message === "string") { + engineMessage = message; + } else { + engineMessage = toHex(message.raw); + isBytes = true; + } + + const ENGINE_URL = new URL(engineUrl); + ENGINE_URL.pathname = "/backend-wallet/sign-message"; + const engineRes = await fetch(ENGINE_URL, { + method: "POST", + headers, + body: JSON.stringify({ + message: engineMessage, + isBytes, + chainId: chain?.id, + }), + }); + if (!engineRes.ok) { + const body = await engineRes.text(); + throw new Error( + `Engine request failed with status ${engineRes.status} - ${body}`, + ); + } + const engineJson = (await engineRes.json()) as { + result: Hex; + }; + return engineJson.result; + }, + signTypedData: async (_typedData) => { + const ENGINE_URL = new URL(engineUrl); + ENGINE_URL.pathname = "/backend-wallet/sign-typed-data"; + const engineRes = await fetch(ENGINE_URL, { + method: "POST", + headers, + body: JSON.stringify({ + domain: _typedData.domain, + types: _typedData.types, + value: _typedData.message, + }), + }); + if (!engineRes.ok) { + engineRes.body?.cancel(); + throw new Error( + `Engine request failed with status ${engineRes.status}`, + ); + } + const engineJson = (await engineRes.json()) as { + result: Hex; + }; + return engineJson.result; + }, + }; +} diff --git a/packages/thirdweb/src/wallets/smart/lib/signing.ts b/packages/thirdweb/src/wallets/smart/lib/signing.ts index 5997da09b46..33847690dad 100644 --- a/packages/thirdweb/src/wallets/smart/lib/signing.ts +++ b/packages/thirdweb/src/wallets/smart/lib/signing.ts @@ -288,7 +288,7 @@ async function checkFor712Factory({ * }); * ``` * - * @wallets + * @wallet */ export async function deploySmartAccount(args: { smartAccount: Account;