Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8003e93f19 | ||
|
|
51c8318df7 | ||
|
|
f043f1eef4 | ||
|
|
d68da2239b | ||
|
|
6a6b0fba96 | ||
|
|
c934102c51 | ||
|
|
8c58c131ac | ||
|
|
2779afc15f | ||
|
|
fe50abd5ab | ||
|
|
e830109274 | ||
|
|
e7f42e6ac5 | ||
|
|
91ff13b8aa | ||
|
|
47354d5a19 | ||
|
|
8280efb991 | ||
|
|
5f11ae0cd4 | ||
|
|
c9a0c23c6c | ||
|
|
43b0680124 | ||
|
|
cccbde9a2e | ||
|
|
85a6afc377 | ||
|
|
e74aca9c90 | ||
|
|
b0cac940bd | ||
|
|
1a19e14845 | ||
|
|
464af6a54c | ||
|
|
f3c55d7139 | ||
|
|
9545b43675 | ||
|
|
869bbcfa68 | ||
|
|
35c7d21e36 | ||
|
|
edf3aad8c4 | ||
|
|
ab4190e23a | ||
|
|
9086cdba20 | ||
|
|
0082e996ca | ||
|
|
28955e0ac5 | ||
|
|
05550d045f | ||
|
|
a0c45cb25d | ||
|
|
6fc4d838ff | ||
|
|
fe940b3c66 | ||
|
|
080d7f2122 | ||
|
|
ae75073a4a | ||
|
|
781a626461 | ||
|
|
f630912b9b | ||
|
|
163ab8afe1 | ||
|
|
62cfbb7441 | ||
|
|
d7104f69f4 | ||
|
|
23545cb999 | ||
|
|
f9283f389b | ||
|
|
9fbb29dc25 | ||
|
|
71a1e58eaf | ||
|
|
68b48f4faa | ||
|
|
1c660d4712 | ||
|
|
30b5c30ba0 | ||
|
|
86461f9a07 | ||
|
|
2ebba6139a | ||
|
|
f3bacc16cb | ||
|
|
968913d55b | ||
|
|
bd70db4a5d | ||
|
|
d9a138a90a | ||
|
|
777aed1e5f | ||
|
|
fbed4be959 | ||
|
|
5a26217a43 | ||
|
|
938c1e2f60 | ||
|
|
af9a73d930 | ||
|
|
34c5d24043 | ||
|
|
e612f16a85 | ||
|
|
4a30b9c89a | ||
|
|
ef9cf3409b | ||
|
|
fd20a6cabb | ||
|
|
9095041ea7 | ||
|
|
2dbb9e2de4 | ||
|
|
00b8adf3a2 | ||
|
|
ae3dfa36c3 | ||
|
|
eb603f8908 | ||
|
|
e11b0da20e | ||
|
|
4c51fe6a8f | ||
|
|
64034c0ddb | ||
|
|
90c1e576ff | ||
|
|
27d5a81c49 | ||
|
|
dbadc23e04 | ||
|
|
21d5d0b6af | ||
|
|
b2239c91c9 |
@@ -2,7 +2,7 @@ name: Auto Author Assign
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review, draft]
|
||||
types: [opened, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = [
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/payments",
|
||||
"/payments/x402",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"ogl": "^1.0.11",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.1",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
|
||||
interface VaultWalletListItem {
|
||||
id: string;
|
||||
address: string;
|
||||
metadata?: {
|
||||
label?: string;
|
||||
projectId?: string;
|
||||
teamId?: string;
|
||||
type?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function listProjectServerWallets(params: {
|
||||
managementAccessToken: string;
|
||||
projectId: string;
|
||||
pageSize?: number;
|
||||
}): Promise<ProjectWalletSummary[]> {
|
||||
const { managementAccessToken, projectId, pageSize = 100 } = params;
|
||||
|
||||
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: 0,
|
||||
// @ts-expect-error - Vault SDK expects snake_case pagination fields
|
||||
page_size: pageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data?.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = response.data.items as VaultWalletListItem[];
|
||||
|
||||
return items
|
||||
.filter((item) => {
|
||||
return (
|
||||
item.metadata?.projectId === projectId &&
|
||||
(!item.metadata?.type || item.metadata.type === "server-wallet")
|
||||
);
|
||||
})
|
||||
.map<ProjectWalletSummary>((item) => ({
|
||||
id: item.id,
|
||||
address: item.address,
|
||||
label: item.metadata?.label ?? undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to list project server wallets", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use server";
|
||||
|
||||
import { configure, sendTokens } from "@thirdweb-dev/api";
|
||||
import { THIRDWEB_API_HOST } from "@/constants/urls";
|
||||
|
||||
configure({
|
||||
override: {
|
||||
baseUrl: THIRDWEB_API_HOST,
|
||||
},
|
||||
});
|
||||
|
||||
export async function sendProjectWalletTokens(options: {
|
||||
walletAddress: string;
|
||||
recipientAddress: string;
|
||||
chainId: number;
|
||||
quantityWei: string;
|
||||
publishableKey: string;
|
||||
teamId: string;
|
||||
tokenAddress?: string;
|
||||
secretKey: string;
|
||||
vaultAccessToken?: string;
|
||||
}) {
|
||||
const {
|
||||
walletAddress,
|
||||
recipientAddress,
|
||||
chainId,
|
||||
quantityWei,
|
||||
publishableKey,
|
||||
teamId,
|
||||
tokenAddress,
|
||||
secretKey,
|
||||
vaultAccessToken,
|
||||
} = options;
|
||||
|
||||
if (!secretKey) {
|
||||
return {
|
||||
error: "A project secret key is required to send funds.",
|
||||
ok: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const response = await sendTokens({
|
||||
body: {
|
||||
chainId,
|
||||
from: walletAddress,
|
||||
recipients: [
|
||||
{
|
||||
address: recipientAddress,
|
||||
quantity: quantityWei,
|
||||
},
|
||||
],
|
||||
...(tokenAddress ? { tokenAddress } : {}),
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": publishableKey,
|
||||
"x-secret-key": secretKey,
|
||||
"x-team-id": teamId,
|
||||
...(vaultAccessToken ? { "x-vault-access-token": vaultAccessToken } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error || !response.data) {
|
||||
return {
|
||||
error: response.error || "Failed to submit transfer request.",
|
||||
ok: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
transactionIds: response.data.result?.transactionIds ?? [],
|
||||
} as const;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import Stripe from "stripe";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import {
|
||||
@@ -88,6 +88,9 @@ export async function fetchClientSecret(team: Team) {
|
||||
throw new Error("No customer ID found");
|
||||
}
|
||||
|
||||
// try to get the gclid cookie
|
||||
const gclid = (await cookies()).get("gclid")?.value;
|
||||
|
||||
// Create Checkout Sessions from body params.
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
ui_mode: "embedded",
|
||||
@@ -117,6 +120,8 @@ export async function fetchClientSecret(team: Team) {
|
||||
missing_payment_method: "cancel",
|
||||
},
|
||||
},
|
||||
// if gclid exists, set it as a metadata field so we can attribute the conversion later
|
||||
metadata: gclid ? { gclid } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export function reportContractDeployed(properties: {
|
||||
publisher: string | undefined;
|
||||
contractName: string | undefined;
|
||||
deploymentType?: "asset";
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("contract deployed", properties);
|
||||
}
|
||||
@@ -40,6 +41,7 @@ export function reportContractDeployed(properties: {
|
||||
export function reportContractDeployFailed(properties: {
|
||||
errorMessage: string;
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
publisher: string | undefined;
|
||||
contractName: string | undefined;
|
||||
}) {
|
||||
@@ -171,9 +173,7 @@ export function reportOnboardingCompleted() {
|
||||
*
|
||||
*/
|
||||
export function reportFaucetUsed(properties: { chainId: number }) {
|
||||
posthog.capture("faucet used", {
|
||||
chainId: properties.chainId,
|
||||
});
|
||||
posthog.capture("faucet used", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
@@ -197,12 +197,7 @@ export function reportChainConfigurationAdded(properties: {
|
||||
decimals: number;
|
||||
};
|
||||
}) {
|
||||
posthog.capture("chain configuration added", {
|
||||
chainId: properties.chainId,
|
||||
chainName: properties.chainName,
|
||||
nativeCurrency: properties.nativeCurrency,
|
||||
rpcURLs: properties.rpcURLs,
|
||||
});
|
||||
posthog.capture("chain configuration added", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
@@ -227,12 +222,9 @@ export function reportAssetBuySuccessful(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("asset buy successful", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
posthog.capture("asset buy successful", properties);
|
||||
}
|
||||
|
||||
type TokenSwapParams = {
|
||||
@@ -349,16 +341,12 @@ export function reportTokenSwapCancelled(properties: TokenSwapParams) {
|
||||
*/
|
||||
export function reportAssetBuyFailed(properties: {
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
error: string;
|
||||
}) {
|
||||
posthog.capture("asset buy failed", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
error: properties.error,
|
||||
});
|
||||
posthog.capture("asset buy failed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,14 +359,11 @@ export function reportAssetBuyFailed(properties: {
|
||||
*/
|
||||
export function reportAssetBuyCancelled(properties: {
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset buy cancelled", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
posthog.capture("asset buy cancelled", properties);
|
||||
}
|
||||
|
||||
// Assets Landing Page ----------------------------
|
||||
@@ -423,10 +408,7 @@ export function reportAssetCreationStepConfigured(
|
||||
step: "coin-info" | "token-distribution" | "launch-coin";
|
||||
},
|
||||
) {
|
||||
posthog.capture("asset creation step configured", {
|
||||
assetType: properties.assetType,
|
||||
step: properties.step,
|
||||
});
|
||||
posthog.capture("asset creation step configured", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,11 +422,10 @@ export function reportAssetCreationStepConfigured(
|
||||
export function reportAssetCreationSuccessful(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
contractType: AssetContractType;
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("asset creation successful", {
|
||||
assetType: properties.assetType,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
posthog.capture("asset creation successful", properties);
|
||||
}
|
||||
|
||||
type CoinCreationStep =
|
||||
@@ -466,7 +447,12 @@ type CoinCreationStep =
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetCreationFailed(
|
||||
properties: { contractType: AssetContractType; error: string } & (
|
||||
properties: {
|
||||
contractType: AssetContractType;
|
||||
error: string;
|
||||
is_testnet: boolean | undefined;
|
||||
chainId: number;
|
||||
} & (
|
||||
| {
|
||||
assetType: "nft";
|
||||
step:
|
||||
@@ -481,12 +467,7 @@ export function reportAssetCreationFailed(
|
||||
}
|
||||
),
|
||||
) {
|
||||
posthog.capture("asset creation failed", {
|
||||
assetType: properties.assetType,
|
||||
contractType: properties.contractType,
|
||||
error: properties.error,
|
||||
step: properties.step,
|
||||
});
|
||||
posthog.capture("asset creation failed", properties);
|
||||
}
|
||||
|
||||
type UpsellParams = {
|
||||
@@ -582,6 +563,7 @@ export function reportPaymentLinkBuyFailed(properties: {
|
||||
export function reportAssetPageview(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("asset pageview", properties);
|
||||
}
|
||||
@@ -593,7 +575,10 @@ export function reportAssetPageview(properties: {
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportChainPageview(properties: { chainId: number }) {
|
||||
export function reportChainPageview(properties: {
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("chain pageview", properties);
|
||||
}
|
||||
|
||||
@@ -686,10 +671,7 @@ export function reportProductFeedback(properties: {
|
||||
feedback: string;
|
||||
source: "desktop" | "mobile";
|
||||
}) {
|
||||
posthog.capture("product feedback submitted", {
|
||||
feedback: properties.feedback,
|
||||
source: properties.source,
|
||||
});
|
||||
posthog.capture("product feedback submitted", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -705,3 +687,18 @@ export function reportBridgePageLinkClick(params: {
|
||||
}) {
|
||||
posthog.capture("bridge page link clicked", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track which tokens are users are clicking on in the token list page
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*
|
||||
*/
|
||||
export function reportTokenRowClicked(params: {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
}) {
|
||||
posthog.capture("token row clicked", params);
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ type ActiveStatus = {
|
||||
pay: boolean;
|
||||
inAppWallet: boolean;
|
||||
ecosystemWallet: boolean;
|
||||
engineCloud: boolean;
|
||||
};
|
||||
|
||||
export const isProjectActive = unstable_cache(
|
||||
@@ -479,6 +480,7 @@ export const isProjectActive = unstable_cache(
|
||||
pay: false,
|
||||
rpc: false,
|
||||
sdk: false,
|
||||
engineCloud: false,
|
||||
storage: false,
|
||||
} as ActiveStatus;
|
||||
}
|
||||
@@ -488,7 +490,7 @@ export const isProjectActive = unstable_cache(
|
||||
},
|
||||
["isProjectActive"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
revalidate: 30, // 30 seconds
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
"use server";
|
||||
import type { Address } from "thirdweb";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
|
||||
const UB_BASE_URL = NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST;
|
||||
|
||||
type Webhook = {
|
||||
export type Webhook = {
|
||||
url: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
secret: string;
|
||||
version?: number; // TODO (UB) make this mandatory after migration
|
||||
};
|
||||
|
||||
export async function getWebhooks(props: { clientId: string; teamId: string }) {
|
||||
const authToken = await getAuthToken();
|
||||
export async function getWebhooks(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
@@ -36,28 +36,55 @@ export async function getWebhooks(props: { clientId: string; teamId: string }) {
|
||||
return json.data as Array<Webhook>;
|
||||
}
|
||||
|
||||
export async function createWebhook(props: {
|
||||
export async function getWebhookById(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
webhookId: string;
|
||||
}) {
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as Webhook;
|
||||
}
|
||||
|
||||
export async function createWebhook(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
version?: number;
|
||||
url: string;
|
||||
label: string;
|
||||
secret?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
|
||||
body: JSON.stringify({
|
||||
label: props.label,
|
||||
secret: props.secret,
|
||||
url: props.url,
|
||||
version: props.version,
|
||||
label: params.label,
|
||||
secret: params.secret,
|
||||
url: params.url,
|
||||
version: params.version,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
@@ -67,23 +94,23 @@ export async function createWebhook(props: {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
return (await res.json()) as Webhook;
|
||||
}
|
||||
|
||||
export async function deleteWebhook(props: {
|
||||
export async function deleteWebhook(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
webhookId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/webhooks/${props.webhookId}`,
|
||||
`${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
@@ -94,7 +121,151 @@ export async function deleteWebhook(props: {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateWebhook(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
webhookId: string;
|
||||
authToken: string;
|
||||
body: {
|
||||
version?: number;
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
}) {
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params.body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return json.data as Webhook;
|
||||
}
|
||||
|
||||
export type WebhookSend = {
|
||||
id: string;
|
||||
webhookId: string;
|
||||
webhookUrl: string;
|
||||
createdAt: string;
|
||||
onrampId: string | null;
|
||||
paymentId: string | null;
|
||||
response: string | null;
|
||||
responseStatus: number;
|
||||
status: "PENDING" | "COMPLETED" | "FAILED";
|
||||
success: boolean;
|
||||
transactionId: string | null;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
type WebhookSendsResponse = {
|
||||
data: WebhookSend[];
|
||||
pagination: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getWebhookSends(options: {
|
||||
authToken: string;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
webhookId: string;
|
||||
success?: boolean;
|
||||
}): Promise<WebhookSendsResponse> {
|
||||
const { limit, offset, success, webhookId, authToken } = options;
|
||||
|
||||
const url = new URL(
|
||||
`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/developer/webhook-sends`,
|
||||
);
|
||||
|
||||
url.searchParams.set("webhookId", webhookId);
|
||||
|
||||
if (limit !== undefined) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (success !== undefined) {
|
||||
url.searchParams.set("success", success.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-client-id": options.projectClientId,
|
||||
"x-team-id": options.teamId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorJson = await response.json();
|
||||
throw new Error(errorJson.message);
|
||||
}
|
||||
|
||||
return (await response.json()) as WebhookSendsResponse;
|
||||
}
|
||||
|
||||
export async function resendWebhook(
|
||||
params: {
|
||||
authToken: string;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
} & (
|
||||
| {
|
||||
paymentId: string;
|
||||
}
|
||||
| {
|
||||
onrampId: string;
|
||||
}
|
||||
),
|
||||
) {
|
||||
const url = new URL(
|
||||
`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/developer/webhooks/retry`,
|
||||
);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
"paymentId" in params
|
||||
? { paymentId: params.paymentId }
|
||||
: { onrampId: params.onrampId },
|
||||
),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": params.projectClientId,
|
||||
"x-team-id": params.teamId,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorJson = await response.json();
|
||||
throw new Error(errorJson.message);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type PaymentLink = {
|
||||
@@ -116,17 +287,17 @@ type PaymentLink = {
|
||||
amount: bigint;
|
||||
};
|
||||
|
||||
export async function getPaymentLinks(props: {
|
||||
export async function getPaymentLinks(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}): Promise<Array<PaymentLink>> {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
@@ -159,7 +330,7 @@ export async function getPaymentLinks(props: {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createPaymentLink(props: {
|
||||
export async function createPaymentLink(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
title: string;
|
||||
@@ -171,26 +342,25 @@ export async function createPaymentLink(props: {
|
||||
amount: bigint;
|
||||
purchaseData?: unknown;
|
||||
};
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
|
||||
body: JSON.stringify({
|
||||
title: props.title,
|
||||
imageUrl: props.imageUrl,
|
||||
title: params.title,
|
||||
imageUrl: params.imageUrl,
|
||||
intent: {
|
||||
destinationChainId: props.intent.destinationChainId,
|
||||
destinationTokenAddress: props.intent.destinationTokenAddress,
|
||||
receiver: props.intent.receiver,
|
||||
amount: props.intent.amount.toString(),
|
||||
purchaseData: props.intent.purchaseData,
|
||||
destinationChainId: params.intent.destinationChainId,
|
||||
destinationTokenAddress: params.intent.destinationTokenAddress,
|
||||
receiver: params.intent.receiver,
|
||||
amount: params.intent.amount.toString(),
|
||||
purchaseData: params.intent.purchaseData,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
@@ -207,20 +377,20 @@ export async function createPaymentLink(props: {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deletePaymentLink(props: {
|
||||
export async function deletePaymentLink(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
paymentLinkId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/links/${props.paymentLinkId}`,
|
||||
`${UB_BASE_URL}/v1/developer/links/${encodeURIComponent(params.paymentLinkId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
@@ -231,7 +401,7 @@ export async function deletePaymentLink(props: {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
export type Fee = {
|
||||
@@ -241,14 +411,17 @@ export type Fee = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function getFees(props: { clientId: string; teamId: string }) {
|
||||
const authToken = await getAuthToken();
|
||||
export async function getFees(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
@@ -262,23 +435,23 @@ export async function getFees(props: { clientId: string; teamId: string }) {
|
||||
return json.data as Fee;
|
||||
}
|
||||
|
||||
export async function updateFee(props: {
|
||||
export async function updateFee(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
feeRecipient: string;
|
||||
feeBps: number;
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
|
||||
body: JSON.stringify({
|
||||
feeBps: props.feeBps,
|
||||
feeRecipient: props.feeRecipient,
|
||||
feeBps: params.feeBps,
|
||||
feeRecipient: params.feeRecipient,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
@@ -288,7 +461,7 @@ export async function updateFee(props: {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
export type PaymentsResponse = {
|
||||
@@ -345,36 +518,34 @@ export type Payment = {
|
||||
|
||||
export type BridgePayment = Extract<Payment, { type: BridgePaymentType }>;
|
||||
|
||||
export async function getPayments(props: {
|
||||
export async function getPayments(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
paymentLinkId?: string;
|
||||
limit?: number;
|
||||
authToken: string;
|
||||
offset?: number;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
// Build URL with query parameters if provided
|
||||
const url = new URL(`${UB_BASE_URL}/v1/developer/payments`);
|
||||
|
||||
if (props.limit) {
|
||||
url.searchParams.append("limit", props.limit.toString());
|
||||
if (params.limit) {
|
||||
url.searchParams.append("limit", params.limit.toString());
|
||||
}
|
||||
|
||||
if (props.offset) {
|
||||
url.searchParams.append("offset", props.offset.toString());
|
||||
if (params.offset) {
|
||||
url.searchParams.append("offset", params.offset.toString());
|
||||
}
|
||||
|
||||
if (props.paymentLinkId) {
|
||||
url.searchParams.append("paymentLinkId", props.paymentLinkId);
|
||||
if (params.paymentLinkId) {
|
||||
url.searchParams.append("paymentLinkId", params.paymentLinkId);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
"use client";
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { Mesh, Program, Renderer, Triangle, Vec2 } from "ogl";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const vertex = `
|
||||
attribute vec2 position;
|
||||
void main(){gl_Position=vec4(position,0.0,1.0);}
|
||||
`;
|
||||
|
||||
const fragment = `
|
||||
#ifdef GL_ES
|
||||
precision lowp float;
|
||||
#endif
|
||||
uniform vec2 uResolution;
|
||||
uniform float uTime;
|
||||
uniform float uHueShift;
|
||||
uniform float uNoise;
|
||||
uniform float uScan;
|
||||
uniform float uScanFreq;
|
||||
uniform float uWarp;
|
||||
uniform vec3 uBackgroundColor;
|
||||
uniform float uBackgroundOpacity;
|
||||
uniform float uPatternLightness;
|
||||
#define iTime uTime
|
||||
#define iResolution uResolution
|
||||
|
||||
vec4 buf[8];
|
||||
float rand(vec2 c){return fract(sin(dot(c,vec2(12.9898,78.233)))*43758.5453);}
|
||||
|
||||
mat3 rgb2yiq=mat3(0.299,0.587,0.114,0.596,-0.274,-0.322,0.211,-0.523,0.312);
|
||||
mat3 yiq2rgb=mat3(1.0,0.956,0.621,1.0,-0.272,-0.647,1.0,-1.106,1.703);
|
||||
|
||||
vec3 hueShiftRGB(vec3 col,float deg){
|
||||
vec3 yiq=rgb2yiq*col;
|
||||
float rad=radians(deg);
|
||||
float cosh=cos(rad),sinh=sin(rad);
|
||||
vec3 yiqShift=vec3(yiq.x,yiq.y*cosh-yiq.z*sinh,yiq.y*sinh+yiq.z*cosh);
|
||||
return clamp(yiq2rgb*yiqShift,0.0,1.0);
|
||||
}
|
||||
|
||||
vec4 sigmoid(vec4 x){return 1./(1.+exp(-x));}
|
||||
|
||||
vec4 cppn_fn(vec2 coordinate,float in0,float in1,float in2){
|
||||
buf[6]=vec4(coordinate.x,coordinate.y,0.3948333106474662+in0,0.36+in1);
|
||||
buf[7]=vec4(0.14+in2,sqrt(coordinate.x*coordinate.x+coordinate.y*coordinate.y),0.,0.);
|
||||
buf[0]=mat4(vec4(6.5404263,-3.6126034,0.7590882,-1.13613),vec4(2.4582713,3.1660357,1.2219609,0.06276096),vec4(-5.478085,-6.159632,1.8701609,-4.7742867),vec4(6.039214,-5.542865,-0.90925294,3.251348))*buf[6]+mat4(vec4(0.8473259,-5.722911,3.975766,1.6522468),vec4(-0.24321538,0.5839259,-1.7661959,-5.350116),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(0.21808943,1.1243913,-1.7969975,5.0294676);
|
||||
buf[1]=mat4(vec4(-3.3522482,-6.0612736,0.55641043,-4.4719114),vec4(0.8631464,1.7432913,5.643898,1.6106541),vec4(2.4941394,-3.5012043,1.7184316,6.357333),vec4(3.310376,8.209261,1.1355612,-1.165539))*buf[6]+mat4(vec4(5.24046,-13.034365,0.009859298,15.870829),vec4(2.987511,3.129433,-0.89023495,-1.6822904),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(-5.9457836,-6.573602,-0.8812491,1.5436668);
|
||||
buf[0]=sigmoid(buf[0]);buf[1]=sigmoid(buf[1]);
|
||||
buf[2]=mat4(vec4(-15.219568,8.095543,-2.429353,-1.9381982),vec4(-5.951362,4.3115187,2.6393783,1.274315),vec4(-7.3145227,6.7297835,5.2473326,5.9411426),vec4(5.0796127,8.979051,-1.7278991,-1.158976))*buf[6]+mat4(vec4(-11.967154,-11.608155,6.1486754,11.237008),vec4(2.124141,-6.263192,-1.7050359,-0.7021966),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(-4.17164,-3.2281182,-4.576417,-3.6401186);
|
||||
buf[3]=mat4(vec4(3.1832156,-13.738922,1.879223,3.233465),vec4(0.64300746,12.768129,1.9141049,0.50990224),vec4(-0.049295485,4.4807224,1.4733979,1.801449),vec4(5.0039253,13.000481,3.3991797,-4.5561905))*buf[6]+mat4(vec4(-0.1285731,7.720628,-3.1425676,4.742367),vec4(0.6393625,3.714393,-0.8108378,-0.39174938),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(-1.1811101,-21.621881,0.7851888,1.2329718);
|
||||
buf[2]=sigmoid(buf[2]);buf[3]=sigmoid(buf[3]);
|
||||
buf[4]=mat4(vec4(5.214916,-7.183024,2.7228765,2.6592617),vec4(-5.601878,-25.3591,4.067988,0.4602802),vec4(-10.57759,24.286327,21.102104,37.546658),vec4(4.3024497,-1.9625226,2.3458803,-1.372816))*buf[0]+mat4(vec4(-17.6526,-10.507558,2.2587414,12.462782),vec4(6.265566,-502.75443,-12.642513,0.9112289),vec4(-10.983244,20.741234,-9.701768,-0.7635988),vec4(5.383626,1.4819539,-4.1911616,-4.8444734))*buf[1]+mat4(vec4(12.785233,-16.345072,-0.39901125,1.7955981),vec4(-30.48365,-1.8345358,1.4542528,-1.1118771),vec4(19.872723,-7.337935,-42.941723,-98.52709),vec4(8.337645,-2.7312303,-2.2927687,-36.142323))*buf[2]+mat4(vec4(-16.298317,3.5471997,-0.44300047,-9.444417),vec4(57.5077,-35.609753,16.163465,-4.1534753),vec4(-0.07470326,-3.8656476,-7.0901804,3.1523974),vec4(-12.559385,-7.077619,1.490437,-0.8211543))*buf[3]+vec4(-7.67914,15.927437,1.3207729,-1.6686112);
|
||||
buf[5]=mat4(vec4(-1.4109162,-0.372762,-3.770383,-21.367174),vec4(-6.2103205,-9.35908,0.92529047,8.82561),vec4(11.460242,-22.348068,13.625772,-18.693201),vec4(-0.3429052,-3.9905605,-2.4626114,-0.45033523))*buf[0]+mat4(vec4(7.3481627,-4.3661838,-6.3037653,-3.868115),vec4(1.5462853,6.5488915,1.9701879,-0.58291394),vec4(6.5858274,-2.2180402,3.7127688,-1.3730392),vec4(-5.7973905,10.134961,-2.3395722,-5.965605))*buf[1]+mat4(vec4(-2.5132585,-6.6685553,-1.4029363,-0.16285264),vec4(-0.37908727,0.53738135,4.389061,-1.3024765),vec4(-0.70647055,2.0111287,-5.1659346,-3.728635),vec4(-13.562562,10.487719,-0.9173751,-2.6487076))*buf[2]+mat4(vec4(-8.645013,6.5546675,-6.3944063,-5.5933375),vec4(-0.57783127,-1.077275,36.91025,5.736769),vec4(14.283112,3.7146652,7.1452246,-4.5958776),vec4(2.7192075,3.6021907,-4.366337,-2.3653464))*buf[3]+vec4(-5.9000807,-4.329569,1.2427121,8.59503);
|
||||
buf[4]=sigmoid(buf[4]);buf[5]=sigmoid(buf[5]);
|
||||
buf[6]=mat4(vec4(-1.61102,0.7970257,1.4675229,0.20917463),vec4(-28.793737,-7.1390953,1.5025433,4.656581),vec4(-10.94861,39.66238,0.74318546,-10.095605),vec4(-0.7229728,-1.5483948,0.7301322,2.1687684))*buf[0]+mat4(vec4(3.2547753,21.489103,-1.0194173,-3.3100595),vec4(-3.7316632,-3.3792162,-7.223193,-0.23685838),vec4(13.1804495,0.7916005,5.338587,5.687114),vec4(-4.167605,-17.798311,-6.815736,-1.6451967))*buf[1]+mat4(vec4(0.604885,-7.800309,-7.213122,-2.741014),vec4(-3.522382,-0.12359311,-0.5258442,0.43852118),vec4(9.6752825,-22.853785,2.062431,0.099892326),vec4(-4.3196306,-17.730087,2.5184598,5.30267))*buf[2]+mat4(vec4(-6.545563,-15.790176,-6.0438633,-5.415399),vec4(-43.591583,28.551912,-16.00161,18.84728),vec4(4.212382,8.394307,3.0958717,8.657522),vec4(-5.0237565,-4.450633,-4.4768,-5.5010443))*buf[3]+mat4(vec4(1.6985557,-67.05806,6.897715,1.9004834),vec4(1.8680354,2.3915145,2.5231109,4.081538),vec4(11.158006,1.7294737,2.0738268,7.386411),vec4(-4.256034,-306.24686,8.258898,-17.132736))*buf[4]+mat4(vec4(1.6889864,-4.5852966,3.8534803,-6.3482175),vec4(1.3543309,-1.2640043,9.932754,2.9079645),vec4(-5.2770967,0.07150358,-0.13962056,3.3269649),vec4(28.34703,-4.918278,6.1044083,4.085355))*buf[5]+vec4(6.6818056,12.522166,-3.7075126,-4.104386);
|
||||
buf[7]=mat4(vec4(-8.265602,-4.7027016,5.098234,0.7509808),vec4(8.6507845,-17.15949,16.51939,-8.884479),vec4(-4.036479,-2.3946867,-2.6055532,-1.9866527),vec4(-2.2167742,-1.8135649,-5.9759874,4.8846445))*buf[0]+mat4(vec4(6.7790847,3.5076547,-2.8191125,-2.7028968),vec4(-5.743024,-0.27844876,1.4958696,-5.0517144),vec4(13.122226,15.735168,-2.9397483,-4.101023),vec4(-14.375265,-5.030483,-6.2599335,2.9848232))*buf[1]+mat4(vec4(4.0950394,-0.94011575,-5.674733,4.755022),vec4(4.3809423,4.8310084,1.7425908,-3.437416),vec4(2.117492,0.16342592,-104.56341,16.949184),vec4(-5.22543,-2.994248,3.8350096,-1.9364246))*buf[2]+mat4(vec4(-5.900337,1.7946124,-13.604192,-3.8060522),vec4(6.6583457,31.911177,25.164474,91.81147),vec4(11.840538,4.1503043,-0.7314397,6.768467),vec4(-6.3967767,4.034772,6.1714606,-0.32874924))*buf[3]+mat4(vec4(3.4992442,-196.91893,-8.923708,2.8142626),vec4(3.4806502,-3.1846354,5.1725626,5.1804223),vec4(-2.4009497,15.585794,1.2863957,2.0252278),vec4(-71.25271,-62.441242,-8.138444,0.50670296))*buf[4]+mat4(vec4(-12.291733,-11.176166,-7.3474145,4.390294),vec4(10.805477,5.6337385,-0.9385842,-4.7348723),vec4(-12.869276,-7.039391,5.3029537,7.5436664),vec4(1.4593618,8.91898,3.5101583,5.840625))*buf[5]+vec4(2.2415268,-6.705987,-0.98861027,-2.117676);
|
||||
buf[6]=sigmoid(buf[6]);buf[7]=sigmoid(buf[7]);
|
||||
buf[0]=mat4(vec4(1.6794263,1.3817469,2.9625452,0.),vec4(-1.8834411,-1.4806935,-3.5924516,0.),vec4(-1.3279216,-1.0918057,-2.3124623,0.),vec4(0.2662234,0.23235129,0.44178495,0.))*buf[0]+mat4(vec4(-0.6299101,-0.5945583,-0.9125601,0.),vec4(0.17828953,0.18300213,0.18182953,0.),vec4(-2.96544,-2.5819945,-4.9001055,0.),vec4(1.4195864,1.1868085,2.5176322,0.))*buf[1]+mat4(vec4(-1.2584374,-1.0552157,-2.1688404,0.),vec4(-0.7200217,-0.52666044,-1.438251,0.),vec4(0.15345335,0.15196142,0.272854,0.),vec4(0.945728,0.8861938,1.2766753,0.))*buf[2]+mat4(vec4(-2.4218085,-1.968602,-4.35166,0.),vec4(-22.683098,-18.0544,-41.954372,0.),vec4(0.63792,0.5470648,1.1078634,0.),vec4(-1.5489894,-1.3075932,-2.6444845,0.))*buf[3]+mat4(vec4(-0.49252132,-0.39877754,-0.91366625,0.),vec4(0.95609266,0.7923952,1.640221,0.),vec4(0.30616966,0.15693925,0.8639857,0.),vec4(1.1825981,0.94504964,2.176963,0.))*buf[4]+mat4(vec4(0.35446745,0.3293795,0.59547555,0.),vec4(-0.58784515,-0.48177817,-1.0614829,0.),vec4(2.5271258,1.9991658,4.6846647,0.),vec4(0.13042648,0.08864098,0.30187556,0.))*buf[5]+mat4(vec4(-1.7718065,-1.4033192,-3.3355875,0.),vec4(3.1664357,2.638297,5.378702,0.),vec4(-3.1724713,-2.6107926,-5.549295,0.),vec4(-2.851368,-2.249092,-5.3013067,0.))*buf[6]+mat4(vec4(1.5203838,1.2212278,2.8404984,0.),vec4(1.5210563,1.2651345,2.683903,0.),vec4(2.9789467,2.4364579,5.2347264,0.),vec4(2.2270417,1.8825914,3.8028636,0.))*buf[7]+vec4(-1.5468478,-3.6171484,0.24762098,0.);
|
||||
buf[0]=sigmoid(buf[0]);
|
||||
return vec4(buf[0].x,buf[0].y,buf[0].z,1.);
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fragColor,in vec2 fragCoord){
|
||||
vec2 uv=fragCoord/uResolution.xy*2.-1.;
|
||||
uv.y*=-1.;
|
||||
uv+=uWarp*vec2(sin(uv.y*6.283+uTime*0.5),cos(uv.x*6.283+uTime*0.5))*0.05;
|
||||
fragColor=cppn_fn(uv,0.1*sin(0.3*uTime),0.1*sin(0.69*uTime),0.1*sin(0.44*uTime));
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec4 col;mainImage(col,gl_FragCoord.xy);
|
||||
col.rgb=hueShiftRGB(col.rgb,uHueShift);
|
||||
float scanline_val=sin(gl_FragCoord.y*uScanFreq)*0.5+0.5;
|
||||
col.rgb*=1.-(scanline_val*scanline_val)*uScan;
|
||||
col.rgb+=(rand(gl_FragCoord.xy+uTime)-0.5)*uNoise;
|
||||
|
||||
// Apply lightness control to pattern
|
||||
col.rgb = col.rgb * uPatternLightness;
|
||||
|
||||
// Use background color as base, then add pattern on top
|
||||
vec3 finalColor = uBackgroundColor + col.rgb * uBackgroundOpacity;
|
||||
|
||||
gl_FragColor=vec4(clamp(finalColor,0.0,1.0),1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
hueShift?: number;
|
||||
noiseIntensity?: number;
|
||||
scanlineIntensity?: number;
|
||||
speed?: number;
|
||||
scanlineFrequency?: number;
|
||||
warpAmount?: number;
|
||||
resolutionScale?: number;
|
||||
backgroundColor?: [number, number, number];
|
||||
backgroundOpacity?: number;
|
||||
patternLightness?: number;
|
||||
};
|
||||
|
||||
export function DarkVeil({
|
||||
hueShift = 0,
|
||||
noiseIntensity = 0,
|
||||
scanlineIntensity = 0,
|
||||
speed = 0.5,
|
||||
scanlineFrequency = 0,
|
||||
warpAmount = 0,
|
||||
resolutionScale = 1,
|
||||
backgroundColor = [0, 0, 0],
|
||||
backgroundOpacity = 1,
|
||||
patternLightness = 1.0,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = ref.current as HTMLCanvasElement;
|
||||
const parent = canvas.parentElement as HTMLElement;
|
||||
|
||||
const renderer = new Renderer({
|
||||
dpr: Math.min(window.devicePixelRatio, 2),
|
||||
canvas,
|
||||
});
|
||||
|
||||
const gl = renderer.gl;
|
||||
const geometry = new Triangle(gl);
|
||||
|
||||
const program = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uResolution: { value: new Vec2() },
|
||||
uHueShift: { value: hueShift },
|
||||
uNoise: { value: noiseIntensity },
|
||||
uScan: { value: scanlineIntensity },
|
||||
uScanFreq: { value: scanlineFrequency },
|
||||
uWarp: { value: warpAmount },
|
||||
uBackgroundColor: { value: backgroundColor },
|
||||
uBackgroundOpacity: { value: backgroundOpacity },
|
||||
uPatternLightness: { value: patternLightness },
|
||||
},
|
||||
});
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
|
||||
const resize = () => {
|
||||
const w = parent.clientWidth,
|
||||
h = parent.clientHeight;
|
||||
renderer.setSize(w * resolutionScale, h * resolutionScale);
|
||||
program.uniforms.uResolution.value.set(w, h);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", resize);
|
||||
resize();
|
||||
|
||||
const start = performance.now();
|
||||
let frame = 0;
|
||||
|
||||
const loop = () => {
|
||||
program.uniforms.uTime.value =
|
||||
((performance.now() - start) / 1000) * speed;
|
||||
program.uniforms.uHueShift.value = hueShift;
|
||||
program.uniforms.uNoise.value = noiseIntensity;
|
||||
program.uniforms.uScan.value = scanlineIntensity;
|
||||
program.uniforms.uScanFreq.value = scanlineFrequency;
|
||||
program.uniforms.uWarp.value = warpAmount;
|
||||
program.uniforms.uBackgroundColor.value = backgroundColor;
|
||||
program.uniforms.uBackgroundOpacity.value = backgroundOpacity;
|
||||
program.uniforms.uPatternLightness.value = patternLightness;
|
||||
renderer.render({ scene: mesh });
|
||||
frame = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
loop();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [
|
||||
hueShift,
|
||||
noiseIntensity,
|
||||
scanlineIntensity,
|
||||
speed,
|
||||
scanlineFrequency,
|
||||
warpAmount,
|
||||
resolutionScale,
|
||||
backgroundColor,
|
||||
backgroundOpacity,
|
||||
patternLightness,
|
||||
]);
|
||||
return <canvas ref={ref} className="w-full h-full block bg-background" />;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
tokenAddress: string | undefined;
|
||||
buyAmount: string | undefined;
|
||||
pageType: PageType;
|
||||
isTestnet: boolean | undefined;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [tab, setTab] = useState<"buy" | "swap">("swap");
|
||||
@@ -116,6 +117,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
chainId: props.chain.id,
|
||||
error: errorMessage,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -141,10 +143,11 @@ export function BuyAndSwapEmbed(props: {
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSuccess={(quote) => {
|
||||
onSuccess={({ quote }) => {
|
||||
reportTokenBuySuccessful({
|
||||
buyTokenChainId:
|
||||
quote.type === "buy"
|
||||
@@ -166,6 +169,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -204,7 +208,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
onSuccess={(quote) => {
|
||||
onSuccess={({ quote }) => {
|
||||
reportTokenSwapSuccessful({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
@@ -236,9 +240,10 @@ function TabButton(props: {
|
||||
return (
|
||||
<Button
|
||||
onClick={props.onClick}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-full text-muted-foreground px-5 text-base bg-accent",
|
||||
props.isActive && "text-foreground border-foreground",
|
||||
"rounded-full text-muted-foreground px-4 bg-accent",
|
||||
props.isActive && "text-foreground border-foreground/50",
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { useClipboard } from "@/hooks/useClipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SolanaAddress(props: {
|
||||
address: string;
|
||||
shortenAddress?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const shortenedAddress = useMemo(() => {
|
||||
return props.shortenAddress !== false
|
||||
? `${props.address.slice(0, 4)}...${props.address.slice(-4)}`
|
||||
: props.address;
|
||||
}, [props.address, props.shortenAddress]);
|
||||
|
||||
const lessShortenedAddress = useMemo(() => {
|
||||
return `${props.address.slice(0, 8)}...${props.address.slice(-8)}`;
|
||||
}, [props.address]);
|
||||
|
||||
const { onCopy, hasCopied } = useClipboard(props.address, 2000);
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild tabIndex={-1}>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2 px-0",
|
||||
props.className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="link"
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
|
||||
<span className="cursor-pointer font-mono text-sm">
|
||||
{shortenedAddress}
|
||||
</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-80 border-border"
|
||||
onClick={(e) => {
|
||||
// do not close the hover card when clicking anywhere in the content
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg">Solana Public Key</h3>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
onClick={onCopy}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{hasCopied ? (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
)}
|
||||
{hasCopied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="break-all rounded bg-muted p-3 text-center font-mono text-xs leading-relaxed">
|
||||
{lessShortenedAddress}
|
||||
</p>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
Solana public key for blockchain transactions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function AdvancedSearchInput(props: {
|
||||
value={searchType}
|
||||
onValueChange={(value) => setSearchType(value as SearchType)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] bg-card">
|
||||
<SelectTrigger className="w-[140px] bg-background rounded-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -61,7 +61,7 @@ export function AdvancedSearchInput(props: {
|
||||
<div className="flex flex-1">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="bg-card pl-9 border-r-0 rounded-r-none"
|
||||
className="bg-background pl-9 border-r-0 rounded-r-none rounded-l-full"
|
||||
placeholder={`Search by ${searchType}...`}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -86,7 +86,7 @@ export function AdvancedSearchInput(props: {
|
||||
onClick={handleSearch}
|
||||
variant="outline"
|
||||
disabled={!query.trim() || props.isLoading}
|
||||
className="rounded-l-none gap-2 bg-card disabled:opacity-100"
|
||||
className="rounded-l-none rounded-r-full gap-2 bg-background disabled:opacity-100"
|
||||
>
|
||||
{props.isLoading && <Spinner className="size-4" />}
|
||||
Search
|
||||
|
||||
+22
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, UserIcon } from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
@@ -264,11 +264,24 @@ export function InAppWalletUsersPageContent(
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Top section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col md:flex-row lg:items-center justify-end gap-3">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<UserIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
User Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View and manage your project's users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="w-full lg:w-auto lg:min-w-[320px]">
|
||||
<AdvancedSearchInput
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
@@ -277,7 +290,7 @@ export function InAppWalletUsersPageContent(
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-card"
|
||||
className="gap-2 bg-background rounded-full"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
onClick={downloadCSV}
|
||||
variant="outline"
|
||||
@@ -298,11 +311,11 @@ export function InAppWalletUsersPageContent(
|
||||
data={wallets}
|
||||
isFetched={walletsQuery.isFetched}
|
||||
isPending={walletsQuery.isPending}
|
||||
tableContainerClassName="rounded-b-none"
|
||||
tableContainerClassName="rounded-none border-x-0 border-b-0"
|
||||
title="in-app wallets"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center gap-3 rounded-b-lg border border-t-0 bg-card p-6">
|
||||
<div className="flex justify-center gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={activePage === 1 || walletsQuery.isPending}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: banner is toggled on-demand via API content changes
|
||||
function AnnouncementBannerUI(props: {
|
||||
href: string;
|
||||
label: string;
|
||||
|
||||
@@ -64,7 +64,7 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
<CreateProjectDialogUI
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
const vaultTokens = await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
@@ -74,6 +74,13 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const managementAccessToken = vaultTokens.managementToken?.accessToken;
|
||||
|
||||
if (!managementAccessToken) {
|
||||
throw new Error("Missing management access token for project wallet");
|
||||
}
|
||||
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
@@ -119,12 +119,17 @@ export function useCsvUpload<
|
||||
}
|
||||
Papa.parse(csv, {
|
||||
complete: (results) => {
|
||||
const data = props.csvParser(results.data as T[]);
|
||||
if (!data[0]?.address) {
|
||||
try {
|
||||
const data = props.csvParser(results.data as T[]);
|
||||
if (!data[0]?.address) {
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
setRawData(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
setRawData(data);
|
||||
},
|
||||
header: true,
|
||||
});
|
||||
|
||||
@@ -379,12 +379,27 @@ export function useEngineTransactions(params: {
|
||||
page?: number;
|
||||
status?: EngineStatus;
|
||||
};
|
||||
id?: string;
|
||||
}) {
|
||||
const { instanceUrl, autoUpdate, authToken } = params;
|
||||
const { instanceUrl, autoUpdate, authToken, id } = params;
|
||||
|
||||
return useQuery({
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
if (id) {
|
||||
const res = await fetch(`${instanceUrl}transaction/status/${id}`, {
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
const transaction = (json.result as Transaction) || {};
|
||||
return {
|
||||
transactions: [transaction],
|
||||
totalCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const url = new URL(`${instanceUrl}transaction/get-all`);
|
||||
if (params.queryParams) {
|
||||
for (const key in params.queryParams) {
|
||||
@@ -402,7 +417,6 @@ export function useEngineTransactions(params: {
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return (json.result as TransactionResponse) || {};
|
||||
},
|
||||
queryKey: engineKeys.transactions(instanceUrl, params),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const PROJECT_WALLET_LABEL_SUFFIX = " Wallet";
|
||||
const PROJECT_WALLET_LABEL_MAX_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Builds the default label for a project's primary server wallet.
|
||||
* Ensures a stable naming convention so the wallet can be identified across flows.
|
||||
*/
|
||||
export function getProjectWalletLabel(projectName: string | undefined) {
|
||||
const baseName = projectName?.trim() || "Project";
|
||||
const maxBaseLength = Math.max(
|
||||
1,
|
||||
PROJECT_WALLET_LABEL_MAX_LENGTH - PROJECT_WALLET_LABEL_SUFFIX.length,
|
||||
);
|
||||
|
||||
const normalizedBase =
|
||||
baseName.length > maxBaseLength
|
||||
? baseName.slice(0, maxBaseLength).trimEnd()
|
||||
: baseName;
|
||||
|
||||
return `${normalizedBase}${PROJECT_WALLET_LABEL_SUFFIX}`;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import "server-only";
|
||||
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { getProjectWalletLabel } from "@/lib/project-wallet";
|
||||
|
||||
export type ProjectWalletSummary = {
|
||||
id: string;
|
||||
address: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type VaultWalletListItem = {
|
||||
id: string;
|
||||
address: string;
|
||||
metadata?: {
|
||||
label?: string;
|
||||
projectId?: string;
|
||||
teamId?: string;
|
||||
type?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getProjectWallet(
|
||||
project: Project,
|
||||
): Promise<ProjectWalletSummary | undefined> {
|
||||
const engineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
const managementAccessToken =
|
||||
engineCloudService?.managementAccessToken || undefined;
|
||||
const projectWalletAddress = (
|
||||
engineCloudService as { projectWalletAddress?: string } | undefined
|
||||
)?.projectWalletAddress;
|
||||
|
||||
if (
|
||||
!managementAccessToken ||
|
||||
!NEXT_PUBLIC_THIRDWEB_VAULT_URL ||
|
||||
!projectWalletAddress
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: 0,
|
||||
// @ts-expect-error - SDK expects snake_case for pagination arguments
|
||||
page_size: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items = response.data.items as VaultWalletListItem[] | undefined;
|
||||
|
||||
if (!items?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const defaultLabel = getProjectWalletLabel(project.name);
|
||||
|
||||
const serverWallets = items.filter(
|
||||
(item) => item.metadata?.projectId === project.id,
|
||||
);
|
||||
|
||||
const defaultWallet = serverWallets.find(
|
||||
(item) =>
|
||||
item.address.toLowerCase() === projectWalletAddress.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!defaultWallet) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: defaultWallet.id,
|
||||
address: defaultWallet.address,
|
||||
label: defaultWallet.metadata?.label ?? defaultLabel,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load project wallet", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+1
@@ -8,6 +8,7 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
return (
|
||||
<GridPatternEmbedContainer>
|
||||
<BuyAndSwapEmbed
|
||||
isTestnet={props.chain.testnet}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineDashboardChain(props.chain.chainId, props.chain)}
|
||||
buyAmount={undefined}
|
||||
|
||||
+2
-1
@@ -2,10 +2,11 @@
|
||||
import { reportChainPageview } from "@/analytics/report";
|
||||
import { useEffectOnce } from "@/hooks/useEffectOnce";
|
||||
|
||||
export function ChainPageView(props: { chainId: number }) {
|
||||
export function ChainPageView(props: { chainId: number; is_testnet: boolean }) {
|
||||
useEffectOnce(() => {
|
||||
reportChainPageview({
|
||||
chainId: props.chainId,
|
||||
is_testnet: props.is_testnet,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export default async function ChainPageLayout(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<ChainPageView chainId={chain.chainId} />
|
||||
<ChainPageView chainId={chain.chainId} is_testnet={chain.testnet} />
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
|
||||
+21
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
import type { NFT, ThirdwebClient, ThirdwebContract } from "thirdweb";
|
||||
import { getNFT as getErc721NFT } from "thirdweb/extensions/erc721";
|
||||
import { getNFT as getErc1155NFT } from "thirdweb/extensions/erc1155";
|
||||
@@ -89,7 +89,7 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="flex h-[400px] items-center justify-center py-20">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
@@ -179,15 +179,26 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
<NFTDetailsTab client={contract.client} nft={nft} />
|
||||
)}
|
||||
|
||||
{tabs.map((tb) => {
|
||||
return (
|
||||
tb.title === tab && (
|
||||
<div className="w-full" key={tb.title}>
|
||||
{tb.children}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Spinner className="size-4" />
|
||||
<p className="text-sm text-muted-foreground"> Loading </p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{tabs.map((tb) => {
|
||||
return (
|
||||
tb.title === tab && (
|
||||
<div className="w-full" key={tb.title}>
|
||||
{tb.children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
@@ -5,11 +5,13 @@ import { useEffectOnce } from "@/hooks/useEffectOnce";
|
||||
export function AssetPageView(props: {
|
||||
assetType: "nft" | "coin";
|
||||
chainId: number;
|
||||
is_testnet: boolean;
|
||||
}) {
|
||||
useEffectOnce(() => {
|
||||
reportAssetPageview({
|
||||
assetType: props.assetType,
|
||||
chainId: props.chainId,
|
||||
is_testnet: props.is_testnet,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+3
@@ -122,6 +122,7 @@ export function TokenDropClaim(props: {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
toast.error("Failed to approve spending", {
|
||||
@@ -160,6 +161,7 @@ export function TokenDropClaim(props: {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
toast.error("Failed to buy tokens", {
|
||||
@@ -172,6 +174,7 @@ export function TokenDropClaim(props: {
|
||||
assetType: "coin",
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC20",
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
setStepsUI({
|
||||
|
||||
+12
-1
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getUnixTime, subDays } from "date-fns";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
|
||||
@@ -35,6 +36,11 @@ export function useTokenTransfers(params: {
|
||||
url.searchParams.set("page", params.page.toString());
|
||||
url.searchParams.set("limit", params.limit.toString());
|
||||
url.searchParams.set("clientId", NEXT_PUBLIC_DASHBOARD_CLIENT_ID);
|
||||
const THIRTY_DAYS_AGO = subDays(new Date(), 30);
|
||||
url.searchParams.set(
|
||||
"block_timestamp_from",
|
||||
getUnixTime(THIRTY_DAYS_AGO).toString(),
|
||||
);
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
@@ -46,7 +52,12 @@ export function useTokenTransfers(params: {
|
||||
return data;
|
||||
},
|
||||
queryKey: ["token-transfers", params],
|
||||
refetchInterval: 5000,
|
||||
refetchInterval: (data) => {
|
||||
if (data?.state.error) {
|
||||
return false;
|
||||
}
|
||||
return 5000;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
retryOnMount: false,
|
||||
|
||||
+6
-1
@@ -76,7 +76,11 @@ export async function ERC20PublicPage(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
|
||||
<AssetPageView
|
||||
assetType="coin"
|
||||
chainId={props.chainMetadata.chainId}
|
||||
is_testnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
<PageHeader containerClassName="max-w-7xl" />
|
||||
|
||||
<div className="border-b border-dashed">
|
||||
@@ -199,6 +203,7 @@ function BuyEmbed(props: {
|
||||
tokenAddress={props.clientContract.address}
|
||||
buyAmount={undefined}
|
||||
pageType="asset"
|
||||
isTestnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-1
@@ -18,7 +18,11 @@ export function NFTPublicPageLayout(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<AssetPageView assetType="nft" chainId={props.chainMetadata.chainId} />
|
||||
<AssetPageView
|
||||
assetType="nft"
|
||||
chainId={props.chainMetadata.chainId}
|
||||
is_testnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
<PageHeader containerClassName="max-w-8xl" />
|
||||
<div className="border-b">
|
||||
<div className="container max-w-8xl">
|
||||
|
||||
+4
@@ -120,6 +120,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
console.error(errorMessage);
|
||||
@@ -147,6 +148,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
assetType: "nft",
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
props.onSuccess?.();
|
||||
@@ -163,6 +165,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -180,6 +183,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+4
@@ -77,6 +77,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -103,6 +104,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
assetType: "nft",
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
props.onSuccess?.();
|
||||
@@ -115,6 +117,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -131,6 +134,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+1
@@ -137,6 +137,7 @@ export function SettingsPlatformFees({
|
||||
onChange={(value) =>
|
||||
form.setValue("platform_fee_basis_points", value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
value={form.watch("platform_fee_basis_points")}
|
||||
|
||||
+1
@@ -151,6 +151,7 @@ export function SettingsRoyalties({
|
||||
onChange={(value) =>
|
||||
form.setValue("seller_fee_basis_points", value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
value={form.watch("seller_fee_basis_points")}
|
||||
|
||||
+17
-5
@@ -190,25 +190,37 @@ export async function generateContractLayoutMetadata(params: {
|
||||
.replace("Testnet", "")
|
||||
.trim();
|
||||
|
||||
const title = `${contractDisplayName} | ${cleanedChainName} Smart Contract`;
|
||||
let title = `${contractDisplayName} | ${cleanedChainName} Smart Contract`;
|
||||
let description = "";
|
||||
|
||||
if (isERC721 || isERC1155) {
|
||||
description = `View tokens, source code, transactions, balances, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}.`;
|
||||
} else if (isERC20) {
|
||||
description = `View ERC20 tokens, transactions, balances, source code, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}`;
|
||||
title = `${contractMetadata.name} (${contractMetadata.symbol}) on ${cleanedChainName} | Buy, Swap, Bridge & Price`;
|
||||
description = `Buy, swap & bridge ${contractMetadata.name} (${contractMetadata.symbol}) on ${cleanedChainName} with thirdweb Bridge. View contract address, holders, analytics, transactions and live price.`;
|
||||
} else {
|
||||
description = `View tokens, transactions, balances, source code, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}`;
|
||||
description = `View tokens, transactions, balances, source code, and analytics for the ${contractMetadata.name} smart contract on ${cleanedChainName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
description: description,
|
||||
title: title,
|
||||
openGraph: {
|
||||
description: description,
|
||||
title: title,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
const fallbackTitle = `${shortenIfAddress(params.contractAddress)} | ${params.chainIdOrSlug}`;
|
||||
const fallbackDescription = `View tokens, transactions, balances, source code, and analytics for the smart contract on Chain ID ${params.chainIdOrSlug}`;
|
||||
|
||||
return {
|
||||
description: `View tokens, transactions, balances, source code, and analytics for the smart contract on Chain ID ${params.chainIdOrSlug}`,
|
||||
title: `${shortenIfAddress(params.contractAddress)} | ${params.chainIdOrSlug}`,
|
||||
description: fallbackDescription,
|
||||
title: fallbackTitle,
|
||||
openGraph: {
|
||||
description: fallbackDescription,
|
||||
title: fallbackTitle,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import type { Status } from "thirdweb/bridge";
|
||||
import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { BridgeStatus } from "./bridge-status";
|
||||
|
||||
const meta = {
|
||||
component: BridgeStatus,
|
||||
title: "chain/tx/bridge-status",
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container max-w-6xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof BridgeStatus>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const completedStatus: Status = {
|
||||
status: "COMPLETED",
|
||||
paymentId:
|
||||
"0x8e0a0d9c0254a75a3004a28e0fb69bae7f2d659938ddd461753627270930db60",
|
||||
originAmount: 129935n,
|
||||
destinationAmount: 100000n,
|
||||
transactions: [
|
||||
{
|
||||
chainId: 42161,
|
||||
transactionHash:
|
||||
"0x37daf698878e73590adb2d6f833e810304a9981829ad983fb355245af1183107",
|
||||
},
|
||||
{
|
||||
chainId: 8453,
|
||||
transactionHash:
|
||||
"0xc72abba2761bb48f605125dc12c17b15088303db960816fa12316f0c90796417",
|
||||
},
|
||||
],
|
||||
originChainId: 42161,
|
||||
originTokenAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
||||
originToken: {
|
||||
chainId: 42161,
|
||||
address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
||||
symbol: "USDC",
|
||||
name: "USD Coin",
|
||||
decimals: 6,
|
||||
iconUri: "https://ethereum-optimism.github.io/data/USDC/logo.png",
|
||||
},
|
||||
destinationChainId: 8453,
|
||||
destinationTokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
destinationToken: {
|
||||
chainId: 8453,
|
||||
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
symbol: "USDC",
|
||||
name: "USD Coin",
|
||||
decimals: 6,
|
||||
iconUri:
|
||||
"https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694",
|
||||
},
|
||||
sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
|
||||
receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
|
||||
};
|
||||
|
||||
const pendingStatus: Status = {
|
||||
...completedStatus,
|
||||
status: "PENDING",
|
||||
};
|
||||
|
||||
const failedStatus: Status = {
|
||||
...completedStatus,
|
||||
status: "FAILED",
|
||||
};
|
||||
|
||||
const notFoundStatus: Status = {
|
||||
...completedStatus,
|
||||
status: "NOT_FOUND",
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
export const Completed: Story = {
|
||||
args: {
|
||||
bridgeStatus: completedStatus,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
bridgeStatus: pendingStatus,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
bridgeStatus: failedStatus,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const NotFound: Story = {
|
||||
args: {
|
||||
bridgeStatus: notFoundStatus,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPurchaseData: Story = {
|
||||
args: {
|
||||
bridgeStatus: {
|
||||
...completedStatus,
|
||||
purchaseData: {
|
||||
userId: "68d645b7ded999651272bf1e",
|
||||
credits: 32000,
|
||||
transactionId: "fd2606d1-90df-45c6-bd2c-b19a34764a31",
|
||||
},
|
||||
},
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CodeClient } from "@workspace/ui/components/code/code.client";
|
||||
import { Img } from "@workspace/ui/components/img";
|
||||
import { Spinner } from "@workspace/ui/components/spinner";
|
||||
import { ArrowRightIcon, CircleCheckIcon, CircleXIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
|
||||
import type { Status, Token } from "thirdweb/bridge";
|
||||
import { status } from "thirdweb/bridge";
|
||||
import { toTokens } from "thirdweb/utils";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchChain } from "@/utils/fetchChain";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
|
||||
type PurchaseData = Exclude<Status, { status: "NOT_FOUND" }>["purchaseData"];
|
||||
|
||||
export function BridgeStatus(props: {
|
||||
bridgeStatus: Status;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { bridgeStatus } = props;
|
||||
const purchaseDataString =
|
||||
bridgeStatus.status !== "NOT_FOUND" && bridgeStatus.purchaseData
|
||||
? getPurchaseData(bridgeStatus.purchaseData)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border relative">
|
||||
{bridgeStatus.status === "FAILED" &&
|
||||
bridgeStatus.transactions.length > 0 && (
|
||||
<FailedBridgeStatusContent
|
||||
transactions={bridgeStatus.transactions}
|
||||
client={props.client}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(bridgeStatus.status === "COMPLETED" ||
|
||||
bridgeStatus.status === "PENDING") && (
|
||||
<BridgeStatusContent
|
||||
bridgeStatus={bridgeStatus}
|
||||
client={props.client}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="px-6 lg:px-8 py-7 space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground "> Status </p>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-sm capitalize",
|
||||
bridgeStatus.status === "COMPLETED" && "text-green-500",
|
||||
bridgeStatus.status === "PENDING" && "text-blue-500",
|
||||
bridgeStatus.status === "FAILED" && "text-red-500",
|
||||
)}
|
||||
>
|
||||
{bridgeStatus.status === "COMPLETED" && (
|
||||
<CircleCheckIcon className="size-3.5" />
|
||||
)}
|
||||
{bridgeStatus.status === "PENDING" && (
|
||||
<Spinner className="size-3.5" />
|
||||
)}
|
||||
{bridgeStatus.status === "FAILED" && (
|
||||
<CircleXIcon className="size-3.5" />
|
||||
)}
|
||||
{bridgeStatus.status === "NOT_FOUND" && (
|
||||
<CircleXIcon className="size-3.5" />
|
||||
)}
|
||||
|
||||
{bridgeStatus.status.toLowerCase().replace("_", " ")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground ">Payment ID</p>
|
||||
<CopyTextButton
|
||||
textToCopy={bridgeStatus.paymentId}
|
||||
textToShow={`${bridgeStatus.paymentId.slice(0, 6)}...${bridgeStatus.paymentId.slice(-4)}`}
|
||||
tooltip="Payment ID"
|
||||
className="text-sm translate-x-1.5"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{purchaseDataString && (
|
||||
<div className="px-6 lg:px-8 py-7 space-y-2 border-t border-dashed">
|
||||
<p className="text-sm text-muted-foreground ">Purchase Data</p>
|
||||
<CodeClient
|
||||
code={purchaseDataString}
|
||||
lang="json"
|
||||
className="[&_code]:text-xs"
|
||||
scrollableClassName="p-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenInfo(props: {
|
||||
label: string;
|
||||
addressLabel: string;
|
||||
token: Token;
|
||||
amountWei: bigint | undefined;
|
||||
walletAddress: string;
|
||||
client: ThirdwebClient;
|
||||
txHash: string | undefined;
|
||||
}) {
|
||||
const chainQuery = useChainQuery(props.token.chainId);
|
||||
|
||||
return (
|
||||
<div className="flex-1 pt-10 pb-9 px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{props.label}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="pb-7 pt-6 border-b border-dashed">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative hover:ring-2 hover:ring-offset-2 hover:ring-offset-card hover:ring-foreground/30 rounded-full">
|
||||
<Link
|
||||
href={
|
||||
props.token.address.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? `/${chainQuery.data?.slug || props.token.chainId}`
|
||||
: `/${chainQuery.data?.slug || props.token.chainId}/${props.token.address}`
|
||||
}
|
||||
target="_blank"
|
||||
aria-label="View Token"
|
||||
className="absolute inset-0 z-10"
|
||||
/>
|
||||
|
||||
<Img
|
||||
src={props.token.iconUri}
|
||||
alt={props.token.name}
|
||||
className="size-10 rounded-full"
|
||||
fallback={
|
||||
<div className="size-10 from-blue-500 to-foreground rounded-full bg-gradient-to-b" />
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-0.5 -right-0.5 bg-background p-0.5 rounded-full">
|
||||
<Img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: chainQuery.data?.icon?.url,
|
||||
})}
|
||||
alt={props.token.name}
|
||||
className="size-4 rounded-full"
|
||||
fallback={
|
||||
<div className="size-4 from-blue-500 to-foreground rounded-full bg-gradient-to-b" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-lg text-foreground font-medium tracking-tight leading-none">
|
||||
{props.amountWei ? (
|
||||
<>
|
||||
{toTokens(props.amountWei, props.token.decimals)}{" "}
|
||||
{props.token.symbol}
|
||||
</>
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</p>
|
||||
<SkeletonContainer
|
||||
skeletonData={`Chain ${props.token.chainId}`}
|
||||
loadedData={chainQuery.data?.name}
|
||||
className="[&_*]:flex"
|
||||
render={(data) => (
|
||||
<Link
|
||||
href={`/${chainQuery.data?.slug || props.token.chainId}`}
|
||||
className="text-sm text-muted-foreground leading-none hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{data}
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2.5 justify-between">
|
||||
<p className="text-sm text-muted-foreground ">{props.addressLabel}</p>
|
||||
<WalletAddress
|
||||
address={props.walletAddress}
|
||||
client={props.client}
|
||||
className="py-0.5 h-auto text-sm [&>*]:!font-sans"
|
||||
iconClassName="size-3.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<p className="text-sm text-muted-foreground ">Token Address</p>
|
||||
<CopyTextButton
|
||||
textToCopy={props.token.address}
|
||||
textToShow={`${props.token.address.slice(0, 6)}...${props.token.address.slice(-4)}`}
|
||||
tooltip="Token Address"
|
||||
className="text-sm translate-x-1.5"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<p className="text-sm text-muted-foreground ">Transaction Hash</p>
|
||||
{props.txHash ? (
|
||||
<CopyTextButton
|
||||
textToCopy={props.txHash}
|
||||
textToShow={`${props.txHash.slice(0, 6)}...${props.txHash.slice(-4)}`}
|
||||
tooltip="Transaction Hash"
|
||||
className="text-sm translate-x-1.5"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground ">N/A</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BridgeStatusContent(props: {
|
||||
bridgeStatus: Exclude<Status, { status: "FAILED" } | { status: "NOT_FOUND" }>;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { bridgeStatus } = props;
|
||||
|
||||
const fromTxHash = bridgeStatus.transactions.find(
|
||||
(tx) => tx.chainId === bridgeStatus.originChainId,
|
||||
)?.transactionHash;
|
||||
const toTxHash = bridgeStatus.transactions.find(
|
||||
(tx) => tx.chainId === bridgeStatus.destinationChainId,
|
||||
)?.transactionHash;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 border-b border-dashed">
|
||||
<TokenInfo
|
||||
label="From"
|
||||
token={bridgeStatus.originToken}
|
||||
amountWei={bridgeStatus.originAmount}
|
||||
walletAddress={bridgeStatus.sender}
|
||||
client={props.client}
|
||||
txHash={fromTxHash}
|
||||
addressLabel="Sender"
|
||||
/>
|
||||
|
||||
<div className="border-t lg:border-l lg:border-t-0 border-dashed relative">
|
||||
<div className="p-1.5 rounded-full bg-background absolute top-0 lg:top-1/2 left-1/2 lg:left-0 -translate-y-1/2 -translate-x-1/2 border">
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground rotate-90 lg:rotate-0" />
|
||||
</div>
|
||||
<TokenInfo
|
||||
label="To"
|
||||
token={bridgeStatus.destinationToken}
|
||||
// TODO - destinationAmount is not available in PENDING status, fix it in API
|
||||
amountWei={
|
||||
bridgeStatus.status === "COMPLETED"
|
||||
? bridgeStatus.destinationAmount
|
||||
: undefined
|
||||
}
|
||||
walletAddress={bridgeStatus.receiver}
|
||||
client={props.client}
|
||||
txHash={toTxHash}
|
||||
addressLabel="Receiver"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedBridgeStatusContent(props: {
|
||||
transactions: { chainId: number; transactionHash: string }[];
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-7 space-y-1.5 border-b border-dashed">
|
||||
<h3 className="text-base font-medium tracking-tight mb-3">
|
||||
Transactions
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.transactions.map((tx) => {
|
||||
return (
|
||||
<TxHashRow
|
||||
key={tx.chainId}
|
||||
client={props.client}
|
||||
chainId={tx.chainId}
|
||||
txHash={tx.transactionHash}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useChainQuery(chainId: number | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!chainId,
|
||||
queryKey: ["chain-metadata", chainId],
|
||||
queryFn: async () => {
|
||||
if (!chainId) {
|
||||
return null;
|
||||
}
|
||||
return fetchChain(chainId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function TxHashRow(props: {
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
txHash: string;
|
||||
}) {
|
||||
const chainQuery = useChainQuery(props.chainId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: chainQuery.data?.icon?.url,
|
||||
})}
|
||||
alt={chainQuery.data?.name}
|
||||
className="size-4 rounded-full"
|
||||
fallback={
|
||||
<div className="size-4 from-blue-500 to-foreground rounded-full bg-gradient-to-b" />
|
||||
}
|
||||
/>
|
||||
<SkeletonContainer
|
||||
skeletonData={`Chain ${props.chainId}`}
|
||||
loadedData={chainQuery.data?.name}
|
||||
render={(data) => (
|
||||
<p className="text-sm text-muted-foreground">{data}</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<CopyTextButton
|
||||
textToCopy={props.txHash}
|
||||
textToShow={`${props.txHash.slice(0, 6)}...${props.txHash.slice(-4)}`}
|
||||
tooltip="Copy Transaction Hash"
|
||||
className="text-sm"
|
||||
copyIconPosition="right"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BridgeStatusWithPolling(props: {
|
||||
bridgeStatus: Status;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
transactionHash: string;
|
||||
}) {
|
||||
const bridgeStatusQuery = useQuery({
|
||||
enabled:
|
||||
props.bridgeStatus.status === "PENDING" ||
|
||||
props.bridgeStatus.status === "NOT_FOUND",
|
||||
queryKey: ["bridge-status", props.transactionHash, props.chainId],
|
||||
queryFn: () =>
|
||||
status({
|
||||
chainId: props.chainId,
|
||||
transactionHash: props.transactionHash as `0x${string}`,
|
||||
client: props.client,
|
||||
}),
|
||||
refetchInterval(query) {
|
||||
const status = query.state.data?.status;
|
||||
if (status === "COMPLETED" || status === "FAILED") {
|
||||
return false;
|
||||
}
|
||||
return 5000;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<BridgeStatus
|
||||
bridgeStatus={bridgeStatusQuery.data || props.bridgeStatus}
|
||||
client={props.client}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getPurchaseData(purchaseData: PurchaseData) {
|
||||
try {
|
||||
return JSON.stringify(purchaseData, null, 2);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+352
-126
@@ -1,16 +1,42 @@
|
||||
import { toTokens, ZERO_ADDRESS } from "thirdweb";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { PlainTextCodeBlock } from "@workspace/ui/components/code/plaintext-code";
|
||||
import { ToolTipLabel } from "@workspace/ui/components/tooltip";
|
||||
import { formatDate, formatDistanceToNowStrict, fromUnixTime } from "date-fns";
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
Clock4Icon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import { toTokens } from "thirdweb";
|
||||
import { status } from "thirdweb/bridge";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import {
|
||||
eth_getBlockByHash,
|
||||
eth_getTransactionByHash,
|
||||
eth_getTransactionReceipt,
|
||||
getRpcClient,
|
||||
} from "thirdweb/rpc";
|
||||
import { hexToNumber, shortenAddress, toEther } from "thirdweb/utils";
|
||||
import type { TransactionReceipt } from "thirdweb/transaction";
|
||||
import { hexToNumber, toEther } from "thirdweb/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { getChain } from "../../../utils";
|
||||
import { BridgeStatusWithPolling } from "./bridge-status";
|
||||
import { BridgeAndOverviewTabs } from "./tabs";
|
||||
|
||||
type Transaction = Awaited<ReturnType<typeof eth_getTransactionByHash>>;
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ chain_id: string; txHash: `0x${string}` }>;
|
||||
@@ -25,142 +51,342 @@ export default async function Page(props: {
|
||||
client: serverThirdwebClient,
|
||||
});
|
||||
|
||||
const [transaction, receipt] = await Promise.all([
|
||||
const [transaction, receipt, bridgeStatus] = await Promise.all([
|
||||
eth_getTransactionByHash(rpcRequest, {
|
||||
hash: params.txHash,
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
eth_getTransactionReceipt(rpcRequest, {
|
||||
hash: params.txHash,
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
status({
|
||||
chainId: chain.chainId,
|
||||
transactionHash: params.txHash,
|
||||
client: serverThirdwebClient,
|
||||
}).catch(() => undefined),
|
||||
]);
|
||||
if (!transaction.blockHash) {
|
||||
return <div>no tx found</div>;
|
||||
|
||||
if (!transaction?.blockHash || !receipt) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center grow">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="p-2 rounded-full bg-background border">
|
||||
<CircleAlertIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
Transaction not found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const block = await eth_getBlockByHash(rpcRequest, {
|
||||
blockHash: transaction.blockHash,
|
||||
});
|
||||
|
||||
const overviewContent = (
|
||||
<GeneericTxDetails
|
||||
transaction={transaction}
|
||||
receipt={receipt}
|
||||
block={block}
|
||||
chain={chain}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="container flex flex-col gap-y-10 py-6">
|
||||
<header className="flex flex-col gap-y-2">
|
||||
<p className="font-bold text-3xl">Transaction Details</p>
|
||||
<div className="flex gap-x-2">
|
||||
{shortenAddress(transaction.from)} called on{" "}
|
||||
{shortenAddress(transaction.to || ZERO_ADDRESS)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="pb-20">
|
||||
<div className="flex h-14 items-center border-border border-b">
|
||||
<Breadcrumb className="container max-w-6xl">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/chainlist">Chainlist</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href={`/${chain.slug}`}>
|
||||
{chain.name.replace("Mainnet", "")}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Transaction Details</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<Separator orientation="horizontal" />
|
||||
<div className="container max-w-6xl py-10">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Transaction Details
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Transaction hash</div>
|
||||
<p>{transaction.hash}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
Status {/* and method */}
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
<Badge
|
||||
className={
|
||||
receipt.status === "success" ? "bg-green-500" : "bg-red-500"
|
||||
}
|
||||
>
|
||||
{receipt.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Block</div>
|
||||
<p>{Number(transaction.blockNumber)}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Timestamp</div>
|
||||
<p>
|
||||
{new Date(
|
||||
Number.parseInt(block.timestamp.toString()) * 1000,
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator orientation="horizontal" />
|
||||
|
||||
<section className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">From</div>
|
||||
<p>{transaction.from}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
Interacted with contract
|
||||
</div>
|
||||
<p>{transaction.to}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator orientation="horizontal" />
|
||||
|
||||
<section className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Value</div>
|
||||
<p>{toEther(transaction.value)} ETH</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Transaction fee</div>
|
||||
<p>{toEther((transaction.gasPrice || 0n) * receipt.gasUsed)} ETH</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Gas price</div>
|
||||
<p>{toEther(transaction.gasPrice || 0n)} ETH</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Gas usage</div>
|
||||
<p>{receipt.gasUsed.toString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Gas fees (Gwei)</div>
|
||||
<p>
|
||||
Base: {toTokens(block.baseFeePerGas || 0n, 9)} | Max:{" "}
|
||||
{toTokens(transaction.maxFeePerGas || 0n, 9)} | Max priority:{" "}
|
||||
{transaction.maxPriorityFeePerGas?.toString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Burnt fees</div>
|
||||
<p>{toEther(block.baseFeePerGas || 0n * receipt.gasUsed)}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator orientation="horizontal" />
|
||||
|
||||
<section className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Other</div>
|
||||
<p>
|
||||
Txn type: {hexToNumber(transaction.typeHex || "0x0")} (
|
||||
{transaction.type}) | Nonce: {transaction.nonce} | Position:{" "}
|
||||
{transaction.transactionIndex}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 items-start">
|
||||
<div className="flex items-center gap-x-2">Raw Input:</div>
|
||||
<div className="break-words rounded-lg bg-card p-4 text-sm">
|
||||
{transaction.input}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container max-w-6xl">
|
||||
{bridgeStatus ? (
|
||||
<BridgeAndOverviewTabs
|
||||
bridgeStatus={
|
||||
<BridgeStatusWithPolling
|
||||
bridgeStatus={bridgeStatus}
|
||||
client={getClientThirdwebClient()}
|
||||
chainId={chain.chainId}
|
||||
transactionHash={params.txHash}
|
||||
/>
|
||||
}
|
||||
overview={overviewContent}
|
||||
/>
|
||||
) : (
|
||||
overviewContent
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneericTxDetails(props: {
|
||||
transaction: Transaction;
|
||||
receipt: TransactionReceipt;
|
||||
block: {
|
||||
timestamp: bigint;
|
||||
baseFeePerGas: bigint | null;
|
||||
};
|
||||
chain: ChainMetadata;
|
||||
}) {
|
||||
const { transaction, receipt, chain, block } = props;
|
||||
|
||||
const timestamp = getDatefromTimestamp(block.timestamp);
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 lg:p-6 bg-card">
|
||||
{/* section 1 */}
|
||||
<section className="border-b pb-6 space-y-5 lg:space-y-3">
|
||||
<GridItem
|
||||
label="Transaction hash"
|
||||
tooltip="A TxHash or transaction hash is a unique 66-character identifier that is generated whenever a transaction is executed."
|
||||
>
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.hash}
|
||||
textToShow={transaction.hash}
|
||||
tooltip="Copy transaction hash"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
className="truncate -translate-x-1.5 max-w-full"
|
||||
/>
|
||||
</GridItem>
|
||||
|
||||
<GridItem label="Status" tooltip="The status of the transaction.">
|
||||
<Badge
|
||||
className="py-0.5 gap-1.5 px-1.5 capitalize"
|
||||
variant={receipt.status === "success" ? "success" : "destructive"}
|
||||
>
|
||||
{receipt.status === "success" ? (
|
||||
<CircleCheckIcon className="size-3" />
|
||||
) : (
|
||||
<CircleAlertIcon className="size-3" />
|
||||
)}
|
||||
{receipt.status}
|
||||
</Badge>
|
||||
</GridItem>
|
||||
|
||||
{transaction.blockNumber && (
|
||||
<GridItem
|
||||
label="Block"
|
||||
tooltip="Number of the block in which the transaction is recorded. Block confirmations indicate how many blocks have been added since the transaction was produced."
|
||||
>
|
||||
<p>{Number(transaction.blockNumber)}</p>
|
||||
</GridItem>
|
||||
)}
|
||||
|
||||
{timestamp && (
|
||||
<GridItem
|
||||
label="Timestamp"
|
||||
tooltip="The date and time at which a transaction is validated."
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Clock4Icon className="size-3" />
|
||||
<p>
|
||||
{formatDistanceToNowStrict(timestamp, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
<p>({formatDate(timestamp, "PP pp z")})</p>
|
||||
</div>
|
||||
</GridItem>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* section 2 */}
|
||||
<section className="border-b py-6 space-y-5 lg:space-y-3">
|
||||
<GridItem label="From" tooltip="The sending party of the transaction.">
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.from}
|
||||
textToShow={transaction.from}
|
||||
tooltip="Copy from address"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
className="truncate -translate-x-1.5 max-w-full"
|
||||
/>
|
||||
</GridItem>
|
||||
|
||||
{transaction.to && (
|
||||
<GridItem
|
||||
label="Interacted with (To)"
|
||||
tooltip="The receiving party of the transaction (could be a contract address)."
|
||||
>
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.to}
|
||||
textToShow={transaction.to}
|
||||
tooltip="Copy to address"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
className="truncate -translate-x-1.5 max-w-full"
|
||||
/>
|
||||
</GridItem>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* section 3 */}
|
||||
<section className="py-6 space-y-5 lg:space-y-3 border-b">
|
||||
<GridItem
|
||||
label="Value"
|
||||
tooltip="The value being transacted in ETH and fiat value. Note: You can click the fiat value (if available) to see historical value at the time of transaction."
|
||||
>
|
||||
<p>
|
||||
{toEther(transaction.value)} {chain.nativeCurrency?.symbol}
|
||||
</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Transaction Fee"
|
||||
tooltip="Amount paid to the validator for processing the transaction."
|
||||
>
|
||||
<p>
|
||||
{toEther((transaction.gasPrice || 0n) * receipt.gasUsed)}{" "}
|
||||
{chain.nativeCurrency?.symbol}
|
||||
</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Gas Price"
|
||||
tooltip="The price per gas unit the sender is willing to pay"
|
||||
>
|
||||
<p>
|
||||
{toEther(transaction.gasPrice || 0n)} {chain.nativeCurrency?.symbol}
|
||||
</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Gas Usage"
|
||||
tooltip="The amount of gas used by the transaction."
|
||||
>
|
||||
<p>{receipt.gasUsed.toString()}</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Gas Fees"
|
||||
tooltip="Base Fee refers to the network Base Fee at the time of the block, while Max Fee & Max Priority Fee refer to the max amount a user is willing to pay for their tx & to give to the miner respectively."
|
||||
>
|
||||
<p className="flex gap-2.5">
|
||||
<span>Base: {toTokens(block.baseFeePerGas || 0n, 9)} Gwei</span>
|
||||
|
||||
{transaction.maxFeePerGas && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span>
|
||||
Max: {toTokens(transaction.maxFeePerGas || 0n, 9)} Gwei
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{transaction.maxPriorityFeePerGas && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span>
|
||||
Max priority: {transaction.maxPriorityFeePerGas?.toString()}{" "}
|
||||
Gwei
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Burnt fees"
|
||||
tooltip="The amount of fees that were burnt by the transaction."
|
||||
>
|
||||
<p>
|
||||
{toEther((block.baseFeePerGas || 0n) * receipt.gasUsed)}{" "}
|
||||
{chain.nativeCurrency?.symbol}
|
||||
</p>
|
||||
</GridItem>
|
||||
</section>
|
||||
|
||||
{/* section 4 */}
|
||||
<section className="pt-6 space-y-5 lg:space-y-3">
|
||||
<GridItem
|
||||
label="Other Attributes"
|
||||
tooltip="Other data related to this transaction."
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="gap-1 py-1">
|
||||
<span className="text-muted-foreground">Txn type:</span>
|
||||
<span className="capitalize">
|
||||
{hexToNumber(transaction.typeHex || "0x0")} ({transaction.type})
|
||||
</span>
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1 py-1">
|
||||
<span className="text-muted-foreground">Nonce: </span>
|
||||
<span>{transaction.nonce}</span>
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1 py-1">
|
||||
<span className="text-muted-foreground">Position: </span>
|
||||
<span>{transaction.transactionIndex}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Input Data"
|
||||
tooltip="Additional data included for this transaction. Commonly used as part of contract interaction or as a message sent to the recipient."
|
||||
>
|
||||
<PlainTextCodeBlock
|
||||
code={transaction.input}
|
||||
className="[&_code]:!whitespace-pre-wrap break-all [&_code]:text-xs text-muted-foreground w-full"
|
||||
scrollableClassName="max-h-[400px] p-2 min-h-[80px]"
|
||||
/>
|
||||
</GridItem>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridItem(props: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 lg:grid grid-cols-[300px_1fr] items-start">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ToolTipLabel label={props.tooltip}>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-auto w-auto p-1 hidden lg:block"
|
||||
>
|
||||
<div>
|
||||
<InfoIcon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<p className="text-muted-foreground text-sm">{props.label}</p>
|
||||
</div>
|
||||
<div className="text-sm w-full">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDatefromTimestamp(timestamp: bigint) {
|
||||
try {
|
||||
return fromUnixTime(Number(timestamp));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
|
||||
export function BridgeAndOverviewTabs(props: {
|
||||
bridgeStatus: React.ReactNode;
|
||||
overview: React.ReactNode;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"bridge" | "overview">("bridge");
|
||||
return (
|
||||
<div>
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "Bridge",
|
||||
onClick: () => setTab("bridge"),
|
||||
isActive: tab === "bridge",
|
||||
},
|
||||
{
|
||||
name: "Overview",
|
||||
onClick: () => setTab("overview"),
|
||||
isActive: tab === "overview",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="h-3" />
|
||||
{tab === "bridge" && props.bridgeStatus}
|
||||
{tab === "overview" && props.overview}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+5
@@ -50,6 +50,7 @@ import {
|
||||
} from "@/constants/addresses";
|
||||
import { LAST_USED_PROJECT_ID, LAST_USED_TEAM_ID } from "@/constants/cookie";
|
||||
import { ZERO_FEE_CHAINS } from "@/constants/fee-config";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import {
|
||||
useCustomFactoryAbi,
|
||||
useFunctionParamsFromABI,
|
||||
@@ -206,6 +207,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
|
||||
const activeAccount = useActiveAccount();
|
||||
const walletChain = useActiveWalletChain();
|
||||
const walletChainMetadata = useV5DashboardChain(walletChain?.id);
|
||||
|
||||
const { onError } = useTxNotifications(
|
||||
"Successfully deployed contract",
|
||||
"Failed to deploy contract",
|
||||
@@ -656,6 +659,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
chainId: walletChain.id,
|
||||
contractName: metadata.name,
|
||||
publisher: rewriteTwPublisher(metadata.publisher),
|
||||
is_testnet: walletChainMetadata?.testnet,
|
||||
});
|
||||
|
||||
deployStatusModal.nextStep();
|
||||
@@ -694,6 +698,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
contractName: metadata.name,
|
||||
errorMessage: parsedError,
|
||||
publisher: rewriteTwPublisher(metadata.publisher),
|
||||
is_testnet: walletChain.testnet,
|
||||
});
|
||||
|
||||
deployStatusModal.close();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { reportTokenRowClicked } from "@/analytics/report";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -39,6 +40,7 @@ export function TokensTable(props: {
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Market cap</TableHead>
|
||||
<TableHead>Volume (24h)</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -64,6 +66,9 @@ export function TokensTable(props: {
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: tokens.length > 0
|
||||
@@ -81,6 +86,7 @@ export function TokensTable(props: {
|
||||
<AvatarImage
|
||||
src={token.iconUri ?? undefined}
|
||||
alt={token.symbol}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{token.symbol?.slice(0, 2)?.toUpperCase()}
|
||||
@@ -90,20 +96,9 @@ export function TokensTable(props: {
|
||||
<span className="font-medium leading-none text-base">
|
||||
{token.symbol}
|
||||
</span>
|
||||
<Link
|
||||
href={
|
||||
token.address.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? `/${token.chainId}`
|
||||
: `/${token.chainId}/${token.address}`
|
||||
}
|
||||
prefetch={false}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground leading-none">
|
||||
{token.name}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground leading-none">
|
||||
{token.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -120,6 +115,35 @@ export function TokensTable(props: {
|
||||
? formatUsdCompact(token.volume24hUsd)
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 rounded-full bg-card hover:border-foreground/50 text-muted-foreground hover:bg-inverted hover:text-inverted-foreground"
|
||||
>
|
||||
<Link
|
||||
onClick={() =>
|
||||
reportTokenRowClicked({
|
||||
chainId: token.chainId,
|
||||
tokenAddress: token.address,
|
||||
})
|
||||
}
|
||||
aria-label={`Buy ${token.symbol}`}
|
||||
href={
|
||||
token.address.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? `/${token.chainId}`
|
||||
: `/${token.chainId}/${token.address}`
|
||||
}
|
||||
prefetch={false}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
Buy
|
||||
<ArrowRightIcon className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BringToFrontIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { PageHeader } from "./components/header";
|
||||
@@ -35,13 +34,10 @@ export default async function Page() {
|
||||
<h1 className="text-4xl font-bold tracking-tighter mb-1">
|
||||
Discover and swap any tokens on any chain, instantly
|
||||
</h1>
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
href="https://thirdweb.com/monetize/bridge"
|
||||
target="_blank"
|
||||
>
|
||||
Powered by thirdweb bridge
|
||||
</Link>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
85+ chains, 4500+ tokens and 9+ million routes supported
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TokenPage chains={chains} />
|
||||
|
||||
+11
-1
@@ -55,7 +55,7 @@ export function CreateProjectFormOnboarding(props: {
|
||||
<CreateProjectForm
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
const vaultTokens = await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
@@ -65,6 +65,16 @@ export function CreateProjectFormOnboarding(props: {
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const managementAccessToken =
|
||||
vaultTokens.managementToken?.accessToken;
|
||||
|
||||
if (!managementAccessToken) {
|
||||
throw new Error(
|
||||
"Missing management access token for project wallet",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
+29
-22
@@ -25,6 +25,34 @@ import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
|
||||
import { searchParams } from "../search-params";
|
||||
|
||||
function getStatusBadge(invoice: Stripe.Invoice) {
|
||||
switch (invoice.status) {
|
||||
case "paid":
|
||||
return <Badge variant="success">Paid</Badge>;
|
||||
case "open":
|
||||
// we treat "uncollectible" as unpaid
|
||||
case "uncollectible": {
|
||||
// if the invoice has a due date, use that, otherwise use the effective at date (which is the date the invoice was created)
|
||||
const effectiveDueDate = invoice.due_date || invoice.effective_at;
|
||||
|
||||
// if the invoice due date is in the past, we want to display it as past due
|
||||
if (effectiveDueDate && effectiveDueDate * 1000 < Date.now()) {
|
||||
return <Badge variant="destructive">Past Due</Badge>;
|
||||
}
|
||||
return <Badge variant="outline">Open</Badge>;
|
||||
}
|
||||
case "void":
|
||||
return <Badge variant="secondary">Void</Badge>;
|
||||
|
||||
default:
|
||||
return <Badge variant="outline">Unknown</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function isInvoicePayable(invoice: Stripe.Invoice) {
|
||||
return invoice.status === "open" || invoice.status === "uncollectible";
|
||||
}
|
||||
|
||||
export function BillingHistory(props: {
|
||||
teamSlug: string;
|
||||
invoices: Stripe.Invoice[];
|
||||
@@ -57,27 +85,6 @@ export function BillingHistory(props: {
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (invoice: Stripe.Invoice) => {
|
||||
switch (invoice.status) {
|
||||
case "paid":
|
||||
return <Badge variant="success">Paid</Badge>;
|
||||
case "open":
|
||||
// we treate "uncollectible" as unpaid
|
||||
case "uncollectible": {
|
||||
// if the invoice due date is in the past, we want to display it as past due
|
||||
if (invoice.due_date && invoice.due_date * 1000 < Date.now()) {
|
||||
return <Badge variant="destructive">Past Due</Badge>;
|
||||
}
|
||||
return <Badge variant="outline">Open</Badge>;
|
||||
}
|
||||
case "void":
|
||||
return <Badge variant="secondary">Void</Badge>;
|
||||
|
||||
default:
|
||||
return <Badge variant="outline">Unknown</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (props.invoices.length === 0) {
|
||||
if (props.status === "open") {
|
||||
return (
|
||||
@@ -126,7 +133,7 @@ export function BillingHistory(props: {
|
||||
<TableCell>{getStatusBadge(invoice)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{invoice.status === "open" && (
|
||||
{isInvoicePayable(invoice) && (
|
||||
<>
|
||||
{/* always show the crypto payment button */}
|
||||
<ToolTipLabel
|
||||
|
||||
+1
-1
@@ -552,7 +552,7 @@ export function AccountAbstractionSettingsPage(
|
||||
you build your own rules.{" "}
|
||||
<UnderlineLink
|
||||
className="text-primary-500"
|
||||
href="https://portal.thirdweb.com/wallets/smart-wallet/sponsorship-rules#setting-up-a-server-verifier"
|
||||
href="https://portal.thirdweb.com/transactions/policies#server-verifier"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
+3
-3
@@ -28,11 +28,11 @@ export function QuickStartSection(props: {
|
||||
icon={ArrowRightLeftIcon}
|
||||
setupTime={5}
|
||||
id="swap_tokens"
|
||||
features={["Swap any token", "Cross-chain swaps"]}
|
||||
features={["Swap any token", "Cross-chain swap"]}
|
||||
description="Swap tokens cross-chain with dedicated swapping endpoints."
|
||||
link={{
|
||||
href: `https://portal.thirdweb.com/payments/swap`,
|
||||
label: "Setup Swaps",
|
||||
href: `https://portal.thirdweb.com/bridge/swap`,
|
||||
label: "Setup Swap",
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
+36
-20
@@ -2,6 +2,9 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Spinner } from "@workspace/ui/components/spinner";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
@@ -19,7 +22,6 @@ import {
|
||||
type RouteDiscoveryValidationSchema,
|
||||
routeDiscoveryValidationSchema,
|
||||
} from "@/schema/validations";
|
||||
import { RouteDiscoveryCard } from "./RouteDiscoveryCard";
|
||||
|
||||
export const RouteDiscovery = ({
|
||||
project,
|
||||
@@ -77,33 +79,21 @@ export const RouteDiscovery = ({
|
||||
},
|
||||
);
|
||||
|
||||
const errorText = form.getFieldState("tokenAddress").error?.message;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<RouteDiscoveryCard
|
||||
bottomText=""
|
||||
errorText={form.getFieldState("tokenAddress").error?.message}
|
||||
noPermissionText={undefined}
|
||||
saveButton={
|
||||
// Only show the submit button in the default state
|
||||
{
|
||||
disabled: !form.formState.isDirty,
|
||||
isPending: submitDiscoveryMutation.isPending,
|
||||
type: "submit",
|
||||
variant: "outline",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
<div className="relative rounded-lg border border-border bg-card">
|
||||
<div className="relative border-dashed border-b px-4 py-6 lg:px-6">
|
||||
<h3 className="font-semibold text-xl tracking-tight mb-1">
|
||||
Add a token to Bridge
|
||||
</h3>
|
||||
<p className="mt-1.5 mb-4 text-muted-foreground max-w-3xl text-sm text-pretty">
|
||||
<p className="mb-4 text-muted-foreground max-w-3xl text-sm text-pretty">
|
||||
Select your chain and input the token address to automatically
|
||||
kick-off the token route discovery process. <br /> This may take
|
||||
up to 20-40 minutes to complete.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 max-w-3xl">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -142,7 +132,33 @@ export const RouteDiscovery = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteDiscoveryCard>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-4 lg:px-6">
|
||||
{errorText ? (
|
||||
<p className="text-destructive-text text-sm">{errorText}</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="gap-1.5 rounded-full"
|
||||
disabled={
|
||||
!form.formState.isDirty || submitDiscoveryMutation.isPending
|
||||
}
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant={"outline"}
|
||||
>
|
||||
{submitDiscoveryMutation.isPending ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<PlusIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
Add Token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
import type React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RouteDiscoveryCard(
|
||||
props: React.PropsWithChildren<{
|
||||
bottomText: React.ReactNode;
|
||||
header?: {
|
||||
description: string | undefined;
|
||||
title: string;
|
||||
};
|
||||
errorText: string | undefined;
|
||||
noPermissionText: string | undefined;
|
||||
saveButton?: {
|
||||
onClick?: () => void;
|
||||
disabled: boolean;
|
||||
isPending: boolean;
|
||||
type?: "submit";
|
||||
variant?:
|
||||
| "ghost"
|
||||
| "default"
|
||||
| "primary"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary";
|
||||
className?: string;
|
||||
label?: string;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border bg-card">
|
||||
<div
|
||||
className={cn(
|
||||
"relative border-border border-b px-4 py-6 lg:px-6",
|
||||
props.noPermissionText && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{props.header && (
|
||||
<>
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
{props.header.title}
|
||||
</h3>
|
||||
{props.header.description && (
|
||||
<p className="mt-1.5 mb-4 text-foreground text-sm">
|
||||
{props.header.description}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[60px] items-center justify-between gap-2 px-4 py-3 lg:px-6">
|
||||
{props.noPermissionText ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{props.noPermissionText}
|
||||
</p>
|
||||
) : props.errorText ? (
|
||||
<p className="text-destructive-text text-sm">{props.errorText}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{props.bottomText}</p>
|
||||
)}
|
||||
|
||||
{props.saveButton && !props.noPermissionText && (
|
||||
<Button
|
||||
className={cn("gap-2", props.saveButton.className)}
|
||||
disabled={props.saveButton.disabled || props.saveButton.isPending}
|
||||
onClick={props.saveButton.onClick}
|
||||
size="sm"
|
||||
type={props.saveButton.type}
|
||||
variant={props.saveButton.variant || "outline"}
|
||||
>
|
||||
{props.saveButton.isPending && <Spinner className="size-3" />}
|
||||
{props.saveButton.label ||
|
||||
(props.saveButton.isPending ? "Submit" : "Submit Token")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+20
-65
@@ -11,6 +11,7 @@ import { PayAnalytics } from "../payments/components/PayAnalytics";
|
||||
import { getUniversalBridgeFiltersFromSearchParams } from "../payments/components/time";
|
||||
import { QuickStartSection } from "./QuickstartSection.client";
|
||||
import { RouteDiscovery } from "./RouteDiscovery";
|
||||
import { ViewTxStatus } from "./view-tx-status";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
@@ -69,72 +70,22 @@ export default async function Page(props: {
|
||||
},
|
||||
},
|
||||
links: [
|
||||
// TODO - add docs when bridge docs are added in portal
|
||||
// {
|
||||
// type: "docs",
|
||||
// href: "https://portal.thirdweb.com/payments",
|
||||
// },
|
||||
{
|
||||
type: "docs",
|
||||
href: "https://portal.thirdweb.com/bridge",
|
||||
},
|
||||
{
|
||||
type: "playground",
|
||||
href: "https://playground.thirdweb.com/bridge/swap-widget",
|
||||
},
|
||||
{
|
||||
type: "api",
|
||||
href: "https://api.thirdweb.com/reference#tag/bridge",
|
||||
},
|
||||
],
|
||||
}}
|
||||
footer={{
|
||||
center: {
|
||||
links: [
|
||||
{
|
||||
href: "https://playground.thirdweb.com/payments/ui-components",
|
||||
label: "UI Component",
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/payments/fund-wallet",
|
||||
label: "Buy Crypto",
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/payments/commerce",
|
||||
label: "Checkout",
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/payments/transactions",
|
||||
label: "Transactions",
|
||||
},
|
||||
],
|
||||
title: "Demos",
|
||||
},
|
||||
left: {
|
||||
links: [
|
||||
{
|
||||
href: "https://portal.thirdweb.com/payments",
|
||||
label: "Overview",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/typescript/v5/convertCryptoToFiat",
|
||||
label: "TypeScript",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/react/v5/pay/fund-wallets",
|
||||
label: "React",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/dotnet/universal-bridge/quickstart",
|
||||
label: ".NET",
|
||||
},
|
||||
],
|
||||
title: "Documentation",
|
||||
},
|
||||
right: {
|
||||
links: [
|
||||
{
|
||||
href: "https://www.youtube.com/watch?v=aBu175-VsNY",
|
||||
label: "Implement cross-chain payments in any app",
|
||||
},
|
||||
],
|
||||
title: "Tutorials",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-6">
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<PayAnalytics
|
||||
client={client}
|
||||
@@ -149,12 +100,16 @@ export default async function Page(props: {
|
||||
|
||||
<RouteDiscovery client={client} project={project} />
|
||||
|
||||
<QuickStartSection
|
||||
projectSlug={params.project_slug}
|
||||
teamSlug={params.team_slug}
|
||||
clientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
<ViewTxStatus client={client} />
|
||||
|
||||
<div className="pt-4">
|
||||
<QuickStartSection
|
||||
projectSlug={params.project_slug}
|
||||
teamSlug={params.team_slug}
|
||||
clientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProjectPage>
|
||||
);
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function ViewTxStatus(props: { client: ThirdwebClient }) {
|
||||
const [txHash, setTxHash] = useState("");
|
||||
const [chainId, setChainId] = useState<number>(1);
|
||||
|
||||
const isValidTxHash = txHash.length === 66 && txHash.startsWith("0x");
|
||||
const isEnabled = !!txHash && !!chainId && isValidTxHash;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border">
|
||||
<div className="p-4 lg:p-6 mb-3">
|
||||
<h3 className="font-semibold text-xl tracking-tight mb-1">
|
||||
View transaction status
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Select chain and enter the transaction hash to view the transaction
|
||||
status.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 max-w-3xl">
|
||||
<div className="space-y-2">
|
||||
<Label>Chain</Label>
|
||||
<SingleNetworkSelector
|
||||
chainId={chainId}
|
||||
onChange={setChainId}
|
||||
client={props.client}
|
||||
disableChainId
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="view-tx-hash">Transaction Hash</Label>
|
||||
<Input
|
||||
id="view-tx-hash"
|
||||
value={txHash}
|
||||
onChange={(e) => setTxHash(e.target.value)}
|
||||
placeholder="0x.."
|
||||
/>
|
||||
{txHash && !isValidTxHash && (
|
||||
<p className="text-sm text-destructive-text">
|
||||
Invalid transaction hash
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 lg:px-6 py-4 border-t border-dashed justify-end items-center flex">
|
||||
<Button
|
||||
asChild
|
||||
disabled={!isEnabled}
|
||||
size="sm"
|
||||
variant={isEnabled ? "default" : "outline"}
|
||||
className="gap-1.5 rounded-full"
|
||||
>
|
||||
<Link href={`/${chainId.toString()}/tx/${txHash}`} target="_blank">
|
||||
View Transaction Status <ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
|
||||
type TabKey =
|
||||
| "ts"
|
||||
| "react"
|
||||
| "react-native"
|
||||
| "dotnet"
|
||||
| "unity"
|
||||
| "unreal"
|
||||
| "api"
|
||||
| "curl";
|
||||
|
||||
const tabNames: Record<TabKey, string> = {
|
||||
api: "API",
|
||||
ts: "TypeScript",
|
||||
react: "React",
|
||||
"react-native": "React Native",
|
||||
dotnet: ".NET",
|
||||
unity: "Unity",
|
||||
unreal: "Unreal Engine",
|
||||
curl: "cURL",
|
||||
};
|
||||
|
||||
export function IntegrateAPIKeyCodeTabs(props: {
|
||||
tabs: Record<TabKey, React.ReactNode>;
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabKey>("api");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TabButtons
|
||||
tabs={Object.entries(tabNames).map(([key, name]) => ({
|
||||
isActive: tab === key,
|
||||
name,
|
||||
onClick: () => setTab(key as TabKey),
|
||||
}))}
|
||||
/>
|
||||
<div className="h-2" />
|
||||
{props.tabs[tab]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
@@ -31,5 +31,6 @@ export const Default: Story = {
|
||||
],
|
||||
},
|
||||
teamSlug: "bar",
|
||||
managementAccessToken: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
+101
-191
@@ -1,14 +1,11 @@
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { CodeServer } from "@/components/ui/code/code.server";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon";
|
||||
import { GithubIcon } from "@/icons/brand-icons/GithubIcon";
|
||||
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
|
||||
@@ -18,17 +15,34 @@ import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
|
||||
import { ContractIcon } from "@/icons/ContractIcon";
|
||||
import { InsightIcon } from "@/icons/InsightIcon";
|
||||
import { PayIcon } from "@/icons/PayIcon";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { getProjectWalletLabel } from "@/lib/project-wallet";
|
||||
import {
|
||||
getProjectWallet,
|
||||
type ProjectWalletSummary,
|
||||
} from "@/lib/server/project-wallet";
|
||||
import { ClientIDSection } from "./ClientIDSection";
|
||||
import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
|
||||
import { ProjectWalletControls } from "./ProjectWalletControls.client";
|
||||
import { ProjectWalletSetup } from "./ProjectWalletSetup.client";
|
||||
import { SecretKeySection } from "./SecretKeySection";
|
||||
|
||||
export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
|
||||
export async function ProjectFTUX(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
wallet?: ProjectWalletSummary | undefined;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
const projectWallet = props.wallet ?? (await getProjectWallet(props.project));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<IntegrateAPIKeySection
|
||||
<ProjectWalletSection
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
wallet={projectWallet}
|
||||
managementAccessToken={props.managementAccessToken}
|
||||
/>
|
||||
<IntegrateAPIKeySection project={props.project} />
|
||||
<ProductsSection
|
||||
projectSlug={props.project.slug}
|
||||
teamSlug={props.teamSlug}
|
||||
@@ -39,15 +53,67 @@ export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Integrate API key section ------------------------------------------------------------
|
||||
|
||||
function IntegrateAPIKeySection({
|
||||
project,
|
||||
teamSlug,
|
||||
}: {
|
||||
export function ProjectWalletSection(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
wallet: ProjectWalletSummary | undefined;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
const defaultLabel = getProjectWalletLabel(props.project.name);
|
||||
const walletAddress = props.wallet?.address;
|
||||
const label = props.wallet?.label ?? defaultLabel;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full border border-border bg-background p-2">
|
||||
<WalletProductIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg tracking-tight">
|
||||
Project Wallet
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Use it for deployments, payments, and API integrations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{walletAddress ? (
|
||||
<>
|
||||
<ProjectWalletControls
|
||||
label={label}
|
||||
project={props.project}
|
||||
walletAddress={walletAddress}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Link
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/transactions`}
|
||||
>
|
||||
View Transactions
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ProjectWalletSetup
|
||||
managementAccessToken={props.managementAccessToken}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Integrate API key section ------------------------------------------------------------
|
||||
|
||||
function IntegrateAPIKeySection({ project }: { project: Project }) {
|
||||
const secretKeyMasked = project.secretKeys[0]?.masked;
|
||||
const clientId = project.publishableKey;
|
||||
|
||||
@@ -69,194 +135,38 @@ function IntegrateAPIKeySection({
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
<IntegrationCodeExamples project={project} teamSlug={teamSlug} />
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Run this command in your terminal to send a test transaction using
|
||||
your Project Wallet.
|
||||
</p>
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={curlCodeExample(project)}
|
||||
lang="bash"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationCodeExamples(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<IntegrateAPIKeyCodeTabs
|
||||
tabs={{
|
||||
dotnet: (
|
||||
<div className="flex flex-col gap-3">
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={dotNotCodeExample(props.project)}
|
||||
lang="csharp"
|
||||
/>
|
||||
<Alert className="bg-background" variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Configure your app's bundle ID in "Allowed Bundle IDs" in
|
||||
Project
|
||||
</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
Go to{" "}
|
||||
<UnderlineLink
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
|
||||
target="_blank"
|
||||
>
|
||||
Project settings
|
||||
</UnderlineLink>{" "}
|
||||
and add your app's bundle ID to the "Allowed Bundle IDs" list.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
),
|
||||
react: (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={reactCodeExample(props.project)}
|
||||
lang="ts"
|
||||
/>
|
||||
),
|
||||
"react-native": (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={reactCodeExample(props.project)}
|
||||
lang="ts"
|
||||
/>
|
||||
),
|
||||
ts: (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={typescriptCodeExample(props.project)}
|
||||
lang="ts"
|
||||
/>
|
||||
),
|
||||
unity: (
|
||||
<Alert className="bg-background" variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Configure Client ID in Thirdweb Manager prefab
|
||||
</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
Configure "Client ID" and "Bundle ID" in{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/unity/v5/thirdwebmanager"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Thirdweb Manager prefab
|
||||
</UnderlineLink>
|
||||
<span className="block text-sm">
|
||||
Make sure to configure your app's bundle ID in "Allowed Bundle
|
||||
IDs" in{" "}
|
||||
<UnderlineLink
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Project settings
|
||||
</UnderlineLink>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
unreal: (
|
||||
<Alert className="bg-background" variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Configure Client ID in Thirdweb Unreal Plugin{" "}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
Configure "Client ID" and "Bundle ID" in{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/unreal-engine/getting-started"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
thirdweb plugin settings
|
||||
</UnderlineLink>
|
||||
<span className="block text-sm">
|
||||
Make sure to configure your app's bundle ID in "Allowed Bundle
|
||||
IDs" in{" "}
|
||||
<UnderlineLink
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Project settings
|
||||
</UnderlineLink>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
api: (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={apiCodeExample(props.project)}
|
||||
lang="javascript"
|
||||
/>
|
||||
),
|
||||
curl: (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={curlCodeExample(props.project)}
|
||||
lang="bash"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const typescriptCodeExample = (project: Project) => `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
// use clientId for client side usage
|
||||
clientId: "${project.publishableKey}",
|
||||
// use secretKey for server side usage
|
||||
secretKey: "${project.secretKeys[0]?.masked}", // replace this with full secret key
|
||||
});`;
|
||||
|
||||
const reactCodeExample = (project: Project) => `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "${project.publishableKey}",
|
||||
});`;
|
||||
|
||||
const dotNotCodeExample = (project: Project) => `\
|
||||
using Thirdweb;
|
||||
|
||||
// For web applications
|
||||
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}");
|
||||
|
||||
// For native applications - Replace "yourBundleId" with your app's bundle ID
|
||||
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}", bundleId: "yourBundleId");
|
||||
|
||||
// For backend applications (Note: below shown secret key is not the full secret key)
|
||||
var client = ThirdwebClient.Create(secretKey: "${project.secretKeys[0]?.masked}");`;
|
||||
|
||||
const apiCodeExample = (project: Project): string => `\
|
||||
// Server-side only: replace with your full secret key. Never expose in browser code.
|
||||
fetch('https://api.thirdweb.com/v1/wallets/server', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-secret-key': '${project.secretKeys[0]?.masked ?? "<YOUR_SECRET_KEY>"}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: 'treasury-wallet-123'
|
||||
})
|
||||
});`;
|
||||
|
||||
const curlCodeExample = (project: Project): string => `\
|
||||
// Server-side only: replace with your full secret key. Never expose in browser code.
|
||||
curl https://api.thirdweb.com/v1/wallets/server \\
|
||||
curl https://api.thirdweb.com/v1/transactions \\
|
||||
--request POST \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--header 'x-secret-key: ${project.secretKeys[0]?.masked ?? "<YOUR_SECRET_KEY>"}' \\
|
||||
--data '{
|
||||
"identifier": "treasury-wallet-123"
|
||||
}'`;
|
||||
"chainId": 421614,
|
||||
"transactions": [
|
||||
{
|
||||
"data": "0x",
|
||||
"to": "vitalik.eth",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
}'
|
||||
`;
|
||||
|
||||
// products section ------------------------------------------------------------
|
||||
|
||||
function ProductsSection(props: { teamSlug: string; projectSlug: string }) {
|
||||
|
||||
+747
@@ -0,0 +1,747 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
ShuffleIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { toWei } from "thirdweb";
|
||||
import { useWalletBalance } from "thirdweb/react";
|
||||
import { isAddress } from "thirdweb/utils";
|
||||
import { z } from "zod";
|
||||
import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets";
|
||||
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
|
||||
|
||||
type ProjectWalletControlsProps = {
|
||||
walletAddress: string;
|
||||
label: string;
|
||||
project: Pick<
|
||||
Project,
|
||||
"id" | "publishableKey" | "teamId" | "services" | "name"
|
||||
>;
|
||||
defaultChainId?: number;
|
||||
};
|
||||
|
||||
export function ProjectWalletControls(props: ProjectWalletControlsProps) {
|
||||
const { walletAddress, label, project, defaultChainId } = props;
|
||||
const [isSendOpen, setIsSendOpen] = useState(false);
|
||||
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
|
||||
const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1);
|
||||
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
|
||||
const [selectedWalletId, setSelectedWalletId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const client = useMemo(() => getClientThirdwebClient(), []);
|
||||
const chain = useV5DashboardChain(selectedChainId);
|
||||
const queryClient = useQueryClient();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const engineCloudService = useMemo(
|
||||
() => project.services?.find((service) => service.name === "engineCloud"),
|
||||
[project.services],
|
||||
);
|
||||
const managementAccessToken =
|
||||
engineCloudService?.managementAccessToken ?? undefined;
|
||||
const isManagedVault = !!engineCloudService?.encryptedAdminKey;
|
||||
|
||||
const serverWalletsQuery = useQuery({
|
||||
enabled: !!managementAccessToken,
|
||||
queryFn: async () => {
|
||||
if (!managementAccessToken) {
|
||||
return [] as ProjectWalletSummary[];
|
||||
}
|
||||
|
||||
return listProjectServerWallets({
|
||||
managementAccessToken,
|
||||
projectId: project.id,
|
||||
});
|
||||
},
|
||||
queryKey: ["project", project.id, "server-wallets", managementAccessToken],
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const serverWallets = serverWalletsQuery.data ?? [];
|
||||
const currentWalletAddressLower = walletAddress.toLowerCase();
|
||||
const otherWallets = useMemo(() => {
|
||||
return serverWallets.filter((wallet) => {
|
||||
return wallet.address.toLowerCase() !== currentWalletAddressLower;
|
||||
});
|
||||
}, [serverWallets, currentWalletAddressLower]);
|
||||
|
||||
const canChangeWallet = otherWallets.length > 0;
|
||||
|
||||
const handleOpenChangeWallet = () => {
|
||||
const currentWallet = serverWallets.find(
|
||||
(wallet) => wallet.address.toLowerCase() === currentWalletAddressLower,
|
||||
);
|
||||
|
||||
const nextWalletId =
|
||||
otherWallets[0]?.id ?? currentWallet?.id ?? serverWallets[0]?.id;
|
||||
|
||||
setSelectedWalletId(nextWalletId);
|
||||
setIsChangeWalletOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseChangeWallet = () => {
|
||||
setIsChangeWalletOpen(false);
|
||||
setSelectedWalletId(undefined);
|
||||
};
|
||||
|
||||
const selectedWallet = useMemo(() => {
|
||||
if (!selectedWalletId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return serverWallets.find((wallet) => wallet.id === selectedWalletId);
|
||||
}, [selectedWalletId, serverWallets]);
|
||||
|
||||
const isSelectionDifferent = Boolean(
|
||||
selectedWallet &&
|
||||
selectedWallet.address.toLowerCase() !== currentWalletAddressLower,
|
||||
);
|
||||
|
||||
const changeWalletMutation = useMutation({
|
||||
mutationFn: async (wallet: ProjectWalletSummary) => {
|
||||
await updateDefaultProjectWallet({
|
||||
project: project as Project,
|
||||
projectWalletAddress: wallet.address,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update project wallet";
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: async (_, wallet) => {
|
||||
const descriptionLabel = wallet.label ?? wallet.address;
|
||||
toast.success("Project wallet updated", {
|
||||
description: `Now pointing to ${descriptionLabel}`,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"project",
|
||||
project.id,
|
||||
"server-wallets",
|
||||
managementAccessToken,
|
||||
],
|
||||
});
|
||||
handleCloseChangeWallet();
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const balanceQuery = useWalletBalance({
|
||||
address: walletAddress,
|
||||
chain,
|
||||
client,
|
||||
});
|
||||
|
||||
const balanceDisplay = balanceQuery.data
|
||||
? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-lg border border-border bg-background p-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row">
|
||||
<div className="flex-1">
|
||||
<p className="text-muted-foreground text-xs">Address</p>
|
||||
<WalletAddress address={walletAddress} client={client} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-muted-foreground text-xs">Label</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-mono text-sm break-all py-2 sm:py-0">
|
||||
{label}
|
||||
</p>
|
||||
{canChangeWallet && (
|
||||
<Button
|
||||
className="h-8 gap-1 sm:ml-4"
|
||||
onClick={handleOpenChangeWallet}
|
||||
size="sm"
|
||||
disabled={serverWalletsQuery.isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
<ShuffleIcon className="size-3.5" />
|
||||
Change default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex-1 flex-col">
|
||||
<p className="text-xs text-muted-foreground pb-2">Balance</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
{balanceQuery.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : balanceDisplay ? (
|
||||
<span className="text-md tracking-tight">
|
||||
{balanceDisplay}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">N/A</span>
|
||||
)}
|
||||
<Button
|
||||
aria-label="Refresh balance"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={balanceQuery.isFetching}
|
||||
onClick={() => balanceQuery.refetch()}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCcwIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
balanceQuery.isFetching && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Open wallet actions"
|
||||
className="h-9 w-9"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 rounded-xl">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsSendOpen(true)}
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send funds
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsReceiveOpen(true)}
|
||||
>
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
Receive funds
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-col flex-1">
|
||||
<p className="text-xs text-muted-foreground pb-2">Network</p>
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
className="max-w-xs rounded-lg"
|
||||
client={client}
|
||||
disableDeprecated
|
||||
disableChainId
|
||||
onChange={setSelectedChainId}
|
||||
placeholder="Select network"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SendProjectWalletModal
|
||||
chainId={selectedChainId}
|
||||
client={client}
|
||||
isManagedVault={isManagedVault}
|
||||
label={label}
|
||||
onClose={() => setIsSendOpen(false)}
|
||||
onSuccess={() => balanceQuery.refetch()}
|
||||
open={isSendOpen}
|
||||
publishableKey={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
walletAddress={walletAddress}
|
||||
/>
|
||||
|
||||
<FundWalletModal
|
||||
checkoutWidgetTitle={`Fund ${label}`}
|
||||
client={client}
|
||||
defaultChainId={selectedChainId}
|
||||
description="Use your card or crypto to deposit into this server wallet"
|
||||
onOpenChange={setIsReceiveOpen}
|
||||
open={isReceiveOpen}
|
||||
recipientAddress={walletAddress}
|
||||
title="Fund project wallet"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
handleCloseChangeWallet();
|
||||
}
|
||||
}}
|
||||
open={isChangeWalletOpen}
|
||||
>
|
||||
<DialogContent className="gap-0 p-0">
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>Change default wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose another server wallet to use as the default for this
|
||||
project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 pb-8 lg:px-6">
|
||||
{serverWalletsQuery.isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
) : serverWallets.length <= 1 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You need at least two server wallets to pick a different
|
||||
default.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Select
|
||||
onValueChange={(value) => setSelectedWalletId(value)}
|
||||
value={selectedWalletId ?? ""}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select server wallet">
|
||||
{selectedWallet ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={selectedWallet.address}
|
||||
client={client}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">
|
||||
{selectedWallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
{selectedWallet.address.toLowerCase() ===
|
||||
currentWalletAddressLower && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Select server wallet"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverWallets.map((wallet) => {
|
||||
const isCurrent =
|
||||
wallet.address.toLowerCase() ===
|
||||
currentWalletAddressLower;
|
||||
|
||||
return (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={wallet.address}
|
||||
client={client}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">
|
||||
{wallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
|
||||
<Button
|
||||
onClick={handleCloseChangeWallet}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={changeWalletMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedWallet ||
|
||||
!isSelectionDifferent ||
|
||||
changeWalletMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectedWallet) {
|
||||
changeWalletMutation.mutate(selectedWallet);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{changeWalletMutation.isPending && (
|
||||
<Spinner className="mr-2 size-4" />
|
||||
)}
|
||||
Save default
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createSendFormSchema = (secretKeyLabel: string) =>
|
||||
z.object({
|
||||
chainId: z.number({
|
||||
required_error: "Select a network",
|
||||
}),
|
||||
toAddress: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Destination address is required")
|
||||
.refine((value) => Boolean(isAddress(value)), {
|
||||
message: "Enter a valid wallet address",
|
||||
}),
|
||||
amount: z.string().trim().min(1, "Amount is required"),
|
||||
secretKey: z.string().trim().min(1, `${secretKeyLabel} is required`),
|
||||
vaultAccessToken: z.string().trim(),
|
||||
});
|
||||
|
||||
const SECRET_KEY_LABEL = "Project secret key";
|
||||
|
||||
type SendFormValues = z.infer<ReturnType<typeof createSendFormSchema>>;
|
||||
|
||||
function SendProjectWalletModal(props: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
walletAddress: string;
|
||||
publishableKey: string;
|
||||
teamId: string;
|
||||
chainId: number;
|
||||
label: string;
|
||||
client: ReturnType<typeof getClientThirdwebClient>;
|
||||
isManagedVault: boolean;
|
||||
}) {
|
||||
const {
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
walletAddress,
|
||||
publishableKey,
|
||||
teamId,
|
||||
chainId,
|
||||
label,
|
||||
client,
|
||||
isManagedVault,
|
||||
} = props;
|
||||
|
||||
const secretKeyLabel = SECRET_KEY_LABEL;
|
||||
const secretKeyPlaceholder = "Enter your project secret key";
|
||||
const secretKeyHelper =
|
||||
"Your project secret key was generated when you created your project. If you lost it, regenerate one from Project settings.";
|
||||
const vaultAccessTokenHelper =
|
||||
"Vault access tokens are optional credentials with server wallet permissions. Manage them in Vault settings.";
|
||||
|
||||
const formSchema = useMemo(() => createSendFormSchema(SECRET_KEY_LABEL), []);
|
||||
|
||||
const form = useForm<SendFormValues>({
|
||||
defaultValues: {
|
||||
amount: "0",
|
||||
chainId,
|
||||
secretKey: "",
|
||||
vaultAccessToken: "",
|
||||
toAddress: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const selectedChain = useV5DashboardChain(form.watch("chainId"));
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- form submission chainId must track selector state
|
||||
useEffect(() => {
|
||||
form.setValue("chainId", chainId);
|
||||
}, [chainId, form]);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- reset cached inputs when modal closes to avoid leaking state
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const currentValues = form.getValues();
|
||||
form.reset({
|
||||
amount: "0",
|
||||
chainId,
|
||||
secretKey: currentValues.secretKey ?? "",
|
||||
vaultAccessToken: currentValues.vaultAccessToken ?? "",
|
||||
toAddress: "",
|
||||
});
|
||||
}
|
||||
}, [open, chainId, form]);
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async (values: SendFormValues) => {
|
||||
const quantityWei = toWei(values.amount).toString();
|
||||
const secretKeyValue = values.secretKey.trim();
|
||||
const vaultAccessTokenValue = values.vaultAccessToken.trim();
|
||||
|
||||
const result = await sendProjectWalletTokens({
|
||||
chainId: values.chainId,
|
||||
publishableKey,
|
||||
quantityWei,
|
||||
recipientAddress: values.toAddress,
|
||||
teamId,
|
||||
walletAddress,
|
||||
secretKey: secretKeyValue,
|
||||
...(vaultAccessTokenValue
|
||||
? { vaultAccessToken: vaultAccessTokenValue }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
const errorMessage =
|
||||
typeof result.error === "string"
|
||||
? result.error
|
||||
: "Failed to send funds";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return result.transactionIds;
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
onSuccess: (transactionIds) => {
|
||||
toast.success("Transfer submitted", {
|
||||
description:
|
||||
transactionIds && transactionIds.length > 0
|
||||
? `Transaction ID: ${transactionIds[0]}`
|
||||
: undefined,
|
||||
});
|
||||
onSuccess();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogContent className="gap-0 p-0">
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>Send from {label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Execute a one-off transfer using your server wallet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
void sendMutation.mutateAsync(values);
|
||||
})}
|
||||
>
|
||||
<div className="space-y-4 px-4 pb-8 lg:px-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chainId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network</FormLabel>
|
||||
<FormControl>
|
||||
<SingleNetworkSelector
|
||||
chainId={field.value}
|
||||
className="bg-card"
|
||||
client={client}
|
||||
disableDeprecated
|
||||
onChange={(nextChainId) => {
|
||||
field.onChange(nextChainId);
|
||||
}}
|
||||
placeholder="Select network"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="toAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Send to</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="0x..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Sending native token
|
||||
{selectedChain?.nativeCurrency?.symbol
|
||||
? ` (${selectedChain.nativeCurrency.symbol})`
|
||||
: ""}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{secretKeyLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
placeholder={secretKeyPlaceholder}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{secretKeyHelper}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isManagedVault && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vaultAccessToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Vault access token
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
(optional)
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
placeholder="Enter a vault access token (optional)"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{vaultAccessTokenHelper}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
|
||||
<Button onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!form.formState.isValid || sendMutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
{sendMutation.isPending && <Spinner className="size-4" />}
|
||||
Submit transfer
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
|
||||
import CreateServerWallet from "../../transactions/server-wallets/components/create-server-wallet.client";
|
||||
|
||||
export function ProjectWalletSetup(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
const { project, teamSlug, managementAccessToken } = props;
|
||||
const router = useDashboardRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useMemo(() => getClientThirdwebClient(), []);
|
||||
const [selectedWalletId, setSelectedWalletId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const serverWalletsQuery = useQuery({
|
||||
enabled: Boolean(managementAccessToken),
|
||||
queryFn: async () => {
|
||||
if (!managementAccessToken) {
|
||||
return [] as ProjectWalletSummary[];
|
||||
}
|
||||
|
||||
return listProjectServerWallets({
|
||||
managementAccessToken,
|
||||
projectId: project.id,
|
||||
});
|
||||
},
|
||||
queryKey: ["project", project.id, "server-wallets", managementAccessToken],
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const serverWallets = serverWalletsQuery.data ?? [];
|
||||
|
||||
const effectiveSelectedWalletId = useMemo(() => {
|
||||
if (
|
||||
selectedWalletId &&
|
||||
serverWallets.some((wallet) => wallet.id === selectedWalletId)
|
||||
) {
|
||||
return selectedWalletId;
|
||||
}
|
||||
|
||||
return serverWallets[0]?.id;
|
||||
}, [selectedWalletId, serverWallets]);
|
||||
|
||||
const selectedWallet = useMemo(() => {
|
||||
if (!effectiveSelectedWalletId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return serverWallets.find(
|
||||
(wallet) => wallet.id === effectiveSelectedWalletId,
|
||||
);
|
||||
}, [effectiveSelectedWalletId, serverWallets]);
|
||||
|
||||
const setDefaultWalletMutation = useMutation({
|
||||
mutationFn: async (wallet: ProjectWalletSummary) => {
|
||||
await updateDefaultProjectWallet({
|
||||
project,
|
||||
projectWalletAddress: wallet.address,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update project wallet";
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: async (_, wallet) => {
|
||||
const descriptionLabel = wallet.label ?? wallet.address;
|
||||
toast.success("Project wallet updated", {
|
||||
description: `Now pointing to ${descriptionLabel}`,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"project",
|
||||
project.id,
|
||||
"server-wallets",
|
||||
managementAccessToken,
|
||||
],
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const canSelectExisting =
|
||||
Boolean(managementAccessToken) && serverWallets.length > 0;
|
||||
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>No project wallet set</AlertTitle>
|
||||
<AlertDescription>
|
||||
Set a project wallet to use for dashboard and API integrations.
|
||||
</AlertDescription>
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
{serverWalletsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" />
|
||||
<span>Loading existing server wallets…</span>
|
||||
</div>
|
||||
) : canSelectExisting ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Choose an existing server wallet
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These wallets were already created for this project. Pick one to
|
||||
set as the default.
|
||||
</p>
|
||||
<Select
|
||||
onValueChange={(value) => setSelectedWalletId(value)}
|
||||
value={effectiveSelectedWalletId ?? ""}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select server wallet">
|
||||
{selectedWallet ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={selectedWallet.address}
|
||||
client={client}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{selectedWallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
"Select server wallet"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverWallets.map((wallet) => (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={wallet.address}
|
||||
client={client}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{wallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
disabled={!selectedWallet || setDefaultWalletMutation.isPending}
|
||||
onClick={() => {
|
||||
if (selectedWallet) {
|
||||
setDefaultWalletMutation.mutate(selectedWallet);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{setDefaultWalletMutation.isPending && (
|
||||
<Spinner className="mr-2 size-4" />
|
||||
)}
|
||||
Set as project wallet
|
||||
</Button>
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
setAsProjectWallet
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Create a server wallet to start using transactions and other
|
||||
project features.
|
||||
</p>
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
setAsProjectWallet
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
+6
-6
@@ -48,7 +48,11 @@ export function ProjectSidebarLayout(props: {
|
||||
{
|
||||
href: `${props.layoutPath}/transactions`,
|
||||
icon: ArrowLeftRightIcon,
|
||||
label: "Transactions",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Transactions <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: `${props.layoutPath}/contracts`,
|
||||
@@ -81,11 +85,7 @@ export function ProjectSidebarLayout(props: {
|
||||
{
|
||||
href: `${props.layoutPath}/tokens`,
|
||||
icon: TokenIcon,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Tokens <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
label: "Tokens",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+13
@@ -1,3 +1,4 @@
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
@@ -110,6 +111,7 @@ export function TransactionsTable(props: {
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<EngineStatus | undefined>(undefined);
|
||||
const [filterId, setFilterId] = useState<string | undefined>(undefined);
|
||||
const autoUpdateId = useId();
|
||||
const pageSize = 10;
|
||||
const transactionsQuery = useEngineTransactions({
|
||||
@@ -121,6 +123,7 @@ export function TransactionsTable(props: {
|
||||
page: page,
|
||||
status,
|
||||
},
|
||||
id: filterId,
|
||||
});
|
||||
|
||||
const transactions = transactionsQuery.data?.transactions ?? [];
|
||||
@@ -160,6 +163,16 @@ export function TransactionsTable(props: {
|
||||
onCheckedChange={(v) => setAutoUpdate(!!v)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className="max-w-[250px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setFilterId(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by Queue ID"
|
||||
value={filterId || ""}
|
||||
/>
|
||||
<StatusSelector
|
||||
setStatus={(v) => {
|
||||
setStatus(v);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-fi
|
||||
import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar";
|
||||
import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getProjectWallet } from "@/lib/server/project-wallet";
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
@@ -38,7 +39,10 @@ import type {
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
|
||||
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
|
||||
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import {
|
||||
ProjectFTUX,
|
||||
ProjectWalletSection,
|
||||
} from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
|
||||
import { TransactionsChartCardAsync } from "./components/Transactions";
|
||||
import { ProjectHighlightsCard } from "./overview/highlights-card";
|
||||
@@ -64,6 +68,9 @@ type PageProps = {
|
||||
searchParams: Promise<PageSearchParams>;
|
||||
};
|
||||
|
||||
// Revalidate this page data every 30 seconds
|
||||
export const revalidate = 30;
|
||||
|
||||
export default async function ProjectOverviewPage(props: PageProps) {
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
@@ -104,6 +111,11 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
const projectWallet = await getProjectWallet(project);
|
||||
const managementAccessToken =
|
||||
project.services?.find((service) => service.name === "engineCloud")
|
||||
?.managementAccessToken ?? undefined;
|
||||
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<ProjectPage
|
||||
@@ -125,6 +137,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
>
|
||||
{isActive ? (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<ProjectWalletSection
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
wallet={projectWallet}
|
||||
managementAccessToken={managementAccessToken}
|
||||
/>
|
||||
<ResponsiveTimeFilters defaultRange={defaultRange} />
|
||||
<ProjectAnalytics
|
||||
authToken={authToken}
|
||||
@@ -137,7 +155,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectFTUX project={project} teamSlug={params.team_slug} />
|
||||
<ProjectFTUX
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
wallet={projectWallet}
|
||||
managementAccessToken={managementAccessToken}
|
||||
/>
|
||||
)}
|
||||
</ProjectPage>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
|
||||
+1
@@ -156,6 +156,7 @@ export async function PayAnalytics(props: {
|
||||
client={props.client}
|
||||
projectClientId={props.projectClientId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ export function PaymentHistory(props: {
|
||||
client: ThirdwebClient;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const { data: payPurchaseData, isLoading } = useQuery<
|
||||
@@ -40,6 +41,7 @@ export function PaymentHistory(props: {
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
+4
@@ -15,6 +15,8 @@ export function QuickStartSection(props: {
|
||||
projectSlug: string;
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
@@ -43,7 +45,9 @@ export function QuickStartSection(props: {
|
||||
action={
|
||||
<CreatePaymentLinkButton
|
||||
clientId={props.clientId}
|
||||
projectWalletAddress={props.projectWalletAddress}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button
|
||||
className="w-full gap-2 group text-foreground bg-background"
|
||||
|
||||
+46
-9
@@ -45,7 +45,12 @@ const formSchema = z.object({
|
||||
});
|
||||
|
||||
export function CreatePaymentLinkButton(
|
||||
props: PropsWithChildren<{ clientId: string; teamId: string }>,
|
||||
props: PropsWithChildren<{
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}>,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -55,8 +60,10 @@ export function CreatePaymentLinkButton(
|
||||
<DialogContent className="p-0 !max-w-lg">
|
||||
<CreatePaymentLinkDialogContent
|
||||
clientId={props.clientId}
|
||||
projectWalletAddress={props.projectWalletAddress}
|
||||
setOpen={setOpen}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -67,6 +74,8 @@ function CreatePaymentLinkDialogContent(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const client = getClientThirdwebClient();
|
||||
|
||||
@@ -119,6 +128,7 @@ function CreatePaymentLinkDialogContent(props: {
|
||||
amount: toUnits(values.amount.toString(), token.decimals),
|
||||
},
|
||||
title: values.title,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -182,14 +192,41 @@ function CreatePaymentLinkDialogContent(props: {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<Input
|
||||
className="w-full bg-card"
|
||||
{...field}
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
placeholder="Address or ENS"
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
className="w-full bg-card sm:flex-1"
|
||||
{...field}
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
placeholder="Address or ENS"
|
||||
required
|
||||
/>
|
||||
{props.projectWalletAddress && (
|
||||
<Button
|
||||
className="sm:w-auto"
|
||||
onClick={() => {
|
||||
if (!props.projectWalletAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue(
|
||||
"recipient",
|
||||
props.projectWalletAddress,
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Use Project Wallet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
+25
-3
@@ -40,7 +40,12 @@ import { ErrorState } from "../../components/ErrorState";
|
||||
import { formatTokenAmount } from "../../components/format";
|
||||
import { CreatePaymentLinkButton } from "./CreatePaymentLinkButton.client";
|
||||
|
||||
export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
|
||||
export function PaymentLinksTable(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
@@ -49,17 +54,28 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
|
||||
The payments you have created in this project
|
||||
</p>
|
||||
</div>
|
||||
<PaymentLinksTableInner clientId={props.clientId} teamId={props.teamId} />
|
||||
<PaymentLinksTableInner
|
||||
clientId={props.clientId}
|
||||
projectWalletAddress={props.projectWalletAddress}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
|
||||
function PaymentLinksTableInner(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const paymentLinksQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
return getPaymentLinks({
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
},
|
||||
queryKey: ["payment-links", props.clientId, props.teamId],
|
||||
@@ -71,6 +87,7 @@ function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
|
||||
return await Promise.all(
|
||||
paymentLinks.map(async (paymentLink) => {
|
||||
const { data } = await getPayments({
|
||||
authToken: props.authToken,
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
paymentLinkId: paymentLink.id,
|
||||
@@ -120,7 +137,9 @@ function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
|
||||
<CreatePaymentLinkButton
|
||||
key="create-payment-link"
|
||||
clientId={props.clientId}
|
||||
projectWalletAddress={props.projectWalletAddress}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button className="gap-2 rounded-full" variant="default" size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -215,6 +234,7 @@ function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
paymentLinkId={paymentLink.id}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button size="icon" variant="ghost">
|
||||
<TrashIcon className="size-4" strokeWidth={1} />
|
||||
@@ -264,6 +284,7 @@ function DeletePaymentLinkButton(
|
||||
paymentLinkId: string;
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}>,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -274,6 +295,7 @@ function DeletePaymentLinkButton(
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
paymentLinkId: id,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
|
||||
+9
@@ -6,6 +6,7 @@ import { getProject } from "@/api/project/projects";
|
||||
import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { PayIcon } from "@/icons/PayIcon";
|
||||
import { getProjectWallet } from "@/lib/server/project-wallet";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { AdvancedSection } from "./components/AdvancedSection.client";
|
||||
import { QuickStartSection } from "./components/QuickstartSection.client";
|
||||
@@ -29,6 +30,8 @@ export default async function Page(props: {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const projectWallet = await getProjectWallet(project);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
@@ -51,7 +54,9 @@ export default async function Page(props: {
|
||||
component: (
|
||||
<CreatePaymentLinkButton
|
||||
clientId={project.publishableKey}
|
||||
projectWalletAddress={projectWallet?.address}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
>
|
||||
<Button className="gap-1.5 rounded-full" size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -141,13 +146,17 @@ export default async function Page(props: {
|
||||
<div className="flex flex-col gap-12">
|
||||
<PaymentLinksTable
|
||||
clientId={project.publishableKey}
|
||||
projectWalletAddress={projectWallet?.address}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
<QuickStartSection
|
||||
projectSlug={params.project_slug}
|
||||
teamSlug={params.team_slug}
|
||||
clientId={project.publishableKey}
|
||||
projectWalletAddress={projectWallet?.address}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
<AdvancedSection
|
||||
|
||||
+392
-196
@@ -3,16 +3,26 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import {
|
||||
DotIcon,
|
||||
MoreVerticalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
WebhookIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type PropsWithChildren, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { randomPrivateKey } from "thirdweb/wallets";
|
||||
import { z } from "zod";
|
||||
import type { Webhook } from "@/api/universal-bridge/developer";
|
||||
import {
|
||||
createWebhook,
|
||||
deleteWebhook,
|
||||
getWebhooks,
|
||||
updateWebhook,
|
||||
} from "@/api/universal-bridge/developer";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +37,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormDescription,
|
||||
@@ -45,29 +61,26 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { shortenString } from "@/utils/usedapp-external";
|
||||
|
||||
type PayWebhooksPageProps = {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
authToken: string;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
const webhooksQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
return await getWebhooks({
|
||||
const res = await getWebhooks({
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
queryKey: ["webhooks", props.clientId, props.teamId],
|
||||
});
|
||||
@@ -78,11 +91,18 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
|
||||
if (!webhooksQuery.data?.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 rounded-lg border border-border p-8 text-center">
|
||||
<h2 className="font-semibold text-xl">No webhooks configured yet.</h2>
|
||||
<div className="flex flex-col items-center gap-6 rounded-lg border border-border bg-card p-8 text-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-xl">No webhooks configured</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a webhook to receive notifications for bridge, swap or onramp
|
||||
events.
|
||||
</p>
|
||||
</div>
|
||||
<CreatePaymentWebhookButton
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button className="gap-1.5 rounded-full">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -95,49 +115,24 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Url</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-center">Version</TableHead>
|
||||
<TableHead className="text-center">Delete</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooksQuery.data.map((webhook) => (
|
||||
<TableRow key={webhook.id}>
|
||||
<TableCell className="font-medium">{webhook.label}</TableCell>
|
||||
<TableCell>{webhook.url}</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{webhook.version || "1"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<DeleteWebhookButton
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
webhookId={webhook.id}
|
||||
>
|
||||
<Button size="icon" variant="ghost">
|
||||
<TrashIcon className="size-5" strokeWidth={1} />
|
||||
</Button>
|
||||
</DeleteWebhookButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="space-y-4">
|
||||
{webhooksQuery.data.map((webhook) => (
|
||||
<WebhookCard
|
||||
authToken={props.authToken}
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<CreatePaymentWebhookButton
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button className="gap-1.5 rounded-full">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -149,15 +144,105 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookCard(props: {
|
||||
webhook: Webhook;
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
layoutPath: string;
|
||||
}) {
|
||||
const { webhook, clientId, teamId, layoutPath } = props;
|
||||
return (
|
||||
<div
|
||||
key={webhook.id}
|
||||
className="rounded-xl border bg-card p-4 lg:p-6 relative hover:border-active-border"
|
||||
>
|
||||
<div className="flex mb-4">
|
||||
<div className="p-2 rounded-full bg-background border">
|
||||
<WebhookIcon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-medium mb-2">
|
||||
<Link
|
||||
href={`${layoutPath}/${webhook.id}`}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
{webhook.label || "Untitled Webhook"}
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<EditPaymentWebhookButton
|
||||
clientId={clientId}
|
||||
teamId={teamId}
|
||||
webhook={webhook}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
|
||||
<DeleteWebhookButton
|
||||
clientId={clientId}
|
||||
teamId={teamId}
|
||||
webhookId={webhook.id}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created{" "}
|
||||
{formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
|
||||
</p>
|
||||
|
||||
{webhook.version && (
|
||||
<>
|
||||
<DotIcon className="size-4 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
v{webhook.version || "1"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-[80%]">
|
||||
<CopyTextButton
|
||||
textToCopy={webhook.url}
|
||||
textToShow={webhook.url}
|
||||
tooltip="Copy URL"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground text-xs -translate-x-1.5 truncate"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
label: z.string().min(3, "Label must be at least 3 characters long"),
|
||||
secret: z.string().optional(),
|
||||
url: z.string().url("Please enter a valid URL."),
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
function CreatePaymentWebhookButton(
|
||||
props: PropsWithChildren<PayWebhooksPageProps>,
|
||||
props: PropsWithChildren<{
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}>,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [secretStored, setSecretStored] = useState(false);
|
||||
@@ -166,26 +251,135 @@ function CreatePaymentWebhookButton(
|
||||
return randomPrivateKey();
|
||||
}, [open]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
defaultValues: {
|
||||
label: "",
|
||||
url: "",
|
||||
version: "2",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: z.infer<typeof formSchema>) => {
|
||||
await createWebhook({
|
||||
clientId: props.clientId,
|
||||
async function handleSubmit(values: z.infer<typeof formSchema>) {
|
||||
await createWebhook({
|
||||
clientId: props.clientId,
|
||||
authToken: props.authToken,
|
||||
label: values.label,
|
||||
secret,
|
||||
teamId: props.teamId,
|
||||
url: values.url,
|
||||
version: Number(values.version),
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Webhook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Receive a webhook notification when a bridge, swap or onramp event
|
||||
occurs.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<BridgeWebhookModalContent
|
||||
type="create"
|
||||
handleSubmit={handleSubmit}
|
||||
secret={secret}
|
||||
setOpen={setOpen}
|
||||
secretStored={secretStored}
|
||||
setSecretStored={setSecretStored}
|
||||
clientId={props.clientId}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPaymentWebhookButton(props: {
|
||||
webhook: Webhook;
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function handleSubmit(values: z.infer<typeof formSchema>) {
|
||||
await updateWebhook({
|
||||
clientId: props.clientId,
|
||||
webhookId: props.webhook.id,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
body: {
|
||||
label: values.label,
|
||||
secret,
|
||||
teamId: props.teamId,
|
||||
url: values.url,
|
||||
version: Number(values.version),
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Webhook</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<BridgeWebhookModalContent
|
||||
type="edit"
|
||||
handleSubmit={handleSubmit}
|
||||
setOpen={setOpen}
|
||||
clientId={props.clientId}
|
||||
webhook={props.webhook}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BridgeWebhookModalContent(
|
||||
props: {
|
||||
clientId: string;
|
||||
handleSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
|
||||
setOpen: (value: boolean) => void;
|
||||
} & (
|
||||
| {
|
||||
type: "create";
|
||||
secret: string;
|
||||
secretStored: boolean;
|
||||
setSecretStored: (value: boolean) => void;
|
||||
}
|
||||
| {
|
||||
type: "edit";
|
||||
webhook: Webhook;
|
||||
}
|
||||
),
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
defaultValues:
|
||||
props.type === "create"
|
||||
? {
|
||||
label: "",
|
||||
url: "",
|
||||
version: "2",
|
||||
}
|
||||
: {
|
||||
label: props.webhook.label,
|
||||
url: props.webhook.url,
|
||||
version: props.webhook.version?.toString() || "2",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: z.infer<typeof formSchema>) => {
|
||||
await props.handleSubmit(values);
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
@@ -193,137 +387,129 @@ function CreatePaymentWebhookButton(
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
createMutation.mutateAsync(values, {
|
||||
onError: (err) => {
|
||||
toast.error("Failed to create webhook", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setSecretStored(false);
|
||||
toast.success("Webhook created successfully");
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
form.setValue("url", "");
|
||||
form.setValue("label", "");
|
||||
form.setValue("version", "2");
|
||||
form.setValue("secret", undefined);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Webhook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Receive a webhook notification when a bridge, swap or onramp
|
||||
event occurs.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>URL</RequiredFormLabel>
|
||||
<Input {...field} placeholder="https://" />
|
||||
<FormDescription>
|
||||
This is the URL that will receive the webhook.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>Label</RequiredFormLabel>
|
||||
<Input {...field} placeholder="My Webhook" />
|
||||
<FormDescription>
|
||||
A label to help you identify this webhook.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="version"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Version</FormLabel>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="v2" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">v2</SelectItem>
|
||||
<SelectItem value="1">v1</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the data format of the webhook payload (v2
|
||||
recommended, v1 for legacy users).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<FormLabel>Webhook Secret</FormLabel>
|
||||
|
||||
<CopyTextButton
|
||||
className="!h-auto my-1 w-full justify-between truncate bg-card px-3 py-3 font-mono"
|
||||
copyIconPosition="right"
|
||||
textToCopy={secret}
|
||||
textToShow={shortenString(secret)}
|
||||
tooltip="Copy Webhook Secret"
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
mutation.mutateAsync(values, {
|
||||
onError: (err) => {
|
||||
toast.error("Failed to create webhook", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
props.setOpen(false);
|
||||
toast.success(
|
||||
`Webhook ${props.type === "create" ? "created" : "updated"} successfully`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>URL</RequiredFormLabel>
|
||||
<Input {...field} placeholder="https://" autoComplete="off" />
|
||||
<FormDescription>
|
||||
Passed as a bearer token in all webhook requests to verify the
|
||||
authenticity of the request.
|
||||
This is the URL that will receive the webhook.
|
||||
</FormDescription>
|
||||
<CheckboxWithLabel className="my-2 text-foreground">
|
||||
<Checkbox
|
||||
checked={secretStored}
|
||||
onCheckedChange={(v) => {
|
||||
setSecretStored(!!v);
|
||||
}}
|
||||
/>
|
||||
I confirm that I've securely stored my webhook secret
|
||||
</CheckboxWithLabel>
|
||||
</section>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>Label</RequiredFormLabel>
|
||||
<Input {...field} placeholder="My Webhook" />
|
||||
<FormDescription>
|
||||
A label to help you identify this webhook.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={createMutation.isPending || !secretStored}
|
||||
type="submit"
|
||||
>
|
||||
Create Webhook
|
||||
{createMutation.isPending && <Spinner className="size-4" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormField
|
||||
name="version"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Version</FormLabel>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="v2" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">v2</SelectItem>
|
||||
<SelectItem value="1">v1</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the data format of the webhook payload (v2 recommended,
|
||||
v1 for legacy users).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{props.type === "create" && (
|
||||
<section>
|
||||
<FormLabel>Webhook Secret</FormLabel>
|
||||
|
||||
<CopyTextButton
|
||||
className="!h-auto my-1 w-full justify-between truncate bg-card px-3 py-3 font-mono"
|
||||
copyIconPosition="right"
|
||||
textToCopy={props.secret}
|
||||
textToShow={shortenString(props.secret)}
|
||||
tooltip="Copy Webhook Secret"
|
||||
/>
|
||||
<FormDescription>
|
||||
Passed as a bearer token in all webhook requests to verify the
|
||||
authenticity of the request.
|
||||
</FormDescription>
|
||||
|
||||
<CheckboxWithLabel className="my-2 text-foreground">
|
||||
<Checkbox
|
||||
checked={!!props.secretStored}
|
||||
onCheckedChange={(v) => {
|
||||
props.setSecretStored(!!v);
|
||||
}}
|
||||
/>
|
||||
I confirm that I've securely stored my webhook secret
|
||||
</CheckboxWithLabel>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
(props.type === "create" && !props.secretStored)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{props.type === "create" ? "Create Webhook" : "Update Webhook"}
|
||||
{mutation.isPending && <Spinner className="size-4" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteWebhookButton(
|
||||
props: PropsWithChildren<PayWebhooksPageProps & { webhookId: string }>,
|
||||
) {
|
||||
function DeleteWebhookButton(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
webhookId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
@@ -332,8 +518,8 @@ function DeleteWebhookButton(
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
webhookId: id,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
@@ -343,7 +529,17 @@ function DeleteWebhookButton(
|
||||
});
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
+35
-1
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { type Fee, updateFee } from "@/api/universal-bridge/developer";
|
||||
import { SettingsCard } from "@/components/blocks/SettingsCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,6 +26,8 @@ interface PayConfigProps {
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
fees: Fee;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export const PayConfig: React.FC<PayConfigProps> = (props) => {
|
||||
@@ -46,6 +49,7 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
|
||||
feeBps: values.developerFeeBPS,
|
||||
feeRecipient: values.payoutAddress,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -103,7 +107,37 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Recipient address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="0x..." />
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
{...field}
|
||||
className="sm:flex-1"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
{props.projectWalletAddress && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!props.projectWalletAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue(
|
||||
"payoutAddress",
|
||||
props.projectWalletAddress,
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Use Project Wallet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
+6
@@ -3,6 +3,7 @@ import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { getFees } from "@/api/universal-bridge/developer";
|
||||
import { getProjectWallet } from "@/lib/server/project-wallet";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb";
|
||||
import { PayConfig } from "./PayConfig";
|
||||
@@ -31,9 +32,12 @@ export default async function Page(props: {
|
||||
redirect(`/team/${team_slug}`);
|
||||
}
|
||||
|
||||
const projectWallet = await getProjectWallet(project);
|
||||
|
||||
let fees = await getFees({
|
||||
clientId: project.publishableKey,
|
||||
teamId: team.id,
|
||||
authToken: authToken,
|
||||
}).catch(() => {
|
||||
return {
|
||||
createdAt: "",
|
||||
@@ -64,7 +68,9 @@ export default async function Page(props: {
|
||||
fees={fees}
|
||||
project={project}
|
||||
teamId={team.id}
|
||||
projectWalletAddress={projectWallet?.address}
|
||||
teamSlug={team_slug}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+4
@@ -74,6 +74,10 @@ export type CreateNFTCollectionFunctions = {
|
||||
count: number;
|
||||
};
|
||||
gasless: boolean;
|
||||
onNotEnoughFunds: (data: {
|
||||
requiredAmount: string;
|
||||
balance: string;
|
||||
}) => void;
|
||||
}) => Promise<void>;
|
||||
lazyMintNFTs: (params: {
|
||||
values: CreateNFTCollectionAllValues;
|
||||
|
||||
+98
-3
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
import { useRef } from "react";
|
||||
import { encode, getContract, type ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
encode,
|
||||
estimateGasCost,
|
||||
getContract,
|
||||
getGasPrice,
|
||||
type PreparedTransaction,
|
||||
type ThirdwebClient,
|
||||
toEther,
|
||||
} from "thirdweb";
|
||||
import { deployERC721Contract, deployERC1155Contract } from "thirdweb/deploys";
|
||||
import { multicall } from "thirdweb/extensions/common";
|
||||
import {
|
||||
@@ -14,7 +22,8 @@ import {
|
||||
} from "thirdweb/extensions/erc1155";
|
||||
import { grantRole } from "thirdweb/extensions/permissions";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { maxUint256, resolvePromisedValue } from "thirdweb/utils";
|
||||
import { getWalletBalance } from "thirdweb/wallets";
|
||||
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
|
||||
import { revalidatePathAction } from "@/actions/revalidate";
|
||||
import { reportContractDeployed } from "@/analytics/report";
|
||||
@@ -39,6 +48,10 @@ export function CreateNFTPage(props: {
|
||||
const contractAddressRef = useRef<string | undefined>(undefined);
|
||||
const getChain = useGetV5DashboardChain();
|
||||
const sendAndConfirmTx = useSendAndConfirmTx();
|
||||
const sendAndConfirmTxNoPayModal = useSendAndConfirmTx({
|
||||
payModal: false,
|
||||
});
|
||||
|
||||
function getAccount(params: { gasless: boolean }) {
|
||||
if (!activeAccount) {
|
||||
throw new Error("Wallet is not connected");
|
||||
@@ -130,6 +143,7 @@ export function CreateNFTPage(props: {
|
||||
contractName: contractType,
|
||||
deploymentType: "asset",
|
||||
publisher: "deployer.thirdweb.eth",
|
||||
is_testnet: chain.testnet,
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
@@ -218,6 +232,10 @@ export function CreateNFTPage(props: {
|
||||
count: number;
|
||||
};
|
||||
gasless: boolean;
|
||||
onNotEnoughFunds: (data: {
|
||||
requiredAmount: string;
|
||||
balance: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const { values, batch } = params;
|
||||
const contract = getDeployedContract({
|
||||
@@ -277,7 +295,46 @@ export function CreateNFTPage(props: {
|
||||
data: encodedTransactions,
|
||||
});
|
||||
|
||||
await sendAndConfirmTx.mutateAsync(tx);
|
||||
// if there are more than one batches, on the first batch, we check if the user has enough funds to cover the cost of all the batches ->
|
||||
// calculate the cost of one batch, multiply by the number of batches to get the total cost
|
||||
// if the user does not have enough funds, call the onNotEnoughFunds callback
|
||||
|
||||
const totalBatches = Math.ceil(values.nfts.length / batch.count);
|
||||
|
||||
if (batch.startIndex === 0 && totalBatches > 1 && !params.gasless) {
|
||||
if (!activeAccount) {
|
||||
throw new Error("Wallet is not connected");
|
||||
}
|
||||
|
||||
const costPerBatch = await getTotalTransactionCost({
|
||||
tx: tx,
|
||||
from: activeAccount.address,
|
||||
});
|
||||
|
||||
const totalCost = costPerBatch * BigInt(totalBatches);
|
||||
|
||||
const walletBalance = await getWalletBalance({
|
||||
address: activeAccount.address,
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
});
|
||||
|
||||
if (walletBalance.value < totalCost) {
|
||||
params.onNotEnoughFunds({
|
||||
balance: toEther(walletBalance.value),
|
||||
requiredAmount: toEther(totalCost),
|
||||
});
|
||||
throw new Error(
|
||||
`Not enough funds: Required ${toEther(totalCost)}, Balance ${toEther(walletBalance.value)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await sendAndConfirmTxNoPayModal.mutateAsync(tx);
|
||||
} else if (totalBatches > 1) {
|
||||
await sendAndConfirmTxNoPayModal.mutateAsync(tx);
|
||||
} else {
|
||||
await sendAndConfirmTx.mutateAsync(tx);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetAdmins(params: {
|
||||
@@ -390,3 +447,41 @@ function transformSocialUrls(
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
}
|
||||
|
||||
async function getTransactionGasCost(tx: PreparedTransaction, from?: string) {
|
||||
try {
|
||||
const gasCost = await estimateGasCost({
|
||||
from,
|
||||
transaction: tx,
|
||||
});
|
||||
|
||||
const bufferCost = gasCost.wei / 10n;
|
||||
|
||||
// Note: get tx.value AFTER estimateGasCost
|
||||
// add 10% extra gas cost to the estimate to ensure user buys enough to cover the tx cost
|
||||
return gasCost.wei + bufferCost;
|
||||
} catch {
|
||||
if (from) {
|
||||
// try again without passing from
|
||||
return await getTransactionGasCost(tx);
|
||||
}
|
||||
// fallback if both fail, use the tx value + 1M * gas price
|
||||
const gasPrice = await getGasPrice({
|
||||
chain: tx.chain,
|
||||
client: tx.client,
|
||||
});
|
||||
|
||||
return 1_000_000n * gasPrice;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTotalTransactionCost(params: {
|
||||
tx: PreparedTransaction;
|
||||
from?: string;
|
||||
}) {
|
||||
const [txValue, txGasCost] = await Promise.all([
|
||||
resolvePromisedValue(params.tx.value),
|
||||
getTransactionGasCost(params.tx, params.from),
|
||||
]);
|
||||
return (txValue || 0n) + txGasCost;
|
||||
}
|
||||
|
||||
+180
-32
@@ -1,14 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
ImageOffIcon,
|
||||
RefreshCcwIcon,
|
||||
ShoppingBagIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
BuyWidget,
|
||||
TokenProvider,
|
||||
TokenSymbol,
|
||||
useActiveAccount,
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { ChainOverview } from "../../_common/chain-overview";
|
||||
import { StepCard } from "../../_common/step-card";
|
||||
import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell";
|
||||
@@ -71,6 +77,10 @@ export function LaunchNFT(props: {
|
||||
const walletRequiresApproval = activeWallet?.id !== "inApp";
|
||||
const account = useActiveAccount();
|
||||
const contractAddressRef = useRef<string | null>(null);
|
||||
const { theme } = useTheme();
|
||||
const chainMeta = useV5DashboardChain(
|
||||
Number(formValues.collectionInfo.chain),
|
||||
);
|
||||
|
||||
function updateStatus(
|
||||
index: number,
|
||||
@@ -161,6 +171,18 @@ export function LaunchNFT(props: {
|
||||
const [isGasless, setIsGasless] = useState(canEnableGasless);
|
||||
const showGaslessSection = false; // activeWallet?.id === "inApp";
|
||||
|
||||
const [notEnoughFunds, setNotEnoughFunds] = useState<
|
||||
| false
|
||||
| {
|
||||
values: {
|
||||
requiredAmount: string;
|
||||
balance: string;
|
||||
};
|
||||
isBuySuccess: boolean;
|
||||
showBuyWidget: boolean;
|
||||
}
|
||||
>(false);
|
||||
|
||||
const contractLink = contractAddressRef.current
|
||||
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}`
|
||||
: null;
|
||||
@@ -204,6 +226,16 @@ export function LaunchNFT(props: {
|
||||
},
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
onNotEnoughFunds: (data) => {
|
||||
setNotEnoughFunds({
|
||||
values: {
|
||||
balance: data.balance,
|
||||
requiredAmount: data.requiredAmount,
|
||||
},
|
||||
isBuySuccess: false,
|
||||
showBuyWidget: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
batchesProcessedRef.current += 1;
|
||||
@@ -216,6 +248,16 @@ export function LaunchNFT(props: {
|
||||
},
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
onNotEnoughFunds: (data) => {
|
||||
setNotEnoughFunds({
|
||||
values: {
|
||||
balance: data.balance,
|
||||
requiredAmount: data.requiredAmount,
|
||||
},
|
||||
isBuySuccess: false,
|
||||
showBuyWidget: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -274,6 +316,8 @@ export function LaunchNFT(props: {
|
||||
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
|
||||
error: errorMessage,
|
||||
step: currentStep.id,
|
||||
is_testnet: chainMeta.testnet,
|
||||
chainId: Number(formValues.collectionInfo.chain),
|
||||
});
|
||||
|
||||
throw error;
|
||||
@@ -283,6 +327,8 @@ export function LaunchNFT(props: {
|
||||
reportAssetCreationSuccessful({
|
||||
assetType: "nft",
|
||||
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
|
||||
chainId: Number(formValues.collectionInfo.chain),
|
||||
is_testnet: chainMeta?.testnet ?? false,
|
||||
});
|
||||
|
||||
props.onLaunchSuccess();
|
||||
@@ -357,41 +403,143 @@ export function LaunchNFT(props: {
|
||||
className="gap-0 overflow-hidden p-0 md:max-w-[480px]"
|
||||
dialogCloseClassName="hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<DialogHeader className="space-y-0.5">
|
||||
<DialogTitle className="font-semibold text-xl tracking-tight">
|
||||
Status
|
||||
</DialogTitle>
|
||||
{walletRequiresApproval && (
|
||||
<DialogDescription>
|
||||
Each step will prompt a signature request in your wallet
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{/** biome-ignore lint/complexity/useOptionalChain: can't change it to optional chain */}
|
||||
{notEnoughFunds && notEnoughFunds.showBuyWidget ? (
|
||||
<div className="bg-card">
|
||||
<div className="border-b py-2 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setNotEnoughFunds({
|
||||
...notEnoughFunds,
|
||||
showBuyWidget: false,
|
||||
})
|
||||
}
|
||||
className="gap-2 px-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<BuyWidget
|
||||
client={props.client}
|
||||
chain={chain}
|
||||
amount={String(
|
||||
Number(notEnoughFunds.values.requiredAmount) -
|
||||
Number(notEnoughFunds.values.balance),
|
||||
)}
|
||||
buttonLabel={`Buy ${chainMeta?.nativeCurrency?.symbol || "ETH"}`}
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
className="!border-none !w-full !rounded-none"
|
||||
onSuccess={() => {
|
||||
setNotEnoughFunds({
|
||||
...notEnoughFunds,
|
||||
isBuySuccess: true,
|
||||
showBuyWidget: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6 p-6 mb-2">
|
||||
<DialogHeader className="space-y-0.5">
|
||||
<DialogTitle className="font-semibold text-xl tracking-tight">
|
||||
Status
|
||||
</DialogTitle>
|
||||
{walletRequiresApproval && (
|
||||
<DialogDescription>
|
||||
Each step will prompt a signature request in your wallet
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<MultiStepStatus
|
||||
onRetry={handleRetry}
|
||||
renderError={(step, errorMessage) => {
|
||||
if (
|
||||
props.teamPlan === "free" &&
|
||||
errorMessage.toLowerCase().includes("storage limit")
|
||||
) {
|
||||
return (
|
||||
<StorageErrorPlanUpsell
|
||||
onRetry={() => handleRetry(step)}
|
||||
teamSlug={props.teamSlug}
|
||||
trackingCampaign="create-nft"
|
||||
/>
|
||||
);
|
||||
}
|
||||
<MultiStepStatus
|
||||
onRetry={handleRetry}
|
||||
renderError={(step, errorMessage) => {
|
||||
if (
|
||||
props.teamPlan === "free" &&
|
||||
errorMessage.toLowerCase().includes("storage limit")
|
||||
) {
|
||||
return (
|
||||
<StorageErrorPlanUpsell
|
||||
onRetry={() => handleRetry(step)}
|
||||
teamSlug={props.teamSlug}
|
||||
trackingCampaign="create-nft"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
steps={steps}
|
||||
/>
|
||||
</div>
|
||||
if (notEnoughFunds) {
|
||||
return (
|
||||
<div>
|
||||
{notEnoughFunds.isBuySuccess ? (
|
||||
<p className="text-success-text text-sm mt-1.5 mb-2">
|
||||
Funds bought successfully
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 mb-2 space-y-1">
|
||||
<p className="text-destructive-text text-sm">
|
||||
Not enough funds
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Required Funds:{" "}
|
||||
{notEnoughFunds.values.requiredAmount}{" "}
|
||||
{chainMeta?.nativeCurrency?.symbol || "ETH"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Balance: {notEnoughFunds.values.balance}{" "}
|
||||
{chainMeta?.nativeCurrency?.symbol || "ETH"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-4 border-border border-t bg-card p-6">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleRetry(step);
|
||||
setNotEnoughFunds(false);
|
||||
}}
|
||||
size="sm"
|
||||
variant={
|
||||
notEnoughFunds.isBuySuccess
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCcwIcon className="size-3.5" />
|
||||
Retry
|
||||
</Button>
|
||||
|
||||
{!notEnoughFunds.isBuySuccess && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNotEnoughFunds({
|
||||
...notEnoughFunds,
|
||||
showBuyWidget: true,
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<ShoppingBagIcon className="size-3.5" />
|
||||
Buy Funds
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
steps={steps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-4 border-border border-t bg-card p-6">
|
||||
{!isComplete ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
+6
-1
@@ -178,12 +178,14 @@ export function CreateTokenAssetPage(props: {
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
const chainMetadata = getChain(Number(params.values.chain));
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(params.values.chain),
|
||||
contractName: "DropERC20",
|
||||
contractName: "ERC20Asset",
|
||||
deploymentType: "asset",
|
||||
publisher: account.address,
|
||||
is_testnet: chainMetadata.testnet,
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
@@ -299,12 +301,15 @@ export function CreateTokenAssetPage(props: {
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
const chainMetadata = getChain(Number(values.chain));
|
||||
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(values.chain),
|
||||
contractName: "DropERC20",
|
||||
deploymentType: "asset",
|
||||
publisher: "deployer.thirdweb.eth",
|
||||
is_testnet: chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
+4
@@ -230,6 +230,8 @@ export function LaunchTokenStatus(props: {
|
||||
: "ERC20Asset",
|
||||
error: errorMessage,
|
||||
step: currentStep.id,
|
||||
is_testnet: chainMetadata?.testnet,
|
||||
chainId: Number(formValues.chain),
|
||||
});
|
||||
|
||||
updateStatus(i, {
|
||||
@@ -247,6 +249,8 @@ export function LaunchTokenStatus(props: {
|
||||
formValues.saleMode === "drop-erc20:token-drop"
|
||||
? "DropERC20"
|
||||
: "ERC20Asset",
|
||||
chainId: Number(formValues.chain),
|
||||
is_testnet: chainMetadata?.testnet,
|
||||
});
|
||||
|
||||
if (contractAddressRef.current) {
|
||||
|
||||
+2
-3
@@ -1,10 +1,10 @@
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { UnifiedTransactionsTable } from "../components/transactions-table.client";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import { TransactionAnalyticsFilter } from "./filter";
|
||||
import { TransactionsChartCard } from "./tx-chart/tx-chart";
|
||||
import { TransactionsTable } from "./tx-table/tx-table";
|
||||
|
||||
export function TransactionsAnalyticsPageContent(props: {
|
||||
searchParams: {
|
||||
@@ -34,11 +34,10 @@ export function TransactionsAnalyticsPageContent(props: {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TransactionsTable
|
||||
<UnifiedTransactionsTable
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
|
||||
+17
@@ -5,15 +5,19 @@ import type { Project } from "@/api/project/projects";
|
||||
import { type Step, StepsCard } from "@/components/blocks/StepsCard";
|
||||
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
|
||||
import { SendTestSolanaTransaction } from "./send-test-solana-tx.client";
|
||||
import { SendTestTransaction } from "./send-test-tx.client";
|
||||
|
||||
interface Props {
|
||||
managementAccessToken: string | undefined;
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
solanaWallets: SolanaWallet[];
|
||||
hasTransactions: boolean;
|
||||
teamSlug: string;
|
||||
testTxWithWallet?: string | undefined;
|
||||
testSolanaTxWithWallet?: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
isManagedVault: boolean;
|
||||
}
|
||||
@@ -85,6 +89,19 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (props.testSolanaTxWithWallet) {
|
||||
return (
|
||||
<SendTestSolanaTransaction
|
||||
isManagedVault={props.isManagedVault}
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
walletId={props.testSolanaTxWithWallet}
|
||||
wallets={props.solanaWallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (finalSteps.length === 0 || isComplete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLinkIcon, Loader2Icon, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import * as z from "zod";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { TryItOut } from "../server-wallets/components/try-it-out";
|
||||
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
secretKey: z.string().min(1, "Secret key is required"),
|
||||
walletIndex: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function SendTestSolanaTransactionModal(props: {
|
||||
wallets?: SolanaWallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
walletId?: string;
|
||||
isManagedVault: boolean;
|
||||
client: ThirdwebClient;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [, setTxChain] = useQueryState("txChain", {
|
||||
history: "push",
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
secretKey: "",
|
||||
walletIndex:
|
||||
props.wallets && props.walletId
|
||||
? props.wallets
|
||||
.findIndex((w) => w.id === props.walletId)
|
||||
?.toString()
|
||||
.replace("-1", "0")
|
||||
: "0",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const selectedWalletIndex = Number.parseInt(form.watch("walletIndex"));
|
||||
const selectedWallet = props.wallets?.[selectedWalletIndex];
|
||||
|
||||
const sendDummySolanaTxMutation = useMutation({
|
||||
mutationFn: async (args: { walletAddress: string; secretKey: string }) => {
|
||||
const response = await engineCloudProxy({
|
||||
body: JSON.stringify({
|
||||
executionOptions: {
|
||||
chainId: "solana:devnet",
|
||||
signerAddress: args.walletAddress,
|
||||
maxBlockhashRetries: 0,
|
||||
commitment: "confirmed",
|
||||
},
|
||||
instructions: [
|
||||
{
|
||||
programId: "11111111111111111111111111111111", // System Program
|
||||
accounts: [
|
||||
{
|
||||
pubkey: args.walletAddress,
|
||||
isSigner: true,
|
||||
isWritable: true,
|
||||
},
|
||||
{
|
||||
pubkey: args.walletAddress,
|
||||
isSigner: false,
|
||||
isWritable: true,
|
||||
},
|
||||
],
|
||||
data: "AgAAAAAAAAAAAAAAAA==", // Transfer 0 SOL (base64)
|
||||
encoding: "base64",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.project.publishableKey,
|
||||
"x-team-id": props.project.teamId,
|
||||
...(props.isManagedVault
|
||||
? { "x-secret-key": args.secretKey }
|
||||
: { "x-vault-access-token": args.secretKey }),
|
||||
},
|
||||
method: "POST",
|
||||
pathname: "/v1/solana/transaction",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = response.error ?? "Failed to send Solana transaction";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Test Solana transaction sent successfully!");
|
||||
// Set the transaction chain to solana so the table switches
|
||||
setTxChain("solana");
|
||||
// Close the modal after successful transaction
|
||||
setTimeout(() => {
|
||||
props.onOpenChange(false);
|
||||
}, 1000);
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = sendDummySolanaTxMutation.isPending;
|
||||
|
||||
// Early return in render phase
|
||||
if (!props.wallets || props.wallets.length === 0 || !selectedWallet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await sendDummySolanaTxMutation.mutateAsync({
|
||||
secretKey: data.secretKey,
|
||||
walletAddress: selectedWallet.publicKey,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["solana-transactions", props.project.id],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Test Solana Transaction</DialogTitle>
|
||||
<DialogDescription>
|
||||
Test your Solana server wallet by sending a 0 SOL transfer transaction
|
||||
on Solana Devnet
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Funding Alert */}
|
||||
<Alert>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<p className="font-medium">⚠️ Fund your wallet first</p>
|
||||
<p className="text-sm">
|
||||
Your Solana wallet needs SOL on Devnet to pay for transaction fees.
|
||||
Get free Devnet SOL from the Solana faucet:
|
||||
</p>
|
||||
<Link
|
||||
href="https://faucet.solana.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-link-foreground hover:underline text-sm"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
https://faucet.solana.com/
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Wallet Selector */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Select Wallet</p>
|
||||
<Select
|
||||
disabled={props.wallets.length === 1}
|
||||
value={form.watch("walletIndex")}
|
||||
onValueChange={(value) => form.setValue("walletIndex", value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.wallets.map((wallet, index) => (
|
||||
<SelectItem
|
||||
key={wallet.id}
|
||||
value={index.toString()}
|
||||
className="font-mono"
|
||||
>
|
||||
{wallet.metadata.label || `Wallet ${index + 1}`} (
|
||||
{wallet.publicKey.slice(0, 6)}...
|
||||
{wallet.publicKey.slice(-4)})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Wallet Address Display */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Public Key</p>
|
||||
<code className="text-xs font-mono bg-muted p-2 rounded break-all">
|
||||
{selectedWallet.publicKey}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Network Info */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Network</p>
|
||||
<div className="flex items-center gap-2 bg-muted p-2 rounded">
|
||||
<div className="size-4 rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
|
||||
<span className="text-sm">Solana Devnet</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret Key Input */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">
|
||||
{props.isManagedVault ? "Secret Key" : "Vault Access Token"}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...form.register("secretKey")}
|
||||
className="pr-10 font-mono"
|
||||
placeholder={
|
||||
props.isManagedVault
|
||||
? "Enter your secret key"
|
||||
: "Enter your vault access token"
|
||||
}
|
||||
type="password"
|
||||
/>
|
||||
<LockIcon className="absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
{form.formState.errors.secretKey && (
|
||||
<p className="text-destructive text-sm">
|
||||
{form.formState.errors.secretKey.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
|
||||
<Button
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full min-w-[200px] md:w-auto"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Send Transaction"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendTestSolanaTransaction(props: {
|
||||
wallets?: SolanaWallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
expanded?: boolean;
|
||||
walletId?: string;
|
||||
isManagedVault: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
// Early return in render phase
|
||||
if (!props.wallets || props.wallets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 w-full rounded-md border bg-background p-6">
|
||||
<TryItOut useEngineAPI={false} />
|
||||
<div className="mt-6 flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" className="w-full md:w-auto">
|
||||
Test Request
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<SendTestSolanaTransactionModal
|
||||
{...props}
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
/>
|
||||
</Dialog>
|
||||
<Button
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => {
|
||||
if (props.walletId) {
|
||||
router.replace(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
);
|
||||
} else {
|
||||
localStorage.setItem("solanEngineFtuxCompleted", "true");
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
{props.walletId ? "Close" : "Complete Setup"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
export type SolanaTransactionStatus =
|
||||
| "QUEUED"
|
||||
| "SUBMITTED"
|
||||
| "CONFIRMED"
|
||||
| "FAILED";
|
||||
|
||||
type SolanaTransactionParamsSerialized = {
|
||||
instructions: Array<{
|
||||
programId: string;
|
||||
keys: Array<{
|
||||
pubkey: string;
|
||||
isSigner: boolean;
|
||||
isWritable: boolean;
|
||||
}>;
|
||||
data: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type SolanaExecutionParamsSerialized = {
|
||||
type: "SOLANA";
|
||||
signerAddress: string;
|
||||
chainId: string;
|
||||
commitment?: "processed" | "confirmed" | "finalized";
|
||||
computeUnitLimit?: number;
|
||||
computeUnitPrice?: number;
|
||||
skipPreflight?: boolean;
|
||||
};
|
||||
|
||||
type SolanaExecutorError = {
|
||||
errorCode?: string;
|
||||
message?: string;
|
||||
innerError?: unknown;
|
||||
reason?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type SolanaTransactionMeta = {
|
||||
err?: unknown | null;
|
||||
status?: {
|
||||
Ok?: unknown | null;
|
||||
};
|
||||
fee: number;
|
||||
preBalances: number[];
|
||||
postBalances: number[];
|
||||
innerInstructions: unknown[];
|
||||
logMessages: string[];
|
||||
preTokenBalances: unknown[];
|
||||
postTokenBalances: unknown[];
|
||||
rewards: unknown[];
|
||||
loadedAddresses: {
|
||||
writable: string[];
|
||||
readonly: string[];
|
||||
};
|
||||
computeUnitsConsumed?: number;
|
||||
costUnits?: number;
|
||||
};
|
||||
|
||||
type SolanaTransactionDetails = {
|
||||
transaction: {
|
||||
signatures: string[];
|
||||
message: {
|
||||
header: {
|
||||
numRequiredSignatures: number;
|
||||
numReadonlySignedAccounts: number;
|
||||
numReadonlyUnsignedAccounts: number;
|
||||
};
|
||||
accountKeys: string[];
|
||||
recentBlockhash: string;
|
||||
instructions: Array<{
|
||||
programIdIndex: number;
|
||||
accounts: number[];
|
||||
data: string;
|
||||
stackHeight?: number;
|
||||
}>;
|
||||
addressTableLookups: unknown[];
|
||||
};
|
||||
};
|
||||
meta: SolanaTransactionMeta;
|
||||
version: number | "legacy";
|
||||
};
|
||||
|
||||
type SolanaExecutionResultSerialized =
|
||||
| {
|
||||
status: "QUEUED";
|
||||
}
|
||||
| {
|
||||
status: "FAILED";
|
||||
error: SolanaExecutorError;
|
||||
}
|
||||
| {
|
||||
status: "SUBMITTED";
|
||||
monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR";
|
||||
signature: string;
|
||||
submissionAttemptNumber: number;
|
||||
}
|
||||
| ({
|
||||
status: "CONFIRMED";
|
||||
signature: string;
|
||||
signerAddress: string;
|
||||
chainId: string;
|
||||
submissionAttemptNumber: number;
|
||||
slot: number;
|
||||
blockTime: number | null;
|
||||
transaction: SolanaTransactionDetails;
|
||||
} & (
|
||||
| {
|
||||
onchainStatus: "SUCCESS";
|
||||
}
|
||||
| {
|
||||
onchainStatus: "REVERTED";
|
||||
error?: string;
|
||||
}
|
||||
));
|
||||
|
||||
export type SolanaTransaction = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
chainId: string;
|
||||
signerAddress: string;
|
||||
transactionParams: SolanaTransactionParamsSerialized;
|
||||
signature: string | null;
|
||||
status: SolanaTransactionStatus | null;
|
||||
confirmedAt: Date | null;
|
||||
confirmedAtSlot: string | null;
|
||||
blockTime: number | null;
|
||||
enrichedData: unknown[];
|
||||
executionParams: SolanaExecutionParamsSerialized;
|
||||
executionResult: SolanaExecutionResultSerialized | null;
|
||||
createdAt: Date;
|
||||
errorMessage: string | null;
|
||||
cancelledAt: Date | null;
|
||||
};
|
||||
|
||||
export type SolanaTransactionsResponse = {
|
||||
transactions: SolanaTransaction[];
|
||||
pagination: {
|
||||
totalCount: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
};
|
||||
-417
@@ -1,417 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useId, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Wallet } from "../../server-wallets/wallet-table/types";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionStatus,
|
||||
TransactionsResponse,
|
||||
} from "./types";
|
||||
|
||||
// TODO - add Status selector dropdown here
|
||||
export function TransactionsTableUI(props: {
|
||||
getData: (params: {
|
||||
page: number;
|
||||
status: TransactionStatus | undefined;
|
||||
}) => Promise<TransactionsResponse>;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
wallets?: Wallet[];
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [status, setStatus] = useState<TransactionStatus | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const pageSize = 10;
|
||||
const transactionsQuery = useQuery({
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () => props.getData({ page, status }),
|
||||
queryKey: ["transactions", props.project.id, page, status],
|
||||
refetchInterval: autoUpdate ? 4_000 : false,
|
||||
});
|
||||
|
||||
const transactions = transactionsQuery.data?.transactions ?? [];
|
||||
|
||||
const totalCount = transactionsQuery.data?.pagination.totalCount ?? 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const showPagination = totalCount > pageSize;
|
||||
|
||||
const showSkeleton =
|
||||
(transactionsQuery.isPlaceholderData && transactionsQuery.isFetching) ||
|
||||
(transactionsQuery.isLoading && !transactionsQuery.isPlaceholderData);
|
||||
|
||||
const autoUpdateId = useId();
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6 lg:flex-row lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Transaction History
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Transactions sent from server wallets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-5 border-border border-t pt-4 lg:border-none lg:pt-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label htmlFor={autoUpdateId}>Auto Update</Label>
|
||||
<Switch
|
||||
checked={autoUpdate}
|
||||
id={autoUpdateId}
|
||||
onCheckedChange={(v) => setAutoUpdate(!!v)}
|
||||
/>
|
||||
</div>
|
||||
<StatusSelector
|
||||
setStatus={(v) => {
|
||||
setStatus(v);
|
||||
// reset page
|
||||
setPage(1);
|
||||
}}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-none border-x-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Queue ID</TableHead>
|
||||
<TableHead>Chain</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>From</TableHead>
|
||||
<TableHead>Tx Hash</TableHead>
|
||||
<TableHead>Queued</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{showSkeleton
|
||||
? new Array(pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
|
||||
<SkeletonRow key={i} />
|
||||
))
|
||||
: transactions.map((tx) => (
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
key={`${tx.id}${tx.chainId}`}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions/tx/${tx.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{/* Queue ID */}
|
||||
<TableCell className="font-medium">
|
||||
<CopyAddressButton
|
||||
address={tx.id ? tx.id : ""}
|
||||
className="text-muted-foreground"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Chain Id */}
|
||||
<TableCell>
|
||||
<TxChainCell
|
||||
chainId={tx.chainId || undefined}
|
||||
client={props.client}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Status */}
|
||||
<TableCell>
|
||||
<TxStatusCell transaction={tx} />
|
||||
</TableCell>
|
||||
|
||||
{/* From Address */}
|
||||
<TableCell>
|
||||
{tx.from ? (
|
||||
<WalletAddress
|
||||
address={tx.from}
|
||||
client={props.client}
|
||||
/>
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Tx Hash */}
|
||||
<TableCell>
|
||||
<TxHashCell transaction={tx} />
|
||||
</TableCell>
|
||||
|
||||
{/* Queued At */}
|
||||
<TableCell>
|
||||
<TxQueuedAtCell transaction={tx} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!showSkeleton && transactions.length === 0 && (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
No transactions found
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const statusDetails = {
|
||||
CONFIRMED: {
|
||||
name: "Confirmed",
|
||||
type: "success",
|
||||
},
|
||||
FAILED: {
|
||||
name: "Failed",
|
||||
type: "destructive",
|
||||
},
|
||||
QUEUED: {
|
||||
name: "Queued",
|
||||
type: "warning",
|
||||
},
|
||||
SUBMITTED: {
|
||||
name: "Submitted",
|
||||
type: "warning",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function StatusSelector(props: {
|
||||
status: TransactionStatus | undefined;
|
||||
setStatus: (value: TransactionStatus | undefined) => void;
|
||||
}) {
|
||||
const statuses = Object.keys(statusDetails) as TransactionStatus[];
|
||||
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
if (v === "all") {
|
||||
props.setStatus(undefined);
|
||||
} else {
|
||||
props.setStatus(v as TransactionStatus);
|
||||
}
|
||||
}}
|
||||
value={props.status || "all"}
|
||||
>
|
||||
<SelectTrigger className="min-w-[160px] rounded-full">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All Transactions</SelectItem>
|
||||
{statuses.map((item) => {
|
||||
return (
|
||||
<SelectItem key={item} value={item}>
|
||||
{statusDetails[item].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<TableRow className="h-[73px]">
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[152px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[155px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[58px] rounded-full" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[145px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[160px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[108px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TxChainCell(props: {
|
||||
chainId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { chainId } = props;
|
||||
const { idToChain } = useAllChainsData();
|
||||
if (!chainId) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = idToChain.get(Number.parseInt(chainId));
|
||||
|
||||
if (!chain) {
|
||||
return `Chain ID: ${chainId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
client={props.client}
|
||||
src={chain.icon?.url}
|
||||
/>
|
||||
<div className="max-w-[150px] truncate">
|
||||
{chain.name ?? `Chain ID: ${chainId}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxStatusCell(props: { transaction: Transaction }) {
|
||||
const { transaction } = props;
|
||||
const { errorMessage } = transaction;
|
||||
const minedAt = transaction.confirmedAt;
|
||||
const status =
|
||||
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
|
||||
|
||||
const onchainStatus =
|
||||
transaction.executionResult &&
|
||||
"onchainStatus" in transaction.executionResult
|
||||
? transaction.executionResult.onchainStatus
|
||||
: null;
|
||||
|
||||
const tooltip =
|
||||
onchainStatus !== "REVERTED"
|
||||
? errorMessage
|
||||
: status === "CONFIRMED" && minedAt
|
||||
? `Completed ${format(new Date(minedAt), "PP pp")}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ToolTipLabel hoverable label={tooltip}>
|
||||
<Badge className="gap-2" variant={statusDetails[status].type}>
|
||||
{statusDetails[status].name}
|
||||
{errorMessage && <InfoIcon className="size-3" />}
|
||||
</Badge>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function TxHashCell(props: { transaction: Transaction }) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const { chainId, transactionHash } = props.transaction;
|
||||
if (!transactionHash) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
|
||||
const explorer = chain?.explorers?.[0];
|
||||
|
||||
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
|
||||
if (!explorer) {
|
||||
return (
|
||||
<CopyTextButton
|
||||
className="font-mono text-muted-foreground text-sm"
|
||||
copyIconPosition="left"
|
||||
textToCopy={transactionHash}
|
||||
textToShow={shortHash}
|
||||
tooltip="Copy transaction hash"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={`${explorer.url}/tx/${transactionHash}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{shortHash}{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function TxQueuedAtCell(props: { transaction: Transaction }) {
|
||||
const value = props.transaction.createdAt;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return (
|
||||
<ToolTipLabel label={format(date, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { Wallet } from "../../server-wallets/wallet-table/types";
|
||||
import { TransactionsTableUI } from "./tx-table-ui";
|
||||
import type { TransactionStatus, TransactionsResponse } from "./types";
|
||||
|
||||
export function TransactionsTable(props: {
|
||||
project: Project;
|
||||
wallets?: Wallet[];
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<TransactionsTableUI
|
||||
client={props.client}
|
||||
getData={async ({ page, status }) => {
|
||||
return await getTransactions({
|
||||
page,
|
||||
project: props.project,
|
||||
status,
|
||||
});
|
||||
}}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function getTransactions({
|
||||
project,
|
||||
page,
|
||||
status,
|
||||
}: {
|
||||
project: Project;
|
||||
page: number;
|
||||
status: TransactionStatus | undefined;
|
||||
}) {
|
||||
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": project.publishableKey,
|
||||
"x-team-id": project.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
pathname: `/v1/transactions`,
|
||||
searchParams: {
|
||||
limit: "20",
|
||||
page: page.toString(),
|
||||
status: status ?? undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!transactions.ok) {
|
||||
throw new Error(transactions.error);
|
||||
}
|
||||
|
||||
return transactions.data.result;
|
||||
}
|
||||
+19
@@ -97,3 +97,22 @@ export type TransactionsResponse = {
|
||||
transactions: Transaction[];
|
||||
pagination: Pagination;
|
||||
};
|
||||
|
||||
export const statusDetails = {
|
||||
CONFIRMED: {
|
||||
name: "Confirmed",
|
||||
type: "success",
|
||||
},
|
||||
FAILED: {
|
||||
name: "Failed",
|
||||
type: "destructive",
|
||||
},
|
||||
QUEUED: {
|
||||
name: "Queued",
|
||||
type: "warning",
|
||||
},
|
||||
SUBMITTED: {
|
||||
name: "Submitted",
|
||||
type: "warning",
|
||||
},
|
||||
} as const;
|
||||
|
||||
+774
@@ -0,0 +1,774 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
CheckIcon,
|
||||
MoreVerticalIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useWalletBalance } from "thirdweb/react";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
predictSmartAccountAddress,
|
||||
} from "thirdweb/wallets/smart";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { SolanaAddress } from "@/components/blocks/solana-address";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { updateDefaultProjectWallet } from "../lib/vault.client";
|
||||
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
|
||||
import type { Wallet as EVMWallet } from "../server-wallets/wallet-table/types";
|
||||
import { CreateSolanaWallet } from "../solana-wallets/components/create-solana-wallet.client";
|
||||
import { UpgradeSolanaPermissions } from "../solana-wallets/components/upgrade-solana-permissions.client";
|
||||
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
|
||||
|
||||
type WalletChain = "evm" | "solana";
|
||||
|
||||
interface ServerWalletsTableProps {
|
||||
evmWallets: EVMWallet[];
|
||||
evmTotalRecords: number;
|
||||
evmCurrentPage: number;
|
||||
evmTotalPages: number;
|
||||
solanaWallets: SolanaWallet[];
|
||||
solanaTotalRecords: number;
|
||||
solanaCurrentPage: number;
|
||||
solanaTotalPages: number;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
solanaPermissionError?: boolean;
|
||||
}
|
||||
|
||||
export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
const {
|
||||
evmWallets,
|
||||
solanaWallets,
|
||||
project,
|
||||
teamSlug,
|
||||
managementAccessToken,
|
||||
evmTotalRecords,
|
||||
evmCurrentPage,
|
||||
evmTotalPages,
|
||||
solanaTotalRecords,
|
||||
solanaCurrentPage,
|
||||
solanaTotalPages,
|
||||
client,
|
||||
solanaPermissionError,
|
||||
} = props;
|
||||
|
||||
const [activeChain, setActiveChain] = useState<WalletChain>("evm");
|
||||
const [selectedChainId, setSelectedChainId] = useState<number>(1);
|
||||
const [showSmartAccount, setShowSmartAccount] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const wallets = activeChain === "evm" ? evmWallets : solanaWallets;
|
||||
const currentPage =
|
||||
activeChain === "evm" ? evmCurrentPage : solanaCurrentPage;
|
||||
const totalPages = activeChain === "evm" ? evmTotalPages : solanaTotalPages;
|
||||
const totalRecords =
|
||||
activeChain === "evm" ? evmTotalRecords : solanaTotalRecords;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<WalletProductIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Server Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage server wallets for your project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="flex flex-row gap-2.5">
|
||||
{activeChain === "evm" && (
|
||||
<>
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
onChange={setSelectedChainId}
|
||||
client={client}
|
||||
disableChainId
|
||||
className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50"
|
||||
placeholder="Select network"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{activeChain === "solana" && (
|
||||
<CreateSolanaWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
disabled={solanaPermissionError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeChain === "evm" && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label
|
||||
className="text-sm text-muted-foreground"
|
||||
htmlFor="showSmartAccount"
|
||||
>
|
||||
Show ERC4337 Smart Account
|
||||
</Label>
|
||||
<Switch
|
||||
id="showSmartAccount"
|
||||
checked={showSmartAccount}
|
||||
onCheckedChange={setShowSmartAccount}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chain Tabs */}
|
||||
<div className="px-6">
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: (
|
||||
<span className="flex items-center gap-2">
|
||||
EVM Wallets
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||
{evmTotalRecords}
|
||||
</Badge>
|
||||
</span>
|
||||
),
|
||||
onClick: () => setActiveChain("evm"),
|
||||
isActive: activeChain === "evm",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<span className="flex items-center gap-2">
|
||||
Solana Wallets
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||
{solanaTotalRecords}
|
||||
</Badge>
|
||||
</span>
|
||||
),
|
||||
onClick: () => setActiveChain("solana"),
|
||||
isActive: activeChain === "solana",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table Content */}
|
||||
{activeChain === "solana" && solanaPermissionError ? (
|
||||
<div className="p-6">
|
||||
<UpgradeSolanaPermissions project={project} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer className="rounded-none border-x-0 border-b-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead className="w-[280px]">
|
||||
{activeChain === "evm"
|
||||
? showSmartAccount
|
||||
? "Smart Account Address"
|
||||
: "Wallet Address"
|
||||
: "Public Key"}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px]">
|
||||
<div className="flex w-[180px] items-center gap-1.5">
|
||||
Balance
|
||||
{wallets.length > 0 && (
|
||||
<ToolTipLabel
|
||||
contentClassName="capitalize font-normal tracking-normal leading-normal"
|
||||
label="Refresh Balance"
|
||||
>
|
||||
<Button
|
||||
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
|
||||
onClick={async (e) => {
|
||||
const buttonEl = e.currentTarget;
|
||||
buttonEl.setAttribute("data-pending", "true");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
activeChain === "evm"
|
||||
? ["walletBalance", selectedChainId]
|
||||
: ["solanaWalletBalance"],
|
||||
});
|
||||
buttonEl.setAttribute("data-pending", "false");
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeChain === "evm" &&
|
||||
evmWallets.map((wallet) => (
|
||||
<EVMWalletRow
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
client={client}
|
||||
chainId={selectedChainId}
|
||||
showSmartAccount={showSmartAccount}
|
||||
/>
|
||||
))}
|
||||
{activeChain === "solana" &&
|
||||
solanaWallets.map((wallet) => (
|
||||
<SolanaWalletRow
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
client={client}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{wallets.length === 0 && (
|
||||
<div className="py-24 flex flex-col items-center justify-center px-4 text-center gap-4">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<XIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
No {activeChain === "evm" ? "EVM" : "Solana"} wallets found
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<WalletsPagination
|
||||
activeChain={activeChain}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
teamSlug={teamSlug}
|
||||
projectSlug={project.slug}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wallets Pagination Component
|
||||
function WalletsPagination({
|
||||
activeChain,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
teamSlug,
|
||||
projectSlug,
|
||||
}: {
|
||||
activeChain: WalletChain;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalRecords: number;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const pageParam = activeChain === "evm" ? "page" : "solana_page";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center border-t p-6">
|
||||
<div className="mb-4 text-muted-foreground text-sm">
|
||||
Found {totalRecords} {activeChain === "evm" ? "EVM" : "Solana"} wallets
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
|
||||
currentPage > 1 ? currentPage - 1 : 1
|
||||
}`}
|
||||
legacyBehavior
|
||||
passHref
|
||||
>
|
||||
<PaginationPrevious
|
||||
className={
|
||||
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(pageNumber) => (
|
||||
<PaginationItem key={`page-${pageNumber}`}>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${pageNumber}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationLink isActive={currentPage === pageNumber}>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
|
||||
currentPage < totalPages ? currentPage + 1 : totalPages
|
||||
}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationNext
|
||||
className={
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Wallet Row Component
|
||||
function EVMWalletRow({
|
||||
wallet,
|
||||
project,
|
||||
teamSlug,
|
||||
client,
|
||||
chainId,
|
||||
showSmartAccount,
|
||||
}: {
|
||||
wallet: EVMWallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
showSmartAccount: boolean;
|
||||
}) {
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
|
||||
const smartAccountQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
return await predictSmartAccountAddress({
|
||||
adminAddress: wallet.address,
|
||||
chain: chain,
|
||||
client: client,
|
||||
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
});
|
||||
},
|
||||
enabled: showSmartAccount,
|
||||
queryKey: ["smart-account-address", wallet.address, chainId],
|
||||
});
|
||||
|
||||
const engineService = project.services.find((s) => s.name === "engineCloud");
|
||||
const isDefault =
|
||||
engineService?.projectWalletAddress &&
|
||||
wallet.address.toLowerCase() ===
|
||||
engineService.projectWalletAddress.toLowerCase();
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-foreground",
|
||||
!wallet.metadata.label && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{wallet.metadata.label || "N/A"}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{showSmartAccount ? (
|
||||
smartAccountQuery.isPending ? (
|
||||
<Skeleton className="h-7 w-24" />
|
||||
) : smartAccountQuery.data ? (
|
||||
<WalletAddress address={smartAccountQuery.data} client={client} />
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)
|
||||
) : (
|
||||
<WalletAddress address={wallet.address} client={client} />
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{showSmartAccount ? (
|
||||
smartAccountQuery.isPending ? (
|
||||
<Skeleton className="h-5 w-16" />
|
||||
) : smartAccountQuery.data ? (
|
||||
<WalletBalance
|
||||
address={smartAccountQuery.data}
|
||||
chainId={chainId}
|
||||
client={client}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)
|
||||
) : (
|
||||
<WalletBalance
|
||||
address={wallet.address}
|
||||
chainId={chainId}
|
||||
client={client}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<DateCell date={wallet.createdAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<EVMWalletActions
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
wallet={wallet}
|
||||
client={client}
|
||||
chainId={chainId}
|
||||
fundAddress={
|
||||
showSmartAccount ? smartAccountQuery.data : wallet.address
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Wallet Row Component
|
||||
function SolanaWalletRow({
|
||||
wallet,
|
||||
project,
|
||||
teamSlug,
|
||||
client,
|
||||
}: {
|
||||
wallet: SolanaWallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const engineService = project.services.find(
|
||||
(s) => s.name === "engineCloud",
|
||||
) as { projectSolanaWalletPublicKey?: string } | undefined;
|
||||
|
||||
const isDefault =
|
||||
engineService?.projectSolanaWalletPublicKey &&
|
||||
wallet.publicKey.toLowerCase() ===
|
||||
engineService.projectSolanaWalletPublicKey.toLowerCase();
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-foreground",
|
||||
!wallet.metadata.label && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{wallet.metadata.label || "N/A"}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<SolanaAddress address={wallet.publicKey} shortenAddress={true} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<SolanaWalletBalance publicKey={wallet.publicKey} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<DateCell date={wallet.createdAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<SolanaWalletActions
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
wallet={wallet}
|
||||
client={client}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared Components
|
||||
function DateCell({ date }: { date: string }) {
|
||||
if (!date) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const dateObj = new Date(date);
|
||||
return (
|
||||
<ToolTipLabel label={format(dateObj, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function EVMWalletActions({
|
||||
wallet,
|
||||
project,
|
||||
teamSlug,
|
||||
client,
|
||||
chainId,
|
||||
fundAddress,
|
||||
}: {
|
||||
wallet: EVMWallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
fundAddress: string | undefined;
|
||||
}) {
|
||||
const [showFundModal, setShowFundModal] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateDefaultProjectWallet({
|
||||
project,
|
||||
projectWalletAddress: wallet.address,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Wallet set as project wallet");
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to set default wallet",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="rounded-xl">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?testTxWithWallet=${wallet.id}`}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send test transaction
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{fundAddress && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowFundModal(true)}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<WalletProductIcon className="size-4 text-muted-foreground" />
|
||||
Fund wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDefaultMutation.mutate()}
|
||||
disabled={setDefaultMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<CheckIcon className="size-4 text-muted-foreground" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{fundAddress && (
|
||||
<FundWalletModal
|
||||
open={showFundModal}
|
||||
onOpenChange={setShowFundModal}
|
||||
title="Fund server wallet"
|
||||
description="Send funds to the server wallet"
|
||||
recipientAddress={fundAddress}
|
||||
client={client}
|
||||
defaultChainId={chainId}
|
||||
checkoutWidgetTitle={
|
||||
wallet.metadata.label
|
||||
? `Fund ${wallet.metadata.label}`
|
||||
: "Fund server wallet"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SolanaWalletActions({
|
||||
wallet,
|
||||
project,
|
||||
teamSlug,
|
||||
}: {
|
||||
wallet: SolanaWallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="rounded-xl">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?testSolanaTxWithWallet=${wallet.id}`}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send test transaction
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletBalance({
|
||||
address,
|
||||
chainId,
|
||||
client,
|
||||
}: {
|
||||
address: string;
|
||||
chainId: number;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
const balance = useWalletBalance({
|
||||
address,
|
||||
chain,
|
||||
client,
|
||||
});
|
||||
|
||||
if (balance.isFetching) {
|
||||
return <Skeleton className="h-5 w-16" />;
|
||||
}
|
||||
|
||||
if (!balance.data) {
|
||||
return <span className="text-muted-foreground">N/A</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{balance.data.displayValue} {balance.data.symbol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SolanaWalletBalance({ publicKey }: { publicKey: string }) {
|
||||
const balance = useQuery({
|
||||
queryFn: async () => {
|
||||
// TODO: Implement actual Solana balance fetching
|
||||
return {
|
||||
displayValue: "0",
|
||||
symbol: "SOL",
|
||||
};
|
||||
},
|
||||
queryKey: ["solanaWalletBalance", publicKey],
|
||||
});
|
||||
|
||||
if (balance.isFetching) {
|
||||
return <Skeleton className="h-5 w-16" />;
|
||||
}
|
||||
|
||||
if (!balance.data) {
|
||||
return <span className="text-muted-foreground">N/A</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{balance.data.displayValue} {balance.data.symbol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+877
@@ -0,0 +1,877 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useId, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { SolanaAddress } from "@/components/blocks/solana-address";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type {
|
||||
SolanaTransaction,
|
||||
SolanaTransactionStatus,
|
||||
SolanaTransactionsResponse,
|
||||
} from "../analytics/solana-tx-table/types";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionStatus,
|
||||
TransactionsResponse,
|
||||
} from "../analytics/tx-table/types";
|
||||
import { getSolanaNetworkName, getSolscanUrl } from "../lib/solana-utils";
|
||||
|
||||
type TransactionChain = "evm" | "solana";
|
||||
|
||||
interface UnifiedTransactionsTableProps {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export function UnifiedTransactionsTable({
|
||||
project,
|
||||
teamSlug,
|
||||
client,
|
||||
}: UnifiedTransactionsTableProps) {
|
||||
const router = useDashboardRouter();
|
||||
|
||||
// Use nuqs to manage chain selection in URL - this is the source of truth
|
||||
const [activeChainParam, setActiveChainParam] = useQueryState("txChain", {
|
||||
defaultValue: "evm",
|
||||
history: "push",
|
||||
});
|
||||
|
||||
const activeChain = (activeChainParam || "evm") as TransactionChain;
|
||||
const setActiveChain = (chain: TransactionChain) =>
|
||||
setActiveChainParam(chain);
|
||||
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [status, setStatus] = useState<
|
||||
TransactionStatus | SolanaTransactionStatus | undefined
|
||||
>(undefined);
|
||||
const [id, setId] = useState<string | undefined>(undefined);
|
||||
const [from, setFrom] = useState<string | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Fetch EVM transactions
|
||||
const evmTransactionsQuery = useQuery({
|
||||
enabled: activeChain === "evm",
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
getEVMTransactions({
|
||||
page,
|
||||
project,
|
||||
status: status as TransactionStatus | undefined,
|
||||
id,
|
||||
from,
|
||||
}),
|
||||
queryKey: ["evm-transactions", project.id, page, status, id, from],
|
||||
refetchInterval: autoUpdate && activeChain === "evm" ? 4_000 : false,
|
||||
});
|
||||
|
||||
// Fetch Solana transactions
|
||||
const solanaTransactionsQuery = useQuery({
|
||||
enabled: activeChain === "solana",
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
getSolanaTransactions({
|
||||
page,
|
||||
project,
|
||||
status: status as SolanaTransactionStatus | undefined,
|
||||
id,
|
||||
from,
|
||||
}),
|
||||
queryKey: ["solana-transactions", project.id, page, status, id, from],
|
||||
refetchInterval: autoUpdate && activeChain === "solana" ? 4_000 : false,
|
||||
});
|
||||
|
||||
const autoUpdateId = useId();
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{/* Unified Header & Filters */}
|
||||
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Transaction History
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Transactions sent from server wallets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-border border-t pt-4 lg:border-none lg:pt-0">
|
||||
<Label htmlFor={autoUpdateId}>Auto Update</Label>
|
||||
<Switch
|
||||
checked={autoUpdate}
|
||||
id={autoUpdateId}
|
||||
onCheckedChange={(v) => setAutoUpdate(!!v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
||||
<Input
|
||||
className="w-full sm:max-w-[200px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setId(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by Queue ID"
|
||||
value={id || ""}
|
||||
/>
|
||||
<Input
|
||||
className="w-full sm:max-w-[200px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setFrom(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by wallet address"
|
||||
value={from || ""}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<ChainSelector
|
||||
activeChain={activeChain}
|
||||
setActiveChain={(chain) => {
|
||||
setActiveChain(chain);
|
||||
setPage(1);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
/>
|
||||
<StatusSelector
|
||||
activeChain={activeChain}
|
||||
setStatus={(v) => {
|
||||
setStatus(v);
|
||||
setPage(1);
|
||||
}}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render different table structures based on chain */}
|
||||
{activeChain === "evm" ? (
|
||||
<EVMTransactionsTable
|
||||
client={client}
|
||||
page={page}
|
||||
project={project}
|
||||
query={evmTransactionsQuery}
|
||||
router={router}
|
||||
setPage={setPage}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
) : (
|
||||
<SolanaTransactionsTable
|
||||
page={page}
|
||||
project={project}
|
||||
query={solanaTransactionsQuery}
|
||||
router={router}
|
||||
setPage={setPage}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Transactions Table Component
|
||||
function EVMTransactionsTable(props: {
|
||||
query: ReturnType<typeof useQuery<TransactionsResponse>>;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
client: ThirdwebClient;
|
||||
router: ReturnType<typeof useDashboardRouter>;
|
||||
teamSlug: string;
|
||||
project: Project;
|
||||
}) {
|
||||
const { query, page, setPage, client, router, teamSlug, project } = props;
|
||||
const pageSize = 10;
|
||||
|
||||
const transactions = query.data?.transactions ?? [];
|
||||
const totalCount = query.data?.pagination.totalCount ?? 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const showPagination = totalCount > pageSize;
|
||||
|
||||
const showSkeleton =
|
||||
(query.isPlaceholderData && query.isFetching) ||
|
||||
(query.isLoading && !query.isPlaceholderData);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer className="rounded-none border-x-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Queue ID</TableHead>
|
||||
<TableHead>Chain</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>From</TableHead>
|
||||
<TableHead>Tx Hash</TableHead>
|
||||
<TableHead>Queued</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{showSkeleton
|
||||
? new Array(pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
|
||||
<SkeletonRow key={i} />
|
||||
))
|
||||
: transactions.map((tx) => (
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
key={`${tx.id}${tx.chainId}`}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<CopyAddressButton
|
||||
address={tx.id}
|
||||
className="text-muted-foreground"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EVMChainCell
|
||||
chainId={tx.chainId || undefined}
|
||||
client={client}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EVMStatusCell transaction={tx} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tx.from ? (
|
||||
<WalletAddress address={tx.from} client={client} />
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EVMTxHashCell transaction={tx} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EVMQueuedAtCell transaction={tx} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!showSkeleton && transactions.length === 0 && (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
No transactions found
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Transactions Table Component
|
||||
function SolanaTransactionsTable(props: {
|
||||
query: ReturnType<typeof useQuery<SolanaTransactionsResponse>>;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
router: ReturnType<typeof useDashboardRouter>;
|
||||
teamSlug: string;
|
||||
project: Project;
|
||||
}) {
|
||||
const { query, page, setPage, router, teamSlug, project } = props;
|
||||
const pageSize = 10;
|
||||
|
||||
const transactions = query.data?.transactions ?? [];
|
||||
const totalCount = query.data?.pagination.totalCount ?? 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const showPagination = totalCount > pageSize;
|
||||
|
||||
const showSkeleton =
|
||||
(query.isPlaceholderData && query.isFetching) ||
|
||||
(query.isLoading && !query.isPlaceholderData);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer className="rounded-none border-x-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Queue ID</TableHead>
|
||||
<TableHead>Network</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Signer</TableHead>
|
||||
<TableHead>Signature</TableHead>
|
||||
<TableHead>Queued</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{showSkeleton
|
||||
? new Array(pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
|
||||
<SkeletonRow key={i} />
|
||||
))
|
||||
: transactions.map((tx) => (
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
key={`${tx.id}${tx.chainId}`}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<CopyAddressButton
|
||||
address={tx.id}
|
||||
className="text-muted-foreground"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaChainCell chainId={tx.chainId} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaStatusCell transaction={tx} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaAddress
|
||||
address={tx.signerAddress}
|
||||
shortenAddress={true}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaTxHashCell transaction={tx} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaQueuedAtCell transaction={tx} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!showSkeleton && transactions.length === 0 && (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
No transactions found
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Chain selector component
|
||||
function ChainSelector(props: {
|
||||
activeChain: TransactionChain;
|
||||
setActiveChain: (chain: TransactionChain) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(v) => props.setActiveChain(v as TransactionChain)}
|
||||
value={props.activeChain}
|
||||
>
|
||||
<SelectTrigger className="min-w-[160px] rounded-full">
|
||||
<SelectValue placeholder="Select Chain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="evm">EVM</SelectItem>
|
||||
<SelectItem value="solana">Solana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Status selector component
|
||||
const evmStatusDetails = {
|
||||
CONFIRMED: { name: "Confirmed", type: "success" as const },
|
||||
FAILED: { name: "Failed", type: "destructive" as const },
|
||||
QUEUED: { name: "Queued", type: "warning" as const },
|
||||
SUBMITTED: { name: "Submitted", type: "warning" as const },
|
||||
};
|
||||
|
||||
const solanaStatusDetails = {
|
||||
CONFIRMED: { name: "Confirmed", type: "success" as const },
|
||||
FAILED: { name: "Failed", type: "destructive" as const },
|
||||
QUEUED: { name: "Queued", type: "warning" as const },
|
||||
SUBMITTED: { name: "Submitted", type: "warning" as const },
|
||||
};
|
||||
|
||||
function StatusSelector(props: {
|
||||
activeChain: TransactionChain;
|
||||
status: TransactionStatus | SolanaTransactionStatus | undefined;
|
||||
setStatus: (
|
||||
value: TransactionStatus | SolanaTransactionStatus | undefined,
|
||||
) => void;
|
||||
}) {
|
||||
const statusDetails =
|
||||
props.activeChain === "evm" ? evmStatusDetails : solanaStatusDetails;
|
||||
const statuses = Object.keys(statusDetails) as (
|
||||
| TransactionStatus
|
||||
| SolanaTransactionStatus
|
||||
)[];
|
||||
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
if (v === "all") {
|
||||
props.setStatus(undefined);
|
||||
} else {
|
||||
props.setStatus(v as TransactionStatus | SolanaTransactionStatus);
|
||||
}
|
||||
}}
|
||||
value={props.status || "all"}
|
||||
>
|
||||
<SelectTrigger className="min-w-[160px] rounded-full">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All Transactions</SelectItem>
|
||||
{statuses.map((item) => {
|
||||
return (
|
||||
<SelectItem key={item} value={item}>
|
||||
{statusDetails[item].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton row component
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<TableRow className="h-[73px]">
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[152px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[155px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[58px] rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[145px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[160px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[108px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Chain cell component
|
||||
function EVMChainCell(props: {
|
||||
chainId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { chainId } = props;
|
||||
const { idToChain } = useAllChainsData();
|
||||
if (!chainId) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = idToChain.get(Number.parseInt(chainId));
|
||||
|
||||
if (!chain) {
|
||||
return `Chain ID: ${chainId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
client={props.client}
|
||||
src={chain.icon?.url}
|
||||
/>
|
||||
<div className="max-w-[150px] truncate">
|
||||
{chain.name ?? `Chain ID: ${chainId}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Chain cell component
|
||||
function SolanaChainCell(props: { chainId: string | undefined }) {
|
||||
const { chainId } = props;
|
||||
if (!chainId) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const network = getSolanaNetworkName(chainId);
|
||||
const displayName = network.charAt(0).toUpperCase() + network.slice(1);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
|
||||
<span>Solana {displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Status cell component
|
||||
function EVMStatusCell(props: { transaction: Transaction }) {
|
||||
const { transaction } = props;
|
||||
const { errorMessage } = transaction;
|
||||
const minedAt = transaction.confirmedAt;
|
||||
const status =
|
||||
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
|
||||
|
||||
const onchainStatus =
|
||||
transaction.executionResult &&
|
||||
"onchainStatus" in transaction.executionResult
|
||||
? transaction.executionResult.onchainStatus
|
||||
: null;
|
||||
|
||||
const tooltip =
|
||||
onchainStatus !== "REVERTED"
|
||||
? errorMessage
|
||||
: status === "CONFIRMED" && minedAt
|
||||
? `Completed ${format(new Date(minedAt), "PP pp")}`
|
||||
: undefined;
|
||||
|
||||
const statusDetails = evmStatusDetails[status] || {
|
||||
name: status,
|
||||
type: "default" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolTipLabel hoverable label={tooltip}>
|
||||
<Badge className="gap-2" variant={statusDetails.type}>
|
||||
{statusDetails.name}
|
||||
{errorMessage && <InfoIcon className="size-3" />}
|
||||
</Badge>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Status cell component
|
||||
function SolanaStatusCell(props: { transaction: SolanaTransaction }) {
|
||||
const { transaction } = props;
|
||||
const { errorMessage } = transaction;
|
||||
const status =
|
||||
(transaction.executionResult?.status as SolanaTransactionStatus) ??
|
||||
transaction.status ??
|
||||
"QUEUED";
|
||||
|
||||
const onchainStatus =
|
||||
transaction.executionResult &&
|
||||
"onchainStatus" in transaction.executionResult
|
||||
? transaction.executionResult.onchainStatus
|
||||
: null;
|
||||
|
||||
const tooltip =
|
||||
onchainStatus !== "REVERTED"
|
||||
? errorMessage
|
||||
: status === "CONFIRMED" && transaction.confirmedAt
|
||||
? `Completed ${format(new Date(transaction.confirmedAt), "PP pp")}`
|
||||
: undefined;
|
||||
|
||||
const statusDetails = solanaStatusDetails[status] || {
|
||||
name: status || "QUEUED",
|
||||
type: "default" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolTipLabel hoverable label={tooltip}>
|
||||
<Badge className="gap-2" variant={statusDetails.type}>
|
||||
{statusDetails.name}
|
||||
{errorMessage && <InfoIcon className="size-3" />}
|
||||
</Badge>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Tx Hash cell component
|
||||
function EVMTxHashCell(props: { transaction: Transaction }) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const { chainId, transactionHash } = props.transaction;
|
||||
if (!transactionHash) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
|
||||
const explorer = chain?.explorers?.[0];
|
||||
|
||||
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
|
||||
if (!explorer) {
|
||||
return (
|
||||
<CopyTextButton
|
||||
className="font-mono text-muted-foreground text-sm"
|
||||
copyIconPosition="left"
|
||||
textToCopy={transactionHash}
|
||||
textToShow={shortHash}
|
||||
tooltip="Copy transaction hash"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={`${explorer.url}/tx/${transactionHash}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{shortHash}{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Tx Hash cell component
|
||||
function SolanaTxHashCell(props: { transaction: SolanaTransaction }) {
|
||||
const { chainId, signature, executionResult } = props.transaction;
|
||||
|
||||
// Get signature from executionResult if available, otherwise use top-level signature
|
||||
let hash = signature;
|
||||
if (
|
||||
executionResult &&
|
||||
"signature" in executionResult &&
|
||||
executionResult.signature
|
||||
) {
|
||||
hash = executionResult.signature;
|
||||
}
|
||||
|
||||
if (!hash) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const shortHash = `${hash.slice(0, 6)}...${hash.slice(-4)}`;
|
||||
const explorerUrl = chainId ? getSolscanUrl(hash, chainId) : null;
|
||||
|
||||
if (!explorerUrl) {
|
||||
return (
|
||||
<CopyTextButton
|
||||
className="font-mono text-muted-foreground text-sm"
|
||||
copyIconPosition="left"
|
||||
textToCopy={hash}
|
||||
textToShow={shortHash}
|
||||
tooltip="Copy transaction signature"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={explorerUrl}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{shortHash}{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Queued At cell component
|
||||
function EVMQueuedAtCell(props: { transaction: Transaction }) {
|
||||
const value = props.transaction.createdAt;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return (
|
||||
<ToolTipLabel label={format(date, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Queued At cell component
|
||||
function SolanaQueuedAtCell(props: { transaction: SolanaTransaction }) {
|
||||
const value = props.transaction.createdAt;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return (
|
||||
<ToolTipLabel label={format(date, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
async function getEVMTransactions({
|
||||
project,
|
||||
page,
|
||||
status,
|
||||
id,
|
||||
from,
|
||||
}: {
|
||||
project: Project;
|
||||
page: number;
|
||||
status: TransactionStatus | undefined;
|
||||
id: string | undefined;
|
||||
from: string | undefined;
|
||||
}): Promise<TransactionsResponse> {
|
||||
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": project.publishableKey,
|
||||
"x-team-id": project.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
pathname: `/v1/transactions`,
|
||||
searchParams: {
|
||||
limit: "20",
|
||||
page: page.toString(),
|
||||
status: status ?? undefined,
|
||||
id: id ?? undefined,
|
||||
from: from ?? undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!transactions.ok) {
|
||||
return {
|
||||
transactions: [],
|
||||
pagination: {
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return transactions.data.result;
|
||||
}
|
||||
|
||||
async function getSolanaTransactions({
|
||||
project,
|
||||
page,
|
||||
status,
|
||||
id,
|
||||
from,
|
||||
}: {
|
||||
project: Project;
|
||||
page: number;
|
||||
status: SolanaTransactionStatus | undefined;
|
||||
id: string | undefined;
|
||||
from: string | undefined;
|
||||
}): Promise<SolanaTransactionsResponse> {
|
||||
const transactions = await engineCloudProxy<{
|
||||
result: SolanaTransactionsResponse;
|
||||
}>({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": project.publishableKey,
|
||||
"x-team-id": project.teamId,
|
||||
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
|
||||
},
|
||||
method: "GET",
|
||||
pathname: `/v1/solana/transactions`,
|
||||
searchParams: {
|
||||
limit: "20",
|
||||
page: page.toString(),
|
||||
status: status ?? undefined,
|
||||
id: id ?? undefined,
|
||||
from: from ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!transactions.ok) {
|
||||
return {
|
||||
transactions: [],
|
||||
pagination: {
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return transactions.data.result;
|
||||
}
|
||||
+95
-10
@@ -205,6 +205,64 @@ export async function getSingleTransaction({
|
||||
return data.transactions[0];
|
||||
}
|
||||
|
||||
export async function getSingleSolanaTransaction({
|
||||
teamId,
|
||||
clientId,
|
||||
transactionId,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
transactionId: string;
|
||||
}): Promise<
|
||||
import("../analytics/solana-tx-table/types").SolanaTransaction | undefined
|
||||
> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const filters = {
|
||||
filters: [
|
||||
{
|
||||
field: "id",
|
||||
operation: "OR",
|
||||
values: [transactionId],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/search`,
|
||||
{
|
||||
body: JSON.stringify(filters),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
"x-team-id": teamId,
|
||||
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const errorText = await response.text().catch(() => "Unknown error");
|
||||
throw new Error(
|
||||
`Error fetching single Solana transaction data: ${response.status} ${
|
||||
response.statusText
|
||||
} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
const data =
|
||||
rawData.result as import("../analytics/solana-tx-table/types").SolanaTransactionsResponse;
|
||||
|
||||
return data.transactions[0];
|
||||
}
|
||||
|
||||
// Activity log types
|
||||
export type ActivityLogEntry = {
|
||||
id: string;
|
||||
@@ -260,18 +318,45 @@ export async function getTransactionActivityLogs({
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
if (response.status === 401 || response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ActivityLogsResponse;
|
||||
return data.result.activityLogs;
|
||||
}
|
||||
|
||||
export async function getSolanaTransactionActivityLogs({
|
||||
teamId,
|
||||
clientId,
|
||||
transactionId,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
transactionId: string;
|
||||
}): Promise<ActivityLogEntry[]> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/activity-logs?transactionId=${transactionId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
"x-team-id": teamId,
|
||||
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Don't throw on 404 - activity logs might not exist for all transactions
|
||||
if (response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Error fetching activity logs: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Get the Solscan explorer URL for a given transaction signature and chain ID
|
||||
* @param signature - The transaction signature
|
||||
* @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet"
|
||||
* @returns The Solscan URL for the transaction
|
||||
*/
|
||||
export function getSolscanUrl(signature: string, chainId: string): string {
|
||||
const network = chainId.split(":")[1] || "mainnet";
|
||||
|
||||
// Solscan uses different subdomains for different networks
|
||||
switch (network.toLowerCase()) {
|
||||
case "devnet":
|
||||
return `https://solscan.io/tx/${signature}?cluster=devnet`;
|
||||
case "testnet":
|
||||
return `https://solscan.io/tx/${signature}?cluster=testnet`;
|
||||
default:
|
||||
return `https://solscan.io/tx/${signature}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display network name from a Solana chain ID (capitalized)
|
||||
* @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet"
|
||||
* @returns The capitalized network name for display (e.g., "Devnet")
|
||||
*/
|
||||
export function getSolanaNetworkName(chainId: string): string {
|
||||
const network = chainId.split(":")[1] || "mainnet";
|
||||
return network.charAt(0).toUpperCase() + network.slice(1);
|
||||
}
|
||||
+403
-5
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { encrypt } from "@thirdweb-dev/service-utils";
|
||||
import { decrypt, encrypt } from "@thirdweb-dev/service-utils";
|
||||
import {
|
||||
createAccessToken,
|
||||
createEoa,
|
||||
createServiceAccount,
|
||||
createVaultClient,
|
||||
rotateServiceAccount,
|
||||
type VaultClient,
|
||||
} from "@thirdweb-dev/vault-sdk";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { updateProjectClient } from "@/hooks/useApi";
|
||||
import { getProjectWalletLabel } from "@/lib/project-wallet";
|
||||
|
||||
const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
|
||||
"Access Token for All Server Wallets";
|
||||
@@ -126,6 +129,92 @@ export async function createVaultAccountAndAccessToken(props: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProjectServerWallet(props: {
|
||||
project: Project;
|
||||
managementAccessToken: string;
|
||||
label?: string;
|
||||
setAsProjectWallet?: boolean;
|
||||
}) {
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
const walletLabel = props.label?.trim()
|
||||
? props.label.trim()
|
||||
: getProjectWalletLabel(props.project.name);
|
||||
|
||||
const eoa = await createEoa({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: props.managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
metadata: {
|
||||
label: walletLabel,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
type: "server-wallet",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!eoa.success) {
|
||||
throw new Error(eoa.error?.message || "Failed to create server wallet");
|
||||
}
|
||||
|
||||
engineCloudProxy({
|
||||
body: JSON.stringify({
|
||||
signerAddress: eoa.data.address,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.project.publishableKey,
|
||||
"x-team-id": props.project.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
pathname: "/cache/smart-account",
|
||||
}).catch((err) => {
|
||||
console.warn("failed to cache server wallet", err);
|
||||
});
|
||||
|
||||
if (props.setAsProjectWallet) {
|
||||
await updateDefaultProjectWallet({
|
||||
project: props.project,
|
||||
projectWalletAddress: eoa.data.address,
|
||||
});
|
||||
}
|
||||
|
||||
return eoa.data;
|
||||
}
|
||||
|
||||
export async function updateDefaultProjectWallet(props: {
|
||||
project: Project;
|
||||
projectWalletAddress: string;
|
||||
}) {
|
||||
const services = props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
);
|
||||
const engineCloudService = props.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
if (engineCloudService) {
|
||||
const engineCloudServiceWithProjectWallet = {
|
||||
...engineCloudService,
|
||||
projectWalletAddress: props.projectWalletAddress,
|
||||
};
|
||||
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [...services, engineCloudServiceWithProjectWallet],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndEncryptVaultAccessTokens(props: {
|
||||
project: Project;
|
||||
vaultClient: VaultClient;
|
||||
@@ -157,6 +246,13 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
const managementToken = managementTokenResult.data;
|
||||
const walletToken = walletTokenResult.data;
|
||||
|
||||
// create a default project server wallet
|
||||
const defaultProjectServerWallet = await createProjectServerWallet({
|
||||
project,
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
label: getProjectWalletLabel(project.name),
|
||||
});
|
||||
|
||||
if (projectSecretKey) {
|
||||
// verify that the project secret key is valid
|
||||
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
|
||||
@@ -182,7 +278,9 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services,
|
||||
...props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
@@ -191,6 +289,7 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
encryptedAdminKey,
|
||||
encryptedWalletAccessToken,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: defaultProjectServerWallet.address,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -204,7 +303,9 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services,
|
||||
...props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
@@ -213,6 +314,7 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
encryptedAdminKey: null,
|
||||
encryptedWalletAccessToken: null,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: defaultProjectServerWallet.address,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -412,6 +514,29 @@ export async function createWalletAccessToken(props: {
|
||||
},
|
||||
type: "eoa:signStructuredMessage",
|
||||
},
|
||||
{
|
||||
requiredMetadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "eoa:create",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
@@ -433,7 +558,7 @@ export async function createWalletAccessToken(props: {
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "eoa:read",
|
||||
type: "solana:read",
|
||||
},
|
||||
{
|
||||
requiredMetadataPatterns: [
|
||||
@@ -456,7 +581,53 @@ export async function createWalletAccessToken(props: {
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "eoa:create",
|
||||
type: "solana:create",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:signTransaction",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:signMessage",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -531,6 +702,52 @@ async function createManagementAccessToken(props: {
|
||||
],
|
||||
type: "eoa:create",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:read",
|
||||
},
|
||||
{
|
||||
requiredMetadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:create",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
@@ -555,6 +772,187 @@ async function createManagementAccessToken(props: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrades existing access tokens to include Solana permissions
|
||||
* This is needed when a project was created before Solana support was added
|
||||
*
|
||||
* Returns an object with success/error instead of throwing for Next.js server actions
|
||||
*/
|
||||
export async function upgradeAccessTokensForSolana(props: {
|
||||
project: Project;
|
||||
projectSecretKey?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: {
|
||||
managementToken: string;
|
||||
walletToken?: string;
|
||||
};
|
||||
}> {
|
||||
const { project, projectSecretKey } = props;
|
||||
|
||||
try {
|
||||
// Find the engineCloud service
|
||||
const engineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
if (!engineCloudService) {
|
||||
return {
|
||||
success: false,
|
||||
error: "No engineCloud service found on project",
|
||||
};
|
||||
}
|
||||
|
||||
const vaultClient = await initVaultClient();
|
||||
const hasEncryptedAdminKey = !!engineCloudService.encryptedAdminKey;
|
||||
|
||||
// Check if this is an ejected vault (no encrypted admin key stored)
|
||||
if (!hasEncryptedAdminKey) {
|
||||
// For ejected vaults, we only need to update the management token
|
||||
// User manages their own admin key, so we can't create wallet tokens
|
||||
|
||||
// We need the admin key from the user
|
||||
if (!projectSecretKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Admin key required. Please enter your vault admin key.",
|
||||
};
|
||||
}
|
||||
|
||||
// For ejected vault, the "secret key" parameter is actually the admin key
|
||||
const managementTokenResult = await createManagementAccessToken({
|
||||
project,
|
||||
adminKey: projectSecretKey,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
if (!managementTokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create management token: ${managementTokenResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Update only the management token for ejected vaults
|
||||
// Keep everything else the same (no encrypted keys to update)
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
...engineCloudService,
|
||||
managementAccessToken: managementTokenResult.data.accessToken,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
managementToken: managementTokenResult.data.accessToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For non-ejected vaults (with encrypted admin key)
|
||||
if (!projectSecretKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Project secret key is required to upgrade tokens",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the project secret key
|
||||
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
|
||||
if (!project.secretKeys.some((key) => key?.hash === projectSecretKeyHash)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid project secret key",
|
||||
};
|
||||
}
|
||||
|
||||
// Decrypt the admin key (we know it exists from the hasEncryptedAdminKey check)
|
||||
const adminKey = await decrypt(
|
||||
engineCloudService.encryptedAdminKey as string,
|
||||
projectSecretKey,
|
||||
);
|
||||
|
||||
// Create new tokens with Solana permissions
|
||||
const [managementTokenResult, walletTokenResult] = await Promise.all([
|
||||
createManagementAccessToken({ project, adminKey, vaultClient }),
|
||||
createWalletAccessToken({ project, adminKey, vaultClient }),
|
||||
]);
|
||||
|
||||
if (!managementTokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create management token: ${managementTokenResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!walletTokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create wallet token: ${walletTokenResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
const managementToken = managementTokenResult.data;
|
||||
const walletToken = walletTokenResult.data;
|
||||
|
||||
// Encrypt the new wallet token
|
||||
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
|
||||
encrypt(adminKey, projectSecretKey),
|
||||
encrypt(walletToken.accessToken, projectSecretKey),
|
||||
]);
|
||||
|
||||
// Update the project with new tokens
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
...engineCloudService,
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
encryptedAdminKey,
|
||||
encryptedWalletAccessToken,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
managementToken: managementToken.accessToken,
|
||||
walletToken: walletToken.accessToken,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to upgrade access tokens",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function maskSecret(secret: string) {
|
||||
return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`;
|
||||
}
|
||||
|
||||
+72
-15
@@ -9,9 +9,13 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
|
||||
import { EngineChecklist } from "./analytics/ftux.client";
|
||||
import { TransactionAnalyticsSummary } from "./analytics/summary";
|
||||
import { ServerWalletsTable } from "./components/server-wallets-table.client";
|
||||
import { getTransactionAnalyticsSummary } from "./lib/analytics";
|
||||
import type { Wallet } from "./server-wallets/wallet-table/types";
|
||||
import { ServerWalletsTable } from "./server-wallets/wallet-table/wallet-table";
|
||||
import { listSolanaAccounts } from "./solana-wallets/lib/vault.client";
|
||||
import type { SolanaWallet } from "./solana-wallets/wallet-table/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TransactionsAnalyticsPage(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
@@ -20,7 +24,9 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
to?: string | string[] | undefined;
|
||||
interval?: string | string[] | undefined;
|
||||
testTxWithWallet?: string | string[] | undefined;
|
||||
testSolanaTxWithWallet?: string | string[] | undefined;
|
||||
page?: string;
|
||||
solana_page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const [params, searchParams, authToken] = await Promise.all([
|
||||
@@ -58,6 +64,7 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
|
||||
const pageSize = 10;
|
||||
const currentPage = Number.parseInt(searchParams.page ?? "1");
|
||||
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
|
||||
|
||||
const eoas = managementAccessToken
|
||||
? await listEoas({
|
||||
@@ -77,6 +84,32 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
|
||||
const wallets = eoas.data?.items as Wallet[] | undefined;
|
||||
|
||||
// Fetch Solana accounts - gracefully handle permission errors
|
||||
let solanaAccounts: {
|
||||
data: { items: SolanaWallet[]; totalRecords: number };
|
||||
error: Error | null;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
if (managementAccessToken) {
|
||||
solanaAccounts = await listSolanaAccounts({
|
||||
managementAccessToken,
|
||||
page: solanCurrentPage,
|
||||
limit: pageSize,
|
||||
projectId: project.id,
|
||||
});
|
||||
} else {
|
||||
solanaAccounts = {
|
||||
data: { items: [], totalRecords: 0 },
|
||||
error: null,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if error is a permission error
|
||||
const isSolanaPermissionError =
|
||||
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
|
||||
|
||||
const initialData = await getTransactionAnalyticsSummary({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
@@ -127,39 +160,63 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
testTxWithWallet={searchParams.testTxWithWallet as string | undefined}
|
||||
testSolanaTxWithWallet={
|
||||
searchParams.testSolanaTxWithWallet as string | undefined
|
||||
}
|
||||
wallets={wallets ?? []}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
/>
|
||||
{hasTransactions && !searchParams.testTxWithWallet && (
|
||||
<TransactionAnalyticsSummary
|
||||
clientId={project.publishableKey}
|
||||
initialData={initialData}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
)}
|
||||
{hasTransactions &&
|
||||
!searchParams.testTxWithWallet &&
|
||||
!searchParams.testSolanaTxWithWallet && (
|
||||
<TransactionAnalyticsSummary
|
||||
clientId={project.publishableKey}
|
||||
initialData={initialData}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* transactions */}
|
||||
<TransactionsAnalyticsPageContent
|
||||
client={client}
|
||||
project={project}
|
||||
searchParams={searchParams}
|
||||
showAnalytics={hasTransactions && !searchParams.testTxWithWallet}
|
||||
showAnalytics={
|
||||
hasTransactions &&
|
||||
!searchParams.testTxWithWallet &&
|
||||
!searchParams.testSolanaTxWithWallet
|
||||
}
|
||||
teamSlug={params.team_slug}
|
||||
wallets={wallets}
|
||||
/>
|
||||
|
||||
{/* server wallets */}
|
||||
{/* Server Wallets (EVM + Solana) */}
|
||||
{eoas.error ? (
|
||||
<div>Error: {eoas.error.message}</div>
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-destructive font-semibold mb-2">
|
||||
EVM Wallet Error
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{eoas.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ServerWalletsTable
|
||||
client={client}
|
||||
currentPage={currentPage}
|
||||
evmCurrentPage={currentPage}
|
||||
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
evmTotalRecords={eoas.data.totalRecords}
|
||||
evmWallets={eoas.data.items as Wallet[]}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
project={project}
|
||||
solanaCurrentPage={solanCurrentPage}
|
||||
solanaTotalPages={Math.ceil(
|
||||
solanaAccounts.data.totalRecords / pageSize,
|
||||
)}
|
||||
solanaTotalRecords={solanaAccounts.data.totalRecords}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
teamSlug={params.team_slug}
|
||||
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
totalRecords={eoas.data.totalRecords}
|
||||
wallets={eoas.data.items as Wallet[]}
|
||||
solanaPermissionError={isSolanaPermissionError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+9
-42
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createEoa } from "@thirdweb-dev/vault-sdk";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -17,12 +15,13 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { initVaultClient } from "../../lib/vault.client";
|
||||
import { createProjectServerWallet } from "../../lib/vault.client";
|
||||
|
||||
export default function CreateServerWallet(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
setAsProjectWallet?: boolean;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const [label, setLabel] = useState("");
|
||||
@@ -36,49 +35,17 @@ export default function CreateServerWallet(props: {
|
||||
managementAccessToken: string;
|
||||
label: string;
|
||||
}) => {
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
const eoa = await createEoa({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
metadata: {
|
||||
label,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
type: "server-wallet",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!eoa.success) {
|
||||
throw new Error("Failed to create eoa");
|
||||
}
|
||||
|
||||
// no need to await this, it's not blocking
|
||||
engineCloudProxy({
|
||||
body: JSON.stringify({
|
||||
signerAddress: eoa.data.address,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.project.publishableKey,
|
||||
"x-team-id": props.project.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
pathname: "/cache/smart-account",
|
||||
}).catch((err) => {
|
||||
console.warn("failed to cache server wallet", err);
|
||||
const wallet = await createProjectServerWallet({
|
||||
label,
|
||||
managementAccessToken,
|
||||
project: props.project,
|
||||
setAsProjectWallet: props.setAsProjectWallet,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
setModalOpen(false);
|
||||
|
||||
return eoa;
|
||||
setLabel("");
|
||||
return wallet;
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
|
||||
-479
@@ -1,479 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useWalletBalance } from "thirdweb/react";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
predictSmartAccountAddress,
|
||||
} from "thirdweb/wallets/smart";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import CreateServerWallet from "../components/create-server-wallet.client";
|
||||
import type { Wallet } from "./types";
|
||||
|
||||
export function ServerWalletsTableUI({
|
||||
wallets,
|
||||
project,
|
||||
teamSlug,
|
||||
managementAccessToken,
|
||||
totalRecords,
|
||||
currentPage,
|
||||
totalPages,
|
||||
client,
|
||||
}: {
|
||||
wallets: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
totalRecords: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const [selectedChainId, setSelectedChainId] = useState<number>(1);
|
||||
const [showSmartAccount, setShowSmartAccount] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<WalletProductIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Server Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage server wallets for your project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
onChange={setSelectedChainId}
|
||||
client={client}
|
||||
disableChainId
|
||||
className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50"
|
||||
placeholder="Select network"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label
|
||||
className="text-sm text-muted-foreground"
|
||||
htmlFor="showSmartAccount"
|
||||
>
|
||||
Show ERC4337 Smart Account
|
||||
</Label>
|
||||
|
||||
<Switch
|
||||
id="showSmartAccount"
|
||||
checked={showSmartAccount}
|
||||
onCheckedChange={setShowSmartAccount}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-none border-x-0 border-b-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead className="w-[280px]">
|
||||
{showSmartAccount
|
||||
? "Smart Account Address"
|
||||
: "Wallet Address"}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px]">
|
||||
<div className="flex w-[180px] items-center gap-1.5">
|
||||
Balance
|
||||
{wallets.length > 0 && (
|
||||
<ToolTipLabel
|
||||
contentClassName="capitalize font-normal tracking-normal leading-normal"
|
||||
label="Refresh Balance"
|
||||
>
|
||||
<Button
|
||||
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
|
||||
onClick={async (e) => {
|
||||
const buttonEl = e.currentTarget;
|
||||
buttonEl.setAttribute("data-pending", "true");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["walletBalance", selectedChainId],
|
||||
});
|
||||
|
||||
buttonEl.setAttribute("data-pending", "false");
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>Created </TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{wallets.map((wallet) => (
|
||||
<ServerWalletTableRow
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
client={client}
|
||||
chainId={selectedChainId}
|
||||
showSmartAccount={showSmartAccount}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{wallets.length === 0 && (
|
||||
<div className="py-24 flex flex-col items-center justify-center px-4 text-center gap-4">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<XIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">No server wallets found</p>
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{wallets.length > 0 && (
|
||||
<div className="flex justify-end items-center p-4 py-5 lg:px-6 border-t">
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-col items-center border-t p-6">
|
||||
<div className="mb-4 text-muted-foreground text-sm">
|
||||
Found {totalRecords} server wallets
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
|
||||
currentPage > 1 ? currentPage - 1 : 1
|
||||
}`}
|
||||
legacyBehavior
|
||||
passHref
|
||||
>
|
||||
<PaginationPrevious
|
||||
className={
|
||||
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(pageNumber) => (
|
||||
<PaginationItem key={`page-${pageNumber}`}>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?page=${pageNumber}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationLink isActive={currentPage === pageNumber}>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
|
||||
currentPage < totalPages ? currentPage + 1 : totalPages
|
||||
}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationNext
|
||||
className={
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerWalletTableRow(props: {
|
||||
wallet: Wallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
showSmartAccount: boolean;
|
||||
}) {
|
||||
const { wallet, project, teamSlug, client, chainId, showSmartAccount } =
|
||||
props;
|
||||
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
|
||||
const smartAccountAddressQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
const smartAccountAddress = await predictSmartAccountAddress({
|
||||
adminAddress: wallet.address,
|
||||
|
||||
chain: chain,
|
||||
client: client,
|
||||
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
});
|
||||
return smartAccountAddress;
|
||||
},
|
||||
enabled: showSmartAccount,
|
||||
queryKey: ["smart-account-address", wallet.address, chainId],
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow key={wallet.id}>
|
||||
{/* Label */}
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-foreground",
|
||||
!wallet.metadata.label && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{wallet.metadata.label || "N/A"}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Address */}
|
||||
<TableCell>
|
||||
{showSmartAccount ? (
|
||||
<div>
|
||||
{smartAccountAddressQuery.data ? (
|
||||
<WalletAddress
|
||||
address={smartAccountAddressQuery.data}
|
||||
client={client}
|
||||
/>
|
||||
) : smartAccountAddressQuery.isPending ? (
|
||||
<Skeleton className="h-7 w-24" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<WalletAddress address={wallet.address} client={client} />
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Balance */}
|
||||
<TableCell>
|
||||
{showSmartAccount && (
|
||||
// biome-ignore lint/complexity/noUselessFragments: keep for readability
|
||||
<>
|
||||
{smartAccountAddressQuery.isPending ? (
|
||||
<Skeleton className="h-5 w-16" />
|
||||
) : smartAccountAddressQuery.data ? (
|
||||
<WalletBalanceCell
|
||||
address={smartAccountAddressQuery.data}
|
||||
chainId={chainId}
|
||||
client={client}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showSmartAccount && (
|
||||
<WalletBalanceCell
|
||||
address={wallet.address}
|
||||
chainId={chainId}
|
||||
client={client}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<WalletDateCell date={wallet.createdAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<WalletActionsDropdown
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
fundWalletAddress={
|
||||
showSmartAccount ? smartAccountAddressQuery.data : wallet.address
|
||||
}
|
||||
wallet={wallet}
|
||||
client={client}
|
||||
chainId={chainId}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletDateCell({ date }: { date: string }) {
|
||||
if (!date) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const dateObj = new Date(date);
|
||||
return (
|
||||
<ToolTipLabel label={format(dateObj, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletActionsDropdown(props: {
|
||||
fundWalletAddress: string | undefined;
|
||||
wallet: Wallet;
|
||||
teamSlug: string;
|
||||
project: Project;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
}) {
|
||||
const [showFundModal, setShowFundModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="rounded-xl">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/transactions?testTxWithWallet=${props.wallet.id}`}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send test transaction
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{props.fundWalletAddress && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowFundModal(true)}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<WalletProductIcon className="size-4 text-muted-foreground" />
|
||||
Fund wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{props.fundWalletAddress && (
|
||||
<FundWalletModal
|
||||
open={showFundModal}
|
||||
onOpenChange={setShowFundModal}
|
||||
title="Fund server wallet"
|
||||
description="Send funds to the server wallet"
|
||||
recipientAddress={props.fundWalletAddress}
|
||||
client={props.client}
|
||||
defaultChainId={props.chainId}
|
||||
checkoutWidgetTitle={
|
||||
props.wallet.metadata.label
|
||||
? `Fund ${props.wallet.metadata.label}`
|
||||
: "Fund server wallet"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletBalanceCell(props: {
|
||||
address: string;
|
||||
chainId: number;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const chain = useV5DashboardChain(props.chainId);
|
||||
const balance = useWalletBalance({
|
||||
address: props.address,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
if (balance.isFetching) {
|
||||
return <Skeleton className="h-5 w-16" />;
|
||||
}
|
||||
|
||||
if (!balance.data) {
|
||||
return <span className="text-muted-foreground">N/A</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{balance.data.displayValue} {balance.data.symbol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { Wallet } from "./types";
|
||||
import { ServerWalletsTableUI } from "./wallet-table-ui.client";
|
||||
|
||||
export function ServerWalletsTable({
|
||||
wallets,
|
||||
project,
|
||||
teamSlug,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
managementAccessToken,
|
||||
client,
|
||||
}: {
|
||||
wallets: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
totalRecords: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<ServerWalletsTableUI
|
||||
client={client}
|
||||
currentPage={currentPage}
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
wallets={wallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { createSolanaAccount } from "../lib/vault.client";
|
||||
|
||||
export function CreateSolanaWallet(props: {
|
||||
managementAccessToken: string | undefined;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!props.managementAccessToken) {
|
||||
throw new Error("No management access token");
|
||||
}
|
||||
|
||||
const result = await createSolanaAccount({
|
||||
managementAccessToken: props.managementAccessToken,
|
||||
label: label.trim(),
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw result.error || new Error("Failed to create Solana wallet");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Solana wallet created successfully");
|
||||
setOpen(false);
|
||||
setLabel("");
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create Solana wallet",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="gap-2 rounded-full bg-foreground hover:bg-foreground/90"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Create Solana Wallet
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Solana Wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new Solana server wallet for your project
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="label">Wallet Label</Label>
|
||||
<Input
|
||||
id="label"
|
||||
placeholder="e.g., Production Wallet"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !label.trim()}
|
||||
>
|
||||
{createMutation.isPending ? "Creating..." : "Create Wallet"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { KeyRoundIcon, Loader2Icon, ShieldCheckIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { upgradeAccessTokensForSolana } from "../../lib/vault.client";
|
||||
|
||||
const formSchema = z.object({
|
||||
secretKey: z.string().min(1, "Secret key is required"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function UpgradeSolanaPermissions(props: { project: Project }) {
|
||||
const router = useDashboardRouter();
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
|
||||
// Check if this is an ejected vault
|
||||
const engineCloudService = props.project.services.find(
|
||||
(s) => s.name === "engineCloud",
|
||||
);
|
||||
const isEjectedVault = !engineCloudService?.encryptedAdminKey;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
secretKey: "",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: async (data: FormValues) => {
|
||||
return upgradeAccessTokensForSolana({
|
||||
project: props.project,
|
||||
projectSecretKey: data.secretKey,
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (!result.success) {
|
||||
toast.error(result.error || "Failed to upgrade Solana permissions");
|
||||
return;
|
||||
}
|
||||
setIsComplete(true);
|
||||
toast.success("Solana permissions enabled successfully!");
|
||||
// Refresh the page after a short delay to show updated state
|
||||
setTimeout(() => {
|
||||
router.refresh();
|
||||
}, 1500);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to upgrade Solana permissions",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<Card className="border-success/50 bg-success/5">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-success/10">
|
||||
<ShieldCheckIcon className="size-5 text-success" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-success">
|
||||
Solana Access Enabled
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your project now has full Solana functionality
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-background/50 p-3 border border-border/50">
|
||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Refreshing page to load Solana wallets...
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-purple-500/10">
|
||||
<KeyRoundIcon className="size-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Enable Solana Functionality</CardTitle>
|
||||
<CardDescription>
|
||||
Upgrade your project to support Solana wallets and transactions
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
{isEjectedVault
|
||||
? "Why do you need my admin key?"
|
||||
: "Why do you need my secret key?"}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-2 space-y-2 text-sm">
|
||||
<p>
|
||||
Your project was created before Solana support was added. To
|
||||
enable Solana features, we need to update your access tokens with
|
||||
new permissions.
|
||||
</p>
|
||||
{isEjectedVault ? (
|
||||
<>
|
||||
<p>
|
||||
Since you're using an ejected vault, your admin key is used
|
||||
to:
|
||||
</p>
|
||||
<ul className="ml-4 list-disc space-y-1">
|
||||
<li>Create new access tokens with Solana permissions</li>
|
||||
<li>Update your management token with the new permissions</li>
|
||||
</ul>
|
||||
<p className="font-medium">
|
||||
Your admin key is never stored and is only used during this
|
||||
one-time upgrade process.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Your secret key is used to:</p>
|
||||
<ul className="ml-4 list-disc space-y-1">
|
||||
<li>Decrypt your existing admin credentials</li>
|
||||
<li>Create new access tokens with Solana permissions</li>
|
||||
<li>Re-encrypt and securely store the updated credentials</li>
|
||||
</ul>
|
||||
<p className="font-medium">
|
||||
Your secret key is never stored and is only used during this
|
||||
one-time upgrade process.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit((data) => upgradeMutation.mutate(data))}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secretKey">
|
||||
{isEjectedVault ? "Vault Admin Key" : "Project Secret Key"}
|
||||
</Label>
|
||||
<Input
|
||||
{...form.register("secretKey")}
|
||||
id="secretKey"
|
||||
placeholder={
|
||||
isEjectedVault
|
||||
? "Enter your vault admin key"
|
||||
: "Enter your secret key"
|
||||
}
|
||||
type="password"
|
||||
disabled={upgradeMutation.isPending}
|
||||
className="font-mono"
|
||||
/>
|
||||
{form.formState.errors.secretKey && (
|
||||
<p className="text-destructive text-sm">
|
||||
{form.formState.errors.secretKey.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{isEjectedVault
|
||||
? "This is the admin key you received when you ejected your vault"
|
||||
: "You can find your secret key in your project settings"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={upgradeMutation.isPending || !form.formState.isValid}
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{upgradeMutation.isPending ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
Upgrading...
|
||||
</>
|
||||
) : (
|
||||
"Enable Solana"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
createVaultClient,
|
||||
createSolanaAccount as vaultCreateSolanaAccount,
|
||||
listSolanaAccounts as vaultListSolanaAccounts,
|
||||
} from "@thirdweb-dev/vault-sdk";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import type { SolanaWallet } from "../wallet-table/types";
|
||||
|
||||
interface VaultSolanaAccountListItem {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata: {
|
||||
projectId?: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface SolanaAccountResponse {
|
||||
pubkey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function listSolanaAccounts(params: {
|
||||
managementAccessToken: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
projectId?: string;
|
||||
}): Promise<{
|
||||
data: {
|
||||
items: SolanaWallet[];
|
||||
totalRecords: number;
|
||||
};
|
||||
error: Error | null;
|
||||
success: boolean;
|
||||
}> {
|
||||
const { managementAccessToken, page = 1, limit = 100, projectId } = params;
|
||||
|
||||
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
|
||||
return {
|
||||
data: {
|
||||
items: [],
|
||||
totalRecords: 0,
|
||||
},
|
||||
error: new Error("Missing managementAccessToken or vault URL"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await vaultListSolanaAccounts({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: page - 1, // Vault SDK uses 0-based pagination
|
||||
pageSize: limit,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return {
|
||||
data: {
|
||||
items: [],
|
||||
totalRecords: 0,
|
||||
},
|
||||
error: response.error
|
||||
? new Error(JSON.stringify(response.error))
|
||||
: new Error("Failed to fetch Solana accounts"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const items = (response.data.items || []) as VaultSolanaAccountListItem[];
|
||||
|
||||
// Filter by projectId and type, transform to SolanaWallet type
|
||||
const wallets: SolanaWallet[] = items
|
||||
.filter((item) => {
|
||||
// Filter by projectId
|
||||
if (projectId && item.metadata?.projectId !== projectId) {
|
||||
return false;
|
||||
}
|
||||
// Only include server-wallet type
|
||||
return !item.metadata?.type || item.metadata.type === "server-wallet";
|
||||
})
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
publicKey: item.pubkey,
|
||||
metadata: {
|
||||
type: "server-wallet",
|
||||
projectId: item.metadata?.projectId || projectId || "",
|
||||
label: item.metadata?.label,
|
||||
},
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: {
|
||||
items: wallets,
|
||||
totalRecords: wallets.length, // Use filtered count since we're filtering by projectId
|
||||
},
|
||||
error: null,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to list Solana accounts", error);
|
||||
return {
|
||||
data: {
|
||||
items: [],
|
||||
totalRecords: 0,
|
||||
},
|
||||
error: error instanceof Error ? error : new Error("Unknown error"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSolanaAccount(params: {
|
||||
managementAccessToken: string;
|
||||
label: string;
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
}): Promise<{
|
||||
data: SolanaAccountResponse | null;
|
||||
error: Error | null;
|
||||
success: boolean;
|
||||
}> {
|
||||
const { managementAccessToken, label, projectId, teamId } = params;
|
||||
|
||||
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
|
||||
return {
|
||||
data: null,
|
||||
error: new Error("Missing managementAccessToken or vault URL"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await vaultCreateSolanaAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
metadata: {
|
||||
label,
|
||||
projectId,
|
||||
teamId,
|
||||
type: "server-wallet",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return {
|
||||
data: null,
|
||||
error: response.error
|
||||
? new Error(JSON.stringify(response.error))
|
||||
: new Error("Failed to create Solana account"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
pubkey: response.data.pubkey,
|
||||
createdAt: response.data.createdAt,
|
||||
updatedAt: response.data.updatedAt,
|
||||
},
|
||||
error: null,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to create Solana account", error);
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error : new Error("Unknown error"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export type SolanaWallet = {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
metadata: {
|
||||
type: string;
|
||||
projectId: string;
|
||||
label?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
+7
-5
@@ -1,25 +1,27 @@
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TransactionLayout({
|
||||
export default async function TransactionLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { team_slug: string; project_slug: string };
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const { team_slug, project_slug } = await params;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
className="flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
|
||||
href={`/team/${params.team_slug}/${params.project_slug}/transactions`}
|
||||
href={`/team/${team_slug}/${project_slug}/transactions`}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
Back to Transactions
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1">{children}</div>
|
||||
<div className="flex-1 space-y-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+44
-13
@@ -15,9 +15,12 @@ import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import type { Transaction } from "../../analytics/tx-table/types";
|
||||
import {
|
||||
getSingleSolanaTransaction,
|
||||
getSingleTransaction,
|
||||
getSolanaTransactionActivityLogs,
|
||||
getTransactionActivityLogs,
|
||||
} from "../../lib/analytics";
|
||||
import { SolanaTransactionDetailsUI } from "./solana-transaction-details-ui";
|
||||
import { TransactionDetailsUI } from "./transaction-details-ui";
|
||||
|
||||
type AbiItem =
|
||||
@@ -195,17 +198,18 @@ export default async function TransactionPage({
|
||||
redirect(`/team/${team_slug}`);
|
||||
}
|
||||
|
||||
const [transactionData, activityLogs] = await Promise.all([
|
||||
// Try fetching both EVM and Solana transactions
|
||||
const [evmTransactionData, solanaTransactionData] = await Promise.all([
|
||||
getSingleTransaction({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
transactionId: id,
|
||||
}),
|
||||
getTransactionActivityLogs({
|
||||
}).catch(() => null),
|
||||
getSingleSolanaTransaction({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
transactionId: id,
|
||||
}),
|
||||
}).catch(() => null),
|
||||
]);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
@@ -213,23 +217,50 @@ export default async function TransactionPage({
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
if (!transactionData) {
|
||||
notFound();
|
||||
// Determine which transaction type we have and fetch appropriate activity logs
|
||||
if (solanaTransactionData) {
|
||||
const activityLogs = await getSolanaTransactionActivityLogs({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
transactionId: id,
|
||||
});
|
||||
|
||||
// Render Solana transaction details
|
||||
return (
|
||||
<SolanaTransactionDetailsUI
|
||||
activityLogs={activityLogs}
|
||||
client={client}
|
||||
project={project}
|
||||
teamSlug={team_slug}
|
||||
transaction={solanaTransactionData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Decode transaction data on the server
|
||||
const decodedTransactionData = await decodeTransactionData(transactionData);
|
||||
if (evmTransactionData) {
|
||||
const activityLogs = await getTransactionActivityLogs({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
transactionId: id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-2">
|
||||
// Decode transaction data on the server for EVM
|
||||
const decodedTransactionData =
|
||||
await decodeTransactionData(evmTransactionData);
|
||||
|
||||
// Render EVM transaction details
|
||||
return (
|
||||
<TransactionDetailsUI
|
||||
activityLogs={activityLogs}
|
||||
client={client}
|
||||
decodedTransactionData={decodedTransactionData}
|
||||
project={project}
|
||||
teamSlug={team_slug}
|
||||
transaction={transactionData}
|
||||
transaction={evmTransactionData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
// No transaction found
|
||||
notFound();
|
||||
}
|
||||
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
"use client";
|
||||
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { stringify } from "thirdweb/utils";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { SolanaAddress } from "@/components/blocks/solana-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import type { SolanaTransaction } from "../../analytics/solana-tx-table/types";
|
||||
import type { ActivityLogEntry } from "../../lib/analytics";
|
||||
import { getSolanaNetworkName, getSolscanUrl } from "../../lib/solana-utils";
|
||||
|
||||
const solanaStatusDetails = {
|
||||
CONFIRMED: { name: "Confirmed", variant: "success" as const },
|
||||
FAILED: { name: "Failed", variant: "destructive" as const },
|
||||
QUEUED: { name: "Queued", variant: "warning" as const },
|
||||
SUBMITTED: { name: "Submitted", variant: "warning" as const },
|
||||
};
|
||||
|
||||
export function SolanaTransactionDetailsUI({
|
||||
transaction,
|
||||
activityLogs,
|
||||
}: {
|
||||
transaction: SolanaTransaction;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
project: Project;
|
||||
activityLogs: ActivityLogEntry[];
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "logs" | "raw">(
|
||||
"overview",
|
||||
);
|
||||
|
||||
// Extract relevant data from transaction
|
||||
const {
|
||||
id,
|
||||
chainId,
|
||||
signerAddress,
|
||||
signature,
|
||||
confirmedAt,
|
||||
createdAt,
|
||||
executionResult,
|
||||
errorMessage,
|
||||
} = transaction;
|
||||
|
||||
const status = (executionResult?.status ||
|
||||
transaction.status ||
|
||||
"QUEUED") as keyof typeof solanaStatusDetails;
|
||||
|
||||
// Parse network from chainId
|
||||
const network = getSolanaNetworkName(chainId);
|
||||
const networkDisplay = network.charAt(0).toUpperCase() + network.slice(1);
|
||||
|
||||
// Calculate time difference between creation and confirmation
|
||||
const confirmationTime =
|
||||
confirmedAt && createdAt
|
||||
? new Date(confirmedAt).getTime() -
|
||||
(createdAt instanceof Date ? createdAt : new Date(createdAt)).getTime()
|
||||
: null;
|
||||
|
||||
// Get signature from executionResult if available
|
||||
const txSignature =
|
||||
(executionResult && "signature" in executionResult
|
||||
? executionResult.signature
|
||||
: signature) || null;
|
||||
|
||||
// Get slot and blockTime
|
||||
const slot =
|
||||
executionResult && "slot" in executionResult
|
||||
? executionResult.slot
|
||||
: transaction.confirmedAtSlot;
|
||||
const blockTime =
|
||||
executionResult && "blockTime" in executionResult
|
||||
? executionResult.blockTime
|
||||
: transaction.blockTime;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Transaction ID Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Transaction Details
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">Queue ID:</span>
|
||||
<CopyTextButton
|
||||
className="font-mono"
|
||||
copyIconPosition="left"
|
||||
textToCopy={id}
|
||||
textToShow={id}
|
||||
tooltip="Copy Queue ID"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
className="gap-2 text-base"
|
||||
variant={solanaStatusDetails[status].variant}
|
||||
>
|
||||
{solanaStatusDetails[status].name}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "Overview",
|
||||
onClick: () => setActiveTab("overview"),
|
||||
isActive: activeTab === "overview",
|
||||
},
|
||||
{
|
||||
name: "Activity Logs",
|
||||
onClick: () => setActiveTab("logs"),
|
||||
isActive: activeTab === "logs",
|
||||
},
|
||||
{
|
||||
name: "Raw Data",
|
||||
onClick: () => setActiveTab("raw"),
|
||||
isActive: activeTab === "raw",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Transaction Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transaction Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<InfoRow label="Network" value={`Solana ${networkDisplay}`} />
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={solanaStatusDetails[status].name}
|
||||
/>
|
||||
<InfoRow label="Signer Address">
|
||||
<SolanaAddress address={signerAddress} shortenAddress={false} />
|
||||
</InfoRow>
|
||||
{txSignature && (
|
||||
<InfoRow label="Signature">
|
||||
<Link
|
||||
href={getSolscanUrl(txSignature, chainId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-link-foreground hover:underline text-sm"
|
||||
>
|
||||
<code className="font-mono">
|
||||
{txSignature.slice(0, 12)}...{txSignature.slice(-12)}
|
||||
</code>
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Link>
|
||||
</InfoRow>
|
||||
)}
|
||||
{slot && <InfoRow label="Slot" value={slot.toString()} />}
|
||||
{errorMessage && (
|
||||
<InfoRow label="Error">
|
||||
<span className="text-destructive text-sm">
|
||||
{errorMessage}
|
||||
</span>
|
||||
</InfoRow>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Timing Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timing Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<InfoRow label="Queued At">
|
||||
<ToolTipLabel
|
||||
label={format(
|
||||
createdAt instanceof Date ? createdAt : new Date(createdAt),
|
||||
"PP pp z",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">
|
||||
{formatDistanceToNowStrict(
|
||||
createdAt instanceof Date
|
||||
? createdAt
|
||||
: new Date(createdAt),
|
||||
{ addSuffix: true },
|
||||
)}
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
</InfoRow>
|
||||
{confirmedAt && (
|
||||
<>
|
||||
<InfoRow label="Confirmed At">
|
||||
<ToolTipLabel
|
||||
label={format(new Date(confirmedAt), "PP pp z")}
|
||||
>
|
||||
<span className="text-sm">
|
||||
{formatDistanceToNowStrict(new Date(confirmedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
</InfoRow>
|
||||
{confirmationTime && (
|
||||
<InfoRow
|
||||
label="Confirmation Time"
|
||||
value={`${(confirmationTime / 1000).toFixed(2)}s`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{blockTime && (
|
||||
<InfoRow
|
||||
label="Block Time"
|
||||
value={format(new Date(blockTime * 1000), "PP pp z")}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transaction Instructions */}
|
||||
{transaction.transactionParams &&
|
||||
transaction.transactionParams.instructions.length > 0 && (
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Instructions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{transaction.transactionParams.instructions.map(
|
||||
(instruction, index) => (
|
||||
<InstructionCard
|
||||
key={`instruction-${instruction.programId}-${index}`}
|
||||
index={index}
|
||||
instruction={instruction}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Execution Details */}
|
||||
{executionResult && (
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Execution Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CodeClient
|
||||
code={stringify(executionResult, null, 2)}
|
||||
lang="json"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity Logs Tab */}
|
||||
{activeTab === "logs" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity Logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activityLogs.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No activity logs available
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activityLogs.map((log) => (
|
||||
<ActivityLogItem key={log.id} log={log} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Raw Data Tab */}
|
||||
{activeTab === "raw" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Raw Transaction Data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CodeClient code={stringify(transaction, null, 2)} lang="json" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-muted-foreground text-sm font-medium">{label}</dt>
|
||||
<dd className="text-sm">
|
||||
{children || <span className="break-all">{value || "N/A"}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstructionCard({
|
||||
instruction,
|
||||
index,
|
||||
}: {
|
||||
instruction: {
|
||||
programId: string;
|
||||
keys: Array<{
|
||||
pubkey: string;
|
||||
isSigner: boolean;
|
||||
isWritable: boolean;
|
||||
}>;
|
||||
data: string;
|
||||
};
|
||||
index: number;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className="font-medium text-sm">Instruction {index + 1}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium mb-1">
|
||||
Program ID
|
||||
</p>
|
||||
<code className="text-xs font-mono break-all">
|
||||
{instruction.programId}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium mb-1">
|
||||
Accounts ({instruction.keys.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{instruction.keys.map((key, idx) => (
|
||||
<div
|
||||
key={`${key.pubkey}-${idx}`}
|
||||
className="rounded border border-border bg-background p-2"
|
||||
>
|
||||
<code className="text-xs font-mono break-all">
|
||||
{key.pubkey}
|
||||
</code>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{key.isSigner && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Signer
|
||||
</Badge>
|
||||
)}
|
||||
{key.isWritable && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Writable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium mb-1">
|
||||
Data
|
||||
</p>
|
||||
<code className="text-xs font-mono break-all block bg-background p-2 rounded border border-border">
|
||||
{instruction.data || "No data"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityLogItem({ log }: { log: ActivityLogEntry }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{log.eventType}
|
||||
</Badge>
|
||||
<span className="text-sm">{log.stageName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{format(new Date(log.timestamp), "PP pp")}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && log.payload && (
|
||||
<div className="mt-3">
|
||||
<CodeClient
|
||||
code={
|
||||
typeof log.payload === "string"
|
||||
? log.payload
|
||||
: stringify(log.payload, null, 2)
|
||||
}
|
||||
lang="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -22,8 +22,8 @@ import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { statusDetails } from "../../analytics/tx-table/tx-table-ui";
|
||||
import type { Transaction } from "../../analytics/tx-table/types";
|
||||
import { statusDetails } from "../../analytics/tx-table/types";
|
||||
import type { ActivityLogEntry } from "../../lib/analytics";
|
||||
import type { DecodedTransactionData, DecodedTransactionResult } from "./page";
|
||||
|
||||
|
||||
+117
-7
@@ -1,3 +1,4 @@
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
@@ -6,13 +7,20 @@ import type { DurationId } from "@/components/analytics/date-range-selector";
|
||||
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
|
||||
import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { ServerWalletsTable } from "../transactions/components/server-wallets-table.client";
|
||||
import type { Wallet } from "../transactions/server-wallets/wallet-table/types";
|
||||
import { listSolanaAccounts } from "../transactions/solana-wallets/lib/vault.client";
|
||||
import type { SolanaWallet } from "../transactions/solana-wallets/wallet-table/types";
|
||||
import { InAppWalletAnalytics } from "./analytics/chart";
|
||||
import { InAppWalletsSummary } from "./analytics/chart/Summary";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
searchParams: Promise<{
|
||||
@@ -20,6 +28,8 @@ export default async function Page(props: {
|
||||
to?: string;
|
||||
type?: string;
|
||||
interval?: string;
|
||||
page?: string;
|
||||
solana_page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const [searchParams, params] = await Promise.all([
|
||||
@@ -40,12 +50,74 @@ export default async function Page(props: {
|
||||
to: searchParams.to,
|
||||
});
|
||||
|
||||
const project = await getProject(params.team_slug, params.project_slug);
|
||||
const [vaultClient, project] = await Promise.all([
|
||||
createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
}).catch(() => undefined),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const projectEngineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
const managementAccessToken =
|
||||
projectEngineCloudService?.managementAccessToken;
|
||||
|
||||
// Fetch server wallets with pagination (5 per page)
|
||||
const pageSize = 5;
|
||||
const currentPage = Number.parseInt(searchParams.page ?? "1");
|
||||
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
|
||||
|
||||
// Fetch EVM wallets
|
||||
const eoas =
|
||||
vaultClient && managementAccessToken
|
||||
? await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: currentPage - 1,
|
||||
// @ts-expect-error - TODO: fix this
|
||||
page_size: pageSize,
|
||||
},
|
||||
},
|
||||
})
|
||||
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
|
||||
|
||||
// Fetch Solana wallets
|
||||
let solanaAccounts: {
|
||||
data: { items: SolanaWallet[]; totalRecords: number };
|
||||
error: Error | null;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
if (managementAccessToken) {
|
||||
solanaAccounts = await listSolanaAccounts({
|
||||
managementAccessToken,
|
||||
page: solanCurrentPage,
|
||||
limit: pageSize,
|
||||
projectId: project.id,
|
||||
});
|
||||
} else {
|
||||
solanaAccounts = {
|
||||
data: { items: [], totalRecords: 0 },
|
||||
error: null,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for Solana permission errors
|
||||
const isSolanaPermissionError = solanaAccounts.error?.message?.includes(
|
||||
"AUTH_INSUFFICIENT_SCOPE",
|
||||
);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
@@ -103,12 +175,50 @@ export default async function Page(props: {
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
<InAppWalletUsersPageContent
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
projectClientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
{/* Server Wallets Section (EVM + Solana) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{eoas.error ? (
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-destructive font-semibold mb-2">
|
||||
EVM Wallet Error
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{eoas.error.message || "Failed to load EVM wallets"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ServerWalletsTable
|
||||
client={client}
|
||||
evmCurrentPage={currentPage}
|
||||
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
evmTotalRecords={eoas.data.totalRecords}
|
||||
evmWallets={eoas.data.items as Wallet[]}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
project={project}
|
||||
solanaCurrentPage={solanCurrentPage}
|
||||
solanaTotalPages={Math.ceil(
|
||||
solanaAccounts.data.totalRecords / pageSize,
|
||||
)}
|
||||
solanaTotalRecords={solanaAccounts.data.totalRecords}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
teamSlug={params.team_slug}
|
||||
solanaPermissionError={isSolanaPermissionError || false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Wallets Section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
User wallets
|
||||
</h2>
|
||||
<InAppWalletUsersPageContent
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
projectClientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProjectPage>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import type {
|
||||
getWebhookSends,
|
||||
WebhookSend,
|
||||
} from "@/api/universal-bridge/developer";
|
||||
import { WebhookSendsUI } from "./webhook-sends";
|
||||
|
||||
const meta = {
|
||||
title: "app/webhooks/WebhookSendsUI",
|
||||
component: WebhookSendsUI,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container max-w-7xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof WebhookSendsUI>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type WebhookSendResult = Awaited<ReturnType<typeof getWebhookSends>>;
|
||||
|
||||
function createMockWebhookSendItem(): WebhookSend {
|
||||
const type = Math.random() > 0.5 ? "onramp" : "bridge";
|
||||
const status =
|
||||
Math.random() > 0.5
|
||||
? "PENDING"
|
||||
: Math.random() > 0.5
|
||||
? "COMPLETED"
|
||||
: "FAILED";
|
||||
|
||||
const responseStatus =
|
||||
Math.random() > 0.5 ? (Math.random() > 0.5 ? 200 : 500) : 404;
|
||||
|
||||
const responseType = Math.random() > 0.5 ? "json" : "text";
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
webhookId: "8fa29fe7-95a9-4b70-97a6-7e80504f5a75",
|
||||
webhookUrl: "https://example.webhook/foo/bar",
|
||||
paymentId:
|
||||
"0x9620fef2c2267b223b8e998b1453b15324acf3ac03e531c9b60c38d6a02843cb",
|
||||
transactionId:
|
||||
type === "bridge"
|
||||
? "0x79fa097789678db4fcb8712d49133a9447e0039c8a8f93be0cfe3664ac304b80"
|
||||
: null,
|
||||
onrampId: type === "onramp" ? crypto.randomUUID() : null,
|
||||
status: status,
|
||||
body: {
|
||||
version: 1,
|
||||
type: type === "bridge" ? "pay.onchain-transaction" : undefined,
|
||||
data:
|
||||
type === "bridge"
|
||||
? {
|
||||
buyWithCryptoStatus: {
|
||||
quote: {
|
||||
fromToken: {
|
||||
chainId: 42161,
|
||||
tokenAddress: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
decimals: 18,
|
||||
priceUSDCents: 452169,
|
||||
name: "Ether",
|
||||
symbol: "ETH",
|
||||
},
|
||||
toToken: {
|
||||
chainId: 8453,
|
||||
tokenAddress: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
decimals: 18,
|
||||
priceUSDCents: 451824,
|
||||
name: "Ether",
|
||||
symbol: "ETH",
|
||||
},
|
||||
fromAmountWei: "101000000000000",
|
||||
fromAmount: "0.000101",
|
||||
toAmountWei: "100000000000000",
|
||||
toAmount: "0.0001",
|
||||
toAmountMin: "0.0001",
|
||||
toAmountMinWei: "100000000000000",
|
||||
estimated: {
|
||||
fromAmountUSDCents: 46,
|
||||
toAmountMinUSDCents: 45,
|
||||
toAmountUSDCents: 45,
|
||||
slippageBPS: 0,
|
||||
feesUSDCents: 0,
|
||||
gasCostUSDCents: 0,
|
||||
durationSeconds: 60,
|
||||
},
|
||||
createdAt: "2025-09-16T23:20:53.228Z",
|
||||
},
|
||||
status: "PENDING",
|
||||
subStatus: "NONE",
|
||||
fromAddress: "0x2a4f24f935eb178e3e7ba9b53a5ee6d8407c0709",
|
||||
toAddress: "0x2a4f24f935eb178e3e7ba9b53a5ee6d8407c0709",
|
||||
bridge: "Universal Bridge",
|
||||
},
|
||||
}
|
||||
: {
|
||||
buyWithFiatStatus: {
|
||||
intentId: "9833815f-77b8-4e3e-b0cf-b8741be16ede",
|
||||
fromAddress: "0x0000000000000000000000000000000000000000",
|
||||
toAddress: "0xEbE5C4774fd4eEA444094367E01d26f0771e248b",
|
||||
status: "PENDING",
|
||||
quote: {
|
||||
createdAt: "2025-06-19T18:32:42.092Z",
|
||||
estimatedOnRampAmount: "0.002",
|
||||
estimatedOnRampAmountWei: "2000000000000000",
|
||||
estimatedToTokenAmount: "0.002",
|
||||
estimatedToTokenAmountWei: "2000000000000000",
|
||||
onRampToken: {
|
||||
chainId: 42161,
|
||||
address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
|
||||
symbol: "ETH",
|
||||
name: "Ether",
|
||||
decimals: 18,
|
||||
priceUsd: 2511.635965,
|
||||
iconUri: "https://assets.relay.link/icons/1/light.png",
|
||||
},
|
||||
toToken: {
|
||||
chainId: 42161,
|
||||
address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
|
||||
symbol: "ETH",
|
||||
name: "Ether",
|
||||
decimals: 18,
|
||||
priceUsd: 2511.635965,
|
||||
iconUri: "https://assets.relay.link/icons/1/light.png",
|
||||
},
|
||||
estimatedDurationSeconds: 60,
|
||||
},
|
||||
failureMessage: "",
|
||||
purchaseData: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
success: true,
|
||||
responseStatus: responseStatus,
|
||||
response:
|
||||
responseType === "json"
|
||||
? '{ "message": "Success" }'
|
||||
: "This is a plaintext response",
|
||||
createdAt: "2025-09-16T23:20:53.981Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createMockWebhookSendResult(length: number): WebhookSendResult {
|
||||
return {
|
||||
data: Array.from({ length }, createMockWebhookSendItem),
|
||||
pagination: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
total: 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mockEmptyWebhookSendResult: WebhookSendResult = {
|
||||
data: [],
|
||||
pagination: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000000));
|
||||
return createMockWebhookSendResult(1);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TenResults: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return createMockWebhookSendResult(10);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const OneResult: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return createMockWebhookSendResult(1);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FewResults: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return createMockWebhookSendResult(3);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoResults: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return mockEmptyWebhookSendResult;
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error("Failed to fetch webhook sends");
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ResendError: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return createMockWebhookSendResult(1);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error("Failed to resend webhook");
|
||||
},
|
||||
},
|
||||
};
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getWebhookById } from "@/api/universal-bridge/developer";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { WebhookSends } from "./webhook-sends";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
id: string;
|
||||
}>;
|
||||
}) {
|
||||
const [authToken, params] = await Promise.all([getAuthToken(), props.params]);
|
||||
|
||||
const project = await getProject(params.team_slug, params.project_slug);
|
||||
|
||||
if (!project || !authToken) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const webhook = await getWebhookById({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
authToken,
|
||||
webhookId: params.id,
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/team/${params.team_slug}/${params.project_slug}/webhooks/payments`}
|
||||
>
|
||||
Webhooks
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="space-y-0.5 border-b border-dashed pb-6 overflow-hidden ">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{webhook.label || "Untitled Webhook"}
|
||||
</h1>
|
||||
<CopyTextButton
|
||||
textToCopy={webhook.url}
|
||||
textToShow={webhook.url}
|
||||
tooltip="Copy URL"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground text-sm -translate-x-1.5 truncate max-w-full"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WebhookSends
|
||||
webhookId={params.id}
|
||||
authToken={authToken}
|
||||
projectClientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { PlainTextCodeBlock } from "@workspace/ui/components/code/plaintext-code";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { formatDate } from "date-fns";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
DotIcon,
|
||||
RotateCwIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getWebhookSends,
|
||||
resendWebhook,
|
||||
type WebhookSend,
|
||||
} from "@/api/universal-bridge/developer";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
type WebhookSendsProps = {
|
||||
webhookId: string;
|
||||
authToken: string;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export function WebhookSends(props: WebhookSendsProps) {
|
||||
return (
|
||||
<WebhookSendsUI
|
||||
{...props}
|
||||
getWebhookSends={getWebhookSends}
|
||||
resendWebhook={resendWebhook}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhookSendsUI(
|
||||
props: WebhookSendsProps & {
|
||||
getWebhookSends: typeof getWebhookSends;
|
||||
resendWebhook: typeof resendWebhook;
|
||||
},
|
||||
) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [_selectedWebhookSend, setSelectedWebhookSend] =
|
||||
useState<WebhookSend | null>(null);
|
||||
const [successFilter, _setSuccessFilter] = useState<
|
||||
"all" | "success" | "failed"
|
||||
>("all");
|
||||
const isMobile = useIsMobile();
|
||||
const [_isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const isDialogOpen = isMobile && _isDialogOpen;
|
||||
|
||||
const webhookSendsQuery = useQuery({
|
||||
queryKey: ["webhook-sends", props.webhookId, page],
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
queryFn: () =>
|
||||
props.getWebhookSends({
|
||||
webhookId: props.webhookId,
|
||||
authToken: props.authToken,
|
||||
projectClientId: props.projectClientId,
|
||||
teamId: props.teamId,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
success:
|
||||
successFilter === "all" ? undefined : successFilter === "success",
|
||||
}),
|
||||
// preserve the previous data when the page changes
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const totalItems = webhookSendsQuery.data?.pagination.total;
|
||||
const totalPages = totalItems ? Math.ceil(totalItems / pageSize) : 1;
|
||||
const selectedWebhookSend =
|
||||
_selectedWebhookSend || (isMobile ? null : webhookSendsQuery.data?.data[0]);
|
||||
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<div className="pb-4 border-b">
|
||||
<h2 className="text-lg font-semibold">Events</h2>
|
||||
{/* show a filter selector here */}
|
||||
</div>
|
||||
|
||||
{webhookSendsQuery.isError ? (
|
||||
<div className="text-muted-foreground text-base py-20 flex items-center justify-center border-b">
|
||||
Failed to load events
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-b">
|
||||
{/* grid */}
|
||||
<div className="flex flex-col gap-2 lg:grid lg:grid-cols-[1fr_750px] ">
|
||||
{/* left */}
|
||||
<div>
|
||||
{webhookSendsQuery.isFetching &&
|
||||
Array.from({ length: pageSize }).map(() => (
|
||||
// biome-ignore lint/correctness/useJsxKeyInIterable: ok
|
||||
<WebhookSendCompactInfoSkeleton />
|
||||
))}
|
||||
|
||||
{!webhookSendsQuery.isFetching &&
|
||||
webhookSendsQuery.data?.data.map((webhookSend) => (
|
||||
<WebhookSendCompactInfo
|
||||
key={webhookSend.id}
|
||||
webhookSend={webhookSend}
|
||||
isSelected={selectedWebhookSend?.id === webhookSend.id}
|
||||
onSelect={() => {
|
||||
setSelectedWebhookSend(webhookSend);
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!webhookSendsQuery.isFetching &&
|
||||
webhookSendsQuery.data?.data.length === 0 && (
|
||||
<div className="text-muted-foreground text-base py-20 flex items-center justify-center">
|
||||
No events found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* right - desktop */}
|
||||
<div className="hidden lg:block lg:pl-4 lg:border-l lg:pt-4">
|
||||
{webhookSendsQuery.isFetching ? (
|
||||
<div className="h-full flex justify-center items-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
) : selectedWebhookSend ? (
|
||||
<WebhookSendInfo
|
||||
webhookSend={selectedWebhookSend}
|
||||
authToken={props.authToken}
|
||||
projectClientId={props.projectClientId}
|
||||
teamId={props.teamId}
|
||||
resendWebhook={props.resendWebhook}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Dialog for mobile */}
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open);
|
||||
if (!open) {
|
||||
setSelectedWebhookSend(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto p-4">
|
||||
{selectedWebhookSend && (
|
||||
<WebhookSendInfo
|
||||
webhookSend={selectedWebhookSend}
|
||||
authToken={props.authToken}
|
||||
projectClientId={props.projectClientId}
|
||||
teamId={props.teamId}
|
||||
resendWebhook={props.resendWebhook}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{totalPages && totalPages > 1 && (
|
||||
<div className="py-5 border-t">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
totalPages={totalPages}
|
||||
onPageClick={(page) => {
|
||||
setPage(page);
|
||||
setSelectedWebhookSend(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookSendInfo(props: {
|
||||
webhookSend: WebhookSend;
|
||||
authToken: string;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
resendWebhook: typeof resendWebhook;
|
||||
}) {
|
||||
const bodyString = useMemo(() => {
|
||||
try {
|
||||
if (!props.webhookSend.body) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof props.webhookSend.body === "object" &&
|
||||
props.webhookSend.body !== null
|
||||
) {
|
||||
return JSON.stringify(props.webhookSend.body, null, 2);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [props.webhookSend.body]);
|
||||
|
||||
const responseString: { lang: "json" | "text"; code: string } =
|
||||
useMemo(() => {
|
||||
if (!props.webhookSend.response) {
|
||||
return {
|
||||
lang: "text",
|
||||
code: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(props.webhookSend.response);
|
||||
return {
|
||||
lang: "json",
|
||||
code: JSON.stringify(json, null, 2),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
lang: "text",
|
||||
code: props.webhookSend.response,
|
||||
};
|
||||
}
|
||||
}, [props.webhookSend.response]);
|
||||
|
||||
const resendMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (props.webhookSend.onrampId) {
|
||||
const result = await props.resendWebhook({
|
||||
onrampId: props.webhookSend.onrampId,
|
||||
authToken: props.authToken,
|
||||
projectClientId: props.projectClientId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
return result;
|
||||
} else if (props.webhookSend.paymentId) {
|
||||
const result = await props.resendWebhook({
|
||||
paymentId: props.webhookSend.paymentId,
|
||||
authToken: props.authToken,
|
||||
projectClientId: props.projectClientId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
throw new Error("No transaction or onramp ID found");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Webhook resent");
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Failed to resend webhook", {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pb-6 relative fade-in-0 duration-300 animate-in flex flex-col min-w-0">
|
||||
<div className="pr-10">
|
||||
<h3 className="text-xl font-semibold mb-1">
|
||||
<span>{getWebhookSendTitle(props.webhookSend)}.</span>
|
||||
<span>{props.webhookSend.status.toLowerCase()}</span>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sent on{" "}
|
||||
{formatDate(
|
||||
new Date(props.webhookSend.createdAt),
|
||||
"MMM d, yyyy HH:mm:ss",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="lg:absolute lg:top-0 lg:right-0 gap-2 rounded-lg bg-card mt-4 lg:mt-0"
|
||||
onClick={() => resendMutation.mutate()}
|
||||
>
|
||||
{resendMutation.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RotateCwIcon className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
Resend
|
||||
</Button>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* succes */}
|
||||
<div>
|
||||
<h3 className="text-sm mb-0.5 font-medium">Delivery Status</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{" "}
|
||||
{props.webhookSend.success ? "Delivered" : "Failed"}{" "}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* transaction id */}
|
||||
{props.webhookSend.transactionId && (
|
||||
<div>
|
||||
<h3 className="text-sm mb-0.5 font-medium">Transaction ID</h3>
|
||||
<CopyTextButton
|
||||
copyIconPosition="right"
|
||||
textToCopy={props.webhookSend.transactionId}
|
||||
textToShow={props.webhookSend.transactionId}
|
||||
tooltip="Copy Transaction ID"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground text-sm -translate-x-1.5 truncate max-w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment ID */}
|
||||
{props.webhookSend.paymentId && (
|
||||
<CopyTextField
|
||||
label="Payment ID"
|
||||
value={props.webhookSend.paymentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* onramp id */}
|
||||
{props.webhookSend.onrampId && (
|
||||
<CopyTextField label="Onramp ID" value={props.webhookSend.onrampId} />
|
||||
)}
|
||||
|
||||
{/* event id */}
|
||||
{props.webhookSend.id && (
|
||||
<CopyTextField label="Event ID" value={props.webhookSend.id} />
|
||||
)}
|
||||
|
||||
{/* payload */}
|
||||
<div>
|
||||
<h3 className="text-sm mb-2 font-medium">Payload</h3>
|
||||
<CodeClient
|
||||
code={bodyString || ""}
|
||||
lang="json"
|
||||
scrollableClassName="max-h-[350px]"
|
||||
className="[&_code]:text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* response */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium">Response</h3>
|
||||
<DotIcon className="size-3.5 text-muted-foreground hidden" />
|
||||
<StatusBadge status={props.webhookSend.responseStatus} />
|
||||
</div>
|
||||
|
||||
{responseString.lang === "text" ? (
|
||||
<PlainTextCodeBlock
|
||||
code={responseString.code}
|
||||
scrollableClassName="max-h-[350px]"
|
||||
codeClassName="whitespace-pre-wrap text-xs"
|
||||
/>
|
||||
) : (
|
||||
<CodeClient
|
||||
code={responseString.code}
|
||||
lang="json"
|
||||
scrollableClassName="max-h-[350px]"
|
||||
className="[&_code]:text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyTextField(props: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm mb-0.5 font-medium"> {props.label}</h3>
|
||||
<CopyTextButton
|
||||
copyIconPosition="right"
|
||||
textToCopy={props.value}
|
||||
textToShow={props.value}
|
||||
tooltip={`Copy ${props.label}`}
|
||||
variant="ghost"
|
||||
className="text-muted-foreground text-sm -translate-x-1.5 truncate max-w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookSendCompactInfo(props: {
|
||||
webhookSend: WebhookSend;
|
||||
isSelected: boolean;
|
||||
onSelect: (webhookSend: WebhookSend) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-2 border-b last:border-b-0 fade-in-0 duration-300 animate-in">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5 px-4 h-auto w-full justify-start text-left hover:bg-card rounded-lg",
|
||||
props.isSelected && "!bg-accent/70",
|
||||
)}
|
||||
onClick={() => props.onSelect(props.webhookSend)}
|
||||
>
|
||||
<StatusBadge status={props.webhookSend.responseStatus} />
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm text-foreground">
|
||||
<span>{getWebhookSendTitle(props.webhookSend)}.</span>
|
||||
<span>{props.webhookSend.status.toLowerCase()}</span>
|
||||
</h3>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(
|
||||
new Date(props.webhookSend.createdAt),
|
||||
"MMM d, yyyy HH:mm:ss",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-3.5 ml-auto text-muted-foreground opacity-0 transition-opacity",
|
||||
props.isSelected && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookSendCompactInfoSkeleton() {
|
||||
return (
|
||||
<div className="h-[77px] flex gap-3 items-center border-b px-4 last:border-b-0">
|
||||
<Skeleton className="w-[45px] h-[22px] rounded-full" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="w-[240px] h-4" />
|
||||
<Skeleton className="w-[150px] h-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWebhookSendTitle(webhookSend: WebhookSend) {
|
||||
if (typeof webhookSend.body === "object" && webhookSend.body !== null) {
|
||||
if ("type" in webhookSend.body) {
|
||||
const type = webhookSend.body.type;
|
||||
if (typeof type === "string") {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (webhookSend.onrampId) {
|
||||
return "onramp";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function StatusBadge(props: { status: number }) {
|
||||
const variant =
|
||||
props.status >= 200 && props.status < 300 ? "success" : "destructive";
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className="px-1.5 gap-1.5">
|
||||
{variant === "success" ? (
|
||||
<CircleCheckIcon className="size-3 opacity-70" />
|
||||
) : (
|
||||
<CircleAlertIcon className="size-3 opacity-70" />
|
||||
)}
|
||||
{props.status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
+3
@@ -21,6 +21,9 @@ export default async function Page(props: {
|
||||
<PayWebhooksPage
|
||||
clientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
authToken={authToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user