[Playground] Add Engine user transactions example to playground (#7470)

This commit is contained in:
Joaquim Verges
2025-09-05 00:37:54 -07:00
committed by GitHub
parent 4375e7331f
commit acbbf2039f
7 changed files with 547 additions and 118 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@thirdweb-dev/api": patch
---
Update to latest API
@@ -106,6 +106,12 @@ export const headlessComponentsFeatureCards: FeatureCardMetadata[] = [
];
export const transactionsFeatureCards: FeatureCardMetadata[] = [
{
icon: UserIcon,
title: "From User Wallets",
link: "/transactions/users",
description: "Transactions from user wallets with monitoring and retries.",
},
{
icon: PlaneIcon,
title: "Airdrop Tokens",
+4
View File
@@ -219,6 +219,10 @@ const transactions: ShadcnSidebarLink = {
href: "/transactions",
exactMatch: true,
},
{
href: "/transactions/users",
label: "From User Wallets",
},
{
href: "/transactions/airdrop-tokens",
label: "Airdrop Tokens",
@@ -0,0 +1,101 @@
import { User2Icon } from "lucide-react";
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 (
<ThirdwebProvider>
<PageLayout
icon={User2Icon}
description={
<>Transactions from user wallets with monitoring and retries.</>
}
docsLink="https://portal.thirdweb.com/transactions?utm_source=playground"
title="User Transactions"
>
<UserTransactions />
</PageLayout>
</ThirdwebProvider>
);
}
function UserTransactions() {
return (
<>
<CodeExample
code={`\
import { inAppWallet } from "thirdweb/wallets/in-app";
import { ConnectButton, useActiveWallet } from "thirdweb/react";
const wallet = inAppWallet();
function App() {
const activeWallet = useActiveWallet();
const handleClick = async () => {
const walletAddress = activeWallet?.getAccount()?.address;
const authToken = activeWallet?.getAuthToken?.();
// transactions are a simple POST request to the thirdweb API
// or use the @thirdweb-dev/api 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": "<your-project-client-id>",
// uses the in-app wallet's auth token to authenticate the request
"Authorization": "Bearer " + authToken,
},
body: JSON.stringify({
from: walletAddress,
chainId: "84532",
calls: [
{
contractAddress: "0x...",
method: "function claim(address to, uint256 amount)",
params: [walletAddress, "1"],
},
],
}),
});
};
return (
<>
<ConnectButton
client={client}
wallet={[wallet]}
connectButton={{
label: "Login to mint!",
}}
/>
<Button
onClick={handleClick}
>
Mint
</Button>
</>
);
}`}
header={{
description:
"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={<GatewayPreview />}
/>
</>
);
}
@@ -0,0 +1,292 @@
"use client";
import { useMutation, 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 = `http://${process.env.NEXT_PUBLIC_API_URL}`;
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 <Badge variant="warning">Queued</Badge>;
case "SUBMITTED":
return <Badge variant="default">Submitted</Badge>;
case "CONFIRMED":
return <Badge variant="success">Confirmed</Badge>;
case "FAILED":
return <Badge variant="destructive">Failed</Badge>;
default:
return <Badge variant="outline">{"Unknown"}</Badge>;
}
};
const renderTransactionHash = () => {
if (!txStatus) return "-";
const execStatus = txStatus?.status;
let txHash: string | undefined;
if (execStatus === "CONFIRMED") {
txHash = txStatus.transactionHash;
}
if (txHash && chain.blockExplorers?.[0]?.url) {
return (
<a
className="text-blue-500 hover:text-blue-700 underline font-mono text-sm"
href={`${chain.blockExplorers[0].url}/tx/${txHash}`}
rel="noopener noreferrer"
target="_blank"
>
{txHash.slice(0, 6)}...{txHash.slice(-4)}
</a>
);
}
return txHash ? (
<span className="font-mono text-sm">
{txHash.slice(0, 6)}...{txHash.slice(-4)}
</span>
) : (
"-"
);
};
return (
<TableRow>
<TableCell className="font-mono text-sm">
{transactionId.slice(0, 8)}...{transactionId.slice(-4)}
</TableCell>
<TableCell>
{isLoading || !txStatus.executionResult?.status ? (
<Badge variant="warning">Queued</Badge>
) : (
getStatusBadge(txStatus.executionResult?.status)
)}
</TableCell>
<TableCell>{renderTransactionHash()}</TableCell>
</TableRow>
);
}
export function GatewayPreview() {
const [txIds, setTxIds] = useState<string[]>([]);
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],
});
const sendTransactionMutation = useMutation({
mutationFn: async () => {
if (!preparedTx || !activeEOA) {
throw new Error("Missing required data");
}
const response = await fetch(`${url}/v1/transactions`, {
body: stringify({
from: activeEOA.address,
chainId: baseSepolia.id,
transactions: [preparedTx],
}),
headers: {
Authorization: `Bearer ${activeWallet?.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");
}
return txId;
},
onSuccess: (txId) => {
setTxIds((prev) => [...prev, txId]);
},
});
if (activeEOA && activeWallet && activeWallet?.id !== iaw.id) {
return (
<div className="flex flex-col items-center justify-center gap-4">
Please connect with an in-app wallet for this example
<Button
onClick={() => {
disconnect(activeWallet);
}}
>
Disconnect current wallet
</Button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-4">
{isNftLoading ? (
<div className="mt-24 w-full">Loading...</div>
) : (
<>
<div className="flex flex-col justify-center gap-2 p-2">
<ConnectButton
chain={chain}
client={THIRDWEB_CLIENT}
connectButton={{
label: "Login to mint!",
}}
wallets={[iaw]}
/>
</div>
{nft ? (
<MediaRenderer
client={THIRDWEB_CLIENT}
src={nft.metadata.image}
style={{ marginTop: "10px", width: "300px" }}
/>
) : null}
{activeEOA ? (
<div className="flex flex-col justify-center gap-4 p-2">
<p className="mb-2 text-center font-semibold">
You own {ownedNfts?.[0]?.quantityOwned.toString() || "0"}{" "}
{nft?.metadata?.name}
</p>
<Button
disabled={
sendTransactionMutation.isPending || !preparedTx || !activeEOA
}
onClick={() => sendTransactionMutation.mutate()}
>
{sendTransactionMutation.isPending ? "Minting..." : "Mint NFT"}
</Button>
</div>
) : null}
{txIds.length > 0 && (
<div className="w-full max-w-2xl">
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead>Tx ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>TX Hash</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{txIds.map((txId) => (
<TransactionRow key={txId} transactionId={txId} />
))}
</TableBody>
</Table>
</TableContainer>
</div>
)}
</>
)}
</div>
);
}
@@ -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!",
});
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[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 queue a 7702 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)}`,
);
}
const txId = result.data?.result.transactions[0]?.id;
console.log(txId);
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();
},
headers: {
"x-wallet-access-token": authToken,
},
});
if (signResult.error) {
throw new Error(`Error signing message: ${stringify(signResult.error)}`);
}
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"}`,
);
}
});
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();
});
});
+47 -10
View File
@@ -7581,7 +7581,6 @@ packages:
'@walletconnect/[email protected]':
resolution: {integrity: sha512-RQVt58oJ+rwqnPcIvRFeMGKuXb9qkgSmwz4noF8JZGUym3gUAzVs+uW2NQ1Owm9XOJAV+sANrtJ+VoVq1ftElw==}
deprecated: Please follow the migration guide on https://docs.reown.com/appkit/upgrade/wcm
'@walletconnect/[email protected]':
resolution: {integrity: sha512-lHKwXKoB0rdDH1ukxUx7o86xosWbttWIHYMZ8tgAQC1k9VH3CZZCoBcHOAAX8iBzyb0n0UP3/9zRrOcJE5nz7Q==}
@@ -16330,10 +16329,10 @@ snapshots:
dependencies:
'@aws-crypto/sha256-browser': 3.0.0
'@aws-crypto/sha256-js': 3.0.0
'@aws-sdk/client-sso-oidc': 3.592.0
'@aws-sdk/client-sso-oidc': 3.592.0(@aws-sdk/[email protected])
'@aws-sdk/client-sts': 3.592.0
'@aws-sdk/core': 3.592.0
'@aws-sdk/credential-provider-node': 3.592.0(@aws-sdk/[email protected])(@aws-sdk/[email protected])
'@aws-sdk/credential-provider-node': 3.592.0(@aws-sdk/[email protected](@aws-sdk/[email protected]))(@aws-sdk/[email protected])
'@aws-sdk/middleware-host-header': 3.577.0
'@aws-sdk/middleware-logger': 3.577.0
'@aws-sdk/middleware-recursion-detection': 3.577.0
@@ -16376,10 +16375,10 @@ snapshots:
dependencies:
'@aws-crypto/sha256-browser': 3.0.0
'@aws-crypto/sha256-js': 3.0.0
'@aws-sdk/client-sso-oidc': 3.592.0
'@aws-sdk/client-sso-oidc': 3.592.0(@aws-sdk/[email protected])
'@aws-sdk/client-sts': 3.592.0
'@aws-sdk/core': 3.592.0
'@aws-sdk/credential-provider-node': 3.592.0(@aws-sdk/[email protected])(@aws-sdk/[email protected])
'@aws-sdk/credential-provider-node': 3.592.0(@aws-sdk/[email protected](@aws-sdk/[email protected]))(@aws-sdk/[email protected])
'@aws-sdk/middleware-host-header': 3.577.0
'@aws-sdk/middleware-logger': 3.577.0
'@aws-sdk/middleware-recursion-detection': 3.577.0
@@ -16422,10 +16421,10 @@ snapshots:
dependencies:
'@aws-crypto/sha256-browser': 3.0.0
'@aws-crypto/sha256-js': 3.0.0
'@aws-sdk/client-sso-oidc': 3.592.0
'@aws-sdk/client-sso-oidc': 3.592.0(@aws-sdk/[email protected])
'@aws-sdk/client-sts': 3.592.0
'@aws-sdk/core': 3.592.0
'@aws-sdk/credential-provider-node': 3.592.0(@aws-sdk/[email protected])(@aws-sdk/[email protected])
'@aws-sdk/credential-provider-node': 3.592.0(@aws-sdk/[email protected](@aws-sdk/[email protected]))(@aws-sdk/[email protected])
'@aws-sdk/middleware-host-header': 3.577.0
'@aws-sdk/middleware-logger': 3.577.0
'@aws-sdk/middleware-recursion-detection': 3.577.0
@@ -16469,7 +16468,7 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/[email protected]':
'@aws-sdk/[email protected](@aws-sdk/[email protected])':
dependencies:
'@aws-crypto/sha256-browser': 3.0.0
'@aws-crypto/sha256-js': 3.0.0
@@ -16512,6 +16511,7 @@ snapshots:
'@smithy/util-utf8': 3.0.0
tslib: 2.8.1
transitivePeerDependencies:
- '@aws-sdk/client-sts'
- aws-crt
'@aws-sdk/[email protected]':
@@ -16648,7 +16648,7 @@ snapshots:
dependencies:
'@aws-crypto/sha256-browser': 3.0.0
'@aws-crypto/sha256-js': 3.0.0
'@aws-sdk/client-sso-oidc': 3.592.0
'@aws-sdk/client-sso-oidc': 3.592.0(@aws-sdk/[email protected])
'@aws-sdk/core': 3.592.0
'@aws-sdk/credential-provider-node': 3.592.0(@aws-sdk/[email protected])(@aws-sdk/[email protected])
'@aws-sdk/middleware-host-header': 3.577.0
@@ -16767,6 +16767,24 @@ snapshots:
'@smithy/util-stream': 4.2.3
tslib: 2.8.1
'@aws-sdk/[email protected](@aws-sdk/[email protected](@aws-sdk/[email protected]))(@aws-sdk/[email protected])':
dependencies:
'@aws-sdk/client-sts': 3.592.0
'@aws-sdk/credential-provider-env': 3.587.0
'@aws-sdk/credential-provider-http': 3.587.0
'@aws-sdk/credential-provider-process': 3.587.0
'@aws-sdk/credential-provider-sso': 3.592.0(@aws-sdk/[email protected])
'@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/[email protected])
'@aws-sdk/types': 3.577.0
'@smithy/credential-provider-imds': 3.2.8
'@smithy/property-provider': 3.1.11
'@smithy/shared-ini-file-loader': 3.1.12
'@smithy/types': 3.7.2
tslib: 2.8.1
transitivePeerDependencies:
- '@aws-sdk/client-sso-oidc'
- aws-crt
'@aws-sdk/[email protected](@aws-sdk/[email protected])(@aws-sdk/[email protected])':
dependencies:
'@aws-sdk/client-sts': 3.592.0
@@ -16821,6 +16839,25 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/[email protected](@aws-sdk/[email protected](@aws-sdk/[email protected]))(@aws-sdk/[email protected])':
dependencies:
'@aws-sdk/credential-provider-env': 3.587.0
'@aws-sdk/credential-provider-http': 3.587.0
'@aws-sdk/credential-provider-ini': 3.592.0(@aws-sdk/[email protected](@aws-sdk/[email protected]))(@aws-sdk/[email protected])
'@aws-sdk/credential-provider-process': 3.587.0
'@aws-sdk/credential-provider-sso': 3.592.0(@aws-sdk/[email protected])
'@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/[email protected])
'@aws-sdk/types': 3.577.0
'@smithy/credential-provider-imds': 3.2.8
'@smithy/property-provider': 3.1.11
'@smithy/shared-ini-file-loader': 3.1.12
'@smithy/types': 3.7.2
tslib: 2.8.1
transitivePeerDependencies:
- '@aws-sdk/client-sso-oidc'
- '@aws-sdk/client-sts'
- aws-crt
'@aws-sdk/[email protected](@aws-sdk/[email protected])(@aws-sdk/[email protected])':
dependencies:
'@aws-sdk/credential-provider-env': 3.587.0
@@ -17094,7 +17131,7 @@ snapshots:
'@aws-sdk/[email protected](@aws-sdk/[email protected])':
dependencies:
'@aws-sdk/client-sso-oidc': 3.592.0
'@aws-sdk/client-sso-oidc': 3.592.0(@aws-sdk/[email protected])
'@aws-sdk/types': 3.577.0
'@smithy/property-provider': 3.1.11
'@smithy/shared-ini-file-loader': 3.1.12