Skip to content

Commit 149ee05

Browse files
committed
Dustin/engine playground (#5507)
## Problem solved FIxes [DASH-495](https://linear.app/thirdweb/issue/DASH-495/engine-playground) Short description of the bug fixed or feature added Added Engine Section to playground -Airdrop -Mint -Webhooks Updated AppSidebar `expanded: false` to show `otherLinks` section - previously this was unseen by users and there was no scroll option Create EngineAPIHeader for Engine CTA and utm link updates Slack [thread](https://thirdwebdev.slack.com/archives/C07U1LM6S2W/p1731687004602369) for initiative <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces new features and updates to the `playground-web` application, including new API routes for minting and airdropping tokens, enhancements to the UI components, and the addition of new environment variables for build processes. ### Detailed summary - Added environment variables for `ENGINE_ACCESS_TOKEN`, `ENGINE_BACKEND_WALLET`, and `ENGINE_URL`. - Introduced new dependencies: `@thirdweb-dev/engine` and `timeago.js`. - Updated `navLinks.ts` to collapse sections and added a new `Engine` section. - Created new pages for `Webhooks`, `Airdrop`, and `Minting` with respective UI components. - Implemented API routes for transaction status, minting, and airdropping. - Enhanced the `EngineAPIHeader` component to display dynamic content. - Added polling functionality for transaction status updates in the `Sponsorship` and `AirdropERC20` components. - Introduced pagination for transaction results in `ClaimTransactionResults` components. > The following files were skipped due to too many changes: `pnpm-lock.yaml` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 4ae50f5 commit 149ee05

File tree

26 files changed

+2539
-156
lines changed

26 files changed

+2539
-156
lines changed

apps/playground-web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@radix-ui/react-tabs": "^1.1.1",
2929
"@radix-ui/react-tooltip": "1.1.4",
3030
"@tanstack/react-query": "5.61.4",
31+
"@thirdweb-dev/engine": "^0.0.16",
3132
"class-variance-authority": "^0.7.1",
3233
"clsx": "^2.1.1",
3334
"lucide-react": "0.461.0",
@@ -41,6 +42,7 @@
4142
"shiki": "1.22.2",
4243
"tailwind-merge": "^2.5.5",
4344
"thirdweb": "workspace:*",
45+
"timeago.js": "^4.0.2",
4446
"use-debounce": "^10.0.4"
4547
},
4648
"devDependencies": {
21.7 KB
Loading
13.7 KB
Loading
21 KB
Binary file not shown.
23.7 KB
Binary file not shown.
32.9 KB
Binary file not shown.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { Engine } from "@thirdweb-dev/engine";
2+
import * as dotenv from "dotenv";
3+
import type { NextRequest } from "next/server";
4+
import { NextResponse } from "next/server";
5+
6+
dotenv.config();
7+
8+
const CHAIN_ID = "84532";
9+
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
10+
11+
console.log("Environment Variables:");
12+
console.log("CHAIN_ID:", CHAIN_ID);
13+
console.log("BACKEND_WALLET_ADDRESS:", BACKEND_WALLET_ADDRESS);
14+
console.log("ENGINE_URL:", process.env.ENGINE_URL);
15+
console.log(
16+
"ACCESS_TOKEN:",
17+
process.env.ENGINE_ACCESS_TOKEN ? "Set" : "Not Set",
18+
);
19+
20+
const engine = new Engine({
21+
url: process.env.ENGINE_URL as string,
22+
accessToken: process.env.ENGINE_ACCESS_TOKEN as string,
23+
});
24+
25+
interface MintResult {
26+
queueId: string;
27+
status: "Queued" | "Sent" | "Mined" | "error";
28+
transactionHash?: string;
29+
blockExplorerUrl?: string;
30+
errorMessage?: string;
31+
toAddress: string;
32+
amount: string;
33+
chainId: number;
34+
network: "Base Sep";
35+
}
36+
37+
export async function POST(req: NextRequest) {
38+
try {
39+
const body = await req.json();
40+
console.log("Request body:", body);
41+
42+
const { contractAddress, data } = body;
43+
if (!Array.isArray(data)) {
44+
return NextResponse.json(
45+
{ error: "Invalid data format" },
46+
{ status: 400 },
47+
);
48+
}
49+
50+
if (data.length === 0) {
51+
return NextResponse.json({ error: "Empty data array" }, { status: 400 });
52+
}
53+
54+
console.log(`Attempting to mint batch to ${data.length} receivers`);
55+
console.log("Using CONTRACT_ADDRESS:", contractAddress);
56+
57+
const res = await engine.erc20.mintBatchTo(
58+
CHAIN_ID,
59+
contractAddress,
60+
BACKEND_WALLET_ADDRESS,
61+
{
62+
data: data.map((item) => ({
63+
toAddress: item.toAddress,
64+
amount: item.amount,
65+
})),
66+
},
67+
);
68+
69+
console.log("Mint batch initiated, queue ID:", res.result.queueId);
70+
const result = await pollToMine(res.result.queueId, data[0]);
71+
return NextResponse.json([result]);
72+
} catch (error: unknown) {
73+
console.error("Error minting ERC20 tokens", error);
74+
return NextResponse.json(
75+
[
76+
{
77+
queueId: "",
78+
status: "error",
79+
errorMessage:
80+
error instanceof Error
81+
? error.message
82+
: "An unknown error occurred",
83+
toAddress: "",
84+
amount: "",
85+
chainId: Number.parseInt(CHAIN_ID),
86+
network: "Base Sep",
87+
},
88+
],
89+
{ status: 500 },
90+
);
91+
}
92+
}
93+
94+
async function pollToMine(
95+
queueId: string,
96+
firstItem: { toAddress: string; amount: string },
97+
): Promise<MintResult> {
98+
let attempts = 0;
99+
const maxAttempts = 10;
100+
101+
while (attempts < maxAttempts) {
102+
try {
103+
const status = await engine.transaction.status(queueId);
104+
105+
if (status.result.status === "mined") {
106+
console.log(
107+
"Transaction mined! 🥳 ERC20 tokens have been minted",
108+
queueId,
109+
);
110+
const transactionHash = status.result.transactionHash;
111+
const blockExplorerUrl = `https://base-sepolia.blockscout.com/tx/${transactionHash}`;
112+
console.log("View transaction on the blockexplorer:", blockExplorerUrl);
113+
return {
114+
queueId,
115+
status: "Mined",
116+
transactionHash: transactionHash ?? undefined,
117+
blockExplorerUrl: blockExplorerUrl,
118+
toAddress: firstItem.toAddress,
119+
amount: firstItem.amount,
120+
chainId: Number.parseInt(CHAIN_ID),
121+
network: "Base Sep",
122+
};
123+
}
124+
125+
if (status.result.status === "errored") {
126+
console.error("Mint failed", queueId);
127+
console.error(status.result.errorMessage);
128+
return {
129+
queueId,
130+
status: "error",
131+
errorMessage: status.result.errorMessage ?? "Unknown error occurred",
132+
toAddress: firstItem.toAddress,
133+
amount: firstItem.amount,
134+
chainId: Number.parseInt(CHAIN_ID),
135+
network: "Base Sep",
136+
};
137+
}
138+
} catch (error) {
139+
console.error("Error checking transaction status:", error);
140+
}
141+
142+
attempts++;
143+
await new Promise((resolve) => setTimeout(resolve, 5000));
144+
}
145+
146+
return {
147+
queueId,
148+
status: "error",
149+
errorMessage: "Transaction did not mine within the expected time",
150+
toAddress: firstItem.toAddress,
151+
amount: firstItem.amount,
152+
chainId: Number.parseInt(CHAIN_ID),
153+
network: "Base Sep",
154+
};
155+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { Engine } from "@thirdweb-dev/engine";
2+
import * as dotenv from "dotenv";
3+
import type { NextRequest } from "next/server";
4+
import { NextResponse } from "next/server";
5+
6+
dotenv.config();
7+
8+
const BASESEP_CHAIN_ID = "84532";
9+
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
10+
11+
console.log("Environment Variables:");
12+
console.log("CHAIN_ID:", BASESEP_CHAIN_ID);
13+
console.log("BACKEND_WALLET_ADDRESS:", BACKEND_WALLET_ADDRESS);
14+
console.log("ENGINE_URL:", process.env.ENGINE_URL);
15+
console.log(
16+
"ACCESS_TOKEN:",
17+
process.env.ENGINE_ACCESS_TOKEN ? "Set" : "Not Set",
18+
);
19+
20+
const engine = new Engine({
21+
url: process.env.ENGINE_URL as string,
22+
accessToken: process.env.ENGINE_ACCESS_TOKEN as string,
23+
});
24+
25+
type TransactionStatus = "Queued" | "Sent" | "Mined" | "error";
26+
27+
interface ClaimResult {
28+
queueId: string;
29+
status: TransactionStatus;
30+
transactionHash?: string | undefined | null;
31+
blockExplorerUrl?: string | undefined | null;
32+
errorMessage?: string;
33+
toAddress?: string;
34+
amount?: string;
35+
chainId?: string;
36+
timestamp?: number;
37+
}
38+
39+
// Store ongoing polling processes
40+
const pollingProcesses = new Map<string, NodeJS.Timeout>();
41+
42+
// Helper function to make a single claim
43+
async function makeClaimRequest(
44+
chainId: string,
45+
contractAddress: string,
46+
data: {
47+
recipient: string;
48+
quantity: number;
49+
},
50+
): Promise<ClaimResult> {
51+
try {
52+
// Validate the recipient address format
53+
if (!data.recipient.match(/^0x[a-fA-F0-9]{40}$/)) {
54+
throw new Error("Invalid wallet address format");
55+
}
56+
57+
const res = await engine.erc721.claimTo(
58+
chainId,
59+
contractAddress,
60+
BACKEND_WALLET_ADDRESS,
61+
{
62+
receiver: data.recipient.toString(),
63+
quantity: data.quantity.toString(),
64+
txOverrides: {
65+
gas: "530000",
66+
maxFeePerGas: "1000000000",
67+
maxPriorityFeePerGas: "1000000000",
68+
},
69+
},
70+
);
71+
72+
const initialResponse: ClaimResult = {
73+
queueId: res.result.queueId,
74+
status: "Queued",
75+
toAddress: data.recipient,
76+
amount: data.quantity.toString(),
77+
chainId,
78+
timestamp: Date.now(),
79+
};
80+
81+
startPolling(res.result.queueId);
82+
return initialResponse;
83+
} catch (error) {
84+
console.error("Claim request error:", error);
85+
throw error;
86+
}
87+
}
88+
89+
export async function POST(req: NextRequest) {
90+
try {
91+
const body = await req.json();
92+
93+
if (!body.receiver || !body.quantity || !body.contractAddress) {
94+
return NextResponse.json(
95+
{ error: "Missing receiver, quantity, or contract address" },
96+
{ status: 400 },
97+
);
98+
}
99+
100+
// Validate contract address format
101+
if (!body.contractAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
102+
return NextResponse.json(
103+
{ error: "Invalid contract address format" },
104+
{ status: 400 },
105+
);
106+
}
107+
108+
const result = await makeClaimRequest(
109+
BASESEP_CHAIN_ID,
110+
body.contractAddress,
111+
{
112+
recipient: body.receiver,
113+
quantity: Number.parseInt(body.quantity),
114+
},
115+
);
116+
117+
return NextResponse.json({ result });
118+
} catch (error) {
119+
console.error("API Error:", error);
120+
return NextResponse.json(
121+
{
122+
error:
123+
error instanceof Error ? error.message : "Unknown error occurred",
124+
details: error instanceof Error ? error.stack : undefined,
125+
},
126+
{ status: 400 },
127+
);
128+
}
129+
}
130+
131+
function startPolling(queueId: string) {
132+
const maxPollingTime = 5 * 60 * 1000; // 5 minutes timeout
133+
const startTime = Date.now();
134+
135+
const pollingInterval = setInterval(async () => {
136+
try {
137+
// Check if we've exceeded the maximum polling time
138+
if (Date.now() - startTime > maxPollingTime) {
139+
clearInterval(pollingInterval);
140+
pollingProcesses.delete(queueId);
141+
console.log(`Polling timeout for queue ID: ${queueId}`);
142+
return;
143+
}
144+
145+
const result = await pollToMine(queueId);
146+
if (result.status === "Mined" || result.status === "error") {
147+
clearInterval(pollingInterval);
148+
pollingProcesses.delete(queueId);
149+
console.log("Final result:", result);
150+
}
151+
} catch (error) {
152+
console.error("Error in polling process:", error);
153+
clearInterval(pollingInterval);
154+
pollingProcesses.delete(queueId);
155+
}
156+
}, 1500);
157+
158+
pollingProcesses.set(queueId, pollingInterval);
159+
}
160+
161+
async function pollToMine(queueId: string): Promise<ClaimResult> {
162+
console.log(`Polling for queue ID: ${queueId}`);
163+
const status = await engine.transaction.status(queueId);
164+
console.log(`Current status: ${status.result.status}`);
165+
166+
switch (status.result.status) {
167+
case "queued":
168+
console.log("Transaction is queued");
169+
return { queueId, status: "Queued" };
170+
case "sent":
171+
console.log("Transaction is submitted to the network");
172+
return { queueId, status: "Sent" };
173+
case "mined": {
174+
console.log(
175+
"Transaction mined! 🥳 ERC721 token has been claimed",
176+
queueId,
177+
);
178+
const transactionHash = status.result.transactionHash;
179+
const blockExplorerUrl =
180+
status.result.chainId === BASESEP_CHAIN_ID
181+
? `https://base-sepolia.blockscout.com/tx/${transactionHash}`
182+
: "";
183+
console.log("View transaction on the blockexplorer:", blockExplorerUrl);
184+
return {
185+
queueId,
186+
status: "Mined",
187+
transactionHash: transactionHash ?? undefined,
188+
blockExplorerUrl: blockExplorerUrl,
189+
};
190+
}
191+
case "errored":
192+
console.error("Claim failed", queueId);
193+
console.error(status.result.errorMessage);
194+
return {
195+
queueId,
196+
status: "error",
197+
errorMessage: status.result.errorMessage || "Transaction failed",
198+
};
199+
default:
200+
return { queueId, status: "Queued" };
201+
}
202+
}
203+
204+
// Add a new endpoint to check the status
205+
export async function GET(req: NextRequest) {
206+
const { searchParams } = new URL(req.url);
207+
const queueId = searchParams.get("queueId");
208+
209+
if (!queueId) {
210+
return NextResponse.json({ error: "Missing queueId" }, { status: 400 });
211+
}
212+
213+
try {
214+
const result = await pollToMine(queueId);
215+
return NextResponse.json(result);
216+
} catch (error) {
217+
console.error("Error checking transaction status:", error);
218+
return NextResponse.json(
219+
{
220+
status: "error" as TransactionStatus,
221+
error: "Failed to check transaction status",
222+
},
223+
{ status: 500 },
224+
);
225+
}
226+
}

0 commit comments

Comments
 (0)