From 46aa33690850c650f0ea990683ac2c81f0cb726f Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Thu, 29 May 2025 12:25:28 +1200 Subject: [PATCH] [Engine] Add search transactions and batch transaction support --- .changeset/engine-enhancements.md | 95 +++++++++++++ .changeset/stupid-adults-flow.md | 5 + packages/engine/src/client/sdk.gen.ts | 54 +++++++ packages/engine/src/client/types.gen.ts | 66 ++++++++- packages/thirdweb/src/bridge/Chains.test.ts | 2 +- .../src/engine/create-server-wallet.ts | 57 ++++++++ packages/thirdweb/src/engine/get-status.ts | 69 --------- packages/thirdweb/src/engine/index.ts | 14 +- .../src/engine/list-server-wallets.ts | 46 ++++++ .../src/engine/search-transactions.ts | 132 ++++++++++++++++++ .../thirdweb/src/engine/server-wallet.test.ts | 54 +++++++ packages/thirdweb/src/engine/server-wallet.ts | 121 +++++++++++++--- .../thirdweb/src/engine/wait-for-tx-hash.ts | 75 ++++++++++ .../erc1155/read/getOwnedNFTs.test.ts | 25 ++++ .../src/extensions/erc20/drop20.test.ts | 92 +++++++++++- packages/thirdweb/src/insight/get-nfts.ts | 7 +- 16 files changed, 823 insertions(+), 91 deletions(-) create mode 100644 .changeset/engine-enhancements.md create mode 100644 .changeset/stupid-adults-flow.md create mode 100644 packages/thirdweb/src/engine/create-server-wallet.ts create mode 100644 packages/thirdweb/src/engine/list-server-wallets.ts create mode 100644 packages/thirdweb/src/engine/search-transactions.ts create mode 100644 packages/thirdweb/src/engine/wait-for-tx-hash.ts create mode 100644 packages/thirdweb/src/extensions/erc1155/read/getOwnedNFTs.test.ts diff --git a/.changeset/engine-enhancements.md b/.changeset/engine-enhancements.md new file mode 100644 index 00000000000..aa0f65645fa --- /dev/null +++ b/.changeset/engine-enhancements.md @@ -0,0 +1,95 @@ +--- +"thirdweb": minor +--- + +Enhanced Engine functionality with server wallet management, search transactions and batch transaction support: + +- Added `Engine.createServerWallet()` to create a new server wallet with a custom label + + ```ts + import { Engine } from "thirdweb"; + + const serverWallet = await Engine.createServerWallet({ + client, + label: "My Server Wallet", + }); + console.log(serverWallet.address); + console.log(serverWallet.smartAccountAddress); + ``` + +- Added `Engine.getServerWallets()` to list all existing server wallets + + ```ts + import { Engine } from "thirdweb"; + + const serverWallets = await Engine.getServerWallets({ + client, + }); + console.log(serverWallets); + ``` + +- Added `Engine.searchTransactions()` to search for transactions by various filters (id, chainId, from address, etc.) + + ```ts + // Search by transaction IDs + const transactions = await Engine.searchTransactions({ + client, + filters: [ + { + field: "id", + values: ["1", "2", "3"], + }, + ], + }); + + // Search by chain ID and sender address + const transactions = await Engine.searchTransactions({ + client, + filters: [ + { + filters: [ + { + field: "from", + values: ["0x1234567890123456789012345678901234567890"], + }, + { + field: "chainId", + values: ["8453"], + }, + ], + operation: "AND", + }, + ], + pageSize: 100, + page: 0, + }); + ``` + +- Added `serverWallet.enqueueBatchTransaction()` to enqueue multiple transactions in a single batch + + ```ts + // Prepare multiple transactions + const transaction1 = claimTo({ + contract, + to: firstRecipient, + quantity: 1n, + }); + const transaction2 = claimTo({ + contract, + to: secondRecipient, + quantity: 1n, + }); + + // Enqueue as a batch + const { transactionId } = await serverWallet.enqueueBatchTransaction({ + transactions: [transaction1, transaction2], + }); + + // Wait for batch completion + const { transactionHash } = await Engine.waitForTransactionHash({ + client, + transactionId, + }); + ``` + +- Improved server wallet transaction handling with better error reporting diff --git a/.changeset/stupid-adults-flow.md b/.changeset/stupid-adults-flow.md new file mode 100644 index 00000000000..5053443bdae --- /dev/null +++ b/.changeset/stupid-adults-flow.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/engine": patch +--- + +Updated to latest API diff --git a/packages/engine/src/client/sdk.gen.ts b/packages/engine/src/client/sdk.gen.ts index 3bc3cde0993..d5b748b73b3 100644 --- a/packages/engine/src/client/sdk.gen.ts +++ b/packages/engine/src/client/sdk.gen.ts @@ -7,6 +7,8 @@ import type { } from "@hey-api/client-fetch"; import { client as _heyApiClient } from "./client.gen.js"; import type { + CreateAccountData, + CreateAccountResponse, EncodeFunctionDataData, EncodeFunctionDataResponse, GetNativeBalanceData, @@ -15,6 +17,8 @@ import type { GetTransactionAnalyticsResponse, GetTransactionAnalyticsSummaryData, GetTransactionAnalyticsSummaryResponse, + ListAccountsData, + ListAccountsResponse, ReadContractData, ReadContractResponse, SearchTransactionsData, @@ -48,6 +52,56 @@ export type Options< meta?: Record; }; +/** + * List Server Wallets + * List all engine server wallets for the current project. Returns an array of EOA addresses with their corresponding predicted smart account addresses. + */ +export const listAccounts = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + ListAccountsResponse, + unknown, + ThrowOnError + >({ + security: [ + { + name: "x-secret-key", + type: "apiKey", + }, + ], + url: "/v1/accounts", + ...options, + }); +}; + +/** + * Create Server Wallet + * Create a new engine server wallet. This is a helper route for creating a new EOA with your KMS provider, provided as a convenient alternative to creating an EOA directly with your KMS provider. Your KMS credentials are not stored, and usage of created accounts require your KMS credentials to be sent with requests. + */ +export const createAccount = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).post< + CreateAccountResponse, + unknown, + ThrowOnError + >({ + security: [ + { + name: "x-secret-key", + type: "apiKey", + }, + ], + url: "/v1/accounts", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); +}; + /** * Write Contract * Call a write function on a contract. diff --git a/packages/engine/src/client/types.gen.ts b/packages/engine/src/client/types.gen.ts index 61c6801af2a..99a3986e8c5 100644 --- a/packages/engine/src/client/types.gen.ts +++ b/packages/engine/src/client/types.gen.ts @@ -9,7 +9,7 @@ export type TransactionsFilterValue = { | "smartAccountAddress" | "chainId"; values: Array; - operation: "AND" | "OR"; + operation?: "AND" | "OR"; }; export type TransactionsFilterNested = { @@ -92,6 +92,70 @@ export type AaZksyncExecutionOptions = { chainId: string; }; +export type ListAccountsData = { + body?: never; + path?: never; + query?: never; + url: "/v1/accounts"; +}; + +export type ListAccountsResponses = { + /** + * Accounts retrieved successfully + */ + 200: { + result: Array<{ + /** + * EVM address in hex format + */ + address: string; + /** + * The predicted smart account address for use with the default thirdweb v0.7 AccountFactory + */ + smartAccountAddress?: string; + }>; + }; +}; + +export type ListAccountsResponse = + ListAccountsResponses[keyof ListAccountsResponses]; + +export type CreateAccountData = { + body?: { + label: string; + }; + headers?: { + /** + * Vault Access Token used to access your EOA + */ + "x-vault-access-token"?: string; + }; + path?: never; + query?: never; + url: "/v1/accounts"; +}; + +export type CreateAccountResponses = { + /** + * Account created successfully + */ + 201: { + result: { + /** + * EVM address in hex format + */ + address: string; + /** + * The predicted smart account address for use with the default thirdweb v0.7 AccountFactory + */ + smartAccountAddress?: string; + }; + }; +}; + +export type CreateAccountResponse = + CreateAccountResponses[keyof CreateAccountResponses]; + export type WriteContractData = { body?: { /** diff --git a/packages/thirdweb/src/bridge/Chains.test.ts b/packages/thirdweb/src/bridge/Chains.test.ts index a825e5e3530..1a58b735b09 100644 --- a/packages/thirdweb/src/bridge/Chains.test.ts +++ b/packages/thirdweb/src/bridge/Chains.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { TEST_CLIENT } from "~test/test-clients.js"; import { chains } from "./Chains.js"; -describe("chains", () => { +describe.runIf(process.env.TW_SECRET_KEY)("chains", () => { it("should fetch chains", async () => { // Setup const client = TEST_CLIENT; diff --git a/packages/thirdweb/src/engine/create-server-wallet.ts b/packages/thirdweb/src/engine/create-server-wallet.ts new file mode 100644 index 00000000000..8ce9131c1ef --- /dev/null +++ b/packages/thirdweb/src/engine/create-server-wallet.ts @@ -0,0 +1,57 @@ +import { createAccount } from "@thirdweb-dev/engine"; +import { stringify } from "viem"; +import type { ThirdwebClient } from "../client/client.js"; +import { getThirdwebBaseUrl } from "../utils/domains.js"; +import { getClientFetch } from "../utils/fetch.js"; + +export type CreateServerWalletArgs = { + client: ThirdwebClient; + label: string; +}; + +/** + * Create a server wallet. + * @param params - The parameters for the server wallet. + * @param params.client - The thirdweb client to use. + * @param params.label - The label for the server wallet. + * @returns The server wallet signer address and the predicted smart account address. + * @engine + * @example + * ```ts + * import { Engine } from "thirdweb"; + * + * const serverWallet = await Engine.createServerWallet({ + * client, + * label: "My Server Wallet", + * }); + * console.log(serverWallet.address); + * console.log(serverWallet.smartAccountAddress); + * ``` + */ +export async function createServerWallet(params: CreateServerWalletArgs) { + const { client, label } = params; + const result = await createAccount({ + baseUrl: getThirdwebBaseUrl("engineCloud"), + bodySerializer: stringify, + fetch: getClientFetch(client), + body: { + label, + }, + }); + + if (result.error) { + throw new Error( + `Error creating server wallet with label ${label}: ${stringify( + result.error, + )}`, + ); + } + + const data = result.data?.result; + + if (!data) { + throw new Error(`No server wallet created with label ${label}`); + } + + return data; +} diff --git a/packages/thirdweb/src/engine/get-status.ts b/packages/thirdweb/src/engine/get-status.ts index 7f84315c743..b821de2bbf0 100644 --- a/packages/thirdweb/src/engine/get-status.ts +++ b/packages/thirdweb/src/engine/get-status.ts @@ -2,7 +2,6 @@ import { searchTransactions } from "@thirdweb-dev/engine"; import type { Chain } from "../chains/types.js"; import { getCachedChain } from "../chains/utils.js"; import type { ThirdwebClient } from "../client/client.js"; -import type { WaitForReceiptOptions } from "../transaction/actions/wait-for-tx-receipt.js"; import { getThirdwebBaseUrl } from "../utils/domains.js"; import type { Hex } from "../utils/encoding/hex.js"; import { getClientFetch } from "../utils/fetch.js"; @@ -117,71 +116,3 @@ export async function getTransactionStatus(args: { id: data.id, }; } - -/** - * Wait for a transaction to be submitted onchain and return the transaction hash. - * @param args - The arguments for the transaction. - * @param args.client - The thirdweb client to use. - * @param args.transactionId - The id of the transaction to wait for. - * @param args.timeoutInSeconds - The timeout in seconds. - * @engine - * @example - * ```ts - * import { Engine } from "thirdweb"; - * - * const { transactionHash } = await Engine.waitForTransactionHash({ - * client, - * transactionId, // the transaction id returned from enqueueTransaction - * }); - * ``` - */ -export async function waitForTransactionHash(args: { - client: ThirdwebClient; - transactionId: string; - timeoutInSeconds?: number; -}): Promise { - const startTime = Date.now(); - const TIMEOUT_IN_MS = args.timeoutInSeconds - ? args.timeoutInSeconds * 1000 - : 5 * 60 * 1000; // 5 minutes in milliseconds - - while (Date.now() - startTime < TIMEOUT_IN_MS) { - const executionResult = await getTransactionStatus(args); - const status = executionResult.status; - - switch (status) { - case "FAILED": { - throw new Error( - `Transaction failed: ${executionResult.error || "Unknown error"}`, - ); - } - case "CONFIRMED": { - const onchainStatus = - executionResult && "onchainStatus" in executionResult - ? executionResult.onchainStatus - : null; - if (onchainStatus === "REVERTED") { - const revertData = - "revertData" in executionResult - ? executionResult.revertData - : undefined; - throw new Error( - `Transaction reverted: ${revertData?.errorName || ""} ${revertData?.errorArgs ? stringify(revertData.errorArgs) : ""}`, - ); - } - return { - transactionHash: executionResult.transactionHash as Hex, - client: args.client, - chain: executionResult.chain, - }; - } - default: { - // wait for the transaction to be confirmed - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - } - throw new Error( - `Transaction timed out after ${TIMEOUT_IN_MS / 1000} seconds`, - ); -} diff --git a/packages/thirdweb/src/engine/index.ts b/packages/thirdweb/src/engine/index.ts index ae1ef577b65..4948f53e98c 100644 --- a/packages/thirdweb/src/engine/index.ts +++ b/packages/thirdweb/src/engine/index.ts @@ -5,7 +5,19 @@ export { } from "./server-wallet.js"; export { getTransactionStatus, - waitForTransactionHash, type ExecutionResult, type RevertData, } from "./get-status.js"; +export { waitForTransactionHash } from "./wait-for-tx-hash.js"; +export { + searchTransactions, + type SearchTransactionsArgs, +} from "./search-transactions.js"; +export { + createServerWallet, + type CreateServerWalletArgs, +} from "./create-server-wallet.js"; +export { + getServerWallets, + type GetServerWalletsArgs, +} from "./list-server-wallets.js"; diff --git a/packages/thirdweb/src/engine/list-server-wallets.ts b/packages/thirdweb/src/engine/list-server-wallets.ts new file mode 100644 index 00000000000..53e4213ad64 --- /dev/null +++ b/packages/thirdweb/src/engine/list-server-wallets.ts @@ -0,0 +1,46 @@ +import { listAccounts } from "@thirdweb-dev/engine"; +import { stringify } from "viem"; +import type { ThirdwebClient } from "../client/client.js"; +import { getThirdwebBaseUrl } from "../utils/domains.js"; +import { getClientFetch } from "../utils/fetch.js"; + +export type GetServerWalletsArgs = { + client: ThirdwebClient; +}; + +/** + * List all server wallets. + * @param params - The parameters for the server wallet. + * @param params.client - The thirdweb client to use. + * @returns an array of server wallets with their signer address and predicted smart account address. + * @engine + * @example + * ```ts + * import { Engine } from "thirdweb"; + * + * const serverWallets = await Engine.getServerWallets({ + * client, + * }); + * console.log(serverWallets); + * ``` + */ +export async function getServerWallets(params: GetServerWalletsArgs) { + const { client } = params; + const result = await listAccounts({ + baseUrl: getThirdwebBaseUrl("engineCloud"), + bodySerializer: stringify, + fetch: getClientFetch(client), + }); + + if (result.error) { + throw new Error(`Error listing server wallets: ${stringify(result.error)}`); + } + + const data = result.data?.result; + + if (!data) { + throw new Error("No server wallets found"); + } + + return data; +} diff --git a/packages/thirdweb/src/engine/search-transactions.ts b/packages/thirdweb/src/engine/search-transactions.ts new file mode 100644 index 00000000000..798dc621b7b --- /dev/null +++ b/packages/thirdweb/src/engine/search-transactions.ts @@ -0,0 +1,132 @@ +import { + type TransactionsFilterNested, + type TransactionsFilterValue, + searchTransactions as engineSearchTransactions, +} from "@thirdweb-dev/engine"; +import type { ThirdwebClient } from "../client/client.js"; +import { getThirdwebBaseUrl } from "../utils/domains.js"; +import { getClientFetch } from "../utils/fetch.js"; +import { stringify } from "../utils/json.js"; + +export type SearchTransactionsArgs = { + client: ThirdwebClient; + filters?: (TransactionsFilterValue | TransactionsFilterNested)[]; + pageSize?: number; + page?: number; +}; + +/** + * Search for transactions by their ids. + * @param args - The arguments for the search. + * @param args.client - The thirdweb client to use. + * @param args.transactionIds - The ids of the transactions to search for. + * @engine + * @example + * ## Search for transactions by their ids + * + * ```ts + * import { Engine } from "thirdweb"; + * + * const transactions = await Engine.searchTransactions({ + * client, + * filters: [ + * { + * field: "id", + * values: ["1", "2", "3"], + * }, + * ], + * }); + * console.log(transactions); + * ``` + * + * ## Search for transactions by chain id + * + * ```ts + * import { Engine } from "thirdweb"; + * + * const transactions = await Engine.searchTransactions({ + * client, + * filters: [ + * { + * field: "chainId", + * values: ["1", "137"], + * }, + * ], + * }); + * console.log(transactions); + * ``` + * + * ## Search for transactions by sender wallet address + * + * ```ts + * import { Engine } from "thirdweb"; + * + * const transactions = await Engine.searchTransactions({ + * client, + * filters: [ + * { + * field: "from", + * values: ["0x1234567890123456789012345678901234567890"], + * }, + * ], + * }); + * console.log(transactions); + * ``` + * + * ## Combined search + * + * ```ts + * import { Engine } from "thirdweb"; + * + * const transactions = await Engine.searchTransactions({ + * client, + * filters: [ + * { + * filters: [ + * { + * field: "from", + * values: ["0x1234567890123456789012345678901234567890"], + * }, + * { + * field: "chainId", + * values: ["8453"], + * }, + * ], + * operation: "AND", + * }, + * ], + * pageSize: 100, + * page: 0, + * }); + * console.log(transactions); + * ``` + */ +export async function searchTransactions(args: SearchTransactionsArgs) { + const { client, filters, pageSize = 100, page = 1 } = args; + const searchResult = await engineSearchTransactions({ + baseUrl: getThirdwebBaseUrl("engineCloud"), + bodySerializer: stringify, + fetch: getClientFetch(client), + body: { + filters, + limit: pageSize, + page, + }, + }); + + if (searchResult.error) { + throw new Error( + `Error searching for transaction with filters ${stringify(filters)}: ${stringify( + searchResult.error, + )}`, + ); + } + + const data = searchResult.data?.result; + + if (!data) { + throw new Error(`No transactions found with filters ${stringify(filters)}`); + } + + return data; +} diff --git a/packages/thirdweb/src/engine/server-wallet.test.ts b/packages/thirdweb/src/engine/server-wallet.test.ts index eeb059eff69..b032f21ed62 100644 --- a/packages/thirdweb/src/engine/server-wallet.test.ts +++ b/packages/thirdweb/src/engine/server-wallet.test.ts @@ -47,6 +47,23 @@ describe.runIf( }); }); + it("should create a server wallet", async () => { + const serverWallet = await Engine.createServerWallet({ + client: TEST_CLIENT, + label: "My Server Wallet", + }); + expect(serverWallet).toBeDefined(); + + const serverWallets = await Engine.getServerWallets({ + client: TEST_CLIENT, + }); + expect(serverWallets).toBeDefined(); + expect(serverWallets.length).toBeGreaterThan(0); + expect( + serverWallets.find((s) => s.address === serverWallet.address), + ).toBeDefined(); + }); + it("should sign a message", async () => { const signature = await serverWallet.signMessage({ message: "hello", @@ -95,6 +112,16 @@ describe.runIf( transactionId: result.transactionId, }); expect(txHash.transactionHash).toBeDefined(); + + const res = await Engine.searchTransactions({ + client: TEST_CLIENT, + filters: [ + { field: "id", values: [result.transactionId], operation: "OR" }, + ], + }); + expect(res).toBeDefined(); + expect(res.transactions.length).toBe(1); + expect(res.transactions[0]?.id).toBe(result.transactionId); }); it("should send a extension tx", async () => { @@ -115,6 +142,33 @@ describe.runIf( expect(tx).toBeDefined(); }); + it("should enqueue a batch of txs", async () => { + const tokenContract = getContract({ + client: TEST_CLIENT, + chain: baseSepolia, + address: "0x87C52295891f208459F334975a3beE198fE75244", + }); + const claimTx1 = mintTo({ + contract: tokenContract, + to: serverWallet.address, + amount: "0.001", + }); + const claimTx2 = mintTo({ + contract: tokenContract, + to: serverWallet.address, + amount: "0.002", + }); + const tx = await serverWallet.enqueueBatchTransaction({ + transactions: [claimTx1, claimTx2], + }); + expect(tx.transactionId).toBeDefined(); + const txHash = await Engine.waitForTransactionHash({ + client: TEST_CLIENT, + transactionId: tx.transactionId, + }); + expect(txHash.transactionHash).toBeDefined(); + }); + it("should get revert reason", async () => { const nftContract = getContract({ client: TEST_CLIENT, diff --git a/packages/thirdweb/src/engine/server-wallet.ts b/packages/thirdweb/src/engine/server-wallet.ts index 5ce2041d7b0..20eeec0f0d7 100644 --- a/packages/thirdweb/src/engine/server-wallet.ts +++ b/packages/thirdweb/src/engine/server-wallet.ts @@ -19,7 +19,7 @@ import type { Account, SendTransactionOption, } from "../wallets/interfaces/wallet.js"; -import { waitForTransactionHash } from "./get-status.js"; +import { waitForTransactionHash } from "./wait-for-tx-hash.js"; /** * Options for creating an server wallet. @@ -54,6 +54,9 @@ export type ServerWallet = Account & { transaction: PreparedTransaction; simulate?: boolean; }) => Promise<{ transactionId: string }>; + enqueueBatchTransaction: (args: { + transactions: PreparedTransaction[]; + }) => Promise<{ transactionId: string }>; }; /** @@ -102,6 +105,37 @@ export type ServerWallet = Account & { * console.log("Transaction sent:", transactionHash); * ``` * + * ### Sending a batch of transactions + * ```ts + * // prepare the transactions + * const transaction1 = claimTo({ + * contract, + * to: firstRecipient, + * quantity: 1n, + * }); + * const transaction2 = claimTo({ + * contract, + * to: secondRecipient, + * quantity: 1n, + * }); + * + * + * // enqueue the transactions in a batch + * const { transactionId } = await myServerWallet.enqueueBatchTransaction({ + * transactions: [transaction1, transaction2], + * }); + * ``` + * + * ### Polling for the batch of transactions to be submitted onchain + * ```ts + * // optionally poll for the transaction to be submitted onchain + * const { transactionHash } = await Engine.waitForTransactionHash({ + * client, + * transactionId, + * }); + * console.log("Transaction sent:", transactionHash); + * ``` + * * ### Getting the execution status of a transaction * ```ts * const executionResult = await Engine.getTransactionStatus({ @@ -130,16 +164,30 @@ export function serverWallet(options: ServerWalletOptions): ServerWallet { }; }; - const enqueueTx = async (transaction: SendTransactionOption) => { + const enqueueTx = async (transaction: SendTransactionOption[]) => { + if (transaction.length === 0) { + throw new Error("No transactions to enqueue"); + } + const firstTransaction = transaction[0]; + if (!firstTransaction) { + throw new Error("No transactions to enqueue"); + } + const chainId = firstTransaction.chainId; + // Validate all transactions are on the same chain + for (let i = 1; i < transaction.length; i++) { + if (transaction[i]?.chainId !== chainId) { + throw new Error( + `All transactions in batch must be on the same chain. Expected ${chainId}, got ${transaction[i]?.chainId} at index ${i}`, + ); + } + } const body = { - executionOptions: getExecutionOptions(transaction.chainId), - params: [ - { - to: transaction.to ?? undefined, - data: transaction.data, - value: transaction.value?.toString(), - }, - ], + executionOptions: getExecutionOptions(chainId), + params: transaction.map((t) => ({ + to: t.to ?? undefined, + data: t.data, + value: t.value?.toString(), + })), }; const result = await sendTransaction({ @@ -158,11 +206,7 @@ export function serverWallet(options: ServerWalletOptions): ServerWallet { if (!data) { throw new Error("No data returned from engine"); } - const transactionId = data.transactions?.[0]?.id; - if (!transactionId) { - throw new Error("No transactionId returned from engine"); - } - return transactionId; + return data.transactions.map((t) => t.id); }; return { @@ -193,11 +237,54 @@ export function serverWallet(options: ServerWalletOptions): ServerWallet { value: value ?? undefined, }; } - const transactionId = await enqueueTx(serializedTransaction); + const transactionIds = await enqueueTx([serializedTransaction]); + const transactionId = transactionIds[0]; + if (!transactionId) { + throw new Error("No transactionId returned from engine"); + } + return { transactionId }; + }, + enqueueBatchTransaction: async (args: { + transactions: PreparedTransaction[]; + }) => { + const serializedTransactions: SendTransactionOption[] = []; + for (const transaction of args.transactions) { + const [to, data, value] = await Promise.all([ + transaction.to ? resolvePromisedValue(transaction.to) : null, + encode(transaction), + transaction.value ? resolvePromisedValue(transaction.value) : null, + ]); + serializedTransactions.push({ + chainId: transaction.chain.id, + data, + to: to ?? undefined, + value: value ?? undefined, + }); + } + const transactionIds = await enqueueTx(serializedTransactions); + const transactionId = transactionIds[0]; + if (!transactionId) { + throw new Error("No transactionId returned from engine"); + } return { transactionId }; }, sendTransaction: async (transaction: SendTransactionOption) => { - const transactionId = await enqueueTx(transaction); + const transactionIds = await enqueueTx([transaction]); + const transactionId = transactionIds[0]; + if (!transactionId) { + throw new Error("No transactionId returned from engine"); + } + return waitForTransactionHash({ + client, + transactionId, + }); + }, + sendBatchTransaction: async (transactions: SendTransactionOption[]) => { + const transactionIds = await enqueueTx(transactions); + const transactionId = transactionIds[0]; + if (!transactionId) { + throw new Error("No transactionId returned from engine"); + } return waitForTransactionHash({ client, transactionId, diff --git a/packages/thirdweb/src/engine/wait-for-tx-hash.ts b/packages/thirdweb/src/engine/wait-for-tx-hash.ts new file mode 100644 index 00000000000..d04d7e06915 --- /dev/null +++ b/packages/thirdweb/src/engine/wait-for-tx-hash.ts @@ -0,0 +1,75 @@ +import { stringify } from "viem"; + +import type { Hex } from "../utils/encoding/hex.js"; + +import type { ThirdwebClient } from "../client/client.js"; +import type { WaitForReceiptOptions } from "../transaction/actions/wait-for-tx-receipt.js"; +import { getTransactionStatus } from "./get-status.js"; + +/** + * Wait for a transaction to be submitted onchain and return the transaction hash. + * @param args - The arguments for the transaction. + * @param args.client - The thirdweb client to use. + * @param args.transactionId - The id of the transaction to wait for. + * @param args.timeoutInSeconds - The timeout in seconds. + * @engine + * @example + * ```ts + * import { Engine } from "thirdweb"; + * + * const { transactionHash } = await Engine.waitForTransactionHash({ + * client, + * transactionId, // the transaction id returned from enqueueTransaction + * }); + * ``` + */ +export async function waitForTransactionHash(args: { + client: ThirdwebClient; + transactionId: string; + timeoutInSeconds?: number; +}): Promise { + const startTime = Date.now(); + const TIMEOUT_IN_MS = args.timeoutInSeconds + ? args.timeoutInSeconds * 1000 + : 5 * 60 * 1000; // 5 minutes in milliseconds + + while (Date.now() - startTime < TIMEOUT_IN_MS) { + const executionResult = await getTransactionStatus(args); + const status = executionResult.status; + + switch (status) { + case "FAILED": { + throw new Error( + `Transaction failed: ${executionResult.error || "Unknown error"}`, + ); + } + case "CONFIRMED": { + const onchainStatus = + executionResult && "onchainStatus" in executionResult + ? executionResult.onchainStatus + : null; + if (onchainStatus === "REVERTED") { + const revertData = + "revertData" in executionResult + ? executionResult.revertData + : undefined; + throw new Error( + `Transaction reverted: ${revertData?.errorName || "unknown error"} ${revertData?.errorArgs ? stringify(revertData.errorArgs) : ""} - ${executionResult.transactionHash ? executionResult.transactionHash : ""}`, + ); + } + return { + transactionHash: executionResult.transactionHash as Hex, + client: args.client, + chain: executionResult.chain, + }; + } + default: { + // wait for the transaction to be confirmed + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + } + throw new Error( + `Transaction timed out after ${TIMEOUT_IN_MS / 1000} seconds`, + ); +} diff --git a/packages/thirdweb/src/extensions/erc1155/read/getOwnedNFTs.test.ts b/packages/thirdweb/src/extensions/erc1155/read/getOwnedNFTs.test.ts new file mode 100644 index 00000000000..4b36938bd56 --- /dev/null +++ b/packages/thirdweb/src/extensions/erc1155/read/getOwnedNFTs.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { DROP1155_CONTRACT } from "~test/test-contracts.js"; +import { getOwnedNFTs } from "./getOwnedNFTs.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("erc1155.getOwnedNFTs", () => { + it("with indexer", async () => { + const nfts = await getOwnedNFTs({ + contract: DROP1155_CONTRACT, + address: "0x00d4da27dedce60f859471d8f595fdb4ae861557", + }); + expect(nfts.length).toBe(3); + expect(nfts.find((nft) => nft.id === 4n)?.quantityOwned).toBe(411n); + }); + + it("without indexer", async () => { + const nfts = await getOwnedNFTs({ + contract: DROP1155_CONTRACT, + address: "0x00d4da27dedce60f859471d8f595fdb4ae861557", + useIndexer: false, + }); + expect(nfts.length).toBe(3); + expect(nfts.find((nft) => nft.id === 4n)?.quantityOwned).toBe(411n); + }); +}); diff --git a/packages/thirdweb/src/extensions/erc20/drop20.test.ts b/packages/thirdweb/src/extensions/erc20/drop20.test.ts index e8fc5fd52fa..f059d6abb72 100644 --- a/packages/thirdweb/src/extensions/erc20/drop20.test.ts +++ b/packages/thirdweb/src/extensions/erc20/drop20.test.ts @@ -11,7 +11,7 @@ import { import { type ThirdwebContract, getContract } from "../../contract/contract.js"; import { sendAndConfirmTransaction } from "../../transaction/actions/send-and-confirm-transaction.js"; import { resolvePromisedValue } from "../../utils/promise/resolve-promised-value.js"; -import { toEther } from "../../utils/units.js"; +import { toEther, toWei } from "../../utils/units.js"; import { name } from "../common/read/name.js"; import { deployERC20Contract } from "../prebuilts/deploy-erc20.js"; import { canClaim } from "./drops/read/canClaim.js"; @@ -20,6 +20,8 @@ import { claimTo } from "./drops/write/claimTo.js"; import { resetClaimEligibility } from "./drops/write/resetClaimEligibility.js"; import { setClaimConditions } from "./drops/write/setClaimConditions.js"; import { getBalance } from "./read/getBalance.js"; +import { getApprovalForTransaction } from "./write/getApprovalForTransaction.js"; +import { mintTo } from "./write/mintTo.js"; describe.runIf(process.env.TW_SECRET_KEY)( "DropERC20", @@ -135,6 +137,94 @@ describe.runIf(process.env.TW_SECRET_KEY)( ).toBe("2"); }); + it("should allow to claim tokens with erc20 price", async () => { + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_C.address })) + .displayValue, + ).toBe("2"); + const erc20ContractAddres = await deployERC20Contract({ + account: TEST_ACCOUNT_A, + chain: ANVIL_CHAIN, + client: TEST_CLIENT, + type: "TokenERC20", + params: { + name: "Test DropERC20", + }, + }); + const erc20Contract = getContract({ + address: erc20ContractAddres, + chain: ANVIL_CHAIN, + client: TEST_CLIENT, + }); + const mintToTx = mintTo({ + contract: erc20Contract, + to: TEST_ACCOUNT_C.address, + amount: "0.02", + }); + await sendAndConfirmTransaction({ + transaction: mintToTx, + account: TEST_ACCOUNT_A, + }); + expect( + ( + await getBalance({ + contract: erc20Contract, + address: TEST_ACCOUNT_C.address, + }) + ).displayValue, + ).toBe("0.02"); + // set cc with price + await sendAndConfirmTransaction({ + transaction: setClaimConditions({ + contract, + phases: [ + { + price: "0.01", + currencyAddress: erc20ContractAddres, + }, + ], + }), + account: TEST_ACCOUNT_A, + }); + const claimTx = claimTo({ + contract, + to: TEST_ACCOUNT_C.address, + quantity: "2", + }); + // assert value is set correctly + const value = await resolvePromisedValue(claimTx.erc20Value); + expect(value).toBeDefined(); + if (!value) throw new Error("value is undefined"); + expect(value.amountWei).toBe(toWei("0.02")); + const approve = await getApprovalForTransaction({ + transaction: claimTx, + account: TEST_ACCOUNT_C, + }); + if (approve) { + await sendAndConfirmTransaction({ + transaction: approve, + account: TEST_ACCOUNT_C, + }); + } + // claim + await sendAndConfirmTransaction({ + transaction: claimTx, + account: TEST_ACCOUNT_C, + }); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_C.address })) + .displayValue, + ).toBe("4"); + expect( + ( + await getBalance({ + contract: erc20Contract, + address: TEST_ACCOUNT_C.address, + }) + ).displayValue, + ).toBe("0"); + }); + describe("Allowlists", () => { it("should allow to claim tokens with an allowlist", async () => { await sendAndConfirmTransaction({ diff --git a/packages/thirdweb/src/insight/get-nfts.ts b/packages/thirdweb/src/insight/get-nfts.ts index 6bf5d017b90..16ac8094a39 100644 --- a/packages/thirdweb/src/insight/get-nfts.ts +++ b/packages/thirdweb/src/insight/get-nfts.ts @@ -319,7 +319,12 @@ async function transformNFTModel( }); } - return parsedNft; + return { + ...parsedNft, + ...(contract?.type === "erc1155" + ? { quantityOwned: balance ? BigInt(balance) : undefined } + : {}), + }; }), ); }