Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
47354d5a19 Version Packages (#8243)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-14 12:29:02 -07:00
Joaquim VergesandGitHub 8280efb991 [SDK] Fix Coinbase wallet transaction desktop popup for mobile QR login (#8253) 2025-10-14 12:20:50 -07:00
MananTank 5f11ae0cd4 Dashboard: Show Purchase data in bridge tx page, add tx status card in project>bridge page (#8251)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on enhancing the transaction status viewing capabilities in the application. It introduces new components and modifies existing ones to improve user experience and functionality related to transaction data.

### Detailed summary
- Added `ViewTxStatus` component for viewing transaction status.
- Updated `Page` component to include `ViewTxStatus` and adjust layout.
- Improved error handling for transaction retrieval.
- Introduced `WithPurchaseData` story for `BridgeStatus`.
- Enhanced `BridgeStatus` to display purchase data if available.
- Modified `RouteDiscovery` to streamline form submission and error handling.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added a “View Transaction Status” tool with chain selector, hash validation, and link to results in a new tab.
  * Transaction page shows a “Purchase Data” section with formatted JSON when available.

* **Bug Fixes**
  * More graceful handling for missing/invalid transactions/receipts with an inline “Transaction not found” message.

* **UI**
  * Reworked Route Discovery into an inline form with clearer error display and updated spacing.

* **Tests**
  * Added a Storybook variant showcasing purchase data on the Completed status.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-14 18:37:48 +00:00
d4mr c9a0c23c6c [dashboard] add solana policies to vault access token (#8239)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on adding new metadata patterns and required metadata patterns for various transaction types related to `solana`, enhancing the functionality of the `vault.client.ts` file.

### Detailed summary
- Added new `metadataPatterns` for `solana:read`, `solana:create`, `solana:signTransaction`, and `solana:signMessage` types.
- Included `requiredMetadataPatterns` for `solana:create` type.
- Each pattern includes `projectId`, `teamId`, and a fixed `type` of `"server-wallet"`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added Solana-specific permissions to access tokens.
  * Wallet tokens now support solana:read, solana:create, solana:signTransaction, and solana:signMessage.
  * Management tokens now support solana:read and solana:create.
  * Permissions can be scoped via metadata (team, project, server-wallet) for finer control.
  * No changes to existing flows or error handling; new scopes are available during token creation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-14 18:06:44 +00:00
MananTank 43b0680124 [MNY-242] Dashboard: Remove animated background from bridge page (#8219)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on code cleanup and improvements in the `apps/dashboard` project. It removes unused components and makes adjustments to existing components for better styling and functionality.

### Detailed summary
- Deleted `DarkVeil` and `BridgeVeil` components.
- Adjusted the styling of the `PageHeader` component.
- Modified button size and styles in `BuyAndSwapEmbed`.
- Updated `BridgePage` component layout and header styles.
- Improved `DataPill` component styles.
- Refined `DotsBackgroundPattern` visibility and styles.
- Updated dependency versions in `package.json` and `pnpm-lock.yaml`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Style**
  * Removed animated veil background and simplified header visuals; adjusted responsive typography, spacing, borders, paddings, and colors for improved light/dark contrast.
  * Added a small dot icon beside the first pill on mobile, improved pill layout and muted pill text color on larger screens.
  * Made tab buttons smaller and refined active-state styling.

* **Refactor**
  * Removed legacy veil background components to simplify the UI.

* **Chores**
  * Removed an unused graphics dependency from the dashboard.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-13 21:44:06 +00:00
MananTank cccbde9a2e [MNY-240] Dashboard: Show bridge status info on transaction page if available (#8241)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new tabbed interface for bridge status and overview details in the transaction view. It enhances user experience by allowing easy navigation between different states of a bridge transaction.

### Detailed summary
- Added `BridgeAndOverviewTabs` component for tab navigation.
- Implemented `BridgeStatus` component to display bridge transaction status.
- Created stories for different bridge statuses (`Completed`, `Pending`, `Failed`, `NotFound`).
- Enhanced `Page` component to include bridge status with polling.
- Refactored transaction details layout for better clarity.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-10-13 21:28:49 +00:00
MananTank 85a6afc377 [MNY-249] Dashboard: /<chain>/tx/<tx-hash> page UI improvements (#8234)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `page.tsx` component for transaction details in the dashboard, improving its layout and user experience by introducing new components and restructuring sections for better clarity and functionality.

### Detailed summary
- Added `Breadcrumb` for navigation.
- Replaced static text with `CopyTextButton` for transaction hash, from, and to addresses.
- Introduced `GridItem` component for consistent layout of transaction details.
- Enhanced transaction details display with tooltips and icons.
- Improved formatting of timestamps and values.
- Added sections for transaction fee, gas prices, and burnt fees.
- Included input data display using `PlainTextCodeBlock`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-10-13 21:28:49 +00:00
MananTank e74aca9c90 [MNY-239] Dashboard: Show webhook sends for Bridge webhooks (#8213)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the payment and webhook functionalities in the dashboard application by adding `authToken` support across various components and improving the webhook management interface.

### Detailed summary
- Added `authToken` prop to multiple components including `PayAnalytics`, `PaymentHistory`, and `QuickStartSection`.
- Enhanced `getWebhooks`, `createWebhook`, `deleteWebhook`, and `updateWebhook` functions to accept `authToken`.
- Improved the webhook management UI with `WebhookCard` and related functionalities.
- Added dialog components for creating and editing webhooks with validation.
- Updated API calls to include `authToken` for authentication in requests.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Card-based webhooks UI with create/edit modal, per-card actions, delete confirmation, webhook detail page, and webhook sends viewer (history, filtering, pagination, resend).
  - Payments area: payment links and payments viewer wired to authenticated CRUD and paginated payments list; resend and webhook-send actions supported.

- **Enhancements**
  - authToken propagated across webhooks, payments, payment links, fees, and related components to enable authenticated operations.
  - Webhook objects no longer expose secret fields; create/update/delete now return usable payloads.

- **Documentation**
  - Storybook stories added for webhook sends UI (loading, results, error, resend scenarios).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-13 21:28:49 +00:00
MananTank b0cac940bd Dashboard: Add block_timestamp_from param to useTokenTransfers (#8121)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `useTokenTransfers` functionality by adding a time filter for token transfers and improving the refetching logic based on error states.

### Detailed summary
- Introduced a constant `THIRTY_DAYS_AGO` to represent the timestamp 30 days prior.
- Added a search parameter `block_timestamp_from` to the `url` with the calculated timestamp.
- Changed `refetchInterval` to conditionally return `false` if there's an error in the data.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Token transfer history now defaults to the last 30 days for more relevant results.

* **Bug Fixes**
  * Auto-refresh pauses when an error occurs, preventing repeated failures and unnecessary requests.
  * Auto-refresh resumes normal operation once the error clears, keeping data up to date without manual refresh.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-13 20:20:30 +00:00
MananTank 1a19e14845 SDK: Fix getNFT tests, Playground: Fix build (#8242)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on updating the `tokenAddress` format in the tests and modifying the instantiation of the `thirdwebAI` and `thirdwebClient` to use a consistent `SECRET_KEY` variable for better readability and maintainability.

### Detailed summary
- Updated `tokenAddress` format in `getNFT.test.ts` and `getNFTs.test.ts`.
- Changed `thirdwebAI` instantiation in `route.ts` to use `SECRET_KEY` variable.
- Modified `thirdwebClient` instantiation in `paywall/route.ts` to use `SECRET_KEY` variable.
- Maintained existing environment variable usage for wallet addresses and API URLs.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added a new public GET paywall endpoint.

* **Tests**
  * Updated NFT test snapshots to correct token address casing.
  * Removed an obsolete single-line test comment.

* **Chores**
  * API handlers now initialize needed services per request and read required environment secrets.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-13 19:58:30 +00:00
YashandGitHub 464af6a54c Stylus CLI updates (#8235) 2025-10-14 00:11:33 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
f3c55d7139 Version Packages (#8240)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-11 19:31:58 +07:00
Prithvish BaidyaandGitHub 9545b43675 Engine SDK updates (#8238) 2025-10-11 19:24:30 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
869bbcfa68 Version Packages (#8237)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-11 07:42:22 +07:00
Prithvish BaidyaandGitHub 35c7d21e36 [Vault SDK] Add Solana operations support (#8168) 2025-10-11 07:37:27 +07:00
MananTank edf3aad8c4 [BLD-396] Dashboard: Fix changing tabs in /nfts/<id> page shows full page loading indicator (#8224)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `TokenIdPage` component by introducing `Suspense` for improved loading state handling and adjusting the layout of the loading spinner.

### Detailed summary
- Added `Suspense` for handling loading states.
- Updated the loading spinner layout with additional padding (`py-20`).
- Wrapped the tab rendering logic within the `Suspense` component for better user experience during data fetching.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- New Features
  - Introduced a loading experience for NFT tab content using Suspense, providing a fallback UI while content loads.
  - Improves responsiveness when switching tabs by deferring heavy content until ready.

- Style
  - Increased vertical padding for the pending NFT state to enhance visual spacing and readability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-09 22:14:06 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ab4190e23a Version Packages (#8230)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-09 14:19:19 -07:00
Joaquim VergesandGitHub 9086cdba20 [Playground] Add payTo parameter to X402 payment API (#8231) 2025-10-09 14:19:00 -07:00
Joaquim VergesandGitHub 0082e996ca [SDK] Add x402 recipientAddress to payment request and replace deprecated payTo (#8226) 2025-10-09 13:51:19 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
28955e0ac5 Version Packages (#8229)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-10 03:07:52 +07:00
FirekeeperandGitHub 05550d045f Changeset for a0c45cb25d (#8228) 2025-10-10 03:01:01 +07:00
FirekeeperandGitHub a0c45cb25d Export ERC7702 MinimalAccount helpers and events (#8227) 2025-10-10 02:57:14 +07:00
MananTank 6fc4d838ff [MNY-246] Dashboard: Add Buy button in tokens table, update supported tokens count (#8222)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating token-related information and enhancing the `TokensTable` component in the dashboard, including the increase in the number of supported tokens and the addition of a buy button for each token.

### Detailed summary
- Updated token count from `3200+` to `4500+` in `page.tsx`.
- Changed description in `page.tsx` to reflect the new token count.
- Modified `TokensTable` to display the updated token count.
- Added a new button in `TokensTable` for buying tokens, including a link to the token's page.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- New Features
  - Added a Buy action column with a button in the tokens table, including proper skeleton alignment for the new column.

- Style
  - Rounded token avatars for a refined look.
  - Simplified token name display by removing inline linking.

- Documentation
  - Updated copy to reflect “4500+ tokens” on the tokens page and bridge page, including Open Graph/description and UI labels.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-09 18:42:18 +00:00
MananTank fe940b3c66 [BLD-395] Dashboard: Show error UI when parsing CSV fails (#8223)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR improves error handling in the `useCsvUpload` hook by adding a `try-catch` block around the CSV parsing logic. It ensures that if an error occurs during parsing or if the parsed data lacks an address, the component sets an error state and logs the error.

### Detailed summary
- Introduced a `try-catch` block around the CSV parsing logic.
- Added error handling to set `noCsv` to `true` if the parsed data's first entry does not have an `address`.
- Ensured that any errors during parsing are logged to the console.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Bug Fixes**
  * Prevents crashes during CSV uploads by handling parse errors gracefully.
  * Ensures invalid or malformed CSV files are detected and flagged without updating data.
  * Maintains existing behavior for valid files, only updating data when required fields are present.
  * Improves reliability of the upload flow with clearer invalid-file handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-09 18:31:34 +00:00
FirekeeperandGitHub 080d7f2122 Refactor ProjectFTUX API key integration section (#8225) 2025-10-10 00:08:54 +07:00
FirekeeperandGitHub ae75073a4a Refactor project wallet setup and controls UI (#8220) 2025-10-09 22:09:20 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
781a626461 Version Packages (#8217)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-09 01:27:39 -07:00
Joaquim VergesandGitHub f630912b9b [SDK] Export missing x402 types (#8218) 2025-10-09 01:14:37 -07:00
163ab8afe1 Project Wallet (Default Server Wallet) (#8212)
Co-authored-by: Joaquim Verges <[email protected]>
2025-10-08 20:57:32 +07:00
MananTank 62cfbb7441 SDK: Show error in BuyWidget and SwapWidget UI if fetching token details fails (#8215)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces error handling in the `BuyWidget` and `SwapWidget` UIs, displaying appropriate error messages when fetching token details fails.

### Detailed summary
- Added `isError` state to handle errors in token fetching.
- Updated `SelectedTokenButton` to check for errors before rendering.
- Enhanced `SwapUI` to show error messages based on different query states.
- Implemented error display in `FundWallet` for unsupported tokens.
- Modified balance display logic to accommodate new error handling.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- New Features
  - Inline error banners in Buy and Swap widgets when token details fail to load.
  - Clear, specific messages for quote, buy-token, and sell-token fetch failures.
- Improvements
  - Selected token UI now falls back gracefully on errors instead of showing partial data.
  - Balance display uses richer token info (value, decimals, symbol, name) for accuracy.
  - More consistent handling of unsupported tokens across the flow.
- Bug Fixes
  - Prevents stale token icons/details from appearing during error states.
  - Disables auto-retry on token price fetches to reduce UI flicker.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-07 21:36:48 +00:00
Joaquim VergesandGitHub d7104f69f4 [Dashboard] Add engineCloud to ActiveStatus and reduce revalidation time to 30 seconds (#8216) 2025-10-07 14:22:32 -07:00
Joaquim VergesandGitHub 23545cb999 [Docs] Add ERC-7702 Smart EOA session keys documentation (#8214) 2025-10-07 13:35:46 -07:00
MananTank f9283f389b Portal: Add missing redirects for bridge pages (#8201)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the redirect paths in the `redirects.mjs` file to streamline payment-related navigation by adding new routes that point to a `bridge` structure.

### Detailed summary
- Added redirect for `"/payments/sell"` to `"/bridge/sell"`
- Added redirect for `"/payments/swap"` to `"/bridge/swap"`
- Added redirect for `"/payments/tokens"` to `"/bridge/tokens"`
- Added redirect for `"/payments/routes"` to `"/bridge/routes"`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added redirects so payment URLs now route to corresponding bridge destinations:
    * /payments/sell → /bridge/sell
    * /payments/swap → /bridge/swap
    * /payments/tokens → /bridge/tokens
    * /payments/routes → /bridge/routes
  * Existing bookmarks and shared links to payment pages will continue working and automatically route to the updated locations, with no UI changes required.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-07 11:30:51 +00:00
95 changed files with 10173 additions and 1280 deletions
-1
View File
@@ -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;
}
+3 -1
View File
@@ -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" />;
}
@@ -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"
>
@@ -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,
+9 -4
View File
@@ -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;
}
}
@@ -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>
@@ -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,
@@ -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,
},
};
@@ -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;
}
}
@@ -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,339 @@ 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)}</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>
);
}
@@ -39,6 +39,7 @@ export function TokensTable(props: {
<TableHead>Price</TableHead>
<TableHead>Market cap</TableHead>
<TableHead>Volume (24h)</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -64,6 +65,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 +85,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 +95,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 +114,29 @@ 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
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>
@@ -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,
@@ -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>
);
@@ -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>
);
}
@@ -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>
);
@@ -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>
);
}
@@ -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>
);
}
@@ -31,5 +31,6 @@ export const Default: Story = {
],
},
teamSlug: "bar",
managementAccessToken: undefined,
},
};
@@ -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 }) {
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
@@ -156,6 +156,7 @@ export async function PayAnalytics(props: {
client={props.client}
projectClientId={props.projectClientId}
teamId={props.teamId}
authToken={props.authToken}
/>
</div>
);
@@ -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;
},
@@ -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"
@@ -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>
)}
@@ -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;
},
@@ -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
@@ -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>
@@ -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>
)}
@@ -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>
);
@@ -3,14 +3,17 @@
import { 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,
},
],
},
@@ -458,6 +560,98 @@ export async function createWalletAccessToken(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: [
{
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 +725,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: [
{
@@ -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);
@@ -1,8 +1,9 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import {
CheckIcon,
MoreVerticalIcon,
RefreshCcwIcon,
SendIcon,
@@ -10,6 +11,7 @@ import {
} 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 {
@@ -20,6 +22,7 @@ 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 {
DropdownMenu,
@@ -50,7 +53,9 @@ import {
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 "../components/create-server-wallet.client";
import type { Wallet } from "./types";
@@ -287,18 +292,34 @@ function ServerWalletTableRow(props: {
queryKey: ["smart-account-address", wallet.address, chainId],
});
// Get the project wallet address
const engineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const defaultWalletAddress = engineCloudService?.projectWalletAddress;
const isDefaultWallet =
defaultWalletAddress &&
wallet.address.toLowerCase() === defaultWalletAddress.toLowerCase();
return (
<TableRow key={wallet.id}>
{/* Label */}
<TableCell>
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
<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>
{isDefaultWallet && (
<Badge variant="success" className="text-xs">
default
</Badge>
)}
>
{wallet.metadata.label || "N/A"}
</span>
</div>
</TableCell>
{/* Address */}
@@ -391,6 +412,25 @@ function WalletActionsDropdown(props: {
chainId: number;
}) {
const [showFundModal, setShowFundModal] = useState(false);
const router = useDashboardRouter();
const setAsDefaultMutation = useMutation({
mutationFn: async () => {
await updateDefaultProjectWallet({
project: props.project,
projectWalletAddress: props.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 (
<>
@@ -420,6 +460,14 @@ function WalletActionsDropdown(props: {
Fund wallet
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setAsDefaultMutation.mutate()}
disabled={setAsDefaultMutation.isPending}
className="flex items-center gap-2 h-9 rounded-lg"
>
<CheckIcon className="size-4 text-muted-foreground" />
Set as default
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -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");
},
},
};
@@ -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>
);
}
@@ -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>
);
}
@@ -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",
+10 -13
View File
@@ -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>
+4
View File
@@ -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 = {
@@ -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
@@ -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
+4
View File
@@ -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",
+40
View File
@@ -1,5 +1,45 @@
# wagmi-inapp
## 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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wagmi-inapp",
"private": true,
"version": "0.0.4",
"version": "0.0.9",
"type": "module",
"scripts": {
"dev": "vite",
+12
View File
@@ -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 -1
View File
@@ -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"
+251
View File
@@ -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
+35
View File
@@ -1,5 +1,40 @@
# @thirdweb-dev/nebula
## 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
+1 -1
View File
@@ -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.67"
}
+1
View File
@@ -241,6 +241,7 @@ export type ProjectService =
rotationCode?: string | null;
encryptedAdminKey?: string | null;
encryptedWalletAccessToken?: string | null;
projectWalletAddress?: string | null;
}
| ProjectBundlerService
| ProjectEmbeddedWalletsService;
+40
View File
@@ -1,5 +1,45 @@
# thirdweb
## 5.108.14
### Patch Changes
- [#8253](https://github.com/thirdweb-dev/js/pull/8253) [`8280efb`](https://github.com/thirdweb-dev/js/commit/8280efb99189c5b849156df383522f9e1b8f2c52) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Fix cb wallet transaction desktop popup when logged in via mobile QR code
- [#8235](https://github.com/thirdweb-dev/js/pull/8235) [`464af6a`](https://github.com/thirdweb-dev/js/commit/464af6a54cb776158a1656e793ee181c2c221f27) Thanks [@kumaryash90](https://github.com/kumaryash90)! - CLI updates for stylus - new templates
## 5.108.13
### Patch Changes
- Updated dependencies [[`9545b43`](https://github.com/thirdweb-dev/js/commit/9545b4367598408dbb88c63d8a8fe21305f870b5)]:
- @thirdweb-dev/engine@3.3.0
## 5.108.12
### Patch Changes
- [#8226](https://github.com/thirdweb-dev/js/pull/8226) [`0082e99`](https://github.com/thirdweb-dev/js/commit/0082e996cae47a326110e74457c72b3f3936743d) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Add optional recipientAddress to x402 settlePayment
- [#8226](https://github.com/thirdweb-dev/js/pull/8226) [`0082e99`](https://github.com/thirdweb-dev/js/commit/0082e996cae47a326110e74457c72b3f3936743d) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Expose 7702 utility functions
## 5.108.11
### Patch Changes
- [#8228](https://github.com/thirdweb-dev/js/pull/8228) [`05550d0`](https://github.com/thirdweb-dev/js/commit/05550d045f291b8e53afd0d7e73c438bee202fb7) Thanks [@0xFirekeeper](https://github.com/0xFirekeeper)! - Export ERC7702 MinimalAccount helpers and events
## 5.108.10
### 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)! - Export missing x402 types
- [#8215](https://github.com/thirdweb-dev/js/pull/8215) [`62cfbb7`](https://github.com/thirdweb-dev/js/commit/62cfbb7441e66156c55c76d73f5b481f5ea576bc) Thanks [@MananTank](https://github.com/MananTank)! - Show error in BuyWidget and SwapWidget UI if fetching token details fails
- Updated dependencies [[`f630912`](https://github.com/thirdweb-dev/js/commit/f630912b9b0e1d8ebe22b0b52c650f5e40e12baf)]:
- @thirdweb-dev/engine@3.2.2
## 5.108.9
### Patch Changes
+1 -1
View File
@@ -430,5 +430,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.108.9"
"version": "5.108.14"
}
@@ -58,6 +58,8 @@ export async function createStylusProject() {
{ title: "Airdrop ERC20", value: "airdrop20" },
{ title: "Airdrop ERC721", value: "airdrop721" },
{ title: "Airdrop ERC1155", value: "airdrop1155" },
{ title: "ZK ERC721", value: "zk-erc721" },
{ title: "ZK ERC20", value: "zk-erc20" },
],
message: "Select a template:",
name: "projectType",
@@ -119,6 +121,18 @@ export async function createStylusProject() {
newProject = spawnSync("git", ["clone", repoUrl, projectName], {
stdio: "inherit",
});
} else if (projectType === "zk-erc721") {
const repoUrl = "[email protected]:thirdweb-example/stylus-zk-erc721.git";
spinner.start(`Creating new ZK ERC721 Stylus project: ${projectName}...`);
newProject = spawnSync("git", ["clone", repoUrl, projectName], {
stdio: "inherit",
});
} else if (projectType === "zk-erc20") {
const repoUrl = "[email protected]:thirdweb-example/stylus-zk-erc20.git";
spinner.start(`Creating new ZK ERC20 Stylus project: ${projectName}...`);
newProject = spawnSync("git", ["clone", repoUrl, projectName], {
stdio: "inherit",
});
}
if (!newProject || newProject.status !== 0) {
@@ -189,8 +189,8 @@ export function serverWallet(options: ServerWalletOptions): ServerWallet {
};
case "EIP7702":
return {
...executionOptions,
chainId,
from: address,
type: "EIP7702",
};
}
@@ -1,5 +1,53 @@
// --- KEEEP IN SYNC with exports/wallets/in-app.ts ---
export { executedEvent } from "../../extensions/erc7702/__generated__/MinimalAccount/events/Executed.js";
export { sessionCreatedEvent } from "../../extensions/erc7702/__generated__/MinimalAccount/events/SessionCreated.js";
export { valueReceivedEvent } from "../../extensions/erc7702/__generated__/MinimalAccount/events/ValueReceived.js";
// ERC7702 generated helpers
export {
eip712Domain,
isEip712DomainSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/eip712Domain.js";
export {
type GetCallPoliciesForSignerParams,
getCallPoliciesForSigner,
isGetCallPoliciesForSignerSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/getCallPoliciesForSigner.js";
export {
type GetSessionExpirationForSignerParams,
getSessionExpirationForSigner,
isGetSessionExpirationForSignerSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/getSessionExpirationForSigner.js";
export {
type GetSessionStateForSignerParams,
getSessionStateForSigner,
isGetSessionStateForSignerSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/getSessionStateForSigner.js";
export {
type GetTransferPoliciesForSignerParams,
getTransferPoliciesForSigner,
isGetTransferPoliciesForSignerSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/getTransferPoliciesForSigner.js";
export {
type IsWildcardSignerParams,
isIsWildcardSignerSupported,
isWildcardSigner,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/isWildcardSigner.js";
export {
type CreateSessionWithSigParams,
createSessionWithSig,
isCreateSessionWithSigSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/write/createSessionWithSig.js";
export {
type ExecuteParams,
execute,
isExecuteSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/write/execute.js";
export {
type ExecuteWithSigParams,
executeWithSig,
isExecuteWithSigSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/write/executeWithSig.js";
//ACCOUNT
export {
type CreateSessionKeyOptions,
@@ -1,11 +1,60 @@
// --- KEEEP IN SYNC with exports/wallets/in-app.native.ts ---
export { executedEvent } from "../../extensions/erc7702/__generated__/MinimalAccount/events/Executed.js";
export { sessionCreatedEvent } from "../../extensions/erc7702/__generated__/MinimalAccount/events/SessionCreated.js";
export { valueReceivedEvent } from "../../extensions/erc7702/__generated__/MinimalAccount/events/ValueReceived.js";
// ERC7702 generated helpers
export {
eip712Domain,
isEip712DomainSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/eip712Domain.js";
export {
type GetCallPoliciesForSignerParams,
getCallPoliciesForSigner,
isGetCallPoliciesForSignerSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/getCallPoliciesForSigner.js";
export {
type GetSessionExpirationForSignerParams,
getSessionExpirationForSigner,
isGetSessionExpirationForSignerSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/getSessionExpirationForSigner.js";
export {
type GetSessionStateForSignerParams,
getSessionStateForSigner,
isGetSessionStateForSignerSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/getSessionStateForSigner.js";
export {
type GetTransferPoliciesForSignerParams,
getTransferPoliciesForSigner,
isGetTransferPoliciesForSignerSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/getTransferPoliciesForSigner.js";
export {
type IsWildcardSignerParams,
isIsWildcardSignerSupported,
isWildcardSigner,
} from "../../extensions/erc7702/__generated__/MinimalAccount/read/isWildcardSigner.js";
export {
type CreateSessionWithSigParams,
createSessionWithSig,
isCreateSessionWithSigSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/write/createSessionWithSig.js";
export {
type ExecuteParams,
execute,
isExecuteSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/write/execute.js";
export {
type ExecuteWithSigParams,
executeWithSig,
isExecuteWithSigSupported,
} from "../../extensions/erc7702/__generated__/MinimalAccount/write/executeWithSig.js";
//ACCOUNT
export {
type CreateSessionKeyOptions,
createSessionKey,
isCreateSessionKeySupported,
} from "../../extensions/erc7702/account/createSessionKey.js";
export type {
Condition,
LimitType,
+1
View File
@@ -1,3 +1,4 @@
export type { Money, PaymentMiddlewareConfig } from "x402/types";
export { decodePayment, encodePayment } from "../x402/encode.js";
export {
facilitator,
@@ -44,7 +44,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFT", () => {
"uri": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1",
},
"owner": null,
"tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e",
"tokenAddress": "0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e",
"tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1",
"type": "ERC721",
}
@@ -13,7 +13,6 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => {
});
expect(nfts.length).toBe(5);
// TODO (insight): re-enable once insight fixes the client id caching issue
expect(nfts).toMatchInlineSnapshot(`
[
{
@@ -49,7 +48,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => {
"uri": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/0",
},
"owner": null,
"tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e",
"tokenAddress": "0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e",
"tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/0",
"type": "ERC721",
},
@@ -86,7 +85,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => {
"uri": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1",
},
"owner": null,
"tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e",
"tokenAddress": "0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e",
"tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1",
"type": "ERC721",
},
@@ -123,7 +122,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => {
"uri": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/2",
},
"owner": null,
"tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e",
"tokenAddress": "0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e",
"tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/2",
"type": "ERC721",
},
@@ -160,7 +159,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => {
"uri": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/3",
},
"owner": null,
"tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e",
"tokenAddress": "0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e",
"tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/3",
"type": "ERC721",
},
@@ -197,7 +196,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => {
"uri": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/4",
},
"owner": null,
"tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e",
"tokenAddress": "0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e",
"tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/4",
"type": "ERC721",
},
@@ -251,11 +251,14 @@ export function FundWallet(props: FundWalletProps) {
? tokenQuery.data.token
: undefined,
isFetching: tokenQuery.isFetching,
isError:
tokenQuery.isError ||
tokenQuery.data?.type === "unsupported_token",
}
: undefined
}
balance={{
data: tokenBalanceQuery.data?.value,
data: tokenBalanceQuery.data,
isFetching: tokenBalanceQuery.isFetching,
}}
client={props.client}
@@ -279,6 +282,22 @@ export function FundWallet(props: FundWalletProps) {
<Spacer y="md" />
{(tokenQuery.isError ||
tokenQuery.data?.type === "unsupported_token") && (
<div
style={{
border: `1px solid ${theme.colors.borderColor}`,
borderRadius: radius.full,
padding: spacing.xs,
marginBottom: spacing.md,
}}
>
<Text size="sm" color="danger" center>
Failed to fetch token details
</Text>
</div>
)}
{/* Continue Button */}
{activeWalletInfo ? (
<Button
@@ -373,6 +392,7 @@ function TokenSection(props: {
| {
data: TokenWithPrices | undefined;
isFetching: boolean;
isError: boolean;
}
| undefined;
currency: SupportedFiatCurrency;
@@ -381,7 +401,14 @@ function TokenSection(props: {
title: string;
isConnected: boolean;
balance: {
data: bigint | undefined;
data:
| {
value: bigint;
decimals: number;
symbol: string;
name: string;
}
| undefined;
isFetching: boolean;
};
onWalletClick: () => void;
@@ -565,17 +592,16 @@ function TokenSection(props: {
<Text size="xs" color="secondaryText">
Current Balance
</Text>
{props.balance.data === undefined ||
props.selectedToken.data === undefined ? (
{props.balance.data === undefined ? (
<Skeleton height={fontSize.xs} width="100px" />
) : (
<Text size="xs" color="primaryText">
{formatTokenAmount(
props.balance.data,
props.selectedToken.data.decimals,
props.balance.data.value,
props.balance.data.decimals,
5,
)}{" "}
{props.selectedToken.data.symbol}
{props.balance.data.symbol}
</Text>
)}
</div>
@@ -21,6 +21,7 @@ export function SelectedTokenButton(props: {
| {
data: TokenWithPrices | undefined;
isFetching: boolean;
isError: boolean;
}
| undefined;
client: ThirdwebClient;
@@ -47,7 +48,7 @@ export function SelectedTokenButton(props: {
{/* icons */}
<Container relative color="secondaryText">
{/* token icon */}
{props.selectedToken ? (
{props.selectedToken && !props.selectedToken.isError ? (
<Img
key={props.selectedToken?.data?.iconUri}
src={
@@ -112,7 +113,7 @@ export function SelectedTokenButton(props: {
</Container>
{/* token symbol and chain name */}
{props.selectedToken ? (
{props.selectedToken && !props.selectedToken.isError ? (
<Container flex="column" style={{ gap: "3px" }}>
{props.selectedToken?.isFetching ? (
<Skeleton width="60px" height={fontSize.md} />
@@ -96,6 +96,7 @@ function useTokenPrice(options: {
);
},
refetchOnMount: false,
retry: false,
refetchOnWindowFocus: false,
});
}
@@ -320,6 +321,7 @@ export function SwapUI(props: SwapUIProps) {
? {
data: sellTokenQuery.data,
isFetching: sellTokenQuery.isFetching,
isError: sellTokenQuery.isError,
}
: undefined
}
@@ -372,6 +374,7 @@ export function SwapUI(props: SwapUIProps) {
? {
data: buyTokenQuery.data,
isFetching: buyTokenQuery.isFetching,
isError: buyTokenQuery.isError,
}
: undefined
}
@@ -389,7 +392,9 @@ export function SwapUI(props: SwapUIProps) {
/>
{/* error message */}
{preparedResultQuery.error ? (
{preparedResultQuery.error ||
buyTokenQuery.isError ||
sellTokenQuery.isError ? (
<Text
size="sm"
color="danger"
@@ -398,7 +403,13 @@ export function SwapUI(props: SwapUIProps) {
paddingBlock: spacing.md,
}}
>
Failed to get a quote
{preparedResultQuery.error
? "Failed to get a quote"
: buyTokenQuery.isError
? "Failed to fetch buy token details"
: sellTokenQuery.isError
? "Failed to fetch sell token details"
: "Failed to get a quote"}
</Text>
) : (
<Spacer y="md" />
@@ -626,6 +637,7 @@ function TokenSection(props: {
| {
data: TokenWithPrices | undefined;
isFetching: boolean;
isError: boolean;
}
| undefined;
currency: SupportedFiatCurrency;
@@ -188,7 +188,17 @@ function createAccount({
onTransactionRequested: async () => {
// make sure to show the coinbase popup BEFORE doing any transaction preprocessing
// otherwise the popup might get blocked in safari
await showCoinbasePopup(provider);
// but only if using cb smart wallet (web based)
if (window.localStorage) {
// this is the local storage key for the signer type in the cb web sdk
// value can be "scw" (web) or "walletlink" (mobile wallet)
const signerType = window.localStorage.getItem(
"-CBWSDK:SignerConfigurator:SignerType",
);
if (signerType === "scw") {
await showCoinbasePopup(provider);
}
}
},
async sendTransaction(tx: SendTransactionOption) {
const transactionHash = (await provider.request({
+3 -1
View File
@@ -44,6 +44,7 @@ export async function decodePaymentRequest(
price,
network,
facilitator,
payTo,
resourceUrl,
routeConfig = {},
method,
@@ -104,7 +105,7 @@ export async function decodePaymentRequest(
resource: resourceUrl,
description: description ?? "",
mimeType: mimeType ?? "application/json",
payTo: getAddress(facilitator.address),
payTo: getAddress(facilitator.address), // always pay to the facilitator address first
maxTimeoutSeconds: maxTimeoutSeconds ?? 300,
asset: getAddress(asset.address),
outputSchema: {
@@ -117,6 +118,7 @@ export async function decodePaymentRequest(
output: outputSchema,
},
extra: {
recipientAddress: payTo, // input payTo is the final recipient address
...((asset as ERC20TokenAmount["asset"]).eip712 ?? {}),
},
});
@@ -82,6 +82,7 @@ export function wrapFetchWithPayment(
parsedPaymentRequirements,
chain.id,
"exact",
error,
);
if (!selectedPaymentRequirements) {
@@ -141,10 +142,11 @@ function defaultPaymentRequirementsSelector(
paymentRequirements: RequestedPaymentRequirements[],
chainId: number,
scheme: "exact",
error?: string,
) {
if (!paymentRequirements.length) {
throw new Error(
"No valid payment requirements found in server 402 response",
`No valid payment requirements found in server 402 response. ${error}`,
);
}
const currentWalletNetwork = ChainIdToNetwork[chainId] || `eip155:${chainId}`;
+1 -1
View File
@@ -34,7 +34,7 @@ export type PaymentArgs = {
facilitator: ThirdwebX402Facilitator;
/** Optional configuration for the payment middleware route */
routeConfig?: PaymentMiddlewareConfig;
/** @deprecated Use facilitator.address instead */
/** Optional recipient address to receive the payment if different from your facilitator address */
payTo?: string;
};
+6
View File
@@ -1,5 +1,11 @@
# @thirdweb-dev/react-native-adapter
## 0.1.0
### Minor Changes
- [#8168](https://github.com/thirdweb-dev/js/pull/8168) [`35c7d21`](https://github.com/thirdweb-dev/js/commit/35c7d21e368f9ea5636c8663ba31b7d473b5b343) Thanks [@d4mr](https://github.com/d4mr)! - Added Vault operations support for Solana.
## 0.0.3
### Patch Changes
+1 -13
View File
@@ -1,16 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"extends": "//",
"overrides": [
{
"assist": {
"actions": {
"source": {
"useSortedKeys": "off"
}
}
},
"includes": ["package.json"]
}
]
"extends": "//"
}
+11 -2
View File
@@ -7,12 +7,20 @@
"@noble/ciphers": "^1.2.1",
"@noble/curves": "1.8.2",
"@noble/hashes": "1.7.2",
"@solana/addresses": "^3.0.0",
"@solana/keys": "^3.0.0",
"@solana/kit": "^4.0.0",
"@solana/rpc": "^3.0.0",
"@solana/transactions": "^3.0.0",
"abitype": "1.0.8",
"jose": "6.0.11"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"rimraf": "6.0.1"
"@solana-program/system": "^0.8.1",
"bs58": "^6.0.0",
"rimraf": "6.0.1",
"vitest": "3.2.4"
},
"engines": {
"node": ">=18"
@@ -51,6 +59,7 @@
"build:esm": "tsc --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm && printf '{\"type\": \"module\",\"sideEffects\":false}' > ./dist/esm/package.json",
"build:types": "tsc --project ./tsconfig.build.json --module esnext --declarationDir ./dist/types --emitDeclarationOnly --declaration --declarationMap",
"clean": "rimraf dist",
"test:solana": "vitest ./tests/solana.test.ts",
"dev": "tsc --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm --watch",
"fix": "biome check ./src --fix",
"format": "biome format ./src --write",
@@ -59,5 +68,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.0.3"
"version": "0.1.0"
}
@@ -3,15 +3,19 @@ export {
createEoa,
createServiceAccount,
createSignedAccessToken,
createSolanaAccount,
createVaultClient,
getServiceAccount,
listAccessTokens,
listEoas,
listSolanaAccounts,
ping,
revokeAccessToken,
rotateServiceAccount,
signAuthorization,
signMessage,
signSolanaMessage,
signSolanaTransaction,
signStructuredMessage,
signTransaction,
signTypedData,
@@ -31,6 +35,7 @@ export type {
CreateAccessTokenPayload,
CreateEoaPayload,
CreateServiceAccountPayload,
CreateSolanaAccountPayload,
EncryptedPayload,
GenericPayload,
GetAccessTokensData,
@@ -38,6 +43,7 @@ export type {
GetServiceAccountPayload,
ListAccessTokensPayload,
ListEoaPayload,
ListSolanaAccountsPayload,
Payload,
PingPayload,
PolicyComponent,
@@ -49,6 +55,8 @@ export type {
SignAuthorizationRules,
SignedAuthorization,
SignMessagePayload,
SignSolanaMessagePayload,
SignSolanaTransactionPayload,
SignStructuredMessageData,
SignStructuredMessageOptions,
SignStructuredMessagePayload,
+102 -1
View File
@@ -3,18 +3,27 @@ import { x25519 } from "@noble/curves/ed25519";
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha256";
import { bytesToHex, hexToBytes, randomBytes } from "@noble/hashes/utils";
import {
address,
getBase58Encoder,
getTransactionDecoder,
getTransactionEncoder,
} from "@solana/kit";
import type { TypedData } from "abitype";
import * as jose from "jose";
import type {
CheckedSignTypedDataPayload,
CreateAccessTokenPayload,
CreateEoaPayload,
CreateServiceAccountPayload,
CreateSolanaAccountPayload,
EncryptedPayload,
GetServiceAccountPayload,
ListAccessTokensPayload,
ListEoaPayload,
ListSolanaAccountsPayload,
Payload,
PingPayload,
PolicyComponent,
@@ -23,6 +32,8 @@ import type {
RotateServiceAccountPayload,
SignAuthorizationPayload,
SignMessagePayload,
SignSolanaMessagePayload,
SignSolanaTransactionPayload,
SignStructuredMessagePayload,
SignTransactionPayload,
UnencryptedErrorResponse,
@@ -447,6 +458,96 @@ export function listAccessTokens({
});
}
// ========== Solana Functions ==========
export function createSolanaAccount({
client,
request: options,
}: PayloadParams<CreateSolanaAccountPayload>) {
return sendRequest<CreateSolanaAccountPayload>({
client,
request: {
operation: "solana:create",
...options,
},
});
}
export function listSolanaAccounts({
client,
request: options,
}: PayloadParams<ListSolanaAccountsPayload>) {
return sendRequest<ListSolanaAccountsPayload>({
client,
request: {
operation: "solana:list",
...options,
},
});
}
export function signSolanaTransaction({
client,
request: options,
}: PayloadParams<SignSolanaTransactionPayload>) {
return sendRequest<SignSolanaTransactionPayload>({
client,
request: {
operation: "solana:signTransaction",
...options,
},
});
}
/**
* Reconstruct a signed solana transaction from the vault signature using @solana/kit
*/
export function reconstructSolanaSignedTransaction(
base64Transaction: string,
base58Signature: string,
signerPubkey: string,
): Uint8Array {
// Decode the base64 transaction into bytes
const base64TransactionBytes = new Uint8Array(
Buffer.from(base64Transaction, "base64"),
);
// Decode the transaction to get its structure
const transactionDecoder = getTransactionDecoder();
const transaction = transactionDecoder.decode(base64TransactionBytes);
// Decode the base58 signature to bytes
const base58Encoder = getBase58Encoder();
const signatureBytes = base58Encoder.encode(base58Signature);
// Add the signature to the transaction
const signedTransaction = {
...transaction,
signatures: {
...transaction.signatures,
[address(signerPubkey)]: signatureBytes,
},
};
// Re-encode the signed transaction
const transactionEncoder = getTransactionEncoder();
const signedTransactionBytes = transactionEncoder.encode(signedTransaction);
return new Uint8Array(signedTransactionBytes);
}
export function signSolanaMessage({
client,
request: options,
}: PayloadParams<SignSolanaMessagePayload>) {
return sendRequest<SignSolanaMessagePayload>({
client,
request: {
operation: "solana:signMessage",
...options,
},
});
}
const SIGNED_TOKEN_PREFIX = "vt_sat_";
const DEFAULT_SIGNING_CONTEXT = "encryption"; // Default context for HKDF
+154 -2
View File
@@ -58,7 +58,7 @@ export type UnencryptedErrorResponse = {
type EncryptedError = {
code: string;
message: string;
details?: string;
details?: string | { reason: string };
};
type GenericSuccessResponse<Data> = {
@@ -148,6 +148,50 @@ type SignMessageOptions = {
format?: "text" | "hex";
};
// ========== Solana Options Types ==========
// Solana message type that's compatible with both Kit and our Rust serialization
export type SolanaVersionedMessage = {
version: "legacy" | "v0";
header: {
numRequiredSignatures: number;
numReadonlySignedAccounts: number;
numReadonlyUnsignedAccounts: number;
};
staticAccountKeys: string[]; // Use string instead of SolanaAddress for simplicity
recentBlockhash: string;
instructions: Array<{
programIdIndex: number;
accountKeyIndexes: number[];
data: Uint8Array;
}>;
addressTableLookups?: Array<{
accountKey: string; // Use string instead of SolanaAddress
writableIndexes: number[];
readonlyIndexes: number[];
}>;
};
type CreateSolanaAccountOptions = {
metadata: Record<string, MetadataValue>;
};
type GetSolanaAccountsOptions = {
page?: number;
pageSize?: number;
};
type SignSolanaTransactionOptions = {
transaction: string; // Base64 encoded VersionedTransaction
from: string; // Use string for Solana public key
};
type SignSolanaMessageOptions = {
message: string;
from: string; // Use string for Solana public key
format?: "text" | "hex";
};
type CheckedSignTypedDataOptions<
Types extends TypedData,
PrimaryType extends keyof Types | "EIP712Domain" = keyof Types,
@@ -315,12 +359,63 @@ export type PolicyComponent =
useropV07?: UserOperationV07Rules;
};
}
| {
type: "solana:create";
requiredMetadataPatterns?: MetadataRule[];
allowedMetadataPatterns?: MetadataRule[];
}
| {
type: "solana:read";
metadataPatterns?: MetadataRule[];
}
| {
type: "solana:signTransaction";
allowlist?: string[]; // Use string for Solana addresses
metadataPatterns?: MetadataRule[];
payloadPatterns?: Record<string, Rule[]>;
transactionPatterns?: SolanaTransactionPatterns;
}
| {
type: "solana:signMessage";
allowlist?: string[]; // Use string for Solana addresses
metadataPatterns?: MetadataRule[];
messagePattern?: string;
}
| {
type: "accessToken:read";
metadataPatterns?: MetadataRule[];
revealSensitive: boolean;
};
// Solana transaction patterns
export type SolanaTransactionPatterns = {
programs?: ProgramAccessControl;
writableAccounts?: AccountAccessControl;
requiredCosigners?: string[];
maxInstructions?: number;
canPayFees?: boolean;
};
export type ProgramAccessControl =
| {
type: "allow";
programs: string[];
}
| {
type: "deny";
programs: string[];
};
export type AccountAccessControl =
| {
type: "allow";
accounts: string[];
}
| {
type: "deny";
accounts: string[];
};
type OwnerType = string; // Define based on your eoa models
type CreateAccessTokenOptions = {
@@ -399,6 +494,30 @@ type SignMessageData = {
signature: string;
};
// ========== Solana Data Types ==========
type CreateSolanaAccountData = {
pubkey: string;
metadata: Record<string, MetadataValue>;
createdAt: string;
updatedAt: string;
};
type GetSolanaAccountsData = {
accounts: CreateSolanaAccountData[];
totalCount: number;
page: number;
pageSize: number;
};
type SignSolanaTransactionData = {
signature: string; // Base58 encoded signature
signerPubkey: string; // Use string for Solana public key
};
type SignSolanaMessageData = {
signature: string; // Base58 encoded signature
};
type SignTypedDataData = {
signature: string;
};
@@ -553,6 +672,35 @@ export type RevokeAccessTokenPayload = GenericPayload<{
data: AccessTokenData;
}>;
// ========== Solana Payload Types ==========
export type CreateSolanaAccountPayload = GenericPayload<{
operation: "solana:create";
auth: Auth;
options: CreateSolanaAccountOptions;
data: CreateSolanaAccountData;
}>;
export type ListSolanaAccountsPayload = GenericPayload<{
operation: "solana:list";
auth: Auth;
options: GetSolanaAccountsOptions;
data: GetSolanaAccountsData;
}>;
export type SignSolanaTransactionPayload = GenericPayload<{
operation: "solana:signTransaction";
auth: Auth;
options: SignSolanaTransactionOptions;
data: SignSolanaTransactionData;
}>;
export type SignSolanaMessagePayload = GenericPayload<{
operation: "solana:signMessage";
auth: Auth;
options: SignSolanaMessageOptions;
data: SignSolanaMessageData;
}>;
// ========== Union of all payloads ==========
export type Payload =
| PingPayload
@@ -568,4 +716,8 @@ export type Payload =
| RevokeAccessTokenPayload
| SignAuthorizationPayload
| SignStructuredMessagePayload
| ListAccessTokensPayload;
| ListAccessTokensPayload
| CreateSolanaAccountPayload
| ListSolanaAccountsPayload
| SignSolanaTransactionPayload
| SignSolanaMessagePayload;
+6
View File
@@ -0,0 +1,6 @@
# Solana RPC Configuration
SOLANA_RPC_HTTP_URL=http://127.0.0.1:8899
SOLANA_RPC_WS_URL=ws://127.0.0.1:8900
# Vault Configuration
VAULT_URL=http://localhost:3000
+11
View File
@@ -0,0 +1,11 @@
import { dirname, join } from "node:path";
import { loadEnvFile } from "node:process";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
try {
loadEnvFile(join(__dirname, ".env"));
} catch (_error) {
console.warn("No .env file found, using default values");
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import { join } from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
testTimeout: 60000, // 60 seconds default timeout
hookTimeout: 60000, // 60 seconds for beforeAll/afterAll
teardownTimeout: 10000,
sequence: {
shuffle: false, // Run tests in order
},
// load .env.test
setupFiles: [join(__dirname, "./tests/env-setup.ts")],
},
});
+10
View File
@@ -1,5 +1,15 @@
# @thirdweb-dev/wagmi-adapter
## 0.2.164
## 0.2.163
## 0.2.162
## 0.2.161
## 0.2.160
## 0.2.159
## 0.2.158
+1 -1
View File
@@ -56,5 +56,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.159"
"version": "0.2.164"
}
+1146 -130
View File
File diff suppressed because it is too large Load Diff