diff --git a/apps/playground-web/package.json b/apps/playground-web/package.json index 4e490615e85..bd177f53263 100644 --- a/apps/playground-web/package.json +++ b/apps/playground-web/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tooltip": "1.2.7", "@tanstack/react-query": "5.81.5", + "@thirdweb-dev/engine": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "4.1.0", diff --git a/apps/playground-web/src/app/engine/users/page.tsx b/apps/playground-web/src/app/engine/users/page.tsx new file mode 100644 index 00000000000..3d09c793349 --- /dev/null +++ b/apps/playground-web/src/app/engine/users/page.tsx @@ -0,0 +1,97 @@ +import type { Metadata } from "next"; +import { GatewayPreview } from "@/components/account-abstraction/gateway"; +import { PageLayout } from "@/components/blocks/APIHeader"; +import { CodeExample } from "@/components/code/code-example"; +import ThirdwebProvider from "@/components/thirdweb-provider"; +import { metadataBase } from "@/lib/constants"; + +export const metadata: Metadata = { + description: "Transactions from user wallets with monitoring and retries", + metadataBase, + title: "User Transactions | thirdweb", +}; + +export default function Page() { + return ( + + Transactions from user wallets with monitoring and retries. + } + docsLink="https://portal.thirdweb.com/engine?utm_source=playground" + title="User Transactions" + > + + + + ); +} + +function UserTransactions() { + return ( + <> + { + const walletAddress = activeWallet?.getAccount()?.address; + // transactions are a simple POST request to the engine API + // or use the @thirdweb-dev/engine type-safe JS SDK + const response = await fetch( + "https://api.thirdweb.com/v1/contract/write", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-client-id": "", + // uses the in-app wallet's auth token to authenticate the request + "Authorization": "Bearer " + activeWallet?.getAuthToken?.(), + }, + body: JSON.stringify({ + chainId: "84532", + calls: [ + { + contractAddress: "0x...", + method: "function claim(address to, uint256 amount)", + params: [walletAddress, "1"], + }, + ], + }), + }); + }; + + return ( + <> + + + + ); +}`} + header={{ + description: + "Engine can queue, monitor, and retry transactions from your users in-app wallets. All transactions and analytics will be displayed in your developer dashboard.", + title: "Transactions from User Wallets", + }} + lang="tsx" + preview={} + /> + + ); +} diff --git a/apps/playground-web/src/app/navLinks.ts b/apps/playground-web/src/app/navLinks.ts index 29a6be8169a..bbbe099f484 100644 --- a/apps/playground-web/src/app/navLinks.ts +++ b/apps/playground-web/src/app/navLinks.ts @@ -116,6 +116,10 @@ const engineSidebarLinks: SidebarLink = { expanded: false, isCollapsible: false, links: [ + { + href: "/engine/users", + name: "From User Wallets", + }, { href: "/engine/airdrop", name: "Airdrop", diff --git a/apps/playground-web/src/components/account-abstraction/gateway.tsx b/apps/playground-web/src/components/account-abstraction/gateway.tsx new file mode 100644 index 00000000000..ecd7c60f421 --- /dev/null +++ b/apps/playground-web/src/components/account-abstraction/gateway.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { encode, getContract } from "thirdweb"; +import { baseSepolia } from "thirdweb/chains"; +import { claimTo, getNFT, getOwnedNFTs } from "thirdweb/extensions/erc1155"; +import { + ConnectButton, + MediaRenderer, + useActiveAccount, + useActiveWallet, + useDisconnect, + useReadContract, +} from "thirdweb/react"; +import { stringify } from "thirdweb/utils"; +import { inAppWallet } from "thirdweb/wallets/in-app"; +import { THIRDWEB_CLIENT } from "../../lib/client"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; + +const url = "https://api.thirdweb-dev.com"; + +const chain = baseSepolia; +const editionDropAddress = "0x638263e3eAa3917a53630e61B1fBa685308024fa"; +const editionDropTokenId = 2n; + +const editionDropContract = getContract({ + address: editionDropAddress, + chain, + client: THIRDWEB_CLIENT, +}); + +const iaw = inAppWallet(); + +function TransactionRow({ transactionId }: { transactionId: string }) { + const { data: txStatus, isLoading } = useQuery({ + enabled: !!transactionId, + queryFn: async () => { + const response = await fetch(`${url}/v1/transactions/${transactionId}`, { + headers: { + "Content-type": "application/json", + "x-client-id": THIRDWEB_CLIENT.clientId, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to send transaction: ${response.statusText} - ${text}`, + ); + } + + const results = await response.json(); + const transaction = results.result; + + return transaction; + }, + queryKey: ["txStatus", transactionId], + refetchInterval: 2000, + }); + + const getStatusBadge = (status: string) => { + switch (status) { + case undefined: + case "QUEUED": + return Queued; + case "SUBMITTED": + return Submitted; + case "CONFIRMED": + return Confirmed; + case "FAILED": + return Failed; + default: + return {"Unknown"}; + } + }; + + const renderTransactionHash = () => { + if (!txStatus) return "-"; + + const execStatus = txStatus.executionResult?.status; + + let txHash: string | undefined; + if (execStatus === "CONFIRMED") { + txHash = txStatus.transactionHash; + } + + if (txHash && chain.blockExplorers?.[0]?.url) { + return ( + + {txHash.slice(0, 6)}...{txHash.slice(-4)} + + ); + } + + return txHash ? ( + + {txHash.slice(0, 6)}...{txHash.slice(-4)} + + ) : ( + "-" + ); + }; + + return ( + + + {transactionId.slice(0, 8)}...{transactionId.slice(-4)} + + + {isLoading || !txStatus.executionResult?.status ? ( + Queued + ) : ( + getStatusBadge(txStatus.executionResult?.status) + )} + + {renderTransactionHash()} + + ); +} + +export function GatewayPreview() { + const [txIds, setTxIds] = useState([]); + const activeEOA = useActiveAccount(); + const activeWallet = useActiveWallet(); + const { disconnect } = useDisconnect(); + const { data: nft, isLoading: isNftLoading } = useReadContract(getNFT, { + contract: editionDropContract, + tokenId: editionDropTokenId, + }); + const { data: ownedNfts } = useReadContract(getOwnedNFTs, { + // biome-ignore lint/style/noNonNullAssertion: handled by queryOptions + address: activeEOA?.address!, + contract: editionDropContract, + queryOptions: { enabled: !!activeEOA, refetchInterval: 2000 }, + useIndexer: false, + }); + + const { data: preparedTx } = useQuery({ + enabled: !!activeEOA, + queryFn: async () => { + if (!activeEOA) { + throw new Error("No active EOA"); + } + const tx = claimTo({ + contract: editionDropContract, + quantity: 1n, + to: activeEOA.address, + tokenId: editionDropTokenId, + }); + return { + data: await encode(tx), + to: editionDropContract.address, + }; + }, + queryKey: ["tx", activeEOA?.address], + }); + + if (activeEOA && activeWallet && activeWallet?.id !== iaw.id) { + return ( +
+ Please connect with an in-app wallet for this example + +
+ ); + } + + const handleClick = async () => { + if (!preparedTx || !activeEOA) { + return; + } + + const response = await fetch(`${url}/v1/transactions`, { + body: stringify({ + chainId: baseSepolia.id, + transactions: [ + { + type: "encoded", + ...preparedTx, + }, + ], + }), + headers: { + Authorization: `Bearer ${iaw.getAuthToken?.()}`, + "Content-type": "application/json", + "x-client-id": THIRDWEB_CLIENT.clientId, + }, + method: "POST", + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to send transaction: ${response.statusText} - ${text}`, + ); + } + + const results = await response.json(); + const txId = results.result?.transactionIds?.[0]; + if (!txId) { + throw new Error("No transaction ID"); + } + + setTxIds((prev) => [...prev, txId]); + }; + + return ( +
+ {isNftLoading ? ( +
Loading...
+ ) : ( + <> +
+ +
+ {nft ? ( + + ) : null} + {activeEOA ? ( +
+

+ You own {ownedNfts?.[0]?.quantityOwned.toString() || "0"}{" "} + {nft?.metadata?.name} +

+ +
+ ) : null} + {txIds.length > 0 && ( +
+ + + + + Tx ID + Status + TX Hash + + + + {txIds.map((txId) => ( + + ))} + +
+
+
+ )} + + )} +
+ ); +} diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/in-app-gateway.test.ts b/packages/thirdweb/src/wallets/in-app/web/lib/in-app-gateway.test.ts index e48111df520..3152530d770 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/in-app-gateway.test.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/in-app-gateway.test.ts @@ -1,126 +1,110 @@ -import { sendTransaction, signMessage } from "@thirdweb-dev/engine"; +import { + configure, + isSuccessResponse, + sendTransaction, + signMessage, +} from "@thirdweb-dev/engine"; import { beforeAll, describe, expect, it } from "vitest"; import { TEST_CLIENT } from "~test/test-clients.js"; import { sepolia } from "../../../../chains/chain-definitions/sepolia.js"; -import { createThirdwebClient } from "../../../../client/client.js"; import { waitForTransactionHash } from "../../../../engine/wait-for-tx-hash.js"; -import { - getThirdwebBaseUrl, - setThirdwebDomains, -} from "../../../../utils/domains.js"; -import { getClientFetch } from "../../../../utils/fetch.js"; import { stringify } from "../../../../utils/json.js"; import type { Account } from "../../../interfaces/wallet.js"; import { inAppWallet } from "../in-app.js"; -// TODO: productionize this test -describe - .runIf(process.env.TW_SECRET_KEY) - .skip("InAppWallet Gateway Tests", () => { - let account: Account; - let authToken: string | null | undefined; - const clientIdFetch = getClientFetch( - createThirdwebClient({ - clientId: TEST_CLIENT.clientId, - }), - ); +describe.runIf(process.env.TW_SECRET_KEY)("InAppWallet Gateway Tests", () => { + let account: Account; + let authToken: string | null | undefined; - beforeAll(async () => { - setThirdwebDomains({ - bundler: "bundler.thirdweb-dev.com", - engineCloud: "engine.thirdweb-dev.com", - inAppWallet: "embedded-wallet.thirdweb-dev.com", - rpc: "rpc.thirdweb-dev.com", - }); - const wallet = inAppWallet(); - account = await wallet.connect({ - client: TEST_CLIENT, - strategy: "backend", - walletSecret: "test-secret", - }); - authToken = wallet.getAuthToken?.(); - expect(authToken).toBeDefined(); + beforeAll(async () => { + configure({ + clientId: TEST_CLIENT.clientId, + secretKey: TEST_CLIENT.secretKey, }); + const wallet = inAppWallet(); + account = await wallet.connect({ + client: TEST_CLIENT, + strategy: "backend", + walletSecret: "test-secret", + }); + authToken = wallet.getAuthToken?.(); + expect(authToken).toBeDefined(); + }); - it("should sign a message with backend strategy", async () => { - const rawSignature = await account.signMessage({ - message: "Hello, world!", - }); - - // sign via api - const signResult = await signMessage({ - baseUrl: getThirdwebBaseUrl("engineCloud"), - body: { - params: [ - { - format: "text", - message: "Hello, world!", - }, - ], - signingOptions: { - from: account.address, - type: "eoa", - }, - }, - bodySerializer: stringify, - fetch: clientIdFetch, - headers: { - "x-wallet-access-token": authToken, - }, - }); - - const signatureResult = signResult.data?.result?.results[0]; - if (signatureResult && "result" in signatureResult) { - expect(signatureResult.result.signature).toEqual(rawSignature); - } else { - throw new Error( - `Failed to sign message: ${stringify(signatureResult?.error) || "Unknown error"}`, - ); - } + it("should sign a message with backend strategy", async () => { + const rawSignature = await account.signMessage({ + message: "Hello, world!", }); - it("should queue a 4337 transaction", async () => { - const body = { - executionOptions: { - chainId: sepolia.id, - from: account.address, - type: "auto" as const, - }, + // sign via api + const signResult = await signMessage({ + body: { params: [ { - data: "0x", - to: account.address, - value: "0", + format: "text", + message: "Hello, world!", }, ], - }; - const result = await sendTransaction({ - baseUrl: getThirdwebBaseUrl("engineCloud"), - body, - bodySerializer: stringify, - fetch: clientIdFetch, - headers: { - "x-wallet-access-token": authToken, + signingOptions: { + from: account.address, + type: "eoa", }, - }); - if (result.error) { - throw new Error( - `Error sending transaction: ${stringify(result.error)}`, - ); - } + }, + headers: { + "x-wallet-access-token": authToken, + }, + }); - const txId = result.data?.result.transactions[0]?.id; - console.log(txId); - if (!txId) { - throw new Error("No transaction ID found"); - } + if (signResult.error) { + throw new Error(`Error signing message: ${stringify(signResult.error)}`); + } - const tx = await waitForTransactionHash({ - client: TEST_CLIENT, - transactionId: txId, - }); + const signatureResult = signResult.data?.result?.[0]; + if (signatureResult && isSuccessResponse(signatureResult)) { + expect(signatureResult.result.signature).toEqual(rawSignature); + } else { + throw new Error( + `Failed to sign message: ${stringify(signatureResult?.error) || "Unknown error"}`, + ); + } + }); - console.log(tx); - expect(tx.transactionHash).toBeDefined(); + it("should queue a 4337 transaction", async () => { + const body = { + executionOptions: { + chainId: sepolia.id, + from: account.address, + type: "auto" as const, + }, + params: [ + { + data: "0x", + to: account.address, + value: "0", + }, + ], + }; + const result = await sendTransaction({ + body, + headers: { + "x-wallet-access-token": authToken, + }, }); + if (result.error) { + throw new Error(`Error sending transaction: ${stringify(result.error)}`); + } + + const txId = result.data?.result.transactions[0]?.id; + if (!txId) { + throw new Error("No transaction ID found"); + } + + const tx = await waitForTransactionHash({ + client: TEST_CLIENT, + transactionId: txId, + }); + + console.log(tx.transactionHash); + expect(tx.transactionHash).toBeDefined(); }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 655afe3b603..51bfe29a2b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -639,6 +639,9 @@ importers: '@tanstack/react-query': specifier: 5.81.5 version: 5.81.5(react@19.1.0) + '@thirdweb-dev/engine': + specifier: workspace:* + version: link:../../packages/engine class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -7416,7 +7419,6 @@ packages: '@walletconnect/modal@2.7.0': resolution: {integrity: sha512-RQVt58oJ+rwqnPcIvRFeMGKuXb9qkgSmwz4noF8JZGUym3gUAzVs+uW2NQ1Owm9XOJAV+sANrtJ+VoVq1ftElw==} - deprecated: Please follow the migration guide on https://docs.reown.com/appkit/upgrade/wcm '@walletconnect/react-native-compat@2.17.3': resolution: {integrity: sha512-lHKwXKoB0rdDH1ukxUx7o86xosWbttWIHYMZ8tgAQC1k9VH3CZZCoBcHOAAX8iBzyb0n0UP3/9zRrOcJE5nz7Q==} @@ -28994,8 +28996,8 @@ snapshots: '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.8.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.0) eslint-plugin-react: 7.37.5(eslint@8.57.0) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.0) @@ -29014,7 +29016,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1(supports-color@8.1.1) @@ -29025,7 +29027,7 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.10.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) transitivePeerDependencies: - supports-color @@ -29050,18 +29052,18 @@ snapshots: - bluebird - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.8.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -29072,7 +29074,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3