Compare commits
45
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 |
@@ -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;
|
||||
}
|
||||
@@ -687,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" />;
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSuccess={(quote) => {
|
||||
onSuccess={({ quote }) => {
|
||||
reportTokenBuySuccessful({
|
||||
buyTokenChainId:
|
||||
quote.type === "buy"
|
||||
@@ -208,7 +208,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
onSuccess={(quote) => {
|
||||
onSuccess={({ quote }) => {
|
||||
reportTokenSwapSuccessful({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
@@ -240,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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+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>
|
||||
|
||||
+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,
|
||||
|
||||
+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")}
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function Page() {
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
85+ chains, 3200+ tokens and 9+ million routes supported
|
||||
85+ chains, 4500+ tokens and 9+ million routes supported
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+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,
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
+12
-7
@@ -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<{
|
||||
@@ -84,7 +85,7 @@ export default async function Page(props: {
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-6">
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<PayAnalytics
|
||||
client={client}
|
||||
@@ -99,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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+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;
|
||||
};
|
||||
};
|
||||
-447
@@ -1,447 +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 { 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 { 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;
|
||||
id: string | undefined;
|
||||
from: string | 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 [id, setId] = useState<string | undefined>(undefined);
|
||||
const [from, setFrom] = useState<string | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const pageSize = 10;
|
||||
const transactionsQuery = useQuery({
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () => props.getData({ page, status, id, from }),
|
||||
queryKey: ["transactions", props.project.id, page, status, id, from],
|
||||
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">
|
||||
<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-wrap items-center justify-end gap-3">
|
||||
<Input
|
||||
className="max-w-[250px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setId(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by Queue ID"
|
||||
value={id || ""}
|
||||
/>
|
||||
<Input
|
||||
className="max-w-[250px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setFrom(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by wallet address"
|
||||
value={from || ""}
|
||||
/>
|
||||
<div>
|
||||
<StatusSelector
|
||||
setStatus={(v) => {
|
||||
setStatus(v);
|
||||
// reset page
|
||||
setPage(1);
|
||||
}}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
-72
@@ -1,72 +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, id, from }) => {
|
||||
return await getTransactions({
|
||||
page,
|
||||
project: props.project,
|
||||
status,
|
||||
id,
|
||||
from,
|
||||
});
|
||||
}}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function getTransactions({
|
||||
project,
|
||||
page,
|
||||
status,
|
||||
id,
|
||||
from,
|
||||
}: {
|
||||
project: Project;
|
||||
page: number;
|
||||
status: TransactionStatus | undefined;
|
||||
id: string | undefined;
|
||||
from: string | 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,
|
||||
id: id ?? undefined,
|
||||
from: from ?? 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);
|
||||
|
||||
-471
@@ -1,471 +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">
|
||||
<div className="flex flex-row gap-2.5">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{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";
|
||||
|
||||
|
||||
+56
-8
@@ -12,11 +12,15 @@ 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 { ServerWalletsTable } from "../transactions/server-wallets/wallet-table/wallet-table";
|
||||
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<{
|
||||
@@ -25,6 +29,7 @@ export default async function Page(props: {
|
||||
type?: string;
|
||||
interval?: string;
|
||||
page?: string;
|
||||
solana_page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const [searchParams, params] = await Promise.all([
|
||||
@@ -66,7 +71,9 @@ export default async function Page(props: {
|
||||
// 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({
|
||||
@@ -84,7 +91,32 @@ export default async function Page(props: {
|
||||
})
|
||||
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
|
||||
|
||||
const serverWallets = eoas.data?.items as Wallet[] | undefined;
|
||||
// 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,
|
||||
@@ -143,18 +175,34 @@ export default async function Page(props: {
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
{/* Server Wallets Section */}
|
||||
{/* Server Wallets Section (EVM + Solana) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{eoas.error ? null : (
|
||||
{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}
|
||||
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={serverWallets ?? []}
|
||||
solanaPermissionError={isSolanaPermissionError || false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { DarkVeil } from "@/components/DarkVeil";
|
||||
|
||||
const bgBackground: Record<string, [number, number, number]> = {
|
||||
dark: [0, 0, 0],
|
||||
light: [250 / 255, 250 / 255, 250 / 255],
|
||||
};
|
||||
|
||||
const hues = {
|
||||
light: 65,
|
||||
dark: -85,
|
||||
};
|
||||
|
||||
const patternLightnesss = {
|
||||
light: -1,
|
||||
dark: 0.8,
|
||||
};
|
||||
|
||||
export function BridgeVeil() {
|
||||
const theme = useTheme();
|
||||
const isLight = theme.theme === "light";
|
||||
const patternLightness = isLight
|
||||
? patternLightnesss.light
|
||||
: patternLightnesss.dark;
|
||||
const hueShift = isLight ? hues.light : hues.dark;
|
||||
return (
|
||||
<DarkVeil
|
||||
backgroundColor={bgBackground[isLight ? "light" : "dark"]}
|
||||
patternLightness={patternLightness}
|
||||
hueShift={hueShift}
|
||||
warpAmount={0.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { ThirdwebMiniLogo } from "../../(app)/components/ThirdwebMiniLogo";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-border/50 border-dashed">
|
||||
<div className="border-b border-dashed">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-4",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { DotIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import type { Address } from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
@@ -6,7 +7,6 @@ import { getContract } from "thirdweb/contract";
|
||||
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
|
||||
import { FaqSection } from "@/components/blocks/faq-section";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import { BridgeVeil } from "./components/BridgeVeil";
|
||||
import { PillLink } from "./components/client/pill-link";
|
||||
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
|
||||
import { PageHeader } from "./components/header";
|
||||
@@ -14,7 +14,7 @@ import { bridgeAppThirdwebClient } from "./constants";
|
||||
|
||||
const title = "thirdweb Bridge: Buy, Bridge & Swap Crypto on 85+ Chains";
|
||||
const description =
|
||||
"Bridge and swap 3,200+ tokens across 85+ chains (Ethereum, Base, Optimism, Arbitrum, BNB & more). Best-price routing with near-instant finality";
|
||||
"Bridge and swap 4500+ tokens across 85+ chains (Ethereum, Base, Optimism, Arbitrum, BNB & more). Best-price routing with near-instant finality";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
@@ -55,30 +55,27 @@ export default async function BridgePage({
|
||||
|
||||
return (
|
||||
<div className="grow flex flex-col relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-dvh md:h-[1200px] fade-in-0 animate-in duration-700">
|
||||
<BridgeVeil />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<PageHeader />
|
||||
|
||||
<div className="h-20" />
|
||||
<div className="h-12 lg:h-20" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="container">
|
||||
<h1 className="text-5xl md:text-6xl font-bold mb-6 tracking-tighter text-center leading-none text-pretty">
|
||||
<h1 className="text-3xl md:text-6xl font-bold mb-6 tracking-tighter text-center leading-8 lg:leading-none text-balance">
|
||||
Bridge and Swap tokens <br className="max-sm:hidden" /> across any
|
||||
chain, instantly
|
||||
</h1>
|
||||
|
||||
<div className="flex-wrap flex gap-3 items-center justify-center">
|
||||
<div className="flex flex-wrap gap-2 items-center justify-center">
|
||||
<DataPill>85+ Chains Supported</DataPill>
|
||||
<DataPill>3200+ Tokens Supported</DataPill>
|
||||
<DotIcon className="size-3 text-muted-foreground lg:hidden" />
|
||||
<DataPill>4500+ Tokens Supported</DataPill>
|
||||
<DataPill>9+ Million Routes Available</DataPill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-16" />
|
||||
<div className="h-12 lg:h-16" />
|
||||
|
||||
<div className="flex grow items-center justify-center px-4 relative">
|
||||
<DotsBackgroundPattern />
|
||||
@@ -134,7 +131,7 @@ export default async function BridgePage({
|
||||
|
||||
function DataPill(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 text-foreground/50 text-xs bg-accent/30 backdrop-blur-lg border border-border/70 rounded-full px-3 py-1.5 hover:text-foreground transition-colors duration-300">
|
||||
<p className="lg:bg-card flex items-center gap-1.5 text-muted-foreground text-xs lg:backdrop-blur-lg lg:border lg:border-border/70 rounded-full lg:px-3 lg:py-1.5 hover:text-foreground transition-colors duration-300">
|
||||
{props.children}
|
||||
</p>
|
||||
);
|
||||
@@ -144,7 +141,7 @@ function DotsBackgroundPattern(props: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute -inset-x-36 -inset-y-24 text-pink-700/30 dark:text-pink-500/20",
|
||||
"pointer-events-none absolute -inset-x-36 -inset-y-24 text-foreground/20 dark:text-muted-foreground/20 hidden lg:block",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -4,12 +4,14 @@ import { convertToModelMessages, streamText, type UIMessage } from "ai";
|
||||
// Allow streaming responses up to 5 minutes
|
||||
export const maxDuration = 300;
|
||||
|
||||
const thirdwebAI = createThirdwebAI({
|
||||
baseURL: `https://${process.env.NEXT_PUBLIC_API_URL}`,
|
||||
secretKey: process.env.THIRDWEB_SECRET_KEY,
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const SECRET_KEY = process.env.THIRDWEB_SECRET_KEY as string;
|
||||
|
||||
const thirdwebAI = createThirdwebAI({
|
||||
baseURL: `https://${process.env.NEXT_PUBLIC_API_URL}`,
|
||||
secretKey: SECRET_KEY,
|
||||
});
|
||||
|
||||
const body = await req.json();
|
||||
const { messages, id }: { messages: UIMessage[]; id: string } = body;
|
||||
|
||||
|
||||
@@ -8,15 +8,18 @@ import { token } from "../../payments/x402/components/constants";
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const SECRET_KEY = process.env.THIRDWEB_SECRET_KEY as string;
|
||||
|
||||
const client = createThirdwebClient({
|
||||
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
|
||||
secretKey: SECRET_KEY,
|
||||
});
|
||||
|
||||
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
|
||||
// const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_SMART_WALLET as string;
|
||||
const ENGINE_VAULT_ACCESS_TOKEN = process.env
|
||||
.ENGINE_VAULT_ACCESS_TOKEN as string;
|
||||
const API_URL = `https://${process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com"}`;
|
||||
// const API_URL = `https://${process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com"}`;
|
||||
const API_URL = "http://localhost:3030";
|
||||
|
||||
const twFacilitator = facilitator({
|
||||
baseUrl: `${API_URL}/v1/payments/x402`,
|
||||
@@ -38,6 +41,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const amount = queryParams.get("amount") || "0.01";
|
||||
const payTo = queryParams.get("payTo") ?? undefined;
|
||||
const tokenAddress = queryParams.get("tokenAddress") || token.address;
|
||||
const decimals = queryParams.get("decimals") || token.decimals.toString();
|
||||
const waitUntil =
|
||||
@@ -49,6 +53,7 @@ export async function GET(request: NextRequest) {
|
||||
method: "GET",
|
||||
paymentData,
|
||||
network: defineChain(Number(chainId)),
|
||||
payTo,
|
||||
price: {
|
||||
amount: toUnits(amount, parseInt(decimals)).toString(),
|
||||
asset: {
|
||||
|
||||
@@ -45,6 +45,7 @@ export function X402LeftSection(props: {
|
||||
const tokenId = useId();
|
||||
const amountId = useId();
|
||||
const waitUntilId = useId();
|
||||
const payToId = useId();
|
||||
|
||||
const handleChainChange = (chainId: number) => {
|
||||
setSelectedChain(chainId);
|
||||
@@ -81,6 +82,13 @@ export function X402LeftSection(props: {
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePayToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payTo: e.target.value as `0x${string}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleWaitUntilChange = (
|
||||
value: "simulated" | "submitted" | "confirmed",
|
||||
) => {
|
||||
@@ -140,6 +148,22 @@ export function X402LeftSection(props: {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pay To input */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={payToId}>Pay To Address</Label>
|
||||
<Input
|
||||
id={payToId}
|
||||
type="text"
|
||||
placeholder="0x..."
|
||||
value={options.payTo}
|
||||
onChange={handlePayToChange}
|
||||
className="bg-card"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The wallet address that will receive the payment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Wait Until selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={waitUntilId}>Wait Until</Label>
|
||||
|
||||
@@ -738,6 +738,10 @@ const paymentRedirects = {
|
||||
"/connect/pay/buy-with-fiat": "/payments",
|
||||
"/connect/pay/enable-test-mode": "/payments",
|
||||
"/connect/pay/guides/enable-test-mode": "/payments",
|
||||
"/payments/sell": "/bridge/sell",
|
||||
"/payments/swap": "/bridge/swap",
|
||||
"/payments/tokens": "/bridge/tokens",
|
||||
"/payments/routes": "/bridge/routes",
|
||||
};
|
||||
|
||||
const contractRedirects = {
|
||||
|
||||
@@ -245,6 +245,7 @@ Currently, the following tools are available:
|
||||
- `paymentsPurchase`
|
||||
- `getPaymentHistory`
|
||||
- `fetchWithPayment`
|
||||
- `listPayableServices`
|
||||
- `createToken`
|
||||
- `listTokens`
|
||||
- `getTokenOwners`
|
||||
|
||||
@@ -104,7 +104,10 @@ type Options = {
|
||||
swap?: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onSuccess?: (quote: SwapPreparedQuote) => void;
|
||||
onSuccess?: (data: {
|
||||
quote: SwapPreparedQuote;
|
||||
statuses: CompletedStatusResult[];
|
||||
}) => void;
|
||||
onError?: (error: Error, quote: SwapPreparedQuote) => void;
|
||||
onCancel?: (quote: SwapPreparedQuote) => void;
|
||||
onDisconnect?: () => void;
|
||||
@@ -132,7 +135,10 @@ type Options = {
|
||||
error: Error,
|
||||
quote: BuyOrOnrampPrepareResult | undefined,
|
||||
) => void;
|
||||
onSuccess?: (quote: BuyOrOnrampPrepareResult) => void;
|
||||
onSuccess?: (data: {
|
||||
quote: BuyOrOnrampPrepareResult;
|
||||
statuses: CompletedStatusResult[];
|
||||
}) => void;
|
||||
className?: string;
|
||||
country?: string;
|
||||
presetOptions?: [number, number, number];
|
||||
|
||||
@@ -102,7 +102,7 @@ const preparedQuote = await Bridge.Buy.prepare({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="react">
|
||||
The quickest way to setup Bridge in your React app is with the [`SwapWidget`](/references/typescript/v5/widgets/SwapWidget) component.
|
||||
The quickest way to setup Bridge in your React app is with the [`SwapWidget`](/references/typescript/v5/SwapWidget) component.
|
||||
|
||||
### Live Playground
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@doc";
|
||||
|
||||
# Session Keys Guide
|
||||
|
||||
Session keys enable secure transaction execution on behalf of smart accounts without requiring direct access to the main account's private key. This guide will walk you through creating and using session keys with the thirdweb TypeScript SDK.
|
||||
Session keys enable secure transaction execution on behalf of accounts without requiring direct access to the main account's private key. This guide covers session keys for both ERC-4337 smart accounts and ERC-7702 smart EOAs (Externally Owned Accounts).
|
||||
|
||||
<Tabs defaultValue="erc4337">
|
||||
<TabsList>
|
||||
<TabsTrigger value="erc4337">ERC-4337 Smart Accounts</TabsTrigger>
|
||||
<TabsTrigger value="erc7702">ERC-7702 Smart EOAs</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="erc4337">
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -323,8 +333,409 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="erc7702">
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
- A thirdweb client configured
|
||||
- An in-app wallet with ERC-7702 support
|
||||
- A session key account address (can be generated or an existing wallet)
|
||||
|
||||
## Setup
|
||||
|
||||
First, let's set up the necessary imports and configuration:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createThirdwebClient,
|
||||
getContract,
|
||||
sendTransaction,
|
||||
prepareTransaction
|
||||
} from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { inAppWallet, createSessionKey } from "thirdweb/wallets/in-app";
|
||||
|
||||
// Configure your client
|
||||
const client = createThirdwebClient({
|
||||
clientId: "your-client-id",
|
||||
secretKey: "your-secret-key" // Only use in server environments
|
||||
});
|
||||
|
||||
// Your session key account address
|
||||
const sessionKeyAccountAddress = "0x..."; // Replace with your session key address
|
||||
```
|
||||
|
||||
## Step 1: Create ERC-7702 Smart EOA
|
||||
|
||||
Create an in-app wallet configured with ERC-7702 execution mode:
|
||||
|
||||
```typescript
|
||||
const wallet = inAppWallet({
|
||||
executionMode: {
|
||||
mode: "EIP7702",
|
||||
sponsorGas: true, // Enable gas sponsorship
|
||||
},
|
||||
});
|
||||
|
||||
// Connect the wallet (user authentication)
|
||||
const account = await wallet.connect({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
strategy: "google", // or "email", "guest", etc.
|
||||
});
|
||||
|
||||
console.log("Smart EOA address:", account.address);
|
||||
```
|
||||
|
||||
### Execution Modes
|
||||
|
||||
The `executionMode` option allows you to configure the wallet behavior:
|
||||
|
||||
- `mode`: Set to `"EIP7702"` for ERC-7702 smart EOA functionality
|
||||
- `sponsorGas`: Enable gas sponsorship for gasless transactions
|
||||
|
||||
## Step 2: Deploy/Upgrade the Account
|
||||
|
||||
Send a transaction to trigger the account deployment or upgrade to ERC-7702:
|
||||
|
||||
```typescript
|
||||
await sendTransaction({
|
||||
account: account,
|
||||
transaction: prepareTransaction({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
to: account.address,
|
||||
value: 0n, // Self-transfer to trigger deployment
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Smart EOA deployed/upgraded successfully");
|
||||
```
|
||||
|
||||
## Step 3: Get Account Contract
|
||||
|
||||
Create a contract instance for the deployed smart EOA:
|
||||
|
||||
```typescript
|
||||
const accountContract = getContract({
|
||||
address: account.address,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
// ABI will be automatically resolved since the contract is deployed
|
||||
});
|
||||
```
|
||||
|
||||
## Step 4: Create Session Key with Full Permissions
|
||||
|
||||
Add a session key with full execution permissions:
|
||||
|
||||
```typescript
|
||||
const tx = await sendTransaction({
|
||||
account: account,
|
||||
transaction: createSessionKey({
|
||||
account: account,
|
||||
contract: accountContract,
|
||||
sessionKeyAddress: sessionKeyAccountAddress,
|
||||
durationInSeconds: 86400, // 1 day
|
||||
grantFullPermissions: true, // Allow unrestricted access
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Session key created:", tx.transactionHash);
|
||||
```
|
||||
|
||||
### Session Key Options
|
||||
|
||||
- `account`: The admin account that will create the session key
|
||||
- `contract`: The smart EOA contract instance
|
||||
- `sessionKeyAddress`: The address that will be granted session key permissions
|
||||
- `durationInSeconds`: How long the session key should be valid (in seconds)
|
||||
- `grantFullPermissions`: If `true`, grants unrestricted access. If `false`, you must specify policies.
|
||||
|
||||
## Step 5 (Advanced): Create Session Key with Granular Permissions
|
||||
|
||||
For more fine-grained control, you can specify call and transfer policies:
|
||||
|
||||
```typescript
|
||||
import { Condition, LimitType } from "thirdweb/wallets/in-app";
|
||||
|
||||
const tx = await sendTransaction({
|
||||
account: account,
|
||||
transaction: createSessionKey({
|
||||
account: account,
|
||||
contract: accountContract,
|
||||
sessionKeyAddress: sessionKeyAccountAddress,
|
||||
durationInSeconds: 86400, // 1 day
|
||||
grantFullPermissions: false, // Use granular permissions
|
||||
callPolicies: [
|
||||
{
|
||||
target: "0x123...", // Target contract address
|
||||
selector: "0xa9059cbb", // Function selector (e.g., ERC20 transfer)
|
||||
maxValuePerUse: 0n, // Max ETH value per call
|
||||
valueLimit: {
|
||||
limitType: LimitType.Lifetime, // Lifetime limit
|
||||
limit: 1000000n, // Max total value in wei
|
||||
period: 0n, // Not used for lifetime limits
|
||||
},
|
||||
constraints: [
|
||||
{
|
||||
condition: Condition.Equal, // Constraint type
|
||||
index: 0n, // Parameter index
|
||||
refValue: "0x000000000000000000000000abc...", // Expected value
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
transferPolicies: [
|
||||
{
|
||||
target: "0x456...", // Recipient address for transfers
|
||||
maxValuePerUse: 100000n, // Max wei per transfer
|
||||
valueLimit: {
|
||||
limitType: LimitType.Allowance, // Allowance-based limit
|
||||
limit: 1000000n, // Total allowance in wei
|
||||
period: 86400n, // Reset period in seconds
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Session key with policies created:", tx.transactionHash);
|
||||
```
|
||||
|
||||
### Permission Types
|
||||
|
||||
#### Call Policies
|
||||
|
||||
Control which smart contract calls the session key can make:
|
||||
|
||||
- `target`: The contract address the session key can call
|
||||
- `selector`: The function selector (4-byte identifier) that can be called
|
||||
- `maxValuePerUse`: Maximum ETH value that can be sent per transaction
|
||||
- `valueLimit`: Total value limits over time
|
||||
- `constraints`: Parameter-level constraints for function calls
|
||||
|
||||
#### Transfer Policies
|
||||
|
||||
Control native token transfers the session key can make:
|
||||
|
||||
- `target`: The recipient address for transfers
|
||||
- `maxValuePerUse`: Maximum wei per individual transfer
|
||||
- `valueLimit`: Total transfer limits over time
|
||||
|
||||
#### Limit Types
|
||||
|
||||
```typescript
|
||||
enum LimitType {
|
||||
Unlimited = 0, // No limit
|
||||
Lifetime = 1, // One-time total limit
|
||||
Allowance = 2, // Renewable allowance based on period
|
||||
}
|
||||
```
|
||||
|
||||
#### Constraint Conditions
|
||||
|
||||
```typescript
|
||||
enum Condition {
|
||||
Unconstrained = 0, // No constraint
|
||||
Equal = 1, // Must equal reference value
|
||||
Greater = 2, // Must be greater than reference value
|
||||
Less = 3, // Must be less than reference value
|
||||
GreaterOrEqual = 4, // Must be >= reference value
|
||||
LessOrEqual = 5, // Must be <= reference value
|
||||
NotEqual = 6, // Must not equal reference value
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Use the Session Key
|
||||
|
||||
Once created, the session key can be used with Engine or other server-side execution:
|
||||
|
||||
```typescript
|
||||
import { Engine } from "thirdweb/engine";
|
||||
|
||||
const serverWallet = Engine.serverWallet({
|
||||
address: sessionKeyAccountAddress,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
vaultAccessToken: process.env.VAULT_TOKEN as string,
|
||||
});
|
||||
|
||||
// Execute transactions with the session key
|
||||
const tx = await sendTransaction({
|
||||
account: serverWallet,
|
||||
transaction: prepareTransaction({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
to: "0x...", // Target address
|
||||
value: 0n,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Transaction executed:", tx.transactionHash);
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example for setting up and using a session key with ERC-7702:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createThirdwebClient,
|
||||
getContract,
|
||||
sendTransaction,
|
||||
prepareTransaction
|
||||
} from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { inAppWallet, createSessionKey } from "thirdweb/wallets/in-app";
|
||||
import { Engine } from "thirdweb/engine";
|
||||
|
||||
async function setupSessionKeyWith7702() {
|
||||
// Configuration
|
||||
const client = createThirdwebClient({
|
||||
clientId: "your-client-id",
|
||||
secretKey: "your-secret-key"
|
||||
});
|
||||
|
||||
const sessionKeyAccountAddress = "0x..."; // Your session key address
|
||||
|
||||
try {
|
||||
// Step 1: Create 7702 Smart EOA
|
||||
const wallet = inAppWallet({
|
||||
executionMode: {
|
||||
mode: "EIP7702",
|
||||
sponsorGas: true,
|
||||
},
|
||||
});
|
||||
|
||||
const account = await wallet.connect({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
strategy: "google",
|
||||
});
|
||||
|
||||
console.log("Smart EOA created:", account.address);
|
||||
|
||||
// Step 2: Deploy/upgrade the account
|
||||
await sendTransaction({
|
||||
account: account,
|
||||
transaction: prepareTransaction({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
to: account.address,
|
||||
value: 0n,
|
||||
}),
|
||||
});
|
||||
|
||||
// Step 3: Get account contract
|
||||
const accountContract = getContract({
|
||||
address: account.address,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
});
|
||||
|
||||
// Step 4: Create session key
|
||||
const sessionTx = await sendTransaction({
|
||||
account: account,
|
||||
transaction: createSessionKey({
|
||||
account: account,
|
||||
contract: accountContract,
|
||||
sessionKeyAddress: sessionKeyAccountAddress,
|
||||
durationInSeconds: 86400, // 1 day
|
||||
grantFullPermissions: true,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Session key created:", sessionTx.transactionHash);
|
||||
|
||||
// Step 5: Use the session key with Engine
|
||||
const serverWallet = Engine.serverWallet({
|
||||
address: sessionKeyAccountAddress,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
vaultAccessToken: process.env.VAULT_TOKEN as string,
|
||||
});
|
||||
|
||||
// Execute a transaction with the session key
|
||||
const tx = await sendTransaction({
|
||||
account: serverWallet,
|
||||
transaction: prepareTransaction({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
to: "0x...", // Target address
|
||||
value: 0n,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Transaction successful:", tx.transactionHash);
|
||||
return tx;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
setupSessionKeyWith7702()
|
||||
.then((tx) => console.log("Done!", tx.transactionHash))
|
||||
.catch((error) => console.error("Failed:", error));
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Session Key Storage**: Store session keys securely, preferably in a vault system
|
||||
- **Permission Scope**: Use granular permissions (`grantFullPermissions: false`) for production use cases
|
||||
- **Time Limits**: Set appropriate `durationInSeconds` values based on your use case
|
||||
- **Policy Design**: Carefully design call and transfer policies to minimize risk
|
||||
- **Key Rotation**: Regularly rotate session keys and revoke unused ones
|
||||
- **Monitoring**: Monitor session key usage for suspicious activity
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Account not deployed**: Ensure you've sent a transaction to trigger the account deployment/upgrade
|
||||
2. **Invalid signature**: Make sure the admin account is properly authenticated and can sign typed data
|
||||
3. **Permission denied**: Verify the session key has the necessary policies for the operation
|
||||
4. **Expired session key**: Check that the current time is within the session key's validity period
|
||||
|
||||
### Error Handling
|
||||
|
||||
Always wrap your session key operations in try-catch blocks:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const tx = await sendTransaction({
|
||||
account: account,
|
||||
transaction: createSessionKey({
|
||||
account: account,
|
||||
contract: accountContract,
|
||||
sessionKeyAddress: sessionKeyAccountAddress,
|
||||
durationInSeconds: 86400,
|
||||
grantFullPermissions: true,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message.includes("signature")) {
|
||||
console.error("Failed to sign session key creation");
|
||||
} else if (error.message.includes("duration")) {
|
||||
console.error("Invalid duration specified");
|
||||
} else {
|
||||
console.error("Session key creation failed:", error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Learn more about [Smart Wallets](/engine/v3/configure-wallets/server-wallets)
|
||||
- Learn more about [In-App Wallets](/wallets/in-app-wallet/overview)
|
||||
- Explore [Engine API Reference](https://engine.thirdweb.com/reference)
|
||||
- Check out the [TypeScript SDK](/references/typescript/v5/serverWallet) documentation
|
||||
- Check out the [TypeScript SDK](/references/typescript/v5) documentation
|
||||
- Read about [ERC-7702](https://eips.ethereum.org/EIPS/eip-7702) specification
|
||||
|
||||
@@ -195,7 +195,7 @@ To connect with other auth strategies, use external wallets, or sponsor gas for
|
||||
|
||||
- [Buy.prepare](/references/typescript/v5/buy/prepare)
|
||||
- [Sell.prepare](/references/typescript/v5/sell/prepare)
|
||||
- [BuyWidget](/references/typescript/v5/widgets/BuyWidget)
|
||||
- [CheckoutWidget](/references/typescript/v5/widgets/CheckoutWidget)
|
||||
- [TransactionWidget](/references/typescript/v5/widgets/TransactionWidget)
|
||||
- [BuyWidget](/references/typescript/v5/BuyWidget)
|
||||
- [CheckoutWidget](/references/typescript/v5/CheckoutWidget)
|
||||
- [TransactionWidget](/references/typescript/v5/TransactionWidget)
|
||||
|
||||
|
||||
@@ -106,8 +106,8 @@ const preparedQuote = await Bridge.Buy.prepare({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="react">
|
||||
The quickest way to setup payments in your React app is with the [`BuyWidget`](/references/typescript/v5/widgets/BuyWidget), [`CheckoutWidget`](/references/typescript/v5/widgets/CheckoutWidget), and [`TransactionWidget`](/references/typescript/v5/widgets/TransactionWidget) components.
|
||||
|
||||
The quickest way to setup payments in your React app is with the [`BuyWidget`](/references/typescript/v5/BuyWidget), [`CheckoutWidget`](/references/typescript/v5/CheckoutWidget), and [`TransactionWidget`](/references/typescript/v5/TransactionWidget) components.
|
||||
|
||||
### Live Playground
|
||||
|
||||
<ArticleIconCard
|
||||
@@ -116,7 +116,7 @@ const preparedQuote = await Bridge.Buy.prepare({
|
||||
icon={ReactIcon}
|
||||
href="https://playground.thirdweb.com/connect/pay/transactions"
|
||||
/>
|
||||
|
||||
|
||||
### Installation
|
||||
|
||||
Install the thirdweb SDK in your React project:
|
||||
|
||||
@@ -82,4 +82,4 @@ The CheckoutWidget handles the complete payment flow, supporting both crypto and
|
||||
|
||||
## API Reference
|
||||
|
||||
- [CheckoutWidget](/references/typescript/v5/widgets/CheckoutWidget)
|
||||
- [CheckoutWidget](/references/typescript/v5/CheckoutWidget)
|
||||
|
||||
@@ -20,7 +20,7 @@ The easiest way to equip your agents with the ability to pay for API calls is to
|
||||
|
||||
The MCP server comes with all the tools by default, but you can filter the tools available by passing a comma-separated list of tools as a query parameter.
|
||||
|
||||
In this example, we create a ReAct agent using LangChain and filter the MCP tools to `fetchWithPayment` and `getWalletBalance` as the only 2 tools we give our agent. You can view the full list of available tools [here](/ai/mcp#available-tools).
|
||||
In this example, we create a ReAct agent using LangChain and filter the MCP tools to `fetchWithPayment` and `listPayableServices` as the only 2 tools we give our agent. You can view the full list of available tools [here](/ai/mcp#available-tools).
|
||||
|
||||
<Tabs defaultValue="typescript">
|
||||
|
||||
@@ -42,7 +42,7 @@ const model = new ChatOpenAI({
|
||||
|
||||
const mcpServers = {
|
||||
thirdweb: {
|
||||
url: `https://api.thirdweb.com/mcp?secretKey=<your-project-secret-key>&tools=fetchWithPayment,getWalletBalance`
|
||||
url: `https://api.thirdweb.com/mcp?secretKey=<your-project-secret-key>&tools=fetchWithPayment,listPayableServices`
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ const tools = await client.getTools();
|
||||
const agent = createReactAgent({
|
||||
llm: model,
|
||||
tools: tools,
|
||||
prompt: "Use the fetchWithPayment tool to fetch any endpoint. Always pay in {{tokenAddress}}. Your wallet address is {{walletAddress}}."
|
||||
prompt: "Use the fetchWithPayment tool to fetch any endpoint. Discover the available endpoints with the listPayableServices tool."
|
||||
});
|
||||
```
|
||||
|
||||
@@ -68,12 +68,12 @@ from langgraph.prebuilt import create_react_agent
|
||||
client = MultiServerMCPClient(
|
||||
{
|
||||
"thirdweb": {
|
||||
"url": "https://api.thirdweb.com/mcp?secretKey=<your-project-secret-key>&tools=fetchWithPayment,getWalletBalance",
|
||||
"url": "https://api.thirdweb.com/mcp?secretKey=<your-project-secret-key>&tools=fetchWithPayment,listPayableServices",
|
||||
}
|
||||
}
|
||||
)
|
||||
tools = await client.get_tools()
|
||||
agent = create_react_agent("openai:gpt-4.1", tools, prompt="Use the fetchWithPayment tool to fetch any endpoint. Always pay in {{tokenAddress}}. Your wallet address is {{walletAddress}}.")
|
||||
agent = create_react_agent("openai:gpt-4.1", tools, prompt="Use the fetchWithPayment tool to fetch any endpoint. Discover the available endpoints with the listPayableServices tool.")
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
@@ -83,10 +83,10 @@ agent = create_react_agent("openai:gpt-4.1", tools, prompt="Use the fetchWithPay
|
||||
|
||||
You can also create your own MCP tool or wrap all external calls with the `/v1/x402/fetchWithPayment` endpoint to automatically handle payment flows when APIs return a `402 Payment Required` response.
|
||||
|
||||
Pass the target `url`, `method` and optional `from` wallet address to the endpoint that will complete the payment. The address should be one of your [user wallets](/wallets/users) or [server wallets](/wallets/server).
|
||||
Pass the target `url`, `method`. By default, it will use your default project wallet for payments, but you can also pass an optional `from` wallet address to the endpoint that will complete the payment. The address should be one of your [user wallets](/wallets/users) or [server wallets](/wallets/server).
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.thirdweb.com/v1/payments/x402/fetch?url=https://api.example.com/premium&method=GET&from=0x1234... \
|
||||
curl -X POST https://api.thirdweb.com/v1/payments/x402/fetch?url=https://api.example.com/premium&method=GET \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-secret-key: <your-project-secret-key>" \
|
||||
-d '{ ... }' # optional request body passed through to the url called.
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@doc";
|
||||
|
||||
# Session Keys Guide
|
||||
|
||||
Session keys enable secure transaction execution on behalf of accounts without requiring direct access to the main account's private key. This guide covers session keys for both ERC-4337 smart accounts and ERC-7702 smart EOAs (Externally Owned Accounts).
|
||||
|
||||
<Tabs defaultValue="erc7702">
|
||||
<TabsList>
|
||||
<TabsTrigger value="erc7702">EIP-7702 Upgraded EOAs</TabsTrigger>
|
||||
<TabsTrigger value="erc4337">ERC-4337 Smart Accounts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="erc4337">
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
- A thirdweb client configured
|
||||
- Access to a session key account address
|
||||
|
||||
## Setup
|
||||
|
||||
First, let's set up the necessary imports and configuration:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
generateAccount,
|
||||
smartWallet,
|
||||
sendTransaction,
|
||||
getContract,
|
||||
createThirdwebClient
|
||||
} from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { getAllActiveSigners } from "thirdweb/extensions/erc4337";
|
||||
import { Engine } from "thirdweb/engine";
|
||||
|
||||
// Configure your client
|
||||
const client = createThirdwebClient({
|
||||
clientId: "your-client-id",
|
||||
secretKey: "your-secret-key" // Only use in server environments
|
||||
});
|
||||
|
||||
// Your session key account address
|
||||
const sessionKeyAccountAddress = "0x..."; // Replace with your session key address (server wallet)
|
||||
|
||||
// Target address for transactions
|
||||
const targetAddress = "0x..."; // Replace with your target address
|
||||
```
|
||||
|
||||
## Step 1: Configure User Smart Wallet with Session Key
|
||||
|
||||
The first step is to add our session key address as a signer to the user's smart account. This is typically done on the client side since it needs explicit user approval. This can be done by configuring the smart wallet with the session key address and permissions.
|
||||
|
||||
In a React application, this can be done by using the `ConnectButton` or `ConnectEmbed` component. This will automatically configure the smart wallet with the session key address and permissions.
|
||||
|
||||
```typescript
|
||||
<ConnectButton
|
||||
accountAbstraction={{
|
||||
chain: sepolia,
|
||||
sessionKey: {
|
||||
address: sessionKeyAccountAddress,
|
||||
permissions: {
|
||||
// "*" allows all targets, or specify specific contract addresses
|
||||
approvedTargets: "*",
|
||||
},
|
||||
},
|
||||
sponsorGas: true,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
This can also be done in pure TypeScript by using the `smartWallet` function and connecting it to a personal account.
|
||||
|
||||
For this guide, we'll generate a random personal account that will be used to create the smart wallet:
|
||||
|
||||
```typescript
|
||||
// this would be the user's personal account
|
||||
const personalAccount = await generateAccount({
|
||||
client: client,
|
||||
});
|
||||
|
||||
// wrapped in a smart wallet with session key permissions
|
||||
const smart = smartWallet({
|
||||
chain: sepolia,
|
||||
sessionKey: {
|
||||
address: sessionKeyAccountAddress,
|
||||
permissions: {
|
||||
// "*" allows all targets, or specify specific contract addresses
|
||||
approvedTargets: "*",
|
||||
},
|
||||
},
|
||||
sponsorGas: true, // Enable gas sponsorship
|
||||
});
|
||||
|
||||
console.log("Personal account created:", personalAccount.address);
|
||||
```
|
||||
|
||||
### Session Key Permissions
|
||||
|
||||
The `permissions` object allows you to control what the session key can do:
|
||||
|
||||
- `approvedTargets`: Specify which contract addresses the session key can interact with
|
||||
- Use `"*"` for all targets
|
||||
- Use an array of addresses for specific contracts: `["0x123...", "0x456..."]`
|
||||
|
||||
## Step 2: Connect Smart Account
|
||||
|
||||
Connect the smart wallet using the personal account:
|
||||
|
||||
```typescript
|
||||
const smartAccount = await smart.connect({
|
||||
client: client,
|
||||
personalAccount: personalAccount,
|
||||
});
|
||||
|
||||
console.log("Smart account address:", smartAccount.address);
|
||||
```
|
||||
|
||||
Note that in a React application, this would be done automatically by the `ConnectButton` or `ConnectEmbed` component.
|
||||
|
||||
## Step 3 (Optional): Verify Session Key Registration
|
||||
|
||||
Check that the session key is properly registered as an active signer:
|
||||
|
||||
```typescript
|
||||
const signers = await getAllActiveSigners({
|
||||
contract: getContract({
|
||||
address: smartAccount.address,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
}),
|
||||
});
|
||||
|
||||
// Verify the session key is in the list of active signers
|
||||
const isSessionKeyActive = signers
|
||||
.map((s) => s.signer)
|
||||
.includes(sessionKeyAccountAddress);
|
||||
|
||||
console.log("Session key is active:", isSessionKeyActive);
|
||||
console.log("All active signers:", signers.map((s) => s.signer));
|
||||
```
|
||||
|
||||
## Step 4: Create Engine Server Wallet
|
||||
|
||||
Set up an Engine server wallet using the session key for transaction execution:
|
||||
|
||||
```typescript
|
||||
const serverWallet = Engine.serverWallet({
|
||||
address: sessionKeyAccountAddress,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
executionOptions: {
|
||||
entrypointVersion: "0.6", // ERC-4337 entrypoint version
|
||||
signerAddress: sessionKeyAccountAddress,
|
||||
smartAccountAddress: smartAccount.address,
|
||||
type: "ERC4337",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Execution Options
|
||||
|
||||
- `entrypointVersion`: The ERC-4337 entrypoint version to use
|
||||
- `signerAddress`: The session key address that will sign transactions
|
||||
- `smartAccountAddress`: The smart account address that will execute transactions
|
||||
- `type`: The account abstraction type (ERC4337)
|
||||
|
||||
## Step 5: Execute Transactions
|
||||
|
||||
Now you can execute transactions using the session key, here we are sending 0 ETH to the target address for testing purposes:
|
||||
|
||||
```typescript
|
||||
const tx = await sendTransaction({
|
||||
account: serverWallet,
|
||||
transaction: {
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
to: targetAddress,
|
||||
value: 0n, // Amount in wei (0 for no ETH transfer)
|
||||
// data: "0x...", // Optional: contract call data
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Transaction sent:", tx.transactionHash);
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example putting it all together:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
generateAccount,
|
||||
smartWallet,
|
||||
sendTransaction,
|
||||
getContract,
|
||||
createThirdwebClient
|
||||
} from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { getAllActiveSigners } from "thirdweb/extensions/erc4337";
|
||||
import { Engine } from "thirdweb/engine";
|
||||
|
||||
async function executeTransactionWithSessionKey() {
|
||||
// Configuration
|
||||
const client = createThirdwebClient({
|
||||
clientId: "your-client-id",
|
||||
secretKey: "your-secret-key"
|
||||
});
|
||||
|
||||
const sessionKeyAccountAddress = "0x..."; // Your session key address (server wallet)
|
||||
const targetAddress = "0x..."; // Target address for the final transaction
|
||||
|
||||
try {
|
||||
// Step 1: Create personal account
|
||||
const personalAccount = await generateAccount({ client });
|
||||
|
||||
// Step 2: Configure smart wallet
|
||||
const smart = smartWallet({
|
||||
chain: sepolia,
|
||||
sessionKey: {
|
||||
address: sessionKeyAccountAddress,
|
||||
permissions: {
|
||||
approvedTargets: "*",
|
||||
},
|
||||
},
|
||||
sponsorGas: true,
|
||||
});
|
||||
|
||||
// Step 3: Connect smart account
|
||||
const smartAccount = await smart.connect({
|
||||
client,
|
||||
personalAccount,
|
||||
});
|
||||
|
||||
// Step 4: Verify session key
|
||||
const signers = await getAllActiveSigners({
|
||||
contract: getContract({
|
||||
address: smartAccount.address,
|
||||
chain: sepolia,
|
||||
client,
|
||||
}),
|
||||
});
|
||||
|
||||
const isSessionKeyActive = signers
|
||||
.map((s) => s.signer)
|
||||
.includes(sessionKeyAccountAddress);
|
||||
|
||||
if (!isSessionKeyActive) {
|
||||
throw new Error("Session key is not active");
|
||||
}
|
||||
|
||||
// Step 5: Create server wallet
|
||||
const serverWallet = Engine.serverWallet({
|
||||
address: sessionKeyAccountAddress,
|
||||
chain: sepolia,
|
||||
client,
|
||||
executionOptions: {
|
||||
entrypointVersion: "0.6",
|
||||
signerAddress: sessionKeyAccountAddress,
|
||||
smartAccountAddress: smartAccount.address,
|
||||
type: "ERC4337",
|
||||
},
|
||||
});
|
||||
|
||||
// Step 6: Execute transaction
|
||||
const tx = await sendTransaction({
|
||||
account: serverWallet,
|
||||
transaction: {
|
||||
chain: sepolia,
|
||||
client,
|
||||
to: targetAddress,
|
||||
value: 0n,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Transaction successful:", tx.transactionHash);
|
||||
return tx;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error executing transaction:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
executeTransactionWithSessionKey()
|
||||
.then((tx) => console.log("Done!", tx.transactionHash))
|
||||
.catch((error) => console.error("Failed:", error));
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Session Key Storage**: Store session keys securely, preferably in a vault system
|
||||
- **Permission Scope**: Limit session key permissions to only necessary targets
|
||||
- **Key Rotation**: Regularly rotate session keys for enhanced security
|
||||
- **Monitoring**: Monitor session key usage for suspicious activity
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Session key not active**: Ensure the session key is properly registered with the smart account
|
||||
2. **Permission denied**: Check that the target address is included in `approvedTargets`
|
||||
3. **Gas estimation failed**: Verify that gas sponsorship is properly configured
|
||||
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="erc7702">
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
- A thirdweb client configured
|
||||
- An in-app wallet with ERC-7702 support
|
||||
- A session key account address (can be generated or an existing wallet)
|
||||
|
||||
## Setup
|
||||
|
||||
First, let's set up the necessary imports and configuration:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createThirdwebClient,
|
||||
getContract,
|
||||
sendTransaction,
|
||||
prepareTransaction
|
||||
} from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { inAppWallet, createSessionKey } from "thirdweb/wallets/in-app";
|
||||
|
||||
// Configure your client
|
||||
const client = createThirdwebClient({
|
||||
clientId: "your-client-id",
|
||||
secretKey: "your-secret-key" // Only use in server environments
|
||||
});
|
||||
|
||||
// Your session key account address
|
||||
const sessionKeyAccountAddress = "0x..."; // Replace with your session key address (server wallet)
|
||||
```
|
||||
|
||||
## Step 1: Create ERC-7702 Smart EOA
|
||||
|
||||
Create an in-app wallet configured with ERC-7702 execution mode:
|
||||
|
||||
```typescript
|
||||
const wallet = inAppWallet({
|
||||
executionMode: {
|
||||
mode: "EIP7702",
|
||||
sponsorGas: true, // Enable gas sponsorship
|
||||
},
|
||||
});
|
||||
|
||||
// Connect the wallet (user authentication)
|
||||
const account = await wallet.connect({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
strategy: "google", // or "email", "guest", etc.
|
||||
});
|
||||
|
||||
console.log("Upgraded EOA address:", account.address);
|
||||
```
|
||||
|
||||
### Execution Modes
|
||||
|
||||
The `executionMode` option allows you to configure the wallet behavior:
|
||||
|
||||
- `mode`: Set to `"EIP7702"` for EIP-7702 upgraded EOA functionality
|
||||
- `sponsorGas`: Enable gas sponsorship for gasless transactions
|
||||
|
||||
## Step 2: Get Account Contract
|
||||
|
||||
Create a contract instance for the deployed smart EOA:
|
||||
|
||||
```typescript
|
||||
const accountContract = getContract({
|
||||
address: account.address,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
});
|
||||
```
|
||||
|
||||
## Step 3: Create Session Key with Full Permissions
|
||||
|
||||
Add a session key with full execution permissions:
|
||||
|
||||
```typescript
|
||||
const tx = await sendTransaction({
|
||||
account: account,
|
||||
transaction: createSessionKey({
|
||||
account: account,
|
||||
contract: accountContract,
|
||||
sessionKeyAddress: sessionKeyAccountAddress,
|
||||
durationInSeconds: 86400, // 1 day
|
||||
grantFullPermissions: true, // Allow unrestricted access
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Session key created:", tx.transactionHash);
|
||||
```
|
||||
|
||||
### Session Key Options
|
||||
|
||||
- `account`: The admin account that will create the session key
|
||||
- `contract`: The smart EOA contract instance
|
||||
- `sessionKeyAddress`: The address that will be granted session key permissions
|
||||
- `durationInSeconds`: How long the session key should be valid (in seconds)
|
||||
- `grantFullPermissions`: If `true`, grants unrestricted access. If `false`, you must specify policies.
|
||||
|
||||
## Step 4 (Advanced): Create Session Key with Granular Permissions
|
||||
|
||||
For more fine-grained control, you can specify call and transfer policies:
|
||||
|
||||
```typescript
|
||||
import { Condition, LimitType } from "thirdweb/wallets/in-app";
|
||||
|
||||
const tx = await sendTransaction({
|
||||
account: account,
|
||||
transaction: createSessionKey({
|
||||
account: account,
|
||||
contract: accountContract,
|
||||
sessionKeyAddress: sessionKeyAccountAddress,
|
||||
durationInSeconds: 86400, // 1 day
|
||||
grantFullPermissions: false, // Use granular permissions
|
||||
callPolicies: [
|
||||
{
|
||||
target: "0x123...", // Target contract address
|
||||
selector: "0xa9059cbb", // Function selector (e.g., ERC20 transfer)
|
||||
maxValuePerUse: 0n, // Max ETH value per call
|
||||
valueLimit: {
|
||||
limitType: LimitType.Lifetime, // Lifetime limit
|
||||
limit: 1000000n, // Max total value in wei
|
||||
period: 0n, // Not used for lifetime limits
|
||||
},
|
||||
constraints: [
|
||||
{
|
||||
condition: Condition.Equal, // Constraint type
|
||||
index: 0n, // Parameter index
|
||||
refValue: "0x000000000000000000000000abc...", // Expected value
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
transferPolicies: [
|
||||
{
|
||||
target: "0x456...", // Recipient address for transfers
|
||||
maxValuePerUse: 100000n, // Max wei per transfer
|
||||
valueLimit: {
|
||||
limitType: LimitType.Allowance, // Allowance-based limit
|
||||
limit: 1000000n, // Total allowance in wei
|
||||
period: 86400n, // Reset period in seconds
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Session key with policies created:", tx.transactionHash);
|
||||
```
|
||||
|
||||
### Permission Types
|
||||
|
||||
#### Call Policies
|
||||
|
||||
Control which smart contract calls the session key can make:
|
||||
|
||||
- `target`: The contract address the session key can call
|
||||
- `selector`: The function selector (4-byte identifier) that can be called
|
||||
- `maxValuePerUse`: Maximum ETH value that can be sent per transaction
|
||||
- `valueLimit`: Total value limits over time
|
||||
- `constraints`: Parameter-level constraints for function calls
|
||||
|
||||
#### Transfer Policies
|
||||
|
||||
Control native token transfers the session key can make:
|
||||
|
||||
- `target`: The recipient address for transfers
|
||||
- `maxValuePerUse`: Maximum wei per individual transfer
|
||||
- `valueLimit`: Total transfer limits over time
|
||||
|
||||
#### Limit Types
|
||||
|
||||
```typescript
|
||||
enum LimitType {
|
||||
Unlimited = 0, // No limit
|
||||
Lifetime = 1, // One-time total limit
|
||||
Allowance = 2, // Renewable allowance based on period
|
||||
}
|
||||
```
|
||||
|
||||
#### Constraint Conditions
|
||||
|
||||
```typescript
|
||||
enum Condition {
|
||||
Unconstrained = 0, // No constraint
|
||||
Equal = 1, // Must equal reference value
|
||||
Greater = 2, // Must be greater than reference value
|
||||
Less = 3, // Must be less than reference value
|
||||
GreaterOrEqual = 4, // Must be >= reference value
|
||||
LessOrEqual = 5, // Must be <= reference value
|
||||
NotEqual = 6, // Must not equal reference value
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Use the Session Key
|
||||
|
||||
Once created, the session key can be used with Engine or other server-side execution:
|
||||
|
||||
```typescript
|
||||
import { Engine } from "thirdweb/engine";
|
||||
|
||||
const serverWallet = Engine.serverWallet({
|
||||
address: sessionKeyAccountAddress,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
executionOptions: {
|
||||
type: "EIP7702",
|
||||
sessionKeyAddress: sessionKeyAccountAddress,
|
||||
accountAddress: account.address,
|
||||
},
|
||||
});
|
||||
|
||||
// Execute transactions with the session key
|
||||
const tx = await sendTransaction({
|
||||
account: serverWallet,
|
||||
transaction: prepareTransaction({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
to: "0x...", // Target address
|
||||
value: 0n,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Transaction executed:", tx.transactionHash);
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example for setting up and using a session key with ERC-7702:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createThirdwebClient,
|
||||
getContract,
|
||||
sendTransaction,
|
||||
prepareTransaction
|
||||
} from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { inAppWallet, createSessionKey } from "thirdweb/wallets/in-app";
|
||||
import { Engine } from "thirdweb/engine";
|
||||
|
||||
async function setupSessionKeyWith7702() {
|
||||
// Configuration
|
||||
const client = createThirdwebClient({
|
||||
clientId: "your-client-id",
|
||||
secretKey: "your-secret-key"
|
||||
});
|
||||
|
||||
const sessionKeyAccountAddress = "0x..."; // Your session key address (server wallet)
|
||||
|
||||
try {
|
||||
// Step 1: Create 7702 Smart EOA
|
||||
const wallet = inAppWallet({
|
||||
executionMode: {
|
||||
mode: "EIP7702",
|
||||
sponsorGas: true,
|
||||
},
|
||||
});
|
||||
|
||||
const account = await wallet.connect({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
strategy: "google",
|
||||
});
|
||||
|
||||
console.log("Upgraded EOA created:", account.address);
|
||||
|
||||
// Step 2: Get account contract
|
||||
const accountContract = getContract({
|
||||
address: account.address,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
});
|
||||
|
||||
// Step 3: Create session key
|
||||
const sessionTx = await sendTransaction({
|
||||
account: account,
|
||||
transaction: createSessionKey({
|
||||
account: account,
|
||||
contract: accountContract,
|
||||
sessionKeyAddress: sessionKeyAccountAddress,
|
||||
durationInSeconds: 86400, // 1 day
|
||||
grantFullPermissions: true,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Session key created:", sessionTx.transactionHash);
|
||||
|
||||
// Step 4: Use the session key with Engine
|
||||
const serverWallet = Engine.serverWallet({
|
||||
address: sessionKeyAccountAddress,
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
executionOptions: {
|
||||
type: "EIP7702",
|
||||
sessionKeyAddress: sessionKeyAccountAddress,
|
||||
accountAddress: account.address,
|
||||
},
|
||||
});
|
||||
|
||||
// Execute a transaction with the session key
|
||||
const tx = await sendTransaction({
|
||||
account: serverWallet,
|
||||
transaction: prepareTransaction({
|
||||
chain: sepolia,
|
||||
client: client,
|
||||
to: "0x...", // Target address
|
||||
value: 0n,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Transaction successful:", tx.transactionHash);
|
||||
return tx;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
setupSessionKeyWith7702()
|
||||
.then((tx) => console.log("Done!", tx.transactionHash))
|
||||
.catch((error) => console.error("Failed:", error));
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Session Key Storage**: Store session keys securely, preferably in a vault system
|
||||
- **Permission Scope**: Use granular permissions (`grantFullPermissions: false`) for production use cases
|
||||
- **Time Limits**: Set appropriate `durationInSeconds` values based on your use case
|
||||
- **Policy Design**: Carefully design call and transfer policies to minimize risk
|
||||
- **Key Rotation**: Regularly rotate session keys and revoke unused ones
|
||||
- **Monitoring**: Monitor session key usage for suspicious activity
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Account not deployed**: Ensure you've sent a transaction to trigger the account deployment/upgrade
|
||||
2. **Invalid signature**: Make sure the admin account is properly authenticated and can sign typed data
|
||||
3. **Permission denied**: Verify the session key has the necessary policies for the operation
|
||||
4. **Expired session key**: Check that the current time is within the session key's validity period
|
||||
|
||||
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Learn more about [In-App Wallets](/wallets/users)
|
||||
- Explore the [API Reference](/reference)
|
||||
- Check out the [TypeScript SDK](/references/typescript/v5) documentation
|
||||
@@ -65,6 +65,10 @@ export const sidebar: SideBar = {
|
||||
href: `${walletSlug}/export-private-key`,
|
||||
name: "Export Private Keys",
|
||||
},
|
||||
{
|
||||
href: `${walletSlug}/session-keys`,
|
||||
name: "Session Keys",
|
||||
},
|
||||
{
|
||||
href: `${walletSlug}/adapters`,
|
||||
name: "Usage with other libraries",
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
# wagmi-inapp
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`51c8318`](https://github.com/thirdweb-dev/js/commit/51c8318df7b51126496ee94b1fd79a338734fced), [`d68da22`](https://github.com/thirdweb-dev/js/commit/d68da2239b628f7835b0545a143b69e09cdf4141)]:
|
||||
- thirdweb@5.109.0
|
||||
- @thirdweb-dev/wagmi-adapter@0.2.166
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`e7f42e6`](https://github.com/thirdweb-dev/js/commit/e7f42e6ac591102145f5e25983aeaa6f3647ad7f)]:
|
||||
- thirdweb@5.108.15
|
||||
- @thirdweb-dev/wagmi-adapter@0.2.165
|
||||
|
||||
## 0.0.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`8280efb`](https://github.com/thirdweb-dev/js/commit/8280efb99189c5b849156df383522f9e1b8f2c52), [`464af6a`](https://github.com/thirdweb-dev/js/commit/464af6a54cb776158a1656e793ee181c2c221f27)]:
|
||||
- thirdweb@5.108.14
|
||||
- @thirdweb-dev/wagmi-adapter@0.2.164
|
||||
|
||||
## 0.0.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- thirdweb@5.108.13
|
||||
- @thirdweb-dev/wagmi-adapter@0.2.163
|
||||
|
||||
## 0.0.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`0082e99`](https://github.com/thirdweb-dev/js/commit/0082e996cae47a326110e74457c72b3f3936743d), [`0082e99`](https://github.com/thirdweb-dev/js/commit/0082e996cae47a326110e74457c72b3f3936743d)]:
|
||||
- thirdweb@5.108.12
|
||||
- @thirdweb-dev/wagmi-adapter@0.2.162
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`05550d0`](https://github.com/thirdweb-dev/js/commit/05550d045f291b8e53afd0d7e73c438bee202fb7)]:
|
||||
- thirdweb@5.108.11
|
||||
- @thirdweb-dev/wagmi-adapter@0.2.161
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`f630912`](https://github.com/thirdweb-dev/js/commit/f630912b9b0e1d8ebe22b0b52c650f5e40e12baf), [`62cfbb7`](https://github.com/thirdweb-dev/js/commit/62cfbb7441e66156c55c76d73f5b481f5ea576bc)]:
|
||||
- thirdweb@5.108.10
|
||||
- @thirdweb-dev/wagmi-adapter@0.2.160
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wagmi-inapp",
|
||||
"private": true,
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @thirdweb-dev/engine
|
||||
|
||||
## 3.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#8238](https://github.com/thirdweb-dev/js/pull/8238) [`9545b43`](https://github.com/thirdweb-dev/js/commit/9545b4367598408dbb88c63d8a8fe21305f870b5) Thanks [@d4mr](https://github.com/d4mr)! - Add Solana endpoints
|
||||
|
||||
## 3.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8218](https://github.com/thirdweb-dev/js/pull/8218) [`f630912`](https://github.com/thirdweb-dev/js/commit/f630912b9b0e1d8ebe22b0b52c650f5e40e12baf) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Update to latest API spec
|
||||
|
||||
## 3.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@thirdweb-dev/engine",
|
||||
"version": "3.2.1",
|
||||
"version": "3.3.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/thirdweb-dev/js.git#main"
|
||||
|
||||
@@ -11,11 +11,18 @@ import type {
|
||||
CancelTransactionResponses,
|
||||
CreateAccountData,
|
||||
CreateAccountResponses,
|
||||
CreateSolanaAccountData,
|
||||
CreateSolanaAccountResponses,
|
||||
EncodeContractData,
|
||||
EncodeContractResponses,
|
||||
GetActivityLogsData,
|
||||
GetActivityLogsErrors,
|
||||
GetActivityLogsResponses,
|
||||
GetSolanaActivityLogsData,
|
||||
GetSolanaActivityLogsErrors,
|
||||
GetSolanaActivityLogsResponses,
|
||||
GetSolanaTransactionsData,
|
||||
GetSolanaTransactionsResponses,
|
||||
GetTransactionAnalyticsData,
|
||||
GetTransactionAnalyticsResponses,
|
||||
GetTransactionAnalyticsSummaryData,
|
||||
@@ -25,17 +32,30 @@ import type {
|
||||
GetTransactionsResponses,
|
||||
ListAccountsData,
|
||||
ListAccountsResponses,
|
||||
ListSolanaAccountsData,
|
||||
ListSolanaAccountsResponses,
|
||||
ReadContractData,
|
||||
ReadContractResponses,
|
||||
SearchActivityLogsData,
|
||||
SearchActivityLogsErrors,
|
||||
SearchActivityLogsResponses,
|
||||
SearchSolanaActivityLogsData,
|
||||
SearchSolanaActivityLogsErrors,
|
||||
SearchSolanaActivityLogsResponses,
|
||||
SearchSolanaTransactionsData,
|
||||
SearchSolanaTransactionsResponses,
|
||||
SearchTransactionsData,
|
||||
SearchTransactionsResponses,
|
||||
SendSolanaTransactionData,
|
||||
SendSolanaTransactionResponses,
|
||||
SendTransactionData,
|
||||
SendTransactionResponses,
|
||||
SignMessageData,
|
||||
SignMessageResponses,
|
||||
SignSolanaMessageData,
|
||||
SignSolanaMessageResponses,
|
||||
SignSolanaTransactionData,
|
||||
SignSolanaTransactionResponses,
|
||||
SignTypedDataData,
|
||||
SignTypedDataResponses,
|
||||
WriteContractData,
|
||||
@@ -227,6 +247,33 @@ export const encodeContract = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Send Solana Transaction
|
||||
* Execute a Solana transaction with custom instructions
|
||||
*/
|
||||
export const sendSolanaTransaction = <ThrowOnError extends boolean = false>(
|
||||
options: Options<SendSolanaTransactionData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? _heyApiClient).post<
|
||||
SendSolanaTransactionResponses,
|
||||
unknown,
|
||||
ThrowOnError
|
||||
>({
|
||||
security: [
|
||||
{
|
||||
name: "x-secret-key",
|
||||
type: "apiKey",
|
||||
},
|
||||
],
|
||||
url: "/v1/solana/transaction",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel Transaction
|
||||
* Attempt to cancel a queued transaction. Transactions that have been sent and are waiting for mine cannot be cancelled.
|
||||
@@ -300,6 +347,110 @@ export const createAccount = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List Solana Accounts
|
||||
* List all Solana wallets provisioned for the project.
|
||||
*/
|
||||
export const listSolanaAccounts = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<ListSolanaAccountsData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).get<
|
||||
ListSolanaAccountsResponses,
|
||||
unknown,
|
||||
ThrowOnError
|
||||
>({
|
||||
security: [
|
||||
{
|
||||
name: "x-secret-key",
|
||||
type: "apiKey",
|
||||
},
|
||||
],
|
||||
url: "/v1/solana/accounts",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get or Create Solana Account
|
||||
* Create a new solana wallet in the vault or return the existing wallet for a given label.
|
||||
*/
|
||||
export const createSolanaAccount = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<CreateSolanaAccountData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).post<
|
||||
CreateSolanaAccountResponses,
|
||||
unknown,
|
||||
ThrowOnError
|
||||
>({
|
||||
security: [
|
||||
{
|
||||
name: "x-secret-key",
|
||||
type: "apiKey",
|
||||
},
|
||||
],
|
||||
url: "/v1/solana/accounts",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign Solana Message
|
||||
* Sign an arbitrary message with a Solana wallet.
|
||||
*/
|
||||
export const signSolanaMessage = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<SignSolanaMessageData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).post<
|
||||
SignSolanaMessageResponses,
|
||||
unknown,
|
||||
ThrowOnError
|
||||
>({
|
||||
security: [
|
||||
{
|
||||
name: "x-secret-key",
|
||||
type: "apiKey",
|
||||
},
|
||||
],
|
||||
url: "/v1/solana/sign-message",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign Solana Transaction
|
||||
* Sign a serialized Solana transaction.
|
||||
*/
|
||||
export const signSolanaTransaction = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<SignSolanaTransactionData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).post<
|
||||
SignSolanaTransactionResponses,
|
||||
unknown,
|
||||
ThrowOnError
|
||||
>({
|
||||
security: [
|
||||
{
|
||||
name: "x-secret-key",
|
||||
type: "apiKey",
|
||||
},
|
||||
],
|
||||
url: "/v1/solana/sign-transaction",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get Transactions
|
||||
* Search transactions with various filters and pagination
|
||||
@@ -455,3 +606,103 @@ export const searchActivityLogs = <ThrowOnError extends boolean = false>(
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get Solana Transactions
|
||||
* Search Solana transactions with various filters and pagination
|
||||
*/
|
||||
export const getSolanaTransactions = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<GetSolanaTransactionsData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).get<
|
||||
GetSolanaTransactionsResponses,
|
||||
unknown,
|
||||
ThrowOnError
|
||||
>({
|
||||
security: [
|
||||
{
|
||||
name: "x-secret-key",
|
||||
type: "apiKey",
|
||||
},
|
||||
],
|
||||
url: "/v1/solana/transactions",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Search Solana Transactions
|
||||
* Advanced search for Solana transactions with complex nested filters
|
||||
*/
|
||||
export const searchSolanaTransactions = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<SearchSolanaTransactionsData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).post<
|
||||
SearchSolanaTransactionsResponses,
|
||||
unknown,
|
||||
ThrowOnError
|
||||
>({
|
||||
security: [
|
||||
{
|
||||
name: "x-secret-key",
|
||||
type: "apiKey",
|
||||
},
|
||||
],
|
||||
url: "/v1/solana/transactions/search",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get Solana Activity Logs
|
||||
* Get paginated activity logs for a specific Solana transaction with tenancy enforcement
|
||||
*/
|
||||
export const getSolanaActivityLogs = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetSolanaActivityLogsData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? _heyApiClient).get<
|
||||
GetSolanaActivityLogsResponses,
|
||||
GetSolanaActivityLogsErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
security: [
|
||||
{
|
||||
name: "x-secret-key",
|
||||
type: "apiKey",
|
||||
},
|
||||
],
|
||||
url: "/v1/solana/transactions/activity-logs",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Search Solana Activity Logs
|
||||
* Search Solana activity logs across transactions with advanced filtering and tenancy enforcement
|
||||
*/
|
||||
export const searchSolanaActivityLogs = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<SearchSolanaActivityLogsData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).post<
|
||||
SearchSolanaActivityLogsResponses,
|
||||
SearchSolanaActivityLogsErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
security: [
|
||||
{
|
||||
name: "x-secret-key",
|
||||
type: "apiKey",
|
||||
},
|
||||
],
|
||||
url: "/v1/solana/transactions/activity-logs/search",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,54 @@
|
||||
# @thirdweb-dev/nebula
|
||||
|
||||
## 0.2.69
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`51c8318`](https://github.com/thirdweb-dev/js/commit/51c8318df7b51126496ee94b1fd79a338734fced), [`d68da22`](https://github.com/thirdweb-dev/js/commit/d68da2239b628f7835b0545a143b69e09cdf4141)]:
|
||||
- thirdweb@5.109.0
|
||||
|
||||
## 0.2.68
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`e7f42e6`](https://github.com/thirdweb-dev/js/commit/e7f42e6ac591102145f5e25983aeaa6f3647ad7f)]:
|
||||
- thirdweb@5.108.15
|
||||
|
||||
## 0.2.67
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`8280efb`](https://github.com/thirdweb-dev/js/commit/8280efb99189c5b849156df383522f9e1b8f2c52), [`464af6a`](https://github.com/thirdweb-dev/js/commit/464af6a54cb776158a1656e793ee181c2c221f27)]:
|
||||
- thirdweb@5.108.14
|
||||
|
||||
## 0.2.66
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- thirdweb@5.108.13
|
||||
|
||||
## 0.2.65
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`0082e99`](https://github.com/thirdweb-dev/js/commit/0082e996cae47a326110e74457c72b3f3936743d), [`0082e99`](https://github.com/thirdweb-dev/js/commit/0082e996cae47a326110e74457c72b3f3936743d)]:
|
||||
- thirdweb@5.108.12
|
||||
|
||||
## 0.2.64
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`05550d0`](https://github.com/thirdweb-dev/js/commit/05550d045f291b8e53afd0d7e73c438bee202fb7)]:
|
||||
- thirdweb@5.108.11
|
||||
|
||||
## 0.2.63
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`f630912`](https://github.com/thirdweb-dev/js/commit/f630912b9b0e1d8ebe22b0b52c650f5e40e12baf), [`62cfbb7`](https://github.com/thirdweb-dev/js/commit/62cfbb7441e66156c55c76d73f5b481f5ea576bc)]:
|
||||
- thirdweb@5.108.10
|
||||
|
||||
## 0.2.62
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -57,5 +57,5 @@
|
||||
"type": "module",
|
||||
"types": "./dist/types/exports/thirdweb.d.ts",
|
||||
"typings": "./dist/types/exports/thirdweb.d.ts",
|
||||
"version": "0.2.62"
|
||||
"version": "0.2.69"
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ export type ProjectService =
|
||||
rotationCode?: string | null;
|
||||
encryptedAdminKey?: string | null;
|
||||
encryptedWalletAccessToken?: string | null;
|
||||
projectWalletAddress?: string | null;
|
||||
}
|
||||
| ProjectBundlerService
|
||||
| ProjectEmbeddedWalletsService;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user