Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
8003e93f19 Version Packages (#8264)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-16 14:05:56 -05:00
Jonas DanielsandGitHub 51c8318df7 Fetch native currency from chain API when required (#8267) 2025-10-16 14:00:59 -05:00
Joaquim VergesandGitHub f043f1eef4 [Docs] Rename listDiscoveryResources to listPayableServices in MCP tools (#8268) 2025-10-16 11:21:12 -07:00
Manan TankandGitHub d68da2239b [MNY-256] SDK: Update onSuccess prop of BuyWidget, CheckoutWidget, SwapWidget, BridgeWidget components (#8259) 2025-10-16 10:20:44 -07:00
Manan TankandGitHub 6a6b0fba96 [MNY-254] SDK: Preserve token and amount selection in BuyWidget when going back to initial screen (#8260) 2025-10-16 10:20:18 -07:00
Manan TankandGitHub c934102c51 [MNY-253] Dashboard: Add tracking for token row in /tokens page, SDK: BridgeWidget component (#8254) 2025-10-16 09:46:20 -07:00
MananTank 8c58c131ac [MNY-250] Fix Save button not enabled after changing Royalty BPS in contract settings page (#8252)
<!--

## 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 modification to the form handling in two components, adding the `shouldDirty` option to the `setValue` function calls for better form state management.

### Detailed summary
- In `royalties.tsx`, added `shouldDirty: true` to `form.setValue` for `seller_fee_basis_points`.
- In `platform-fees.tsx`, added `shouldDirty: true` to `form.setValue` for `platform_fee_basis_points`.

> ✨ 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**
  * Platform Fees and Royalties settings now mark field edits as dirty immediately, enabling the Save action when values are changed.
  * Unsaved-changes indicators and prompts update right away, reducing the risk of losing edits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-16 11:15:37 +00:00
Joaquim VergesandGitHub 2779afc15f [Docs] Update X402 agent example to use listDiscoveryResources (#8265) 2025-10-16 01:08:03 -07:00
Prithvish BaidyaandGitHub fe50abd5ab Add Solana transactions and wallets tables (#8263) 2025-10-16 04:26:26 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e830109274 Version Packages (#8258)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-15 05:01:50 -07:00
Joaquim VergesandGitHub e7f42e6ac5 [SDK] Fix query cache for block explorers for raw chains (#8257) 2025-10-15 04:59:09 -07:00
Manan TankandGitHub 91ff13b8aa Portal: Fix widget component API reference links (#8255) 2025-10-15 04:46:07 -07:00
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
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9fbb29dc25 Version Packages (#8202)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-07 02:05:50 -07:00
Joaquim VergesandGitHub 71a1e58eaf [AI-SDK-provider] Add default in-memory session store with custom store option (#8211) 2025-10-07 02:00:38 -07:00
MananTank 68b48f4faa [MNY-232] SDK: Fix ExecutingTxScreen missing back button in TransactionWidget (#8193)
<!--

## 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 `onBack` functionality in the `TransactionWidget` and `TransactionModal` components, ensuring that the back navigation behavior is more consistent across these components.

### Detailed summary
- In `TransactionWidget.tsx`, `onBack` now sets the screen to `init-ui`.
- In `TransactionModal.tsx`, `onBack` sets the screen to `deposit`.
- In `ExecutingScreen.tsx`, the `onBack` prop is changed from optional to required.

> ✨ 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 back navigation from the executing transaction screen, allowing users to return to the previous step without closing the modal.
  * Unified back-button behavior across transaction flows for a more consistent experience.
  * Provides clearer control during long-running executions with an explicit back option.
  * Navigation enhancement applies to both bridge and transaction modal flows, improving step-to-step control while keeping transaction processing unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-06 22:33:31 +00:00
MananTank 1c660d4712 [MNY-202] Dashboard: Update resource links in project > bridge page (#8196)
<!--

## 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 `QuickstartSection.client.tsx` and `page.tsx` files in the bridge section of the dashboard. It modifies features, links, and documentation references to align with the new bridge functionality.

### Detailed summary
- Updated `setupTime` to `5` in `QuickstartSection.client.tsx`.
- Changed `features` from `["Swap any token", "Cross-chain swaps"]` to `["Swap any token", "Cross-chain swap"]`.
- Updated link `href` from `https://portal.thirdweb.com/payments/swap` to `https://portal.thirdweb.com/bridge/swap` and changed label to "Setup Swap".
- Added new documentation link to `page.tsx`: `https://portal.thirdweb.com/bridge`.
- Added new playground link to `page.tsx`: `https://playground.thirdweb.com/bridge/swap-widget`.
- Removed old commented-out documentation links related to payments.
- Removed several links under the footer sections for payments and replaced them with new links relevant to the bridge.

> ✨ 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 direct link to Bridge docs in the header for quicker access.
  - Added a Playground link in the header to enable easy testing of the Bridge swap widget.
- Style
  - Removed footer sections from the Bridge page to streamline the layout and reduce clutter.
- Chores
  - Updated the “Setup Swap” feature card and its link to point to the Bridge setup URL for improved navigation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-06 21:22:22 +00:00
MananTank 30b5c30ba0 [PRO-27] Dashboard: copy changes in bridge page (#8191)
<!--

## 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 updates the `description` in the `page.tsx` file for the `bridge` component, enhancing its clarity and accuracy regarding token capabilities. It also modifies the text of a `PillLink` to better reflect its functionality.

### Detailed summary
- Updated the `description` to specify "3,200+ tokens" instead of "9M+ token routes".
- Changed the `PillLink` text from "Integrate Bridge in your apps in minutes, and start generating revenue" to "Accept Payments in Any Token Right Inside Your App".

> ✨ 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

- **Documentation**
  - Updated page metadata to reflect 3,200+ tokens across 85+ chains, with clearer phrasing around finality and pricing for improved SEO and share previews.

- **Style**
  - Refined call-to-action on the Bridge page to “Accept Payments in Any Token Right Inside Your App,” improving clarity and alignment with user goals.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-06 21:12:26 +00:00
MananTank 86461f9a07 [PRO-25] Dashboard: Update bridge page header to show supported tokens, chains and routes count (#8195)
<!--

## 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 content of the `Page` component in the `page.tsx` file by removing a `Link` element and replacing it with a `p` element that provides information about token and chain support.

### Detailed summary
- Removed the `Link` component that directed users to the thirdweb bridge.
- Added a `p` element stating "85+ chains, 3200+ tokens and 9+ million routes supported".

> ✨ 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**
  - Updated the Tokens dashboard header to display marketing stats: “85+ chains, 3200+ tokens, and 9+ million routes supported.”
  - Replaced the previous header link with a plain text paragraph for a cleaner presentation.
  - No changes to data, performance, or underlying functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-06 20:01:56 +00:00
MananTank 2ebba6139a [MNY-235] Dashboard: Fix payment page theme issues (#8199)
<!--

## 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 integrating the `ThemeProvider` from `next-themes` into multiple components within the payment module, enhancing theming support across the application.

### Detailed summary
- Removed `ThemeProvider` from `PayLayout` in `layout.tsx`.
- Added `ThemeProvider` wrapping to `PayPage` in `page.tsx`.
- Integrated `ThemeProvider` in `PayPageWidget` to manage theme settings.
- Updated theme handling logic in `PayPageWidget` to use `getSDKTheme`.
- Wrapped the main structure in `ThemeProvider` in `[id]/page.tsx`.

> ✨ 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
  - Consistent light/dark theme across all Pay pages, with support for forcing a theme per link and reduced visual flicker from system theme changes.
- Style
  - Refined header layout: project image, name, and description grouped; “Secured by thirdweb” badge retained.
  - Improved details on large screens: clearer “Network” label with icon and chain name; seller shown as ENS or shortened address, reordered for readability.
- Refactor
  - Consolidated theming at the page level for consistent behavior across views.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-06 19:51:09 +00:00
MananTank f3bacc16cb [MNY-203] Dashboard: ERC20 token page title, metadata and OG updates (#8194)
<!--

## 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 updates the logic for generating `title` and `description` variables in the `shared-layout.tsx` file, particularly for different types of smart contracts. It enhances the information displayed for ERC20 contracts and adds fallback values for when contract metadata is not available.

### Detailed summary
- Changed `const title` to `let title` to allow reassignment.
- Updated `title` and `description` for `ERC20` contracts using `contractMetadata`.
- Added fallback values for `title` and `description` when an error occurs, including `openGraph` properties.

> ✨ 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**
  * Richer link previews for contract pages via Open Graph metadata, improving social sharing and discoverability.
  * More accurate, branded titles and descriptions: ERC20 pages use token name and symbol; non-ERC20 pages use contract name, with references to Buy, Swap, Bridge & Price.
  * Robust fallbacks ensure meaningful titles, descriptions, and previews even if metadata loading fails.
  * Consistent metadata across success and fallback states for a more reliable sharing experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-06 19:41:13 +00:00
MananTank 968913d55b [MNY-231] SDK: Fix TransactionWidget not updating on currency prop change after initial render (#8192)
<!--

## 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 fixing the `TransactionWidget` to ensure it updates correctly when the `currency` prop changes after the initial render. It also refines how the total cost and display values are calculated based on the selected currency.

### Detailed summary
- Updated `PaymentOverview` to remove the `currency` prop from `TransactionOverViewCompact`.
- Enhanced `TransactionPayment` to calculate `totalFiatCost` and `costToDisplay` based on the selected currency.
- Adjusted the display of USD value to use `costToDisplay`.
- Removed the `currency` type from `UseTransactionDetailsOptions`.
- Simplified `useTransactionDetails` by removing currency-related calculations.

> ✨ 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**
  * Transaction widget now updates correctly when the selected currency changes after initial render.
  * Payment and transaction details display accurate fiat totals, with graceful fallback when pricing data is unavailable.

* **Improvements**
  * Streamlined pricing display to focus on the selected currency, improving clarity and consistency across payment views.

* **Chores**
  * Added a patch-level changeset entry documenting the bug fix.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-06 19:22:28 +00:00
MananTank bd70db4a5d [MNY-211] Dashboard: Add chainId and is_tesnet properties in reported events (#8198)
<!--

## 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 handling of testnet-related functionality across various components in the application. It introduces the `is_testnet` and `isTestnet` props to multiple components and functions, ensuring that the application can properly differentiate between mainnet and testnet environments.

### Detailed summary
- Added `isTestnet` prop to `UniversalBridgeEmbed`.
- Introduced `is_testnet` to the `create-nft-page` and `launch-token` components.
- Updated `ChainPageView` to accept `is_testnet`.
- Included `is_testnet` in the `BuyFundsSection` and various asset page components.
- Modified `reportAssetPageview` and `reportChainPageview` to include `is_testnet`.
- Enhanced `reportContractDeployed` to capture `is_testnet` and chain ID.
- Adjusted functions in `report.ts` to utilize `is_testnet` for analytics tracking.

> ✨ 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
  - Analytics now capture testnet status across chain and asset pageviews, buy flows, contract deployments, and token/NFT creation.
  - Asset creation reports now include chain ID on success and failure for improved tracking accuracy.
- Chores
  - Standardized analytics event reporting to pass complete property objects, ensuring consistent payloads.
  - Propagated testnet indicators through relevant components to enrich analytics without altering UI or workflows.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-06 19:12:07 +00:00
MananTank d9a138a90a [MNY-236] Portal: Update BridgeWidget script docs to reflect new typescript type (#8200)
<!--

## 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 making the `buy` properties optional in the configuration for the "Buy" tab UI, enhancing flexibility by allowing users to omit certain parameters. It also updates the documentation to reflect these changes.

### Detailed summary
- Changed `buy` properties (`amount`, `chainId`) from required to optional in type definitions.
- Updated documentation to remove "Required" tags for `amount` and `chainId`.
- Clarified descriptions for `amount`, `chainId`, and `tokenAddress` in the documentation.

> ✨ 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**
  * Buy configuration is now optional in the bridge widget; amount and chain selection can be omitted and will use defaults.
  * Removed forced preset buy values, enabling more flexible initialization.

* **Documentation**
  * Updated docs to reflect optional Buy fields and default behaviors.
  * Revised examples and type descriptions to indicate optional amount and chain settings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-06 19:06:30 +00:00
Joaquim VergesandGitHub 777aed1e5f [Docs] Add metadata to x402 documentation pages (#8190) 2025-10-06 04:15:16 -07:00
Joaquim VergesandGitHub fbed4be959 [Docs] Update MCP tool filtering to use comma-separated list (#8189) 2025-10-06 04:07:32 -07:00
Joaquim VergesandGitHub 5a26217a43 [Docs] Add MCP tool filtering, agent examples, and available tools list (#8188) 2025-10-06 03:23:24 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
938c1e2f60 Version Packages (#8185)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-05 01:36:29 +13:00
Joaquim VergesandGitHub af9a73d930 [SDK] Fix waitUntil facilitator parameter not being respected (#8184) 2025-10-04 05:06:10 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
34c5d24043 Version Packages (#8182)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-04 19:33:33 +13:00
MananTank e612f16a85 [MNY-192] SDK: BuyWidget Improvements (#8181)
<!--

## 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 several enhancements to the payment features, including optional props for token and chain selection, improvements to UI components, and the addition of a receiver address input for transactions.

### Detailed summary
- Added `receiverAddress` to various components.
- Made `amount` and `chainId` optional in `BuyWidget`.
- Improved UI for `BuyWidget`, allowing token and chain selection.
- Updated `FundWallet` to handle token selection and input.
- Enhanced `DecimalInput` for better user experience.
- Refactored components for better code organization and readability.

> ✨ 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**
  * Receiver address input for Buy/Pay flows.
  * Editable fiat and token amounts, current balance visible, and wallet disconnect option.
  * Copy icon supports configurable size.

* **Improvements**
  * Chain and amount can be optional; token and chain remain changeable.
  * Enhanced token selection, preset amount buttons, compact active-wallet header, improved theming and responsive layouts.
  * Added story variants (light/dark).

* **Documentation**
  * Examples updated to reflect buy prefill changes.

* **Style**
  * Simplified address text styling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-03 23:52:49 +00:00
Jonas DanielsandGitHub 4a30b9c89a Add Google Click ID tracking to Stripe checkout (#8180) 2025-10-03 15:05:20 -07:00
MananTank ef9cf3409b SDK: Delete Bridge Orchestrator, Refactor Bridge widget components (#8169)
<!--

## 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 refactoring and updating various components related to payment and transaction handling in the `thirdweb` library. It introduces new props, modifies existing interfaces, and cleans up the codebase by removing unused files.

### Detailed summary
- Deleted several unused files related to payment and transaction handling.
- Updated `transaction` and `payment` components to use new props and interfaces.
- Replaced `type` definitions with `interface` in some components for consistency.
- Improved error handling and state management in transaction modals.
- Enhanced `SuccessScreen` and `UnsupportedTokenScreen` with new props for better UI.
- Modified various story files to reflect component updates and new props.
- Adjusted payment selection and fund wallet components to align with new data structures.

> The following files were skipped due to too many changes: `packages/thirdweb/src/stories/Bridge/CheckoutWidget.stories.tsx`, `packages/thirdweb/src/stories/Bridge/PaymentSelection.stories.tsx`, `packages/thirdweb/src/stories/Bridge/fixtures.ts`, `packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentOverview.tsx`, `packages/thirdweb/src/stories/Bridge/PaymentDetails.stories.tsx`, `packages/thirdweb/src/react/web/ui/Bridge/TransactionWidget.tsx`, `packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx`, `packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx`

> ✨ 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**
  * Optional fiat currency display across payment & transaction modals (defaults to USD).
  * Token support detection with a dedicated unsupported-token screen.

* **Refactor**
  * Bridge flows reorganized into modular multi-step UIs (selection, quote, preview, execute, success).
  * Components now use explicit metadata (title/description/image) and explicit currency/mode props; branding handling centralized.

* **Documentation**
  * Storybook reorganized: decorator-based theming, updated/added widget stories, and cleaned-up story exports.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-03 15:36:06 +00:00
Joaquim VergesandGitHub fd20a6cabb [SDK] Deprecate payTo parameter and simplify facilitator implementation (#8177) 2025-10-03 02:32:59 -07:00
Jonas DanielsandGitHub 9095041ea7 Fix invoice status logic and refactor billing components (#8175) 2025-10-02 20:44:22 -07:00
Joaquim VergesandGitHub 2dbb9e2de4 [Dashboard] Move server wallet sections to wallets tab (#8174) 2025-10-02 18:00:43 -07:00
Joaquim VergesandGitHub 00b8adf3a2 [Docs] Simplify x402 documentation (#8173) 2025-10-02 15:02:17 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ae3dfa36c3 Version Packages (#8170)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-02 16:08:01 +13:00
Joaquim VergesandGitHub eb603f8908 [Multi] Update dependencies, improve wagmi adapter (#8167) 2025-10-02 15:42:13 +13:00
Joaquim VergesandGitHub e11b0da20e [Dashboard] Update transaction status endpoint path (#8166) 2025-09-30 16:10:00 -07:00
Joaquim VergesandGitHub 4c51fe6a8f [Dashboard] Add transaction filtering by Queue ID to engine v2 (#8163) 2025-09-30 15:53:15 -07:00
MananTank 64034c0ddb Dashboard: Fix Token Rewards UI not accounting for token decimals (#8164)
<!--

## 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 handling of token rewards by adding `decimals` properties for tokens in various components and improving the retrieval of metadata related to tokens.

### Detailed summary
- Added `decimals` property to `token0` and `token1` in `claim-rewards-page.stories.tsx`.
- Introduced `chainMetadata` in `page.tsx`.
- Updated `ClaimRewardsPageUI` to utilize `decimals` for token amounts.
- Refactored `getUnclaimedFees` to fetch `decimals` and `symbol` for tokens.
- Improved token contract retrieval logic in `getUnclaimedFees`.

> ✨ 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 amounts now display using each token’s specific decimals.
  - Chain metadata is passed to unclaimed-fees retrieval to source native token symbol and decimals.

- Bug Fixes
  - Removed hardcoded 18-decimal assumption so amounts render correctly for tokens with different precisions.

- Tests
  - Story/test stubs updated to include decimals for token objects for more realistic scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 22:37:28 +00:00
Jonas DanielsandGitHub 90c1e576ff Add /payments/x402 to framer-rewrites (#8165) 2025-09-30 15:27:56 -07:00
Joaquim VergesandGitHub 27d5a81c49 [Dashboard] Add transaction filtering by Queue ID and wallet address (#8162) 2025-09-30 15:15:10 -07:00
MananTank dbadc23e04 Dashboard: Disable pay modal in ERC1155 set claim conditions if cost estimation is already done in dashboard (#8161)
<!--

## 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 updates the logic for handling transaction confirmations in the `create-nft-page.tsx` file, specifically addressing scenarios with multiple batches.

### Detailed summary
- Added a conditional check for `totalBatches > 1`.
- If true, it uses `sendAndConfirmTxNoPayModal.mutateAsync(tx)` for transaction confirmation.
- If false, it defaults to `sendAndConfirmTx.mutateAsync(tx)` for single batch transactions.

> ✨ 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**
  * Streamlined multi-batch transaction confirmation when configuring NFT claim conditions. When multiple batches are required and a wallet is connected, a no-payment confirmation modal is used to reduce prompts and improve reliability. Single-batch flows continue to use the standard confirmation path.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 19:01:52 +00:00
Joaquim VergesandGitHub 21d5d0b6af [Nebula] Add permanent redirect to thirdweb.com/ai (#8155) 2025-09-30 11:42:56 -07:00
MananTank b2239c91c9 [MNY-214] Dashboard: ERC1155 set claim conditions step UX improvements (#8146)
<!--

## 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 functionality for handling insufficient funds when creating NFTs, enhancing the user experience by allowing users to buy funds directly if their balance is low.

### Detailed summary
- Added `onNotEnoughFunds` callback to handle insufficient funds.
- Implemented logic to calculate total transaction costs.
- Integrated `BuyWidget` to enable users to purchase funds directly.
- Enhanced error handling and user feedback for fund-related issues.

> ✨ 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
  - Gas-cost–aware NFT minting with total cost estimation and balance pre-checks.
  - Insufficient funds handling with a user-facing prompt showing required vs. available balance.
  - Integrated Buy Funds widget with theme and chain awareness, plus retry and back controls.
  - Optional transaction path that skips the pay modal in eligible scenarios.
  - Enhanced batch processing for ERC1155/721 flows with a callback to surface funding issues.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 14:51:10 +00:00
276 changed files with 22597 additions and 8070 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: Auto Author Assign
on:
pull_request:
types: [opened, reopened, ready_for_review, draft]
types: [opened, reopened, ready_for_review]
permissions:
pull-requests: write
+1
View File
@@ -10,6 +10,7 @@ module.exports = [
"/wallets",
"/account-abstraction",
"/payments",
"/payments/x402",
"/auth",
"/in-app-wallets",
"/transactions",
-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;
}
@@ -1,6 +1,6 @@
import "server-only";
import { headers } from "next/headers";
import { cookies, headers } from "next/headers";
import Stripe from "stripe";
import type { Team } from "@/api/team/get-team";
import {
@@ -88,6 +88,9 @@ export async function fetchClientSecret(team: Team) {
throw new Error("No customer ID found");
}
// try to get the gclid cookie
const gclid = (await cookies()).get("gclid")?.value;
// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
@@ -117,6 +120,8 @@ export async function fetchClientSecret(team: Team) {
missing_payment_method: "cancel",
},
},
// if gclid exists, set it as a metadata field so we can attribute the conversion later
metadata: gclid ? { gclid } : undefined,
},
});
+42 -45
View File
@@ -24,6 +24,7 @@ export function reportContractDeployed(properties: {
publisher: string | undefined;
contractName: string | undefined;
deploymentType?: "asset";
is_testnet: boolean | undefined;
}) {
posthog.capture("contract deployed", properties);
}
@@ -40,6 +41,7 @@ export function reportContractDeployed(properties: {
export function reportContractDeployFailed(properties: {
errorMessage: string;
chainId: number;
is_testnet: boolean | undefined;
publisher: string | undefined;
contractName: string | undefined;
}) {
@@ -171,9 +173,7 @@ export function reportOnboardingCompleted() {
*
*/
export function reportFaucetUsed(properties: { chainId: number }) {
posthog.capture("faucet used", {
chainId: properties.chainId,
});
posthog.capture("faucet used", properties);
}
// ----------------------------
@@ -197,12 +197,7 @@ export function reportChainConfigurationAdded(properties: {
decimals: number;
};
}) {
posthog.capture("chain configuration added", {
chainId: properties.chainId,
chainName: properties.chainName,
nativeCurrency: properties.nativeCurrency,
rpcURLs: properties.rpcURLs,
});
posthog.capture("chain configuration added", properties);
}
// ----------------------------
@@ -227,12 +222,9 @@ export function reportAssetBuySuccessful(properties: {
chainId: number;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
is_testnet: boolean | undefined;
}) {
posthog.capture("asset buy successful", {
assetType: properties.assetType,
chainId: properties.chainId,
contractType: properties.contractType,
});
posthog.capture("asset buy successful", properties);
}
type TokenSwapParams = {
@@ -349,16 +341,12 @@ export function reportTokenSwapCancelled(properties: TokenSwapParams) {
*/
export function reportAssetBuyFailed(properties: {
chainId: number;
is_testnet: boolean | undefined;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
error: string;
}) {
posthog.capture("asset buy failed", {
assetType: properties.assetType,
chainId: properties.chainId,
contractType: properties.contractType,
error: properties.error,
});
posthog.capture("asset buy failed", properties);
}
/**
@@ -371,14 +359,11 @@ export function reportAssetBuyFailed(properties: {
*/
export function reportAssetBuyCancelled(properties: {
chainId: number;
is_testnet: boolean | undefined;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
}) {
posthog.capture("asset buy cancelled", {
assetType: properties.assetType,
chainId: properties.chainId,
contractType: properties.contractType,
});
posthog.capture("asset buy cancelled", properties);
}
// Assets Landing Page ----------------------------
@@ -423,10 +408,7 @@ export function reportAssetCreationStepConfigured(
step: "coin-info" | "token-distribution" | "launch-coin";
},
) {
posthog.capture("asset creation step configured", {
assetType: properties.assetType,
step: properties.step,
});
posthog.capture("asset creation step configured", properties);
}
/**
@@ -440,11 +422,10 @@ export function reportAssetCreationStepConfigured(
export function reportAssetCreationSuccessful(properties: {
assetType: "nft" | "coin";
contractType: AssetContractType;
chainId: number;
is_testnet: boolean | undefined;
}) {
posthog.capture("asset creation successful", {
assetType: properties.assetType,
contractType: properties.contractType,
});
posthog.capture("asset creation successful", properties);
}
type CoinCreationStep =
@@ -466,7 +447,12 @@ type CoinCreationStep =
* @MananTank
*/
export function reportAssetCreationFailed(
properties: { contractType: AssetContractType; error: string } & (
properties: {
contractType: AssetContractType;
error: string;
is_testnet: boolean | undefined;
chainId: number;
} & (
| {
assetType: "nft";
step:
@@ -481,12 +467,7 @@ export function reportAssetCreationFailed(
}
),
) {
posthog.capture("asset creation failed", {
assetType: properties.assetType,
contractType: properties.contractType,
error: properties.error,
step: properties.step,
});
posthog.capture("asset creation failed", properties);
}
type UpsellParams = {
@@ -582,6 +563,7 @@ export function reportPaymentLinkBuyFailed(properties: {
export function reportAssetPageview(properties: {
assetType: "nft" | "coin";
chainId: number;
is_testnet: boolean | undefined;
}) {
posthog.capture("asset pageview", properties);
}
@@ -593,7 +575,10 @@ export function reportAssetPageview(properties: {
* ### Who is responsible for this event?
* @MananTank
*/
export function reportChainPageview(properties: { chainId: number }) {
export function reportChainPageview(properties: {
chainId: number;
is_testnet: boolean | undefined;
}) {
posthog.capture("chain pageview", properties);
}
@@ -686,10 +671,7 @@ export function reportProductFeedback(properties: {
feedback: string;
source: "desktop" | "mobile";
}) {
posthog.capture("product feedback submitted", {
feedback: properties.feedback,
source: properties.source,
});
posthog.capture("product feedback submitted", properties);
}
/**
@@ -705,3 +687,18 @@ export function reportBridgePageLinkClick(params: {
}) {
posthog.capture("bridge page link clicked", params);
}
/**
* ### Why do we need to report this event?
* - To track which tokens are users are clicking on in the token list page
*
* ### Who is responsible for this event?
* @MananTank
*
*/
export function reportTokenRowClicked(params: {
chainId: number;
tokenAddress: string;
}) {
posthog.capture("token row clicked", params);
}
+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" />;
}
@@ -34,6 +34,7 @@ export function BuyAndSwapEmbed(props: {
tokenAddress: string | undefined;
buyAmount: string | undefined;
pageType: PageType;
isTestnet: boolean | undefined;
}) {
const { theme } = useTheme();
const [tab, setTab] = useState<"buy" | "swap">("swap");
@@ -116,6 +117,7 @@ export function BuyAndSwapEmbed(props: {
chainId: props.chain.id,
error: errorMessage,
contractType: undefined,
is_testnet: props.isTestnet,
});
}
}}
@@ -141,10 +143,11 @@ export function BuyAndSwapEmbed(props: {
assetType: "coin",
chainId: props.chain.id,
contractType: undefined,
is_testnet: props.isTestnet,
});
}
}}
onSuccess={(quote) => {
onSuccess={({ quote }) => {
reportTokenBuySuccessful({
buyTokenChainId:
quote.type === "buy"
@@ -166,6 +169,7 @@ export function BuyAndSwapEmbed(props: {
assetType: "coin",
chainId: props.chain.id,
contractType: undefined,
is_testnet: props.isTestnet,
});
}
}}
@@ -204,7 +208,7 @@ export function BuyAndSwapEmbed(props: {
pageType: props.pageType,
});
}}
onSuccess={(quote) => {
onSuccess={({ quote }) => {
reportTokenSwapSuccessful({
buyTokenChainId: quote.intent.destinationChainId,
buyTokenAddress: quote.intent.destinationTokenAddress,
@@ -236,9 +240,10 @@ function TabButton(props: {
return (
<Button
onClick={props.onClick}
size="sm"
className={cn(
"rounded-full text-muted-foreground px-5 text-base bg-accent",
props.isActive && "text-foreground border-foreground",
"rounded-full text-muted-foreground px-4 bg-accent",
props.isActive && "text-foreground border-foreground/50",
)}
variant="outline"
>
@@ -0,0 +1,83 @@
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { useClipboard } from "@/hooks/useClipboard";
import { cn } from "@/lib/utils";
export function SolanaAddress(props: {
address: string;
shortenAddress?: boolean;
className?: string;
}) {
const shortenedAddress = useMemo(() => {
return props.shortenAddress !== false
? `${props.address.slice(0, 4)}...${props.address.slice(-4)}`
: props.address;
}, [props.address, props.shortenAddress]);
const lessShortenedAddress = useMemo(() => {
return `${props.address.slice(0, 8)}...${props.address.slice(-8)}`;
}, [props.address]);
const { onCopy, hasCopied } = useClipboard(props.address, 2000);
return (
<HoverCard>
<HoverCardTrigger asChild tabIndex={-1}>
<Button
className={cn(
"flex flex-row items-center gap-2 px-0",
props.className,
)}
onClick={(e) => e.stopPropagation()}
variant="link"
>
<div className="flex size-5 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
<span className="cursor-pointer font-mono text-sm">
{shortenedAddress}
</span>
</Button>
</HoverCardTrigger>
<HoverCardContent
className="w-80 border-border"
onClick={(e) => {
// do not close the hover card when clicking anywhere in the content
e.stopPropagation();
}}
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Solana Public Key</h3>
<Button
className="flex items-center gap-2"
onClick={onCopy}
size="sm"
variant="outline"
>
{hasCopied ? (
<CheckIcon className="h-4 w-4" />
) : (
<CopyIcon className="h-4 w-4" />
)}
{hasCopied ? "Copied!" : "Copy"}
</Button>
</div>
<p className="break-all rounded bg-muted p-3 text-center font-mono text-xs leading-relaxed">
{lessShortenedAddress}
</p>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-muted-foreground text-xs leading-relaxed">
Solana public key for blockchain transactions.
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
@@ -46,7 +46,7 @@ export function AdvancedSearchInput(props: {
value={searchType}
onValueChange={(value) => setSearchType(value as SearchType)}
>
<SelectTrigger className="w-[140px] bg-card">
<SelectTrigger className="w-[140px] bg-background rounded-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -61,7 +61,7 @@ export function AdvancedSearchInput(props: {
<div className="flex flex-1">
<div className="relative flex-1">
<Input
className="bg-card pl-9 border-r-0 rounded-r-none"
className="bg-background pl-9 border-r-0 rounded-r-none rounded-l-full"
placeholder={`Search by ${searchType}...`}
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -86,7 +86,7 @@ export function AdvancedSearchInput(props: {
onClick={handleSearch}
variant="outline"
disabled={!query.trim() || props.isLoading}
className="rounded-l-none gap-2 bg-card disabled:opacity-100"
className="rounded-l-none rounded-r-full gap-2 bg-background disabled:opacity-100"
>
{props.isLoading && <Spinner className="size-4" />}
Search
@@ -2,7 +2,7 @@
import { createColumnHelper } from "@tanstack/react-table";
import { format } from "date-fns";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { ArrowLeftIcon, ArrowRightIcon, UserIcon } from "lucide-react";
import Papa from "papaparse";
import { useCallback, useMemo, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
@@ -264,11 +264,24 @@ export function InAppWalletUsersPageContent(
return (
<div>
<div className="flex flex-col gap-4">
{/* Top section */}
<div className="flex flex-col gap-4">
<div className="flex flex-col md:flex-row lg:items-center justify-end gap-3">
<div className="w-full max-w-lg">
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<UserIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
User Wallets
</h2>
<p className="text-muted-foreground text-sm">
View and manage your project's users
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<div className="w-full lg:w-auto lg:min-w-[320px]">
<AdvancedSearchInput
onSearch={handleSearch}
onClear={handleClearSearch}
@@ -277,7 +290,7 @@ export function InAppWalletUsersPageContent(
/>
</div>
<Button
className="gap-2 bg-card"
className="gap-2 bg-background rounded-full"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
variant="outline"
@@ -298,11 +311,11 @@ export function InAppWalletUsersPageContent(
data={wallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-b-none"
tableContainerClassName="rounded-none border-x-0 border-b-0"
title="in-app wallets"
/>
<div className="flex justify-center gap-3 rounded-b-lg border border-t-0 bg-card p-6">
<div className="flex justify-center gap-3 border-t bg-card p-6">
<Button
className="gap-2 bg-background"
disabled={activePage === 1 || walletsQuery.isPending}
@@ -5,6 +5,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useLocalStorage } from "@/hooks/useLocalStorage";
// biome-ignore lint/correctness/noUnusedVariables: banner is toggled on-demand via API content changes
function AnnouncementBannerUI(props: {
href: string;
label: string;
@@ -64,7 +64,7 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
<CreateProjectDialogUI
createProject={async (params) => {
const res = await createProjectClient(props.teamId, params);
await createVaultAccountAndAccessToken({
const vaultTokens = await createVaultAccountAndAccessToken({
project: res.project,
projectSecretKey: res.secret,
}).catch((error) => {
@@ -74,6 +74,13 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
);
throw error;
});
const managementAccessToken = vaultTokens.managementToken?.accessToken;
if (!managementAccessToken) {
throw new Error("Missing management access token for project wallet");
}
return {
project: res.project,
secret: res.secret,
+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,
});
+16 -2
View File
@@ -379,12 +379,27 @@ export function useEngineTransactions(params: {
page?: number;
status?: EngineStatus;
};
id?: string;
}) {
const { instanceUrl, autoUpdate, authToken } = params;
const { instanceUrl, autoUpdate, authToken, id } = params;
return useQuery({
placeholderData: keepPreviousData,
queryFn: async () => {
if (id) {
const res = await fetch(`${instanceUrl}transaction/status/${id}`, {
headers: getEngineRequestHeaders(authToken),
method: "GET",
});
const json = await res.json();
const transaction = (json.result as Transaction) || {};
return {
transactions: [transaction],
totalCount: 1,
};
}
const url = new URL(`${instanceUrl}transaction/get-all`);
if (params.queryParams) {
for (const key in params.queryParams) {
@@ -402,7 +417,6 @@ export function useEngineTransactions(params: {
});
const json = await res.json();
return (json.result as TransactionResponse) || {};
},
queryKey: engineKeys.transactions(instanceUrl, params),
@@ -0,0 +1,21 @@
const PROJECT_WALLET_LABEL_SUFFIX = " Wallet";
const PROJECT_WALLET_LABEL_MAX_LENGTH = 32;
/**
* Builds the default label for a project's primary server wallet.
* Ensures a stable naming convention so the wallet can be identified across flows.
*/
export function getProjectWalletLabel(projectName: string | undefined) {
const baseName = projectName?.trim() || "Project";
const maxBaseLength = Math.max(
1,
PROJECT_WALLET_LABEL_MAX_LENGTH - PROJECT_WALLET_LABEL_SUFFIX.length,
);
const normalizedBase =
baseName.length > maxBaseLength
? baseName.slice(0, maxBaseLength).trimEnd()
: baseName;
return `${normalizedBase}${PROJECT_WALLET_LABEL_SUFFIX}`;
}
@@ -0,0 +1,99 @@
import "server-only";
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import type { Project } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getProjectWalletLabel } from "@/lib/project-wallet";
export type ProjectWalletSummary = {
id: string;
address: string;
label?: string;
};
type VaultWalletListItem = {
id: string;
address: string;
metadata?: {
label?: string;
projectId?: string;
teamId?: string;
type?: string;
};
};
export async function getProjectWallet(
project: Project,
): Promise<ProjectWalletSummary | undefined> {
const engineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
engineCloudService?.managementAccessToken || undefined;
const projectWalletAddress = (
engineCloudService as { projectWalletAddress?: string } | undefined
)?.projectWalletAddress;
if (
!managementAccessToken ||
!NEXT_PUBLIC_THIRDWEB_VAULT_URL ||
!projectWalletAddress
) {
return undefined;
}
try {
const vaultClient = await createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
});
const response = await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: 0,
// @ts-expect-error - SDK expects snake_case for pagination arguments
page_size: 100,
},
},
});
if (!response.success || !response.data) {
return undefined;
}
const items = response.data.items as VaultWalletListItem[] | undefined;
if (!items?.length) {
return undefined;
}
const defaultLabel = getProjectWalletLabel(project.name);
const serverWallets = items.filter(
(item) => item.metadata?.projectId === project.id,
);
const defaultWallet = serverWallets.find(
(item) =>
item.address.toLowerCase() === projectWalletAddress.toLowerCase(),
);
if (!defaultWallet) {
return undefined;
}
return {
id: defaultWallet.id,
address: defaultWallet.address,
label: defaultWallet.metadata?.label ?? defaultLabel,
};
} catch (error) {
console.error("Failed to load project wallet", error);
return undefined;
}
}
@@ -8,6 +8,7 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
return (
<GridPatternEmbedContainer>
<BuyAndSwapEmbed
isTestnet={props.chain.testnet}
// eslint-disable-next-line no-restricted-syntax
chain={defineDashboardChain(props.chain.chainId, props.chain)}
buyAmount={undefined}
@@ -2,10 +2,11 @@
import { reportChainPageview } from "@/analytics/report";
import { useEffectOnce } from "@/hooks/useEffectOnce";
export function ChainPageView(props: { chainId: number }) {
export function ChainPageView(props: { chainId: number; is_testnet: boolean }) {
useEffectOnce(() => {
reportChainPageview({
chainId: props.chainId,
is_testnet: props.is_testnet,
});
});
@@ -86,7 +86,7 @@ export default async function ChainPageLayout(props: {
return (
<div className="flex grow flex-col">
<ChainPageView chainId={chain.chainId} />
<ChainPageView chainId={chain.chainId} is_testnet={chain.testnet} />
<div className="border-border border-b bg-card">
<TeamHeader />
</div>
@@ -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>
@@ -5,11 +5,13 @@ import { useEffectOnce } from "@/hooks/useEffectOnce";
export function AssetPageView(props: {
assetType: "nft" | "coin";
chainId: number;
is_testnet: boolean;
}) {
useEffectOnce(() => {
reportAssetPageview({
assetType: props.assetType,
chainId: props.chainId,
is_testnet: props.is_testnet,
});
});
@@ -122,6 +122,7 @@ export function TokenDropClaim(props: {
chainId: props.contract.chain.id,
contractType: "DropERC20",
error: errorMessage,
is_testnet: props.chainMetadata.testnet,
});
toast.error("Failed to approve spending", {
@@ -160,6 +161,7 @@ export function TokenDropClaim(props: {
chainId: props.contract.chain.id,
contractType: "DropERC20",
error: errorMessage,
is_testnet: props.chainMetadata.testnet,
});
toast.error("Failed to buy tokens", {
@@ -172,6 +174,7 @@ export function TokenDropClaim(props: {
assetType: "coin",
chainId: props.contract.chain.id,
contractType: "DropERC20",
is_testnet: props.chainMetadata.testnet,
});
setStepsUI({
@@ -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,
@@ -76,7 +76,11 @@ export async function ERC20PublicPage(props: {
return (
<div className="flex grow flex-col">
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
<AssetPageView
assetType="coin"
chainId={props.chainMetadata.chainId}
is_testnet={props.chainMetadata.testnet}
/>
<PageHeader containerClassName="max-w-7xl" />
<div className="border-b border-dashed">
@@ -199,6 +203,7 @@ function BuyEmbed(props: {
tokenAddress={props.clientContract.address}
buyAmount={undefined}
pageType="asset"
isTestnet={props.chainMetadata.testnet}
/>
);
}
@@ -18,7 +18,11 @@ export function NFTPublicPageLayout(props: {
}) {
return (
<div className="flex grow flex-col">
<AssetPageView assetType="nft" chainId={props.chainMetadata.chainId} />
<AssetPageView
assetType="nft"
chainId={props.chainMetadata.chainId}
is_testnet={props.chainMetadata.testnet}
/>
<PageHeader containerClassName="max-w-8xl" />
<div className="border-b">
<div className="container max-w-8xl">
@@ -120,6 +120,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
chainId: props.contract.chain.id,
contractType: "DropERC1155",
error: errorMessage,
is_testnet: props.chainMetadata.testnet,
});
console.error(errorMessage);
@@ -147,6 +148,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
assetType: "nft",
chainId: props.contract.chain.id,
contractType: "DropERC1155",
is_testnet: props.chainMetadata.testnet,
});
props.onSuccess?.();
@@ -163,6 +165,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
chainId: props.contract.chain.id,
contractType: "DropERC1155",
error: errorMessage,
is_testnet: props.chainMetadata.testnet,
});
return;
@@ -180,6 +183,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
chainId: props.contract.chain.id,
contractType: "DropERC1155",
error: errorMessage,
is_testnet: props.chainMetadata.testnet,
});
}
});
@@ -77,6 +77,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
chainId: props.contract.chain.id,
contractType: "DropERC721",
error: errorMessage,
is_testnet: props.chainMetadata.testnet,
});
return;
@@ -103,6 +104,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
assetType: "nft",
chainId: props.contract.chain.id,
contractType: "DropERC721",
is_testnet: props.chainMetadata.testnet,
});
props.onSuccess?.();
@@ -115,6 +117,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
chainId: props.contract.chain.id,
contractType: "DropERC721",
error: errorMessage,
is_testnet: props.chainMetadata.testnet,
});
return;
@@ -131,6 +134,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
chainId: props.contract.chain.id,
contractType: "DropERC721",
error: errorMessage,
is_testnet: props.chainMetadata.testnet,
});
}
};
@@ -137,6 +137,7 @@ export function SettingsPlatformFees({
onChange={(value) =>
form.setValue("platform_fee_basis_points", value, {
shouldValidate: true,
shouldDirty: true,
})
}
value={form.watch("platform_fee_basis_points")}
@@ -151,6 +151,7 @@ export function SettingsRoyalties({
onChange={(value) =>
form.setValue("seller_fee_basis_points", value, {
shouldValidate: true,
shouldDirty: true,
})
}
value={form.watch("seller_fee_basis_points")}
@@ -190,25 +190,37 @@ export async function generateContractLayoutMetadata(params: {
.replace("Testnet", "")
.trim();
const title = `${contractDisplayName} | ${cleanedChainName} Smart Contract`;
let title = `${contractDisplayName} | ${cleanedChainName} Smart Contract`;
let description = "";
if (isERC721 || isERC1155) {
description = `View tokens, source code, transactions, balances, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}.`;
} else if (isERC20) {
description = `View ERC20 tokens, transactions, balances, source code, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}`;
title = `${contractMetadata.name} (${contractMetadata.symbol}) on ${cleanedChainName} | Buy, Swap, Bridge & Price`;
description = `Buy, swap & bridge ${contractMetadata.name} (${contractMetadata.symbol}) on ${cleanedChainName} with thirdweb Bridge. View contract address, holders, analytics, transactions and live price.`;
} else {
description = `View tokens, transactions, balances, source code, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}`;
description = `View tokens, transactions, balances, source code, and analytics for the ${contractMetadata.name} smart contract on ${cleanedChainName}`;
}
return {
description: description,
title: title,
openGraph: {
description: description,
title: title,
},
};
} catch {
const fallbackTitle = `${shortenIfAddress(params.contractAddress)} | ${params.chainIdOrSlug}`;
const fallbackDescription = `View tokens, transactions, balances, source code, and analytics for the smart contract on Chain ID ${params.chainIdOrSlug}`;
return {
description: `View tokens, transactions, balances, source code, and analytics for the smart contract on Chain ID ${params.chainIdOrSlug}`,
title: `${shortenIfAddress(params.contractAddress)} | ${params.chainIdOrSlug}`,
description: fallbackDescription,
title: fallbackTitle,
openGraph: {
description: fallbackDescription,
title: fallbackTitle,
},
};
}
}
@@ -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,342 @@ export default async function Page(props: {
client: serverThirdwebClient,
});
const [transaction, receipt] = await Promise.all([
const [transaction, receipt, bridgeStatus] = await Promise.all([
eth_getTransactionByHash(rpcRequest, {
hash: params.txHash,
}),
}).catch(() => undefined),
eth_getTransactionReceipt(rpcRequest, {
hash: params.txHash,
}),
}).catch(() => undefined),
status({
chainId: chain.chainId,
transactionHash: params.txHash,
client: serverThirdwebClient,
}).catch(() => undefined),
]);
if (!transaction.blockHash) {
return <div>no tx found</div>;
if (!transaction?.blockHash || !receipt) {
return (
<div className="flex flex-col items-center justify-center grow">
<div className="flex flex-col items-center justify-center gap-3">
<div className="p-2 rounded-full bg-background border">
<CircleAlertIcon className="size-5 text-muted-foreground" />
</div>
Transaction not found
</div>
</div>
);
}
const block = await eth_getBlockByHash(rpcRequest, {
blockHash: transaction.blockHash,
});
const overviewContent = (
<GeneericTxDetails
transaction={transaction}
receipt={receipt}
block={block}
chain={chain}
/>
);
return (
<main className="container flex flex-col gap-y-10 py-6">
<header className="flex flex-col gap-y-2">
<p className="font-bold text-3xl">Transaction Details</p>
<div className="flex gap-x-2">
{shortenAddress(transaction.from)} called on{" "}
{shortenAddress(transaction.to || ZERO_ADDRESS)}
</div>
</header>
<main className="pb-20">
<div className="flex h-14 items-center border-border border-b">
<Breadcrumb className="container max-w-6xl">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/chainlist">Chainlist</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={`/${chain.slug}`}>
{chain.name.replace("Mainnet", "")}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Transaction Details</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<Separator orientation="horizontal" />
<div className="container max-w-6xl py-10">
<h1 className="font-semibold text-3xl tracking-tight">
Transaction Details
</h1>
</div>
<section className="flex flex-col gap-y-2">
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Transaction hash</div>
<p>{transaction.hash}</p>
</div>
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">
Status {/* and method */}
</div>
<div className="flex gap-x-2">
<Badge
className={
receipt.status === "success" ? "bg-green-500" : "bg-red-500"
}
>
{receipt.status}
</Badge>
</div>
</div>
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Block</div>
<p>{Number(transaction.blockNumber)}</p>
</div>
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Timestamp</div>
<p>
{new Date(
Number.parseInt(block.timestamp.toString()) * 1000,
).toLocaleString()}
</p>
</div>
</section>
<Separator orientation="horizontal" />
<section className="flex flex-col gap-y-2">
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">From</div>
<p>{transaction.from}</p>
</div>
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">
Interacted with contract
</div>
<p>{transaction.to}</p>
</div>
</section>
<Separator orientation="horizontal" />
<section className="flex flex-col gap-y-2">
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Value</div>
<p>{toEther(transaction.value)} ETH</p>
</div>
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Transaction fee</div>
<p>{toEther((transaction.gasPrice || 0n) * receipt.gasUsed)} ETH</p>
</div>
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Gas price</div>
<p>{toEther(transaction.gasPrice || 0n)} ETH</p>
</div>
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Gas usage</div>
<p>{receipt.gasUsed.toString()}</p>
</div>
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Gas fees (Gwei)</div>
<p>
Base: {toTokens(block.baseFeePerGas || 0n, 9)} | Max:{" "}
{toTokens(transaction.maxFeePerGas || 0n, 9)} | Max priority:{" "}
{transaction.maxPriorityFeePerGas?.toString()}
</p>
</div>
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Burnt fees</div>
<p>{toEther(block.baseFeePerGas || 0n * receipt.gasUsed)}</p>
</div>
</section>
<Separator orientation="horizontal" />
<section className="flex flex-col gap-y-2">
<div className="grid grid-cols-2">
<div className="flex items-center gap-x-2">Other</div>
<p>
Txn type: {hexToNumber(transaction.typeHex || "0x0")} (
{transaction.type}) | Nonce: {transaction.nonce} | Position:{" "}
{transaction.transactionIndex}
</p>
</div>
<div className="grid grid-cols-2 items-start">
<div className="flex items-center gap-x-2">Raw Input:</div>
<div className="break-words rounded-lg bg-card p-4 text-sm">
{transaction.input}
</div>
</div>
</section>
<div className="container max-w-6xl">
{bridgeStatus ? (
<BridgeAndOverviewTabs
bridgeStatus={
<BridgeStatusWithPolling
bridgeStatus={bridgeStatus}
client={getClientThirdwebClient()}
chainId={chain.chainId}
transactionHash={params.txHash}
/>
}
overview={overviewContent}
/>
) : (
overviewContent
)}
</div>
</main>
);
}
function GeneericTxDetails(props: {
transaction: Transaction;
receipt: TransactionReceipt;
block: {
timestamp: bigint;
baseFeePerGas: bigint | null;
};
chain: ChainMetadata;
}) {
const { transaction, receipt, chain, block } = props;
const timestamp = getDatefromTimestamp(block.timestamp);
return (
<div className="border rounded-xl p-4 lg:p-6 bg-card">
{/* section 1 */}
<section className="border-b pb-6 space-y-5 lg:space-y-3">
<GridItem
label="Transaction hash"
tooltip="A TxHash or transaction hash is a unique 66-character identifier that is generated whenever a transaction is executed."
>
<CopyTextButton
textToCopy={transaction.hash}
textToShow={transaction.hash}
tooltip="Copy transaction hash"
variant="ghost"
copyIconPosition="right"
className="truncate -translate-x-1.5 max-w-full"
/>
</GridItem>
<GridItem label="Status" tooltip="The status of the transaction.">
<Badge
className="py-0.5 gap-1.5 px-1.5 capitalize"
variant={receipt.status === "success" ? "success" : "destructive"}
>
{receipt.status === "success" ? (
<CircleCheckIcon className="size-3" />
) : (
<CircleAlertIcon className="size-3" />
)}
{receipt.status}
</Badge>
</GridItem>
{transaction.blockNumber && (
<GridItem
label="Block"
tooltip="Number of the block in which the transaction is recorded. Block confirmations indicate how many blocks have been added since the transaction was produced."
>
<p>{Number(transaction.blockNumber)}</p>
</GridItem>
)}
{timestamp && (
<GridItem
label="Timestamp"
tooltip="The date and time at which a transaction is validated."
>
<div className="flex items-center gap-1.5 flex-wrap">
<Clock4Icon className="size-3" />
<p>
{formatDistanceToNowStrict(timestamp, {
addSuffix: true,
})}
</p>
<p>({formatDate(timestamp, "PP pp z")})</p>
</div>
</GridItem>
)}
</section>
{/* section 2 */}
<section className="border-b py-6 space-y-5 lg:space-y-3">
<GridItem label="From" tooltip="The sending party of the transaction.">
<CopyTextButton
textToCopy={transaction.from}
textToShow={transaction.from}
tooltip="Copy from address"
variant="ghost"
copyIconPosition="right"
className="truncate -translate-x-1.5 max-w-full"
/>
</GridItem>
{transaction.to && (
<GridItem
label="Interacted with (To)"
tooltip="The receiving party of the transaction (could be a contract address)."
>
<CopyTextButton
textToCopy={transaction.to}
textToShow={transaction.to}
tooltip="Copy to address"
variant="ghost"
copyIconPosition="right"
className="truncate -translate-x-1.5 max-w-full"
/>
</GridItem>
)}
</section>
{/* section 3 */}
<section className="py-6 space-y-5 lg:space-y-3 border-b">
<GridItem
label="Value"
tooltip="The value being transacted in ETH and fiat value. Note: You can click the fiat value (if available) to see historical value at the time of transaction."
>
<p>
{toEther(transaction.value)} {chain.nativeCurrency?.symbol}
</p>
</GridItem>
<GridItem
label="Transaction Fee"
tooltip="Amount paid to the validator for processing the transaction."
>
<p>
{toEther((transaction.gasPrice || 0n) * receipt.gasUsed)}{" "}
{chain.nativeCurrency?.symbol}
</p>
</GridItem>
<GridItem
label="Gas Price"
tooltip="The price per gas unit the sender is willing to pay"
>
<p>
{toEther(transaction.gasPrice || 0n)} {chain.nativeCurrency?.symbol}
</p>
</GridItem>
<GridItem
label="Gas Usage"
tooltip="The amount of gas used by the transaction."
>
<p>{receipt.gasUsed.toString()}</p>
</GridItem>
<GridItem
label="Gas Fees"
tooltip="Base Fee refers to the network Base Fee at the time of the block, while Max Fee & Max Priority Fee refer to the max amount a user is willing to pay for their tx & to give to the miner respectively."
>
<p className="flex gap-2.5">
<span>Base: {toTokens(block.baseFeePerGas || 0n, 9)} Gwei</span>
{transaction.maxFeePerGas && (
<>
<span className="text-muted-foreground/50">|</span>
<span>
Max: {toTokens(transaction.maxFeePerGas || 0n, 9)} Gwei
</span>
</>
)}
{transaction.maxPriorityFeePerGas && (
<>
<span className="text-muted-foreground/50">|</span>
<span>
Max priority: {transaction.maxPriorityFeePerGas?.toString()}{" "}
Gwei
</span>
</>
)}
</p>
</GridItem>
<GridItem
label="Burnt fees"
tooltip="The amount of fees that were burnt by the transaction."
>
<p>
{toEther((block.baseFeePerGas || 0n) * receipt.gasUsed)}{" "}
{chain.nativeCurrency?.symbol}
</p>
</GridItem>
</section>
{/* section 4 */}
<section className="pt-6 space-y-5 lg:space-y-3">
<GridItem
label="Other Attributes"
tooltip="Other data related to this transaction."
>
<div className="flex gap-2 flex-wrap">
<Badge variant="outline" className="gap-1 py-1">
<span className="text-muted-foreground">Txn type:</span>
<span className="capitalize">
{hexToNumber(transaction.typeHex || "0x0")} ({transaction.type})
</span>
</Badge>
<Badge variant="outline" className="gap-1 py-1">
<span className="text-muted-foreground">Nonce: </span>
<span>{transaction.nonce}</span>
</Badge>
<Badge variant="outline" className="gap-1 py-1">
<span className="text-muted-foreground">Position: </span>
<span>{transaction.transactionIndex}</span>
</Badge>
</div>
</GridItem>
<GridItem
label="Input Data"
tooltip="Additional data included for this transaction. Commonly used as part of contract interaction or as a message sent to the recipient."
>
<PlainTextCodeBlock
code={transaction.input}
className="[&_code]:!whitespace-pre-wrap break-all [&_code]:text-xs text-muted-foreground w-full"
scrollableClassName="max-h-[400px] p-2 min-h-[80px]"
/>
</GridItem>
</section>
</div>
);
}
function GridItem(props: {
label: string;
children: React.ReactNode;
tooltip?: string;
}) {
return (
<div className="flex flex-col gap-1 lg:grid grid-cols-[300px_1fr] items-start">
<div className="flex items-center gap-0.5">
<ToolTipLabel label={props.tooltip}>
<Button
asChild
variant="ghost"
className="h-auto w-auto p-1 hidden lg:block"
>
<div>
<InfoIcon className="size-3.5 text-muted-foreground" />
</div>
</Button>
</ToolTipLabel>
<p className="text-muted-foreground text-sm">{props.label}</p>
</div>
<div className="text-sm w-full">{props.children}</div>
</div>
);
}
function getDatefromTimestamp(timestamp: bigint) {
try {
return fromUnixTime(Number(timestamp));
} catch {
return undefined;
}
}
@@ -0,0 +1,31 @@
"use client";
import { useState } from "react";
import { TabButtons } from "@/components/ui/tabs";
export function BridgeAndOverviewTabs(props: {
bridgeStatus: React.ReactNode;
overview: React.ReactNode;
}) {
const [tab, setTab] = useState<"bridge" | "overview">("bridge");
return (
<div>
<TabButtons
tabs={[
{
name: "Bridge",
onClick: () => setTab("bridge"),
isActive: tab === "bridge",
},
{
name: "Overview",
onClick: () => setTab("overview"),
isActive: tab === "overview",
},
]}
/>
<div className="h-3" />
{tab === "bridge" && props.bridgeStatus}
{tab === "overview" && props.overview}
</div>
);
}
@@ -50,6 +50,7 @@ import {
} from "@/constants/addresses";
import { LAST_USED_PROJECT_ID, LAST_USED_TEAM_ID } from "@/constants/cookie";
import { ZERO_FEE_CHAINS } from "@/constants/fee-config";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import {
useCustomFactoryAbi,
useFunctionParamsFromABI,
@@ -206,6 +207,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
const activeAccount = useActiveAccount();
const walletChain = useActiveWalletChain();
const walletChainMetadata = useV5DashboardChain(walletChain?.id);
const { onError } = useTxNotifications(
"Successfully deployed contract",
"Failed to deploy contract",
@@ -656,6 +659,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
chainId: walletChain.id,
contractName: metadata.name,
publisher: rewriteTwPublisher(metadata.publisher),
is_testnet: walletChainMetadata?.testnet,
});
deployStatusModal.nextStep();
@@ -694,6 +698,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
contractName: metadata.name,
errorMessage: parsedError,
publisher: rewriteTwPublisher(metadata.publisher),
is_testnet: walletChain.testnet,
});
deployStatusModal.close();
@@ -3,6 +3,7 @@
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { reportTokenRowClicked } from "@/analytics/report";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -39,6 +40,7 @@ export function TokensTable(props: {
<TableHead>Price</TableHead>
<TableHead>Market cap</TableHead>
<TableHead>Volume (24h)</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -64,6 +66,9 @@ export function TokensTable(props: {
<TableCell>
<Skeleton className="ml-auto h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-4 w-20" />
</TableCell>
</TableRow>
))
: tokens.length > 0
@@ -81,6 +86,7 @@ export function TokensTable(props: {
<AvatarImage
src={token.iconUri ?? undefined}
alt={token.symbol}
className="rounded-full"
/>
<AvatarFallback>
{token.symbol?.slice(0, 2)?.toUpperCase()}
@@ -90,20 +96,9 @@ export function TokensTable(props: {
<span className="font-medium leading-none text-base">
{token.symbol}
</span>
<Link
href={
token.address.toLowerCase() ===
NATIVE_TOKEN_ADDRESS.toLowerCase()
? `/${token.chainId}`
: `/${token.chainId}/${token.address}`
}
prefetch={false}
className="before:absolute before:inset-0"
>
<span className="text-sm text-muted-foreground leading-none">
{token.name}
</span>
</Link>
<span className="text-sm text-muted-foreground leading-none">
{token.name}
</span>
</div>
</div>
</TableCell>
@@ -120,6 +115,35 @@ export function TokensTable(props: {
? formatUsdCompact(token.volume24hUsd)
: "N/A"}
</TableCell>
<TableCell>
<Button
asChild
variant="outline"
size="sm"
className="gap-2 rounded-full bg-card hover:border-foreground/50 text-muted-foreground hover:bg-inverted hover:text-inverted-foreground"
>
<Link
onClick={() =>
reportTokenRowClicked({
chainId: token.chainId,
tokenAddress: token.address,
})
}
aria-label={`Buy ${token.symbol}`}
href={
token.address.toLowerCase() ===
NATIVE_TOKEN_ADDRESS.toLowerCase()
? `/${token.chainId}`
: `/${token.chainId}/${token.address}`
}
prefetch={false}
className="before:absolute before:inset-0"
>
Buy
<ArrowRightIcon className="size-3" />
</Link>
</Button>
</TableCell>
</TableRow>
);
})
@@ -1,7 +1,6 @@
import { BringToFrontIcon } from "lucide-react";
import type { Metadata } from "next";
import { unstable_cache } from "next/cache";
import Link from "next/link";
import { Bridge } from "thirdweb";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { PageHeader } from "./components/header";
@@ -35,13 +34,10 @@ export default async function Page() {
<h1 className="text-4xl font-bold tracking-tighter mb-1">
Discover and swap any tokens on any chain, instantly
</h1>
<Link
className="text-muted-foreground hover:text-foreground"
href="https://thirdweb.com/monetize/bridge"
target="_blank"
>
Powered by thirdweb bridge
</Link>
<p className="text-muted-foreground">
85+ chains, 4500+ tokens and 9+ million routes supported
</p>
</div>
</div>
<TokenPage chains={chains} />
@@ -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,
@@ -25,6 +25,34 @@ import { ToolTipLabel } from "@/components/ui/tooltip";
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
import { searchParams } from "../search-params";
function getStatusBadge(invoice: Stripe.Invoice) {
switch (invoice.status) {
case "paid":
return <Badge variant="success">Paid</Badge>;
case "open":
// we treat "uncollectible" as unpaid
case "uncollectible": {
// if the invoice has a due date, use that, otherwise use the effective at date (which is the date the invoice was created)
const effectiveDueDate = invoice.due_date || invoice.effective_at;
// if the invoice due date is in the past, we want to display it as past due
if (effectiveDueDate && effectiveDueDate * 1000 < Date.now()) {
return <Badge variant="destructive">Past Due</Badge>;
}
return <Badge variant="outline">Open</Badge>;
}
case "void":
return <Badge variant="secondary">Void</Badge>;
default:
return <Badge variant="outline">Unknown</Badge>;
}
}
function isInvoicePayable(invoice: Stripe.Invoice) {
return invoice.status === "open" || invoice.status === "uncollectible";
}
export function BillingHistory(props: {
teamSlug: string;
invoices: Stripe.Invoice[];
@@ -57,27 +85,6 @@ export function BillingHistory(props: {
});
};
const getStatusBadge = (invoice: Stripe.Invoice) => {
switch (invoice.status) {
case "paid":
return <Badge variant="success">Paid</Badge>;
case "open":
// we treate "uncollectible" as unpaid
case "uncollectible": {
// if the invoice due date is in the past, we want to display it as past due
if (invoice.due_date && invoice.due_date * 1000 < Date.now()) {
return <Badge variant="destructive">Past Due</Badge>;
}
return <Badge variant="outline">Open</Badge>;
}
case "void":
return <Badge variant="secondary">Void</Badge>;
default:
return <Badge variant="outline">Unknown</Badge>;
}
};
if (props.invoices.length === 0) {
if (props.status === "open") {
return (
@@ -126,7 +133,7 @@ export function BillingHistory(props: {
<TableCell>{getStatusBadge(invoice)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{invoice.status === "open" && (
{isInvoicePayable(invoice) && (
<>
{/* always show the crypto payment button */}
<ToolTipLabel
@@ -552,7 +552,7 @@ export function AccountAbstractionSettingsPage(
you build your own rules.{" "}
<UnderlineLink
className="text-primary-500"
href="https://portal.thirdweb.com/wallets/smart-wallet/sponsorship-rules#setting-up-a-server-verifier"
href="https://portal.thirdweb.com/transactions/policies#server-verifier"
rel="noopener noreferrer"
target="_blank"
>
@@ -28,11 +28,11 @@ export function QuickStartSection(props: {
icon={ArrowRightLeftIcon}
setupTime={5}
id="swap_tokens"
features={["Swap any token", "Cross-chain swaps"]}
features={["Swap any token", "Cross-chain swap"]}
description="Swap tokens cross-chain with dedicated swapping endpoints."
link={{
href: `https://portal.thirdweb.com/payments/swap`,
label: "Setup Swaps",
href: `https://portal.thirdweb.com/bridge/swap`,
label: "Setup Swap",
}}
/>
@@ -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<{
@@ -69,72 +70,22 @@ export default async function Page(props: {
},
},
links: [
// TODO - add docs when bridge docs are added in portal
// {
// type: "docs",
// href: "https://portal.thirdweb.com/payments",
// },
{
type: "docs",
href: "https://portal.thirdweb.com/bridge",
},
{
type: "playground",
href: "https://playground.thirdweb.com/bridge/swap-widget",
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/bridge",
},
],
}}
footer={{
center: {
links: [
{
href: "https://playground.thirdweb.com/payments/ui-components",
label: "UI Component",
},
{
href: "https://playground.thirdweb.com/connect/payments/fund-wallet",
label: "Buy Crypto",
},
{
href: "https://playground.thirdweb.com/connect/payments/commerce",
label: "Checkout",
},
{
href: "https://playground.thirdweb.com/connect/payments/transactions",
label: "Transactions",
},
],
title: "Demos",
},
left: {
links: [
{
href: "https://portal.thirdweb.com/payments",
label: "Overview",
},
{
href: "https://portal.thirdweb.com/typescript/v5/convertCryptoToFiat",
label: "TypeScript",
},
{
href: "https://portal.thirdweb.com/react/v5/pay/fund-wallets",
label: "React",
},
{
href: "https://portal.thirdweb.com/dotnet/universal-bridge/quickstart",
label: ".NET",
},
],
title: "Documentation",
},
right: {
links: [
{
href: "https://www.youtube.com/watch?v=aBu175-VsNY",
label: "Implement cross-chain payments in any app",
},
],
title: "Tutorials",
},
}}
>
<div className="flex flex-col gap-12">
<div className="flex flex-col gap-6">
<ResponsiveSearchParamsProvider value={searchParams}>
<PayAnalytics
client={client}
@@ -149,12 +100,16 @@ export default async function Page(props: {
<RouteDiscovery client={client} project={project} />
<QuickStartSection
projectSlug={params.project_slug}
teamSlug={params.team_slug}
clientId={project.publishableKey}
teamId={project.teamId}
/>
<ViewTxStatus client={client} />
<div className="pt-4">
<QuickStartSection
projectSlug={params.project_slug}
teamSlug={params.team_slug}
clientId={project.publishableKey}
teamId={project.teamId}
/>
</div>
</div>
</ProjectPage>
);
@@ -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>
);
}
@@ -48,7 +48,11 @@ export function ProjectSidebarLayout(props: {
{
href: `${props.layoutPath}/transactions`,
icon: ArrowLeftRightIcon,
label: "Transactions",
label: (
<span className="flex items-center gap-2">
Transactions <Badge>New</Badge>
</span>
),
},
{
href: `${props.layoutPath}/contracts`,
@@ -81,11 +85,7 @@ export function ProjectSidebarLayout(props: {
{
href: `${props.layoutPath}/tokens`,
icon: TokenIcon,
label: (
<span className="flex items-center gap-2">
Tokens <Badge>New</Badge>
</span>
),
label: "Tokens",
},
],
},
@@ -1,3 +1,4 @@
import { Input } from "@workspace/ui/components/input";
import { format, formatDistanceToNowStrict } from "date-fns";
import {
ArrowLeftIcon,
@@ -110,6 +111,7 @@ export function TransactionsTable(props: {
const [autoUpdate, setAutoUpdate] = useState(true);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<EngineStatus | undefined>(undefined);
const [filterId, setFilterId] = useState<string | undefined>(undefined);
const autoUpdateId = useId();
const pageSize = 10;
const transactionsQuery = useEngineTransactions({
@@ -121,6 +123,7 @@ export function TransactionsTable(props: {
page: page,
status,
},
id: filterId,
});
const transactions = transactionsQuery.data?.transactions ?? [];
@@ -160,6 +163,16 @@ export function TransactionsTable(props: {
onCheckedChange={(v) => setAutoUpdate(!!v)}
/>
</div>
<Input
className="max-w-[250px]"
onChange={(e) => {
const value = e.target.value.trim();
setFilterId(value || undefined);
setPage(1);
}}
placeholder="Filter by Queue ID"
value={filterId || ""}
/>
<StatusSelector
setStatus={(v) => {
setStatus(v);
@@ -29,6 +29,7 @@ import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-fi
import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { getFiltersFromSearchParams } from "@/lib/time";
import type {
InAppWalletStats,
@@ -38,7 +39,10 @@ import type {
import { loginRedirect } from "@/utils/redirects";
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import {
ProjectFTUX,
ProjectWalletSection,
} from "./components/ProjectFTUX/ProjectFTUX";
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
import { TransactionsChartCardAsync } from "./components/Transactions";
import { ProjectHighlightsCard } from "./overview/highlights-card";
@@ -64,6 +68,9 @@ type PageProps = {
searchParams: Promise<PageSearchParams>;
};
// Revalidate this page data every 30 seconds
export const revalidate = 30;
export default async function ProjectOverviewPage(props: PageProps) {
const [params, searchParams] = await Promise.all([
props.params,
@@ -104,6 +111,11 @@ export default async function ProjectOverviewPage(props: PageProps) {
teamId: project.teamId,
});
const projectWallet = await getProjectWallet(project);
const managementAccessToken =
project.services?.find((service) => service.name === "engineCloud")
?.managementAccessToken ?? undefined;
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<ProjectPage
@@ -125,6 +137,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
>
{isActive ? (
<div className="flex flex-col gap-4 md:gap-6">
<ProjectWalletSection
project={project}
teamSlug={params.team_slug}
wallet={projectWallet}
managementAccessToken={managementAccessToken}
/>
<ResponsiveTimeFilters defaultRange={defaultRange} />
<ProjectAnalytics
authToken={authToken}
@@ -137,7 +155,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
/>
</div>
) : (
<ProjectFTUX project={project} teamSlug={params.team_slug} />
<ProjectFTUX
project={project}
teamSlug={params.team_slug}
wallet={projectWallet}
managementAccessToken={managementAccessToken}
/>
)}
</ProjectPage>
</ResponsiveSearchParamsProvider>
@@ -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>
);
@@ -74,6 +74,10 @@ export type CreateNFTCollectionFunctions = {
count: number;
};
gasless: boolean;
onNotEnoughFunds: (data: {
requiredAmount: string;
balance: string;
}) => void;
}) => Promise<void>;
lazyMintNFTs: (params: {
values: CreateNFTCollectionAllValues;
@@ -1,6 +1,14 @@
"use client";
import { useRef } from "react";
import { encode, getContract, type ThirdwebClient } from "thirdweb";
import {
encode,
estimateGasCost,
getContract,
getGasPrice,
type PreparedTransaction,
type ThirdwebClient,
toEther,
} from "thirdweb";
import { deployERC721Contract, deployERC1155Contract } from "thirdweb/deploys";
import { multicall } from "thirdweb/extensions/common";
import {
@@ -14,7 +22,8 @@ import {
} from "thirdweb/extensions/erc1155";
import { grantRole } from "thirdweb/extensions/permissions";
import { useActiveAccount } from "thirdweb/react";
import { maxUint256 } from "thirdweb/utils";
import { maxUint256, resolvePromisedValue } from "thirdweb/utils";
import { getWalletBalance } from "thirdweb/wallets";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import { reportContractDeployed } from "@/analytics/report";
@@ -39,6 +48,10 @@ export function CreateNFTPage(props: {
const contractAddressRef = useRef<string | undefined>(undefined);
const getChain = useGetV5DashboardChain();
const sendAndConfirmTx = useSendAndConfirmTx();
const sendAndConfirmTxNoPayModal = useSendAndConfirmTx({
payModal: false,
});
function getAccount(params: { gasless: boolean }) {
if (!activeAccount) {
throw new Error("Wallet is not connected");
@@ -130,6 +143,7 @@ export function CreateNFTPage(props: {
contractName: contractType,
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
is_testnet: chain.testnet,
});
contractAddressRef.current = contractAddress;
@@ -218,6 +232,10 @@ export function CreateNFTPage(props: {
count: number;
};
gasless: boolean;
onNotEnoughFunds: (data: {
requiredAmount: string;
balance: string;
}) => void;
}) {
const { values, batch } = params;
const contract = getDeployedContract({
@@ -277,7 +295,46 @@ export function CreateNFTPage(props: {
data: encodedTransactions,
});
await sendAndConfirmTx.mutateAsync(tx);
// if there are more than one batches, on the first batch, we check if the user has enough funds to cover the cost of all the batches ->
// calculate the cost of one batch, multiply by the number of batches to get the total cost
// if the user does not have enough funds, call the onNotEnoughFunds callback
const totalBatches = Math.ceil(values.nfts.length / batch.count);
if (batch.startIndex === 0 && totalBatches > 1 && !params.gasless) {
if (!activeAccount) {
throw new Error("Wallet is not connected");
}
const costPerBatch = await getTotalTransactionCost({
tx: tx,
from: activeAccount.address,
});
const totalCost = costPerBatch * BigInt(totalBatches);
const walletBalance = await getWalletBalance({
address: activeAccount.address,
chain: contract.chain,
client: contract.client,
});
if (walletBalance.value < totalCost) {
params.onNotEnoughFunds({
balance: toEther(walletBalance.value),
requiredAmount: toEther(totalCost),
});
throw new Error(
`Not enough funds: Required ${toEther(totalCost)}, Balance ${toEther(walletBalance.value)}`,
);
}
await sendAndConfirmTxNoPayModal.mutateAsync(tx);
} else if (totalBatches > 1) {
await sendAndConfirmTxNoPayModal.mutateAsync(tx);
} else {
await sendAndConfirmTx.mutateAsync(tx);
}
}
async function handleSetAdmins(params: {
@@ -390,3 +447,41 @@ function transformSocialUrls(
{} as Record<string, string>,
);
}
async function getTransactionGasCost(tx: PreparedTransaction, from?: string) {
try {
const gasCost = await estimateGasCost({
from,
transaction: tx,
});
const bufferCost = gasCost.wei / 10n;
// Note: get tx.value AFTER estimateGasCost
// add 10% extra gas cost to the estimate to ensure user buys enough to cover the tx cost
return gasCost.wei + bufferCost;
} catch {
if (from) {
// try again without passing from
return await getTransactionGasCost(tx);
}
// fallback if both fail, use the tx value + 1M * gas price
const gasPrice = await getGasPrice({
chain: tx.chain,
client: tx.client,
});
return 1_000_000n * gasPrice;
}
}
async function getTotalTransactionCost(params: {
tx: PreparedTransaction;
from?: string;
}) {
const [txValue, txGasCost] = await Promise.all([
resolvePromisedValue(params.tx.value),
getTransactionGasCost(params.tx, params.from),
]);
return (txValue || 0n) + txGasCost;
}
@@ -1,14 +1,19 @@
"use client";
import {
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpFromLineIcon,
ImageOffIcon,
RefreshCcwIcon,
ShoppingBagIcon,
} from "lucide-react";
import Link from "next/link";
import { useTheme } from "next-themes";
import { useMemo, useRef, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import {
BuyWidget,
TokenProvider,
TokenSymbol,
useActiveAccount,
@@ -37,6 +42,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { ChainOverview } from "../../_common/chain-overview";
import { StepCard } from "../../_common/step-card";
import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell";
@@ -71,6 +77,10 @@ export function LaunchNFT(props: {
const walletRequiresApproval = activeWallet?.id !== "inApp";
const account = useActiveAccount();
const contractAddressRef = useRef<string | null>(null);
const { theme } = useTheme();
const chainMeta = useV5DashboardChain(
Number(formValues.collectionInfo.chain),
);
function updateStatus(
index: number,
@@ -161,6 +171,18 @@ export function LaunchNFT(props: {
const [isGasless, setIsGasless] = useState(canEnableGasless);
const showGaslessSection = false; // activeWallet?.id === "inApp";
const [notEnoughFunds, setNotEnoughFunds] = useState<
| false
| {
values: {
requiredAmount: string;
balance: string;
};
isBuySuccess: boolean;
showBuyWidget: boolean;
}
>(false);
const contractLink = contractAddressRef.current
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}`
: null;
@@ -204,6 +226,16 @@ export function LaunchNFT(props: {
},
gasless: isGasless,
values: formValues,
onNotEnoughFunds: (data) => {
setNotEnoughFunds({
values: {
balance: data.balance,
requiredAmount: data.requiredAmount,
},
isBuySuccess: false,
showBuyWidget: false,
});
},
});
batchesProcessedRef.current += 1;
@@ -216,6 +248,16 @@ export function LaunchNFT(props: {
},
gasless: isGasless,
values: formValues,
onNotEnoughFunds: (data) => {
setNotEnoughFunds({
values: {
balance: data.balance,
requiredAmount: data.requiredAmount,
},
isBuySuccess: false,
showBuyWidget: false,
});
},
});
}
}
@@ -274,6 +316,8 @@ export function LaunchNFT(props: {
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
error: errorMessage,
step: currentStep.id,
is_testnet: chainMeta.testnet,
chainId: Number(formValues.collectionInfo.chain),
});
throw error;
@@ -283,6 +327,8 @@ export function LaunchNFT(props: {
reportAssetCreationSuccessful({
assetType: "nft",
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
chainId: Number(formValues.collectionInfo.chain),
is_testnet: chainMeta?.testnet ?? false,
});
props.onLaunchSuccess();
@@ -357,41 +403,143 @@ export function LaunchNFT(props: {
className="gap-0 overflow-hidden p-0 md:max-w-[480px]"
dialogCloseClassName="hidden"
>
<div className="flex flex-col gap-6 p-6">
<DialogHeader className="space-y-0.5">
<DialogTitle className="font-semibold text-xl tracking-tight">
Status
</DialogTitle>
{walletRequiresApproval && (
<DialogDescription>
Each step will prompt a signature request in your wallet
</DialogDescription>
)}
</DialogHeader>
{/** biome-ignore lint/complexity/useOptionalChain: can't change it to optional chain */}
{notEnoughFunds && notEnoughFunds.showBuyWidget ? (
<div className="bg-card">
<div className="border-b py-2 px-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
setNotEnoughFunds({
...notEnoughFunds,
showBuyWidget: false,
})
}
className="gap-2 px-2 text-muted-foreground hover:text-foreground"
>
<ArrowLeftIcon className="size-4" />
Back
</Button>
</div>
<BuyWidget
client={props.client}
chain={chain}
amount={String(
Number(notEnoughFunds.values.requiredAmount) -
Number(notEnoughFunds.values.balance),
)}
buttonLabel={`Buy ${chainMeta?.nativeCurrency?.symbol || "ETH"}`}
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
className="!border-none !w-full !rounded-none"
onSuccess={() => {
setNotEnoughFunds({
...notEnoughFunds,
isBuySuccess: true,
showBuyWidget: false,
});
}}
/>
</div>
) : (
<div className="flex flex-col gap-6 p-6 mb-2">
<DialogHeader className="space-y-0.5">
<DialogTitle className="font-semibold text-xl tracking-tight">
Status
</DialogTitle>
{walletRequiresApproval && (
<DialogDescription>
Each step will prompt a signature request in your wallet
</DialogDescription>
)}
</DialogHeader>
<MultiStepStatus
onRetry={handleRetry}
renderError={(step, errorMessage) => {
if (
props.teamPlan === "free" &&
errorMessage.toLowerCase().includes("storage limit")
) {
return (
<StorageErrorPlanUpsell
onRetry={() => handleRetry(step)}
teamSlug={props.teamSlug}
trackingCampaign="create-nft"
/>
);
}
<MultiStepStatus
onRetry={handleRetry}
renderError={(step, errorMessage) => {
if (
props.teamPlan === "free" &&
errorMessage.toLowerCase().includes("storage limit")
) {
return (
<StorageErrorPlanUpsell
onRetry={() => handleRetry(step)}
teamSlug={props.teamSlug}
trackingCampaign="create-nft"
/>
);
}
return null;
}}
steps={steps}
/>
</div>
if (notEnoughFunds) {
return (
<div>
{notEnoughFunds.isBuySuccess ? (
<p className="text-success-text text-sm mt-1.5 mb-2">
Funds bought successfully
</p>
) : (
<div className="mt-2 mb-2 space-y-1">
<p className="text-destructive-text text-sm">
Not enough funds
</p>
<p className="text-muted-foreground text-sm">
Required Funds:{" "}
{notEnoughFunds.values.requiredAmount}{" "}
{chainMeta?.nativeCurrency?.symbol || "ETH"}
</p>
<p className="text-muted-foreground text-sm">
Balance: {notEnoughFunds.values.balance}{" "}
{chainMeta?.nativeCurrency?.symbol || "ETH"}
</p>
</div>
)}
<div className="mt-2 flex justify-end gap-4 border-border border-t bg-card p-6">
<div className="flex gap-2">
<Button
onClick={() => {
handleRetry(step);
setNotEnoughFunds(false);
}}
size="sm"
variant={
notEnoughFunds.isBuySuccess
? "default"
: "outline"
}
className="gap-2"
>
<RefreshCcwIcon className="size-3.5" />
Retry
</Button>
{!notEnoughFunds.isBuySuccess && (
<Button
onClick={() => {
setNotEnoughFunds({
...notEnoughFunds,
showBuyWidget: true,
});
}}
size="sm"
className="gap-2"
>
<ShoppingBagIcon className="size-3.5" />
Buy Funds
</Button>
)}
</div>
</div>
);
}
return null;
}}
steps={steps}
/>
</div>
)}
<div className="flex justify-end gap-4 border-border border-t bg-card p-6">
{!isComplete ? (
<Button
onClick={() => {
@@ -178,12 +178,14 @@ export function CreateTokenAssetPage(props: {
teamId: props.teamId,
});
const chainMetadata = getChain(Number(params.values.chain));
reportContractDeployed({
address: contractAddress,
chainId: Number(params.values.chain),
contractName: "DropERC20",
contractName: "ERC20Asset",
deploymentType: "asset",
publisher: account.address,
is_testnet: chainMetadata.testnet,
});
contractAddressRef.current = contractAddress;
@@ -299,12 +301,15 @@ export function CreateTokenAssetPage(props: {
teamId: props.teamId,
});
const chainMetadata = getChain(Number(values.chain));
reportContractDeployed({
address: contractAddress,
chainId: Number(values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
is_testnet: chainMetadata.testnet,
});
return {
@@ -230,6 +230,8 @@ export function LaunchTokenStatus(props: {
: "ERC20Asset",
error: errorMessage,
step: currentStep.id,
is_testnet: chainMetadata?.testnet,
chainId: Number(formValues.chain),
});
updateStatus(i, {
@@ -247,6 +249,8 @@ export function LaunchTokenStatus(props: {
formValues.saleMode === "drop-erc20:token-drop"
? "DropERC20"
: "ERC20Asset",
chainId: Number(formValues.chain),
is_testnet: chainMetadata?.testnet,
});
if (contractAddressRef.current) {
@@ -1,10 +1,10 @@
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { UnifiedTransactionsTable } from "../components/transactions-table.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { TransactionAnalyticsFilter } from "./filter";
import { TransactionsChartCard } from "./tx-chart/tx-chart";
import { TransactionsTable } from "./tx-table/tx-table";
export function TransactionsAnalyticsPageContent(props: {
searchParams: {
@@ -34,11 +34,10 @@ export function TransactionsAnalyticsPageContent(props: {
/>
</>
)}
<TransactionsTable
<UnifiedTransactionsTable
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
wallets={props.wallets}
/>
</div>
</ResponsiveSearchParamsProvider>
@@ -5,15 +5,19 @@ import type { Project } from "@/api/project/projects";
import { type Step, StepsCard } from "@/components/blocks/StepsCard";
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
import { SendTestSolanaTransaction } from "./send-test-solana-tx.client";
import { SendTestTransaction } from "./send-test-tx.client";
interface Props {
managementAccessToken: string | undefined;
project: Project;
wallets: Wallet[];
solanaWallets: SolanaWallet[];
hasTransactions: boolean;
teamSlug: string;
testTxWithWallet?: string | undefined;
testSolanaTxWithWallet?: string | undefined;
client: ThirdwebClient;
isManagedVault: boolean;
}
@@ -85,6 +89,19 @@ export const EngineChecklist: React.FC<Props> = (props) => {
);
}
if (props.testSolanaTxWithWallet) {
return (
<SendTestSolanaTransaction
isManagedVault={props.isManagedVault}
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
walletId={props.testSolanaTxWithWallet}
wallets={props.solanaWallets}
/>
);
}
if (finalSteps.length === 0 || isComplete) {
return null;
}
@@ -0,0 +1,343 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ExternalLinkIcon, Loader2Icon, LockIcon } from "lucide-react";
import Link from "next/link";
import { useQueryState } from "nuqs";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import * as z from "zod";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { TryItOut } from "../server-wallets/components/try-it-out";
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
const formSchema = z.object({
secretKey: z.string().min(1, "Secret key is required"),
walletIndex: z.string(),
});
type FormValues = z.infer<typeof formSchema>;
function SendTestSolanaTransactionModal(props: {
wallets?: SolanaWallet[];
project: Project;
teamSlug: string;
walletId?: string;
isManagedVault: boolean;
client: ThirdwebClient;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const [, setTxChain] = useQueryState("txChain", {
history: "push",
});
const form = useForm<FormValues>({
defaultValues: {
secretKey: "",
walletIndex:
props.wallets && props.walletId
? props.wallets
.findIndex((w) => w.id === props.walletId)
?.toString()
.replace("-1", "0")
: "0",
},
resolver: zodResolver(formSchema),
});
const selectedWalletIndex = Number.parseInt(form.watch("walletIndex"));
const selectedWallet = props.wallets?.[selectedWalletIndex];
const sendDummySolanaTxMutation = useMutation({
mutationFn: async (args: { walletAddress: string; secretKey: string }) => {
const response = await engineCloudProxy({
body: JSON.stringify({
executionOptions: {
chainId: "solana:devnet",
signerAddress: args.walletAddress,
maxBlockhashRetries: 0,
commitment: "confirmed",
},
instructions: [
{
programId: "11111111111111111111111111111111", // System Program
accounts: [
{
pubkey: args.walletAddress,
isSigner: true,
isWritable: true,
},
{
pubkey: args.walletAddress,
isSigner: false,
isWritable: true,
},
],
data: "AgAAAAAAAAAAAAAAAA==", // Transfer 0 SOL (base64)
encoding: "base64",
},
],
}),
headers: {
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
"x-team-id": props.project.teamId,
...(props.isManagedVault
? { "x-secret-key": args.secretKey }
: { "x-vault-access-token": args.secretKey }),
},
method: "POST",
pathname: "/v1/solana/transaction",
});
if (!response.ok) {
const errorMsg = response.error ?? "Failed to send Solana transaction";
throw new Error(errorMsg);
}
return response.data;
},
onError: (error) => {
toast.error(error.message);
},
onSuccess: () => {
toast.success("Test Solana transaction sent successfully!");
// Set the transaction chain to solana so the table switches
setTxChain("solana");
// Close the modal after successful transaction
setTimeout(() => {
props.onOpenChange(false);
}, 1000);
},
});
const isLoading = sendDummySolanaTxMutation.isPending;
// Early return in render phase
if (!props.wallets || props.wallets.length === 0 || !selectedWallet) {
return null;
}
const onSubmit = async (data: FormValues) => {
await sendDummySolanaTxMutation.mutateAsync({
secretKey: data.secretKey,
walletAddress: selectedWallet.publicKey,
});
queryClient.invalidateQueries({
queryKey: ["solana-transactions", props.project.id],
});
};
return (
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Send Test Solana Transaction</DialogTitle>
<DialogDescription>
Test your Solana server wallet by sending a 0 SOL transfer transaction
on Solana Devnet
</DialogDescription>
</DialogHeader>
{/* Funding Alert */}
<Alert>
<AlertDescription className="flex flex-col gap-2">
<p className="font-medium">⚠️ Fund your wallet first</p>
<p className="text-sm">
Your Solana wallet needs SOL on Devnet to pay for transaction fees.
Get free Devnet SOL from the Solana faucet:
</p>
<Link
href="https://faucet.solana.com/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-link-foreground hover:underline text-sm"
>
<ExternalLinkIcon className="size-4" />
https://faucet.solana.com/
</Link>
</AlertDescription>
</Alert>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex flex-col gap-4">
{/* Wallet Selector */}
<div className="flex flex-col gap-2">
<p className="text-sm">Select Wallet</p>
<Select
disabled={props.wallets.length === 1}
value={form.watch("walletIndex")}
onValueChange={(value) => form.setValue("walletIndex", value)}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{props.wallets.map((wallet, index) => (
<SelectItem
key={wallet.id}
value={index.toString()}
className="font-mono"
>
{wallet.metadata.label || `Wallet ${index + 1}`} (
{wallet.publicKey.slice(0, 6)}...
{wallet.publicKey.slice(-4)})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Wallet Address Display */}
<div className="flex flex-col gap-2">
<p className="text-sm">Public Key</p>
<code className="text-xs font-mono bg-muted p-2 rounded break-all">
{selectedWallet.publicKey}
</code>
</div>
{/* Network Info */}
<div className="flex flex-col gap-2">
<p className="text-sm">Network</p>
<div className="flex items-center gap-2 bg-muted p-2 rounded">
<div className="size-4 rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
<span className="text-sm">Solana Devnet</span>
</div>
</div>
{/* Secret Key Input */}
<div className="flex flex-col gap-2">
<p className="text-sm">
{props.isManagedVault ? "Secret Key" : "Vault Access Token"}
</p>
<div className="relative">
<Input
{...form.register("secretKey")}
className="pr-10 font-mono"
placeholder={
props.isManagedVault
? "Enter your secret key"
: "Enter your vault access token"
}
type="password"
/>
<LockIcon className="absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
{form.formState.errors.secretKey && (
<p className="text-destructive text-sm">
{form.formState.errors.secretKey.message}
</p>
)}
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
<Button
className="w-full md:w-auto"
onClick={() => props.onOpenChange(false)}
variant="secondary"
type="button"
>
Close
</Button>
<Button
className="w-full min-w-[200px] md:w-auto"
disabled={isLoading || !form.formState.isValid}
type="submit"
variant="primary"
>
{isLoading ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
"Send Transaction"
)}
</Button>
</div>
</div>
</form>
</DialogContent>
);
}
export function SendTestSolanaTransaction(props: {
wallets?: SolanaWallet[];
project: Project;
teamSlug: string;
expanded?: boolean;
walletId?: string;
isManagedVault: boolean;
client: ThirdwebClient;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useDashboardRouter();
// Early return in render phase
if (!props.wallets || props.wallets.length === 0) {
return null;
}
return (
<div className="mt-3 w-full rounded-md border bg-background p-6">
<TryItOut useEngineAPI={false} />
<div className="mt-6 flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button variant="secondary" className="w-full md:w-auto">
Test Request
</Button>
</DialogTrigger>
<SendTestSolanaTransactionModal
{...props}
open={isModalOpen}
onOpenChange={setIsModalOpen}
/>
</Dialog>
<Button
className="w-full md:w-auto"
onClick={() => {
if (props.walletId) {
router.replace(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
);
} else {
localStorage.setItem("solanEngineFtuxCompleted", "true");
router.refresh();
}
}}
variant="primary"
>
{props.walletId ? "Close" : "Complete Setup"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,141 @@
export type SolanaTransactionStatus =
| "QUEUED"
| "SUBMITTED"
| "CONFIRMED"
| "FAILED";
type SolanaTransactionParamsSerialized = {
instructions: Array<{
programId: string;
keys: Array<{
pubkey: string;
isSigner: boolean;
isWritable: boolean;
}>;
data: string;
}>;
};
type SolanaExecutionParamsSerialized = {
type: "SOLANA";
signerAddress: string;
chainId: string;
commitment?: "processed" | "confirmed" | "finalized";
computeUnitLimit?: number;
computeUnitPrice?: number;
skipPreflight?: boolean;
};
type SolanaExecutorError = {
errorCode?: string;
message?: string;
innerError?: unknown;
reason?: string;
[key: string]: unknown;
};
type SolanaTransactionMeta = {
err?: unknown | null;
status?: {
Ok?: unknown | null;
};
fee: number;
preBalances: number[];
postBalances: number[];
innerInstructions: unknown[];
logMessages: string[];
preTokenBalances: unknown[];
postTokenBalances: unknown[];
rewards: unknown[];
loadedAddresses: {
writable: string[];
readonly: string[];
};
computeUnitsConsumed?: number;
costUnits?: number;
};
type SolanaTransactionDetails = {
transaction: {
signatures: string[];
message: {
header: {
numRequiredSignatures: number;
numReadonlySignedAccounts: number;
numReadonlyUnsignedAccounts: number;
};
accountKeys: string[];
recentBlockhash: string;
instructions: Array<{
programIdIndex: number;
accounts: number[];
data: string;
stackHeight?: number;
}>;
addressTableLookups: unknown[];
};
};
meta: SolanaTransactionMeta;
version: number | "legacy";
};
type SolanaExecutionResultSerialized =
| {
status: "QUEUED";
}
| {
status: "FAILED";
error: SolanaExecutorError;
}
| {
status: "SUBMITTED";
monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR";
signature: string;
submissionAttemptNumber: number;
}
| ({
status: "CONFIRMED";
signature: string;
signerAddress: string;
chainId: string;
submissionAttemptNumber: number;
slot: number;
blockTime: number | null;
transaction: SolanaTransactionDetails;
} & (
| {
onchainStatus: "SUCCESS";
}
| {
onchainStatus: "REVERTED";
error?: string;
}
));
export type SolanaTransaction = {
id: string;
clientId: string;
chainId: string;
signerAddress: string;
transactionParams: SolanaTransactionParamsSerialized;
signature: string | null;
status: SolanaTransactionStatus | null;
confirmedAt: Date | null;
confirmedAtSlot: string | null;
blockTime: number | null;
enrichedData: unknown[];
executionParams: SolanaExecutionParamsSerialized;
executionResult: SolanaExecutionResultSerialized | null;
createdAt: Date;
errorMessage: string | null;
cancelledAt: Date | null;
};
export type SolanaTransactionsResponse = {
transactions: SolanaTransaction[];
pagination: {
totalCount: number;
page: number;
limit: number;
};
};
@@ -1,417 +0,0 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useId, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import type {
Transaction,
TransactionStatus,
TransactionsResponse,
} from "./types";
// TODO - add Status selector dropdown here
export function TransactionsTableUI(props: {
getData: (params: {
page: number;
status: TransactionStatus | undefined;
}) => Promise<TransactionsResponse>;
project: Project;
teamSlug: string;
wallets?: Wallet[];
client: ThirdwebClient;
}) {
const router = useDashboardRouter();
const [autoUpdate, setAutoUpdate] = useState(true);
const [status, setStatus] = useState<TransactionStatus | undefined>(
undefined,
);
const [page, setPage] = useState(1);
const pageSize = 10;
const transactionsQuery = useQuery({
placeholderData: keepPreviousData,
queryFn: () => props.getData({ page, status }),
queryKey: ["transactions", props.project.id, page, status],
refetchInterval: autoUpdate ? 4_000 : false,
});
const transactions = transactionsQuery.data?.transactions ?? [];
const totalCount = transactionsQuery.data?.pagination.totalCount ?? 0;
const totalPages = Math.ceil(totalCount / pageSize);
const showPagination = totalCount > pageSize;
const showSkeleton =
(transactionsQuery.isPlaceholderData && transactionsQuery.isFetching) ||
(transactionsQuery.isLoading && !transactionsQuery.isPlaceholderData);
const autoUpdateId = useId();
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6 lg:flex-row lg:justify-between">
<div>
<h2 className="font-semibold text-2xl tracking-tight">
Transaction History
</h2>
<p className="text-muted-foreground text-sm">
Transactions sent from server wallets
</p>
</div>
<div className="flex items-center justify-end gap-5 border-border border-t pt-4 lg:border-none lg:pt-0">
<div className="flex shrink-0 items-center gap-2">
<Label htmlFor={autoUpdateId}>Auto Update</Label>
<Switch
checked={autoUpdate}
id={autoUpdateId}
onCheckedChange={(v) => setAutoUpdate(!!v)}
/>
</div>
<StatusSelector
setStatus={(v) => {
setStatus(v);
// reset page
setPage(1);
}}
status={status}
/>
</div>
</div>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue ID</TableHead>
<TableHead>Chain</TableHead>
<TableHead>Status</TableHead>
<TableHead>From</TableHead>
<TableHead>Tx Hash</TableHead>
<TableHead>Queued</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{showSkeleton
? new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
<SkeletonRow key={i} />
))
: transactions.map((tx) => (
<TableRow
className="cursor-pointer hover:bg-accent/50"
key={`${tx.id}${tx.chainId}`}
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions/tx/${tx.id}`,
);
}}
>
{/* Queue ID */}
<TableCell className="font-medium">
<CopyAddressButton
address={tx.id ? tx.id : ""}
className="text-muted-foreground"
copyIconPosition="left"
variant="ghost"
/>
</TableCell>
{/* Chain Id */}
<TableCell>
<TxChainCell
chainId={tx.chainId || undefined}
client={props.client}
/>
</TableCell>
{/* Status */}
<TableCell>
<TxStatusCell transaction={tx} />
</TableCell>
{/* From Address */}
<TableCell>
{tx.from ? (
<WalletAddress
address={tx.from}
client={props.client}
/>
) : (
"N/A"
)}
</TableCell>
{/* Tx Hash */}
<TableCell>
<TxHashCell transaction={tx} />
</TableCell>
{/* Queued At */}
<TableCell>
<TxQueuedAtCell transaction={tx} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!showSkeleton && transactions.length === 0 && (
<div className="flex min-h-[200px] items-center justify-center">
No transactions found
</div>
)}
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
);
}
export const statusDetails = {
CONFIRMED: {
name: "Confirmed",
type: "success",
},
FAILED: {
name: "Failed",
type: "destructive",
},
QUEUED: {
name: "Queued",
type: "warning",
},
SUBMITTED: {
name: "Submitted",
type: "warning",
},
} as const;
function StatusSelector(props: {
status: TransactionStatus | undefined;
setStatus: (value: TransactionStatus | undefined) => void;
}) {
const statuses = Object.keys(statusDetails) as TransactionStatus[];
return (
<Select
onValueChange={(v) => {
if (v === "all") {
props.setStatus(undefined);
} else {
props.setStatus(v as TransactionStatus);
}
}}
value={props.status || "all"}
>
<SelectTrigger className="min-w-[160px] rounded-full">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All Transactions</SelectItem>
{statuses.map((item) => {
return (
<SelectItem key={item} value={item}>
{statusDetails[item].name}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
);
}
function SkeletonRow() {
return (
<TableRow className="h-[73px]">
<TableCell>
<Skeleton className="h-6 w-[152px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[155px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[58px] rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[145px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[160px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[108px]" />
</TableCell>
</TableRow>
);
}
function TxChainCell(props: {
chainId: string | undefined;
client: ThirdwebClient;
}) {
const { chainId } = props;
const { idToChain } = useAllChainsData();
if (!chainId) {
return "N/A";
}
const chain = idToChain.get(Number.parseInt(chainId));
if (!chain) {
return `Chain ID: ${chainId}`;
}
return (
<div className="flex items-center gap-2">
<ChainIconClient
className="size-5"
client={props.client}
src={chain.icon?.url}
/>
<div className="max-w-[150px] truncate">
{chain.name ?? `Chain ID: ${chainId}`}
</div>
</div>
);
}
function TxStatusCell(props: { transaction: Transaction }) {
const { transaction } = props;
const { errorMessage } = transaction;
const minedAt = transaction.confirmedAt;
const status =
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
const onchainStatus =
transaction.executionResult &&
"onchainStatus" in transaction.executionResult
? transaction.executionResult.onchainStatus
: null;
const tooltip =
onchainStatus !== "REVERTED"
? errorMessage
: status === "CONFIRMED" && minedAt
? `Completed ${format(new Date(minedAt), "PP pp")}`
: undefined;
return (
<ToolTipLabel hoverable label={tooltip}>
<Badge className="gap-2" variant={statusDetails[status].type}>
{statusDetails[status].name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
);
}
function TxHashCell(props: { transaction: Transaction }) {
const { idToChain } = useAllChainsData();
const { chainId, transactionHash } = props.transaction;
if (!transactionHash) {
return "N/A";
}
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
const explorer = chain?.explorers?.[0];
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
if (!explorer) {
return (
<CopyTextButton
className="font-mono text-muted-foreground text-sm"
copyIconPosition="left"
textToCopy={transactionHash}
textToShow={shortHash}
tooltip="Copy transaction hash"
variant="ghost"
/>
);
}
return (
<Button
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
variant="ghost"
>
<Link
href={`${explorer.url}/tx/${transactionHash}`}
onClick={(e) => {
e.stopPropagation();
}}
rel="noopener noreferrer"
target="_blank"
>
{shortHash}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
);
}
function TxQueuedAtCell(props: { transaction: Transaction }) {
const value = props.transaction.createdAt;
if (!value) {
return;
}
const date = new Date(value);
return (
<ToolTipLabel label={format(date, "PP pp z")}>
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
@@ -1,64 +0,0 @@
"use client";
import type { ThirdwebClient } from "thirdweb";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import { TransactionsTableUI } from "./tx-table-ui";
import type { TransactionStatus, TransactionsResponse } from "./types";
export function TransactionsTable(props: {
project: Project;
wallets?: Wallet[];
teamSlug: string;
client: ThirdwebClient;
}) {
return (
<TransactionsTableUI
client={props.client}
getData={async ({ page, status }) => {
return await getTransactions({
page,
project: props.project,
status,
});
}}
project={props.project}
teamSlug={props.teamSlug}
wallets={props.wallets}
/>
);
}
async function getTransactions({
project,
page,
status,
}: {
project: Project;
page: number;
status: TransactionStatus | undefined;
}) {
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
{
headers: {
"Content-Type": "application/json",
"x-client-id": project.publishableKey,
"x-team-id": project.teamId,
},
method: "GET",
pathname: `/v1/transactions`,
searchParams: {
limit: "20",
page: page.toString(),
status: status ?? undefined,
},
},
);
if (!transactions.ok) {
throw new Error(transactions.error);
}
return transactions.data.result;
}
@@ -97,3 +97,22 @@ export type TransactionsResponse = {
transactions: Transaction[];
pagination: Pagination;
};
export const statusDetails = {
CONFIRMED: {
name: "Confirmed",
type: "success",
},
FAILED: {
name: "Failed",
type: "destructive",
},
QUEUED: {
name: "Queued",
type: "warning",
},
SUBMITTED: {
name: "Submitted",
type: "warning",
},
} as const;
@@ -0,0 +1,774 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import {
CheckIcon,
MoreVerticalIcon,
RefreshCcwIcon,
SendIcon,
XIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import {
DEFAULT_ACCOUNT_FACTORY_V0_7,
predictSmartAccountAddress,
} from "thirdweb/wallets/smart";
import type { Project } from "@/api/project/projects";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { SolanaAddress } from "@/components/blocks/solana-address";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TabButtons } from "@/components/ui/tabs";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { updateDefaultProjectWallet } from "../lib/vault.client";
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
import type { Wallet as EVMWallet } from "../server-wallets/wallet-table/types";
import { CreateSolanaWallet } from "../solana-wallets/components/create-solana-wallet.client";
import { UpgradeSolanaPermissions } from "../solana-wallets/components/upgrade-solana-permissions.client";
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
type WalletChain = "evm" | "solana";
interface ServerWalletsTableProps {
evmWallets: EVMWallet[];
evmTotalRecords: number;
evmCurrentPage: number;
evmTotalPages: number;
solanaWallets: SolanaWallet[];
solanaTotalRecords: number;
solanaCurrentPage: number;
solanaTotalPages: number;
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
client: ThirdwebClient;
solanaPermissionError?: boolean;
}
export function ServerWalletsTable(props: ServerWalletsTableProps) {
const {
evmWallets,
solanaWallets,
project,
teamSlug,
managementAccessToken,
evmTotalRecords,
evmCurrentPage,
evmTotalPages,
solanaTotalRecords,
solanaCurrentPage,
solanaTotalPages,
client,
solanaPermissionError,
} = props;
const [activeChain, setActiveChain] = useState<WalletChain>("evm");
const [selectedChainId, setSelectedChainId] = useState<number>(1);
const [showSmartAccount, setShowSmartAccount] = useState(false);
const queryClient = useQueryClient();
const wallets = activeChain === "evm" ? evmWallets : solanaWallets;
const currentPage =
activeChain === "evm" ? evmCurrentPage : solanaCurrentPage;
const totalPages = activeChain === "evm" ? evmTotalPages : solanaTotalPages;
const totalRecords =
activeChain === "evm" ? evmTotalRecords : solanaTotalRecords;
return (
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
{/* Header */}
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<WalletProductIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
Server Wallets
</h2>
<p className="text-muted-foreground text-sm">
Create and manage server wallets for your project
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<div className="flex flex-row gap-2.5">
{activeChain === "evm" && (
<>
<CreateServerWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
/>
<SingleNetworkSelector
chainId={selectedChainId}
onChange={setSelectedChainId}
client={client}
disableChainId
className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50"
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
/>
</>
)}
{activeChain === "solana" && (
<CreateSolanaWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
disabled={solanaPermissionError}
/>
)}
</div>
{activeChain === "evm" && (
<div className="flex items-center gap-2.5">
<Label
className="text-sm text-muted-foreground"
htmlFor="showSmartAccount"
>
Show ERC4337 Smart Account
</Label>
<Switch
id="showSmartAccount"
checked={showSmartAccount}
onCheckedChange={setShowSmartAccount}
size="sm"
/>
</div>
)}
</div>
</div>
{/* Chain Tabs */}
<div className="px-6">
<TabButtons
tabs={[
{
name: (
<span className="flex items-center gap-2">
EVM Wallets
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{evmTotalRecords}
</Badge>
</span>
),
onClick: () => setActiveChain("evm"),
isActive: activeChain === "evm",
},
{
name: (
<span className="flex items-center gap-2">
Solana Wallets
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{solanaTotalRecords}
</Badge>
</span>
),
onClick: () => setActiveChain("solana"),
isActive: activeChain === "solana",
},
]}
/>
</div>
{/* Table Content */}
{activeChain === "solana" && solanaPermissionError ? (
<div className="p-6">
<UpgradeSolanaPermissions project={project} />
</div>
) : (
<>
<TableContainer className="rounded-none border-x-0 border-b-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Label</TableHead>
<TableHead className="w-[280px]">
{activeChain === "evm"
? showSmartAccount
? "Smart Account Address"
: "Wallet Address"
: "Public Key"}
</TableHead>
<TableHead className="min-w-[80px]">
<div className="flex w-[180px] items-center gap-1.5">
Balance
{wallets.length > 0 && (
<ToolTipLabel
contentClassName="capitalize font-normal tracking-normal leading-normal"
label="Refresh Balance"
>
<Button
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
onClick={async (e) => {
const buttonEl = e.currentTarget;
buttonEl.setAttribute("data-pending", "true");
await queryClient.invalidateQueries({
queryKey:
activeChain === "evm"
? ["walletBalance", selectedChainId]
: ["solanaWalletBalance"],
});
buttonEl.setAttribute("data-pending", "false");
}}
size="sm"
variant="ghost"
>
<RefreshCcwIcon className="size-4" />
</Button>
</ToolTipLabel>
)}
</div>
</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeChain === "evm" &&
evmWallets.map((wallet) => (
<EVMWalletRow
key={wallet.id}
wallet={wallet}
project={project}
teamSlug={teamSlug}
client={client}
chainId={selectedChainId}
showSmartAccount={showSmartAccount}
/>
))}
{activeChain === "solana" &&
solanaWallets.map((wallet) => (
<SolanaWalletRow
key={wallet.id}
wallet={wallet}
project={project}
teamSlug={teamSlug}
client={client}
/>
))}
</TableBody>
</Table>
{wallets.length === 0 && (
<div className="py-24 flex flex-col items-center justify-center px-4 text-center gap-4">
<div className="p-2 rounded-full bg-background border border-border">
<XIcon className="size-5 text-muted-foreground" />
</div>
<p className="text-muted-foreground">
No {activeChain === "evm" ? "EVM" : "Solana"} wallets found
</p>
</div>
)}
</TableContainer>
{totalPages > 1 && (
<WalletsPagination
activeChain={activeChain}
currentPage={currentPage}
totalPages={totalPages}
totalRecords={totalRecords}
teamSlug={teamSlug}
projectSlug={project.slug}
/>
)}
</>
)}
</div>
</div>
);
}
// Wallets Pagination Component
function WalletsPagination({
activeChain,
currentPage,
totalPages,
totalRecords,
teamSlug,
projectSlug,
}: {
activeChain: WalletChain;
currentPage: number;
totalPages: number;
totalRecords: number;
teamSlug: string;
projectSlug: string;
}) {
const pageParam = activeChain === "evm" ? "page" : "solana_page";
return (
<div className="flex flex-col items-center border-t p-6">
<div className="mb-4 text-muted-foreground text-sm">
Found {totalRecords} {activeChain === "evm" ? "EVM" : "Solana"} wallets
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
currentPage > 1 ? currentPage - 1 : 1
}`}
legacyBehavior
passHref
>
<PaginationPrevious
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</Link>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={`page-${pageNumber}`}>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${pageNumber}`}
passHref
>
<PaginationLink isActive={currentPage === pageNumber}>
{pageNumber}
</PaginationLink>
</Link>
</PaginationItem>
),
)}
<PaginationItem>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
currentPage < totalPages ? currentPage + 1 : totalPages
}`}
passHref
>
<PaginationNext
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</Link>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
// EVM Wallet Row Component
function EVMWalletRow({
wallet,
project,
teamSlug,
client,
chainId,
showSmartAccount,
}: {
wallet: EVMWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
chainId: number;
showSmartAccount: boolean;
}) {
const chain = useV5DashboardChain(chainId);
const smartAccountQuery = useQuery({
queryFn: async () => {
return await predictSmartAccountAddress({
adminAddress: wallet.address,
chain: chain,
client: client,
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
});
},
enabled: showSmartAccount,
queryKey: ["smart-account-address", wallet.address, chainId],
});
const engineService = project.services.find((s) => s.name === "engineCloud");
const isDefault =
engineService?.projectWalletAddress &&
wallet.address.toLowerCase() ===
engineService.projectWalletAddress.toLowerCase();
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
)}
>
{wallet.metadata.label || "N/A"}
</span>
{isDefault && (
<Badge variant="success" className="text-xs">
default
</Badge>
)}
</div>
</TableCell>
<TableCell>
{showSmartAccount ? (
smartAccountQuery.isPending ? (
<Skeleton className="h-7 w-24" />
) : smartAccountQuery.data ? (
<WalletAddress address={smartAccountQuery.data} client={client} />
) : (
<span className="text-muted-foreground">N/A</span>
)
) : (
<WalletAddress address={wallet.address} client={client} />
)}
</TableCell>
<TableCell>
{showSmartAccount ? (
smartAccountQuery.isPending ? (
<Skeleton className="h-5 w-16" />
) : smartAccountQuery.data ? (
<WalletBalance
address={smartAccountQuery.data}
chainId={chainId}
client={client}
/>
) : (
<span className="text-muted-foreground">N/A</span>
)
) : (
<WalletBalance
address={wallet.address}
chainId={chainId}
client={client}
/>
)}
</TableCell>
<TableCell>
<DateCell date={wallet.createdAt} />
</TableCell>
<TableCell>
<EVMWalletActions
project={project}
teamSlug={teamSlug}
wallet={wallet}
client={client}
chainId={chainId}
fundAddress={
showSmartAccount ? smartAccountQuery.data : wallet.address
}
/>
</TableCell>
</TableRow>
);
}
// Solana Wallet Row Component
function SolanaWalletRow({
wallet,
project,
teamSlug,
client,
}: {
wallet: SolanaWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
}) {
const engineService = project.services.find(
(s) => s.name === "engineCloud",
) as { projectSolanaWalletPublicKey?: string } | undefined;
const isDefault =
engineService?.projectSolanaWalletPublicKey &&
wallet.publicKey.toLowerCase() ===
engineService.projectSolanaWalletPublicKey.toLowerCase();
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
)}
>
{wallet.metadata.label || "N/A"}
</span>
{isDefault && (
<Badge variant="success" className="text-xs">
default
</Badge>
)}
</div>
</TableCell>
<TableCell>
<SolanaAddress address={wallet.publicKey} shortenAddress={true} />
</TableCell>
<TableCell>
<SolanaWalletBalance publicKey={wallet.publicKey} />
</TableCell>
<TableCell>
<DateCell date={wallet.createdAt} />
</TableCell>
<TableCell>
<SolanaWalletActions
project={project}
teamSlug={teamSlug}
wallet={wallet}
client={client}
/>
</TableCell>
</TableRow>
);
}
// Shared Components
function DateCell({ date }: { date: string }) {
if (!date) {
return "N/A";
}
const dateObj = new Date(date);
return (
<ToolTipLabel label={format(dateObj, "PP pp z")}>
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
function EVMWalletActions({
wallet,
project,
teamSlug,
client,
chainId,
fundAddress,
}: {
wallet: EVMWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
chainId: number;
fundAddress: string | undefined;
}) {
const [showFundModal, setShowFundModal] = useState(false);
const router = useDashboardRouter();
const setDefaultMutation = useMutation({
mutationFn: async () => {
await updateDefaultProjectWallet({
project,
projectWalletAddress: wallet.address,
});
},
onSuccess: () => {
toast.success("Wallet set as project wallet");
router.refresh();
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to set default wallet",
);
},
});
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVerticalIcon className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="rounded-xl">
<DropdownMenuItem asChild>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?testTxWithWallet=${wallet.id}`}
className="flex items-center gap-2 h-9 rounded-lg"
>
<SendIcon className="size-4 text-muted-foreground" />
Send test transaction
</Link>
</DropdownMenuItem>
{fundAddress && (
<DropdownMenuItem
onClick={() => setShowFundModal(true)}
className="flex items-center gap-2 h-9 rounded-lg"
>
<WalletProductIcon className="size-4 text-muted-foreground" />
Fund wallet
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setDefaultMutation.mutate()}
disabled={setDefaultMutation.isPending}
className="flex items-center gap-2 h-9 rounded-lg"
>
<CheckIcon className="size-4 text-muted-foreground" />
Set as default
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{fundAddress && (
<FundWalletModal
open={showFundModal}
onOpenChange={setShowFundModal}
title="Fund server wallet"
description="Send funds to the server wallet"
recipientAddress={fundAddress}
client={client}
defaultChainId={chainId}
checkoutWidgetTitle={
wallet.metadata.label
? `Fund ${wallet.metadata.label}`
: "Fund server wallet"
}
/>
)}
</>
);
}
function SolanaWalletActions({
wallet,
project,
teamSlug,
}: {
wallet: SolanaWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVerticalIcon className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="rounded-xl">
<DropdownMenuItem asChild>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?testSolanaTxWithWallet=${wallet.id}`}
className="flex items-center gap-2 h-9 rounded-lg"
>
<SendIcon className="size-4 text-muted-foreground" />
Send test transaction
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function WalletBalance({
address,
chainId,
client,
}: {
address: string;
chainId: number;
client: ThirdwebClient;
}) {
const chain = useV5DashboardChain(chainId);
const balance = useWalletBalance({
address,
chain,
client,
});
if (balance.isFetching) {
return <Skeleton className="h-5 w-16" />;
}
if (!balance.data) {
return <span className="text-muted-foreground">N/A</span>;
}
return (
<span className="text-sm text-foreground">
{balance.data.displayValue} {balance.data.symbol}
</span>
);
}
function SolanaWalletBalance({ publicKey }: { publicKey: string }) {
const balance = useQuery({
queryFn: async () => {
// TODO: Implement actual Solana balance fetching
return {
displayValue: "0",
symbol: "SOL",
};
},
queryKey: ["solanaWalletBalance", publicKey],
});
if (balance.isFetching) {
return <Skeleton className="h-5 w-16" />;
}
if (!balance.data) {
return <span className="text-muted-foreground">N/A</span>;
}
return (
<span className="text-sm text-foreground">
{balance.data.displayValue} {balance.data.symbol}
</span>
);
}
@@ -0,0 +1,877 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useQueryState } from "nuqs";
import { useId, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { SolanaAddress } from "@/components/blocks/solana-address";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type {
SolanaTransaction,
SolanaTransactionStatus,
SolanaTransactionsResponse,
} from "../analytics/solana-tx-table/types";
import type {
Transaction,
TransactionStatus,
TransactionsResponse,
} from "../analytics/tx-table/types";
import { getSolanaNetworkName, getSolscanUrl } from "../lib/solana-utils";
type TransactionChain = "evm" | "solana";
interface UnifiedTransactionsTableProps {
project: Project;
teamSlug: string;
client: ThirdwebClient;
}
export function UnifiedTransactionsTable({
project,
teamSlug,
client,
}: UnifiedTransactionsTableProps) {
const router = useDashboardRouter();
// Use nuqs to manage chain selection in URL - this is the source of truth
const [activeChainParam, setActiveChainParam] = useQueryState("txChain", {
defaultValue: "evm",
history: "push",
});
const activeChain = (activeChainParam || "evm") as TransactionChain;
const setActiveChain = (chain: TransactionChain) =>
setActiveChainParam(chain);
const [autoUpdate, setAutoUpdate] = useState(true);
const [status, setStatus] = useState<
TransactionStatus | SolanaTransactionStatus | undefined
>(undefined);
const [id, setId] = useState<string | undefined>(undefined);
const [from, setFrom] = useState<string | undefined>(undefined);
const [page, setPage] = useState(1);
// Fetch EVM transactions
const evmTransactionsQuery = useQuery({
enabled: activeChain === "evm",
placeholderData: keepPreviousData,
queryFn: () =>
getEVMTransactions({
page,
project,
status: status as TransactionStatus | undefined,
id,
from,
}),
queryKey: ["evm-transactions", project.id, page, status, id, from],
refetchInterval: autoUpdate && activeChain === "evm" ? 4_000 : false,
});
// Fetch Solana transactions
const solanaTransactionsQuery = useQuery({
enabled: activeChain === "solana",
placeholderData: keepPreviousData,
queryFn: () =>
getSolanaTransactions({
page,
project,
status: status as SolanaTransactionStatus | undefined,
id,
from,
}),
queryKey: ["solana-transactions", project.id, page, status, id, from],
refetchInterval: autoUpdate && activeChain === "solana" ? 4_000 : false,
});
const autoUpdateId = useId();
return (
<div className="overflow-hidden rounded-xl border border-border bg-card">
{/* Unified Header & Filters */}
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="font-semibold text-2xl tracking-tight">
Transaction History
</h2>
<p className="text-muted-foreground text-sm">
Transactions sent from server wallets
</p>
</div>
<div className="flex shrink-0 items-center gap-2 border-border border-t pt-4 lg:border-none lg:pt-0">
<Label htmlFor={autoUpdateId}>Auto Update</Label>
<Switch
checked={autoUpdate}
id={autoUpdateId}
onCheckedChange={(v) => setAutoUpdate(!!v)}
/>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
<Input
className="w-full sm:max-w-[200px]"
onChange={(e) => {
const value = e.target.value.trim();
setId(value || undefined);
setPage(1);
}}
placeholder="Filter by Queue ID"
value={id || ""}
/>
<Input
className="w-full sm:max-w-[200px]"
onChange={(e) => {
const value = e.target.value.trim();
setFrom(value || undefined);
setPage(1);
}}
placeholder="Filter by wallet address"
value={from || ""}
/>
<div className="flex gap-3">
<ChainSelector
activeChain={activeChain}
setActiveChain={(chain) => {
setActiveChain(chain);
setPage(1);
setStatus(undefined);
}}
/>
<StatusSelector
activeChain={activeChain}
setStatus={(v) => {
setStatus(v);
setPage(1);
}}
status={status}
/>
</div>
</div>
</div>
{/* Render different table structures based on chain */}
{activeChain === "evm" ? (
<EVMTransactionsTable
client={client}
page={page}
project={project}
query={evmTransactionsQuery}
router={router}
setPage={setPage}
teamSlug={teamSlug}
/>
) : (
<SolanaTransactionsTable
page={page}
project={project}
query={solanaTransactionsQuery}
router={router}
setPage={setPage}
teamSlug={teamSlug}
/>
)}
</div>
);
}
// EVM Transactions Table Component
function EVMTransactionsTable(props: {
query: ReturnType<typeof useQuery<TransactionsResponse>>;
page: number;
setPage: (page: number) => void;
client: ThirdwebClient;
router: ReturnType<typeof useDashboardRouter>;
teamSlug: string;
project: Project;
}) {
const { query, page, setPage, client, router, teamSlug, project } = props;
const pageSize = 10;
const transactions = query.data?.transactions ?? [];
const totalCount = query.data?.pagination.totalCount ?? 0;
const totalPages = Math.ceil(totalCount / pageSize);
const showPagination = totalCount > pageSize;
const showSkeleton =
(query.isPlaceholderData && query.isFetching) ||
(query.isLoading && !query.isPlaceholderData);
return (
<>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue ID</TableHead>
<TableHead>Chain</TableHead>
<TableHead>Status</TableHead>
<TableHead>From</TableHead>
<TableHead>Tx Hash</TableHead>
<TableHead>Queued</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{showSkeleton
? new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
<SkeletonRow key={i} />
))
: transactions.map((tx) => (
<TableRow
className="cursor-pointer hover:bg-accent/50"
key={`${tx.id}${tx.chainId}`}
onClick={() => {
router.push(
`/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`,
);
}}
>
<TableCell className="font-medium">
<CopyAddressButton
address={tx.id}
className="text-muted-foreground"
copyIconPosition="left"
variant="ghost"
/>
</TableCell>
<TableCell>
<EVMChainCell
chainId={tx.chainId || undefined}
client={client}
/>
</TableCell>
<TableCell>
<EVMStatusCell transaction={tx} />
</TableCell>
<TableCell>
{tx.from ? (
<WalletAddress address={tx.from} client={client} />
) : (
"N/A"
)}
</TableCell>
<TableCell>
<EVMTxHashCell transaction={tx} />
</TableCell>
<TableCell>
<EVMQueuedAtCell transaction={tx} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!showSkeleton && transactions.length === 0 && (
<div className="flex min-h-[200px] items-center justify-center">
No transactions found
</div>
)}
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</>
);
}
// Solana Transactions Table Component
function SolanaTransactionsTable(props: {
query: ReturnType<typeof useQuery<SolanaTransactionsResponse>>;
page: number;
setPage: (page: number) => void;
router: ReturnType<typeof useDashboardRouter>;
teamSlug: string;
project: Project;
}) {
const { query, page, setPage, router, teamSlug, project } = props;
const pageSize = 10;
const transactions = query.data?.transactions ?? [];
const totalCount = query.data?.pagination.totalCount ?? 0;
const totalPages = Math.ceil(totalCount / pageSize);
const showPagination = totalCount > pageSize;
const showSkeleton =
(query.isPlaceholderData && query.isFetching) ||
(query.isLoading && !query.isPlaceholderData);
return (
<>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue ID</TableHead>
<TableHead>Network</TableHead>
<TableHead>Status</TableHead>
<TableHead>Signer</TableHead>
<TableHead>Signature</TableHead>
<TableHead>Queued</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{showSkeleton
? new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
<SkeletonRow key={i} />
))
: transactions.map((tx) => (
<TableRow
className="cursor-pointer hover:bg-accent/50"
key={`${tx.id}${tx.chainId}`}
onClick={() => {
router.push(
`/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`,
);
}}
>
<TableCell className="font-medium">
<CopyAddressButton
address={tx.id}
className="text-muted-foreground"
copyIconPosition="left"
variant="ghost"
/>
</TableCell>
<TableCell>
<SolanaChainCell chainId={tx.chainId} />
</TableCell>
<TableCell>
<SolanaStatusCell transaction={tx} />
</TableCell>
<TableCell>
<SolanaAddress
address={tx.signerAddress}
shortenAddress={true}
/>
</TableCell>
<TableCell>
<SolanaTxHashCell transaction={tx} />
</TableCell>
<TableCell>
<SolanaQueuedAtCell transaction={tx} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!showSkeleton && transactions.length === 0 && (
<div className="flex min-h-[200px] items-center justify-center">
No transactions found
</div>
)}
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</>
);
}
// Chain selector component
function ChainSelector(props: {
activeChain: TransactionChain;
setActiveChain: (chain: TransactionChain) => void;
}) {
return (
<Select
onValueChange={(v) => props.setActiveChain(v as TransactionChain)}
value={props.activeChain}
>
<SelectTrigger className="min-w-[160px] rounded-full">
<SelectValue placeholder="Select Chain" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="evm">EVM</SelectItem>
<SelectItem value="solana">Solana</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
);
}
// Status selector component
const evmStatusDetails = {
CONFIRMED: { name: "Confirmed", type: "success" as const },
FAILED: { name: "Failed", type: "destructive" as const },
QUEUED: { name: "Queued", type: "warning" as const },
SUBMITTED: { name: "Submitted", type: "warning" as const },
};
const solanaStatusDetails = {
CONFIRMED: { name: "Confirmed", type: "success" as const },
FAILED: { name: "Failed", type: "destructive" as const },
QUEUED: { name: "Queued", type: "warning" as const },
SUBMITTED: { name: "Submitted", type: "warning" as const },
};
function StatusSelector(props: {
activeChain: TransactionChain;
status: TransactionStatus | SolanaTransactionStatus | undefined;
setStatus: (
value: TransactionStatus | SolanaTransactionStatus | undefined,
) => void;
}) {
const statusDetails =
props.activeChain === "evm" ? evmStatusDetails : solanaStatusDetails;
const statuses = Object.keys(statusDetails) as (
| TransactionStatus
| SolanaTransactionStatus
)[];
return (
<Select
onValueChange={(v) => {
if (v === "all") {
props.setStatus(undefined);
} else {
props.setStatus(v as TransactionStatus | SolanaTransactionStatus);
}
}}
value={props.status || "all"}
>
<SelectTrigger className="min-w-[160px] rounded-full">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All Transactions</SelectItem>
{statuses.map((item) => {
return (
<SelectItem key={item} value={item}>
{statusDetails[item].name}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
);
}
// Skeleton row component
function SkeletonRow() {
return (
<TableRow className="h-[73px]">
<TableCell>
<Skeleton className="h-6 w-[152px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[155px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[58px] rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[145px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[160px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[108px]" />
</TableCell>
</TableRow>
);
}
// EVM Chain cell component
function EVMChainCell(props: {
chainId: string | undefined;
client: ThirdwebClient;
}) {
const { chainId } = props;
const { idToChain } = useAllChainsData();
if (!chainId) {
return "N/A";
}
const chain = idToChain.get(Number.parseInt(chainId));
if (!chain) {
return `Chain ID: ${chainId}`;
}
return (
<div className="flex items-center gap-2">
<ChainIconClient
className="size-5"
client={props.client}
src={chain.icon?.url}
/>
<div className="max-w-[150px] truncate">
{chain.name ?? `Chain ID: ${chainId}`}
</div>
</div>
);
}
// Solana Chain cell component
function SolanaChainCell(props: { chainId: string | undefined }) {
const { chainId } = props;
if (!chainId) {
return "N/A";
}
const network = getSolanaNetworkName(chainId);
const displayName = network.charAt(0).toUpperCase() + network.slice(1);
return (
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
<span>Solana {displayName}</span>
</div>
);
}
// EVM Status cell component
function EVMStatusCell(props: { transaction: Transaction }) {
const { transaction } = props;
const { errorMessage } = transaction;
const minedAt = transaction.confirmedAt;
const status =
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
const onchainStatus =
transaction.executionResult &&
"onchainStatus" in transaction.executionResult
? transaction.executionResult.onchainStatus
: null;
const tooltip =
onchainStatus !== "REVERTED"
? errorMessage
: status === "CONFIRMED" && minedAt
? `Completed ${format(new Date(minedAt), "PP pp")}`
: undefined;
const statusDetails = evmStatusDetails[status] || {
name: status,
type: "default" as const,
};
return (
<ToolTipLabel hoverable label={tooltip}>
<Badge className="gap-2" variant={statusDetails.type}>
{statusDetails.name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
);
}
// Solana Status cell component
function SolanaStatusCell(props: { transaction: SolanaTransaction }) {
const { transaction } = props;
const { errorMessage } = transaction;
const status =
(transaction.executionResult?.status as SolanaTransactionStatus) ??
transaction.status ??
"QUEUED";
const onchainStatus =
transaction.executionResult &&
"onchainStatus" in transaction.executionResult
? transaction.executionResult.onchainStatus
: null;
const tooltip =
onchainStatus !== "REVERTED"
? errorMessage
: status === "CONFIRMED" && transaction.confirmedAt
? `Completed ${format(new Date(transaction.confirmedAt), "PP pp")}`
: undefined;
const statusDetails = solanaStatusDetails[status] || {
name: status || "QUEUED",
type: "default" as const,
};
return (
<ToolTipLabel hoverable label={tooltip}>
<Badge className="gap-2" variant={statusDetails.type}>
{statusDetails.name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
);
}
// EVM Tx Hash cell component
function EVMTxHashCell(props: { transaction: Transaction }) {
const { idToChain } = useAllChainsData();
const { chainId, transactionHash } = props.transaction;
if (!transactionHash) {
return "N/A";
}
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
const explorer = chain?.explorers?.[0];
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
if (!explorer) {
return (
<CopyTextButton
className="font-mono text-muted-foreground text-sm"
copyIconPosition="left"
textToCopy={transactionHash}
textToShow={shortHash}
tooltip="Copy transaction hash"
variant="ghost"
/>
);
}
return (
<Button
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
variant="ghost"
>
<Link
href={`${explorer.url}/tx/${transactionHash}`}
onClick={(e) => {
e.stopPropagation();
}}
rel="noopener noreferrer"
target="_blank"
>
{shortHash}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
);
}
// Solana Tx Hash cell component
function SolanaTxHashCell(props: { transaction: SolanaTransaction }) {
const { chainId, signature, executionResult } = props.transaction;
// Get signature from executionResult if available, otherwise use top-level signature
let hash = signature;
if (
executionResult &&
"signature" in executionResult &&
executionResult.signature
) {
hash = executionResult.signature;
}
if (!hash) {
return "N/A";
}
const shortHash = `${hash.slice(0, 6)}...${hash.slice(-4)}`;
const explorerUrl = chainId ? getSolscanUrl(hash, chainId) : null;
if (!explorerUrl) {
return (
<CopyTextButton
className="font-mono text-muted-foreground text-sm"
copyIconPosition="left"
textToCopy={hash}
textToShow={shortHash}
tooltip="Copy transaction signature"
variant="ghost"
/>
);
}
return (
<Button
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
variant="ghost"
>
<Link
href={explorerUrl}
onClick={(e) => {
e.stopPropagation();
}}
rel="noopener noreferrer"
target="_blank"
>
{shortHash}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
);
}
// EVM Queued At cell component
function EVMQueuedAtCell(props: { transaction: Transaction }) {
const value = props.transaction.createdAt;
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
return (
<ToolTipLabel label={format(date, "PP pp z")}>
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
// Solana Queued At cell component
function SolanaQueuedAtCell(props: { transaction: SolanaTransaction }) {
const value = props.transaction.createdAt;
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
return (
<ToolTipLabel label={format(date, "PP pp z")}>
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
async function getEVMTransactions({
project,
page,
status,
id,
from,
}: {
project: Project;
page: number;
status: TransactionStatus | undefined;
id: string | undefined;
from: string | undefined;
}): Promise<TransactionsResponse> {
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
{
headers: {
"Content-Type": "application/json",
"x-client-id": project.publishableKey,
"x-team-id": project.teamId,
},
method: "GET",
pathname: `/v1/transactions`,
searchParams: {
limit: "20",
page: page.toString(),
status: status ?? undefined,
id: id ?? undefined,
from: from ?? undefined,
},
},
);
if (!transactions.ok) {
return {
transactions: [],
pagination: {
totalCount: 0,
page: 1,
limit: 20,
},
};
}
return transactions.data.result;
}
async function getSolanaTransactions({
project,
page,
status,
id,
from,
}: {
project: Project;
page: number;
status: SolanaTransactionStatus | undefined;
id: string | undefined;
from: string | undefined;
}): Promise<SolanaTransactionsResponse> {
const transactions = await engineCloudProxy<{
result: SolanaTransactionsResponse;
}>({
headers: {
"Content-Type": "application/json",
"x-client-id": project.publishableKey,
"x-team-id": project.teamId,
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
},
method: "GET",
pathname: `/v1/solana/transactions`,
searchParams: {
limit: "20",
page: page.toString(),
status: status ?? undefined,
id: id ?? undefined,
from: from ?? undefined,
},
});
if (!transactions.ok) {
return {
transactions: [],
pagination: {
totalCount: 0,
page: 1,
limit: 20,
},
};
}
return transactions.data.result;
}
@@ -205,6 +205,64 @@ export async function getSingleTransaction({
return data.transactions[0];
}
export async function getSingleSolanaTransaction({
teamId,
clientId,
transactionId,
}: {
teamId: string;
clientId: string;
transactionId: string;
}): Promise<
import("../analytics/solana-tx-table/types").SolanaTransaction | undefined
> {
const authToken = await getAuthToken();
const filters = {
filters: [
{
field: "id",
operation: "OR",
values: [transactionId],
},
],
};
const response = await fetch(
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/search`,
{
body: JSON.stringify(filters),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
"x-team-id": teamId,
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
},
method: "POST",
},
);
if (!response.ok) {
if (response.status === 401) {
return undefined;
}
const errorText = await response.text().catch(() => "Unknown error");
throw new Error(
`Error fetching single Solana transaction data: ${response.status} ${
response.statusText
} - ${errorText}`,
);
}
const rawData = await response.json();
const data =
rawData.result as import("../analytics/solana-tx-table/types").SolanaTransactionsResponse;
return data.transactions[0];
}
// Activity log types
export type ActivityLogEntry = {
id: string;
@@ -260,18 +318,45 @@ export async function getTransactionActivityLogs({
);
if (!response.ok) {
if (response.status === 401) {
if (response.status === 401 || response.status === 404) {
return [];
}
return [];
}
const data = (await response.json()) as ActivityLogsResponse;
return data.result.activityLogs;
}
export async function getSolanaTransactionActivityLogs({
teamId,
clientId,
transactionId,
}: {
teamId: string;
clientId: string;
transactionId: string;
}): Promise<ActivityLogEntry[]> {
const authToken = await getAuthToken();
const response = await fetch(
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/activity-logs?transactionId=${transactionId}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
"x-team-id": teamId,
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
},
method: "GET",
},
);
if (!response.ok) {
if (response.status === 401 || response.status === 404) {
return [];
}
// Don't throw on 404 - activity logs might not exist for all transactions
if (response.status === 404) {
return [];
}
console.error(
`Error fetching activity logs: ${response.status} ${response.statusText}`,
);
return [];
}
@@ -0,0 +1,29 @@
/**
* Get the Solscan explorer URL for a given transaction signature and chain ID
* @param signature - The transaction signature
* @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet"
* @returns The Solscan URL for the transaction
*/
export function getSolscanUrl(signature: string, chainId: string): string {
const network = chainId.split(":")[1] || "mainnet";
// Solscan uses different subdomains for different networks
switch (network.toLowerCase()) {
case "devnet":
return `https://solscan.io/tx/${signature}?cluster=devnet`;
case "testnet":
return `https://solscan.io/tx/${signature}?cluster=testnet`;
default:
return `https://solscan.io/tx/${signature}`;
}
}
/**
* Get the display network name from a Solana chain ID (capitalized)
* @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet"
* @returns The capitalized network name for display (e.g., "Devnet")
*/
export function getSolanaNetworkName(chainId: string): string {
const network = chainId.split(":")[1] || "mainnet";
return network.charAt(0).toUpperCase() + network.slice(1);
}
@@ -1,16 +1,19 @@
"use client";
import { encrypt } from "@thirdweb-dev/service-utils";
import { decrypt, encrypt } from "@thirdweb-dev/service-utils";
import {
createAccessToken,
createEoa,
createServiceAccount,
createVaultClient,
rotateServiceAccount,
type VaultClient,
} from "@thirdweb-dev/vault-sdk";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { updateProjectClient } from "@/hooks/useApi";
import { getProjectWalletLabel } from "@/lib/project-wallet";
const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
"Access Token for All Server Wallets";
@@ -126,6 +129,92 @@ export async function createVaultAccountAndAccessToken(props: {
}
}
export async function createProjectServerWallet(props: {
project: Project;
managementAccessToken: string;
label?: string;
setAsProjectWallet?: boolean;
}) {
const vaultClient = await initVaultClient();
const walletLabel = props.label?.trim()
? props.label.trim()
: getProjectWalletLabel(props.project.name);
const eoa = await createEoa({
client: vaultClient,
request: {
auth: {
accessToken: props.managementAccessToken,
},
options: {
metadata: {
label: walletLabel,
projectId: props.project.id,
teamId: props.project.teamId,
type: "server-wallet",
},
},
},
});
if (!eoa.success) {
throw new Error(eoa.error?.message || "Failed to create server wallet");
}
engineCloudProxy({
body: JSON.stringify({
signerAddress: eoa.data.address,
}),
headers: {
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
"x-team-id": props.project.teamId,
},
method: "POST",
pathname: "/cache/smart-account",
}).catch((err) => {
console.warn("failed to cache server wallet", err);
});
if (props.setAsProjectWallet) {
await updateDefaultProjectWallet({
project: props.project,
projectWalletAddress: eoa.data.address,
});
}
return eoa.data;
}
export async function updateDefaultProjectWallet(props: {
project: Project;
projectWalletAddress: string;
}) {
const services = props.project.services.filter(
(service) => service.name !== "engineCloud",
);
const engineCloudService = props.project.services.find(
(service) => service.name === "engineCloud",
);
if (engineCloudService) {
const engineCloudServiceWithProjectWallet = {
...engineCloudService,
projectWalletAddress: props.projectWalletAddress,
};
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [...services, engineCloudServiceWithProjectWallet],
},
);
}
}
async function createAndEncryptVaultAccessTokens(props: {
project: Project;
vaultClient: VaultClient;
@@ -157,6 +246,13 @@ async function createAndEncryptVaultAccessTokens(props: {
const managementToken = managementTokenResult.data;
const walletToken = walletTokenResult.data;
// create a default project server wallet
const defaultProjectServerWallet = await createProjectServerWallet({
project,
managementAccessToken: managementToken.accessToken,
label: getProjectWalletLabel(project.name),
});
if (projectSecretKey) {
// verify that the project secret key is valid
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
@@ -182,7 +278,9 @@ async function createAndEncryptVaultAccessTokens(props: {
},
{
services: [
...props.project.services,
...props.project.services.filter(
(service) => service.name !== "engineCloud",
),
{
name: "engineCloud",
actions: [],
@@ -191,6 +289,7 @@ async function createAndEncryptVaultAccessTokens(props: {
encryptedAdminKey,
encryptedWalletAccessToken,
rotationCode: rotationCode,
projectWalletAddress: defaultProjectServerWallet.address,
},
],
},
@@ -204,7 +303,9 @@ async function createAndEncryptVaultAccessTokens(props: {
},
{
services: [
...props.project.services,
...props.project.services.filter(
(service) => service.name !== "engineCloud",
),
{
name: "engineCloud",
actions: [],
@@ -213,6 +314,7 @@ async function createAndEncryptVaultAccessTokens(props: {
encryptedAdminKey: null,
encryptedWalletAccessToken: null,
rotationCode: rotationCode,
projectWalletAddress: defaultProjectServerWallet.address,
},
],
},
@@ -412,6 +514,29 @@ export async function createWalletAccessToken(props: {
},
type: "eoa:signStructuredMessage",
},
{
requiredMetadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
type: "eoa:create",
},
{
metadataPatterns: [
{
@@ -433,7 +558,7 @@ export async function createWalletAccessToken(props: {
},
},
],
type: "eoa:read",
type: "solana:read",
},
{
requiredMetadataPatterns: [
@@ -456,7 +581,53 @@ export async function createWalletAccessToken(props: {
},
},
],
type: "eoa:create",
type: "solana:create",
},
{
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
type: "solana:signTransaction",
},
{
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
type: "solana:signMessage",
},
],
},
@@ -531,6 +702,52 @@ async function createManagementAccessToken(props: {
],
type: "eoa:create",
},
{
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
type: "solana:read",
},
{
requiredMetadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
type: "solana:create",
},
{
metadataPatterns: [
{
@@ -555,6 +772,187 @@ async function createManagementAccessToken(props: {
});
}
/**
* Upgrades existing access tokens to include Solana permissions
* This is needed when a project was created before Solana support was added
*
* Returns an object with success/error instead of throwing for Next.js server actions
*/
export async function upgradeAccessTokensForSolana(props: {
project: Project;
projectSecretKey?: string;
}): Promise<{
success: boolean;
error?: string;
data?: {
managementToken: string;
walletToken?: string;
};
}> {
const { project, projectSecretKey } = props;
try {
// Find the engineCloud service
const engineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
if (!engineCloudService) {
return {
success: false,
error: "No engineCloud service found on project",
};
}
const vaultClient = await initVaultClient();
const hasEncryptedAdminKey = !!engineCloudService.encryptedAdminKey;
// Check if this is an ejected vault (no encrypted admin key stored)
if (!hasEncryptedAdminKey) {
// For ejected vaults, we only need to update the management token
// User manages their own admin key, so we can't create wallet tokens
// We need the admin key from the user
if (!projectSecretKey) {
return {
success: false,
error: "Admin key required. Please enter your vault admin key.",
};
}
// For ejected vault, the "secret key" parameter is actually the admin key
const managementTokenResult = await createManagementAccessToken({
project,
adminKey: projectSecretKey,
vaultClient,
});
if (!managementTokenResult.success) {
return {
success: false,
error: `Failed to create management token: ${managementTokenResult.error}`,
};
}
// Update only the management token for ejected vaults
// Keep everything else the same (no encrypted keys to update)
await updateProjectClient(
{
projectId: project.id,
teamId: project.teamId,
},
{
services: [
...project.services.filter(
(service) => service.name !== "engineCloud",
),
{
...engineCloudService,
managementAccessToken: managementTokenResult.data.accessToken,
},
],
},
);
return {
success: true,
data: {
managementToken: managementTokenResult.data.accessToken,
},
};
}
// For non-ejected vaults (with encrypted admin key)
if (!projectSecretKey) {
return {
success: false,
error: "Project secret key is required to upgrade tokens",
};
}
// Verify the project secret key
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
if (!project.secretKeys.some((key) => key?.hash === projectSecretKeyHash)) {
return {
success: false,
error: "Invalid project secret key",
};
}
// Decrypt the admin key (we know it exists from the hasEncryptedAdminKey check)
const adminKey = await decrypt(
engineCloudService.encryptedAdminKey as string,
projectSecretKey,
);
// Create new tokens with Solana permissions
const [managementTokenResult, walletTokenResult] = await Promise.all([
createManagementAccessToken({ project, adminKey, vaultClient }),
createWalletAccessToken({ project, adminKey, vaultClient }),
]);
if (!managementTokenResult.success) {
return {
success: false,
error: `Failed to create management token: ${managementTokenResult.error}`,
};
}
if (!walletTokenResult.success) {
return {
success: false,
error: `Failed to create wallet token: ${walletTokenResult.error}`,
};
}
const managementToken = managementTokenResult.data;
const walletToken = walletTokenResult.data;
// Encrypt the new wallet token
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
encrypt(adminKey, projectSecretKey),
encrypt(walletToken.accessToken, projectSecretKey),
]);
// Update the project with new tokens
await updateProjectClient(
{
projectId: project.id,
teamId: project.teamId,
},
{
services: [
...project.services.filter(
(service) => service.name !== "engineCloud",
),
{
...engineCloudService,
managementAccessToken: managementToken.accessToken,
encryptedAdminKey,
encryptedWalletAccessToken,
},
],
},
);
return {
success: true,
data: {
managementToken: managementToken.accessToken,
walletToken: walletToken.accessToken,
},
};
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to upgrade access tokens",
};
}
}
export function maskSecret(secret: string) {
return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`;
}
@@ -9,9 +9,13 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
import { EngineChecklist } from "./analytics/ftux.client";
import { TransactionAnalyticsSummary } from "./analytics/summary";
import { ServerWalletsTable } from "./components/server-wallets-table.client";
import { getTransactionAnalyticsSummary } from "./lib/analytics";
import type { Wallet } from "./server-wallets/wallet-table/types";
import { ServerWalletsTable } from "./server-wallets/wallet-table/wallet-table";
import { listSolanaAccounts } from "./solana-wallets/lib/vault.client";
import type { SolanaWallet } from "./solana-wallets/wallet-table/types";
export const dynamic = "force-dynamic";
export default async function TransactionsAnalyticsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -20,7 +24,9 @@ export default async function TransactionsAnalyticsPage(props: {
to?: string | string[] | undefined;
interval?: string | string[] | undefined;
testTxWithWallet?: string | string[] | undefined;
testSolanaTxWithWallet?: string | string[] | undefined;
page?: string;
solana_page?: string;
}>;
}) {
const [params, searchParams, authToken] = await Promise.all([
@@ -58,6 +64,7 @@ export default async function TransactionsAnalyticsPage(props: {
const pageSize = 10;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
const eoas = managementAccessToken
? await listEoas({
@@ -77,6 +84,32 @@ export default async function TransactionsAnalyticsPage(props: {
const wallets = eoas.data?.items as Wallet[] | undefined;
// Fetch Solana accounts - gracefully handle permission errors
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
// Check if error is a permission error
const isSolanaPermissionError =
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
const initialData = await getTransactionAnalyticsSummary({
clientId: project.publishableKey,
teamId: project.teamId,
@@ -127,39 +160,63 @@ export default async function TransactionsAnalyticsPage(props: {
project={project}
teamSlug={params.team_slug}
testTxWithWallet={searchParams.testTxWithWallet as string | undefined}
testSolanaTxWithWallet={
searchParams.testSolanaTxWithWallet as string | undefined
}
wallets={wallets ?? []}
solanaWallets={solanaAccounts.data.items}
/>
{hasTransactions && !searchParams.testTxWithWallet && (
<TransactionAnalyticsSummary
clientId={project.publishableKey}
initialData={initialData}
teamId={project.teamId}
/>
)}
{hasTransactions &&
!searchParams.testTxWithWallet &&
!searchParams.testSolanaTxWithWallet && (
<TransactionAnalyticsSummary
clientId={project.publishableKey}
initialData={initialData}
teamId={project.teamId}
/>
)}
{/* transactions */}
<TransactionsAnalyticsPageContent
client={client}
project={project}
searchParams={searchParams}
showAnalytics={hasTransactions && !searchParams.testTxWithWallet}
showAnalytics={
hasTransactions &&
!searchParams.testTxWithWallet &&
!searchParams.testSolanaTxWithWallet
}
teamSlug={params.team_slug}
wallets={wallets}
/>
{/* server wallets */}
{/* Server Wallets (EVM + Solana) */}
{eoas.error ? (
<div>Error: {eoas.error.message}</div>
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">
{eoas.error.message}
</p>
</div>
) : (
<ServerWalletsTable
client={client}
currentPage={currentPage}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={eoas.data.items as Wallet[]}
managementAccessToken={managementAccessToken ?? undefined}
project={project}
solanaCurrentPage={solanCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
totalRecords={eoas.data.totalRecords}
wallets={eoas.data.items as Wallet[]}
solanaPermissionError={isSolanaPermissionError}
/>
)}
</div>
@@ -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,479 +0,0 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import {
MoreVerticalIcon,
RefreshCcwIcon,
SendIcon,
XIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import {
DEFAULT_ACCOUNT_FACTORY_V0_7,
predictSmartAccountAddress,
} from "thirdweb/wallets/smart";
import type { Project } from "@/api/project/projects";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { cn } from "@/lib/utils";
import CreateServerWallet from "../components/create-server-wallet.client";
import type { Wallet } from "./types";
export function ServerWalletsTableUI({
wallets,
project,
teamSlug,
managementAccessToken,
totalRecords,
currentPage,
totalPages,
client,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
totalRecords: number;
currentPage: number;
totalPages: number;
client: ThirdwebClient;
}) {
const [selectedChainId, setSelectedChainId] = useState<number>(1);
const [showSmartAccount, setShowSmartAccount] = useState(false);
const queryClient = useQueryClient();
return (
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<WalletProductIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
Server Wallets
</h2>
<p className="text-muted-foreground text-sm">
Create and manage server wallets for your project
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<SingleNetworkSelector
chainId={selectedChainId}
onChange={setSelectedChainId}
client={client}
disableChainId
className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50"
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
/>
<div className="flex items-center gap-2.5">
<Label
className="text-sm text-muted-foreground"
htmlFor="showSmartAccount"
>
Show ERC4337 Smart Account
</Label>
<Switch
id="showSmartAccount"
checked={showSmartAccount}
onCheckedChange={setShowSmartAccount}
size="sm"
/>
</div>
</div>
</div>
<TableContainer className="rounded-none border-x-0 border-b-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Label</TableHead>
<TableHead className="w-[280px]">
{showSmartAccount
? "Smart Account Address"
: "Wallet Address"}
</TableHead>
<TableHead className="min-w-[80px]">
<div className="flex w-[180px] items-center gap-1.5">
Balance
{wallets.length > 0 && (
<ToolTipLabel
contentClassName="capitalize font-normal tracking-normal leading-normal"
label="Refresh Balance"
>
<Button
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
onClick={async (e) => {
const buttonEl = e.currentTarget;
buttonEl.setAttribute("data-pending", "true");
await queryClient.invalidateQueries({
queryKey: ["walletBalance", selectedChainId],
});
buttonEl.setAttribute("data-pending", "false");
}}
size="sm"
variant="ghost"
>
<RefreshCcwIcon className="size-4" />
</Button>
</ToolTipLabel>
)}
</div>
</TableHead>
<TableHead>Created </TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wallets.map((wallet) => (
<ServerWalletTableRow
key={wallet.id}
wallet={wallet}
project={project}
teamSlug={teamSlug}
client={client}
chainId={selectedChainId}
showSmartAccount={showSmartAccount}
/>
))}
</TableBody>
</Table>
{wallets.length === 0 && (
<div className="py-24 flex flex-col items-center justify-center px-4 text-center gap-4">
<div className="p-2 rounded-full bg-background border border-border">
<XIcon className="size-5 text-muted-foreground" />
</div>
<p className="text-muted-foreground">No server wallets found</p>
<CreateServerWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
/>
</div>
)}
</TableContainer>
{wallets.length > 0 && (
<div className="flex justify-end items-center p-4 py-5 lg:px-6 border-t">
<CreateServerWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
/>
</div>
)}
{totalPages > 1 && (
<div className="flex flex-col items-center border-t p-6">
<div className="mb-4 text-muted-foreground text-sm">
Found {totalRecords} server wallets
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
currentPage > 1 ? currentPage - 1 : 1
}`}
legacyBehavior
passHref
>
<PaginationPrevious
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</Link>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={`page-${pageNumber}`}>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?page=${pageNumber}`}
passHref
>
<PaginationLink isActive={currentPage === pageNumber}>
{pageNumber}
</PaginationLink>
</Link>
</PaginationItem>
),
)}
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
currentPage < totalPages ? currentPage + 1 : totalPages
}`}
passHref
>
<PaginationNext
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</Link>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
</div>
);
}
function ServerWalletTableRow(props: {
wallet: Wallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
chainId: number;
showSmartAccount: boolean;
}) {
const { wallet, project, teamSlug, client, chainId, showSmartAccount } =
props;
const chain = useV5DashboardChain(chainId);
const smartAccountAddressQuery = useQuery({
queryFn: async () => {
const smartAccountAddress = await predictSmartAccountAddress({
adminAddress: wallet.address,
chain: chain,
client: client,
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
});
return smartAccountAddress;
},
enabled: showSmartAccount,
queryKey: ["smart-account-address", wallet.address, chainId],
});
return (
<TableRow key={wallet.id}>
{/* Label */}
<TableCell>
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
)}
>
{wallet.metadata.label || "N/A"}
</span>
</TableCell>
{/* Address */}
<TableCell>
{showSmartAccount ? (
<div>
{smartAccountAddressQuery.data ? (
<WalletAddress
address={smartAccountAddressQuery.data}
client={client}
/>
) : smartAccountAddressQuery.isPending ? (
<Skeleton className="h-7 w-24" />
) : (
<span className="text-muted-foreground">N/A</span>
)}
</div>
) : (
<WalletAddress address={wallet.address} client={client} />
)}
</TableCell>
{/* Balance */}
<TableCell>
{showSmartAccount && (
// biome-ignore lint/complexity/noUselessFragments: keep for readability
<>
{smartAccountAddressQuery.isPending ? (
<Skeleton className="h-5 w-16" />
) : smartAccountAddressQuery.data ? (
<WalletBalanceCell
address={smartAccountAddressQuery.data}
chainId={chainId}
client={client}
/>
) : (
<span className="text-muted-foreground">N/A</span>
)}
</>
)}
{!showSmartAccount && (
<WalletBalanceCell
address={wallet.address}
chainId={chainId}
client={client}
/>
)}
</TableCell>
<TableCell>
<WalletDateCell date={wallet.createdAt} />
</TableCell>
<TableCell>
<WalletActionsDropdown
project={project}
teamSlug={teamSlug}
fundWalletAddress={
showSmartAccount ? smartAccountAddressQuery.data : wallet.address
}
wallet={wallet}
client={client}
chainId={chainId}
/>
</TableCell>
</TableRow>
);
}
function WalletDateCell({ date }: { date: string }) {
if (!date) {
return "N/A";
}
const dateObj = new Date(date);
return (
<ToolTipLabel label={format(dateObj, "PP pp z")}>
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
function WalletActionsDropdown(props: {
fundWalletAddress: string | undefined;
wallet: Wallet;
teamSlug: string;
project: Project;
client: ThirdwebClient;
chainId: number;
}) {
const [showFundModal, setShowFundModal] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVerticalIcon className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="rounded-xl">
<DropdownMenuItem asChild>
<Link
href={`/team/${props.teamSlug}/${props.project.slug}/transactions?testTxWithWallet=${props.wallet.id}`}
className="flex items-center gap-2 h-9 rounded-lg"
>
<SendIcon className="size-4 text-muted-foreground" />
Send test transaction
</Link>
</DropdownMenuItem>
{props.fundWalletAddress && (
<DropdownMenuItem
onClick={() => setShowFundModal(true)}
className="flex items-center gap-2 h-9 rounded-lg"
>
<WalletProductIcon className="size-4 text-muted-foreground" />
Fund wallet
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{props.fundWalletAddress && (
<FundWalletModal
open={showFundModal}
onOpenChange={setShowFundModal}
title="Fund server wallet"
description="Send funds to the server wallet"
recipientAddress={props.fundWalletAddress}
client={props.client}
defaultChainId={props.chainId}
checkoutWidgetTitle={
props.wallet.metadata.label
? `Fund ${props.wallet.metadata.label}`
: "Fund server wallet"
}
/>
)}
</>
);
}
function WalletBalanceCell(props: {
address: string;
chainId: number;
client: ThirdwebClient;
}) {
const chain = useV5DashboardChain(props.chainId);
const balance = useWalletBalance({
address: props.address,
chain: chain,
client: props.client,
});
if (balance.isFetching) {
return <Skeleton className="h-5 w-16" />;
}
if (!balance.data) {
return <span className="text-muted-foreground">N/A</span>;
}
return (
<span className="text-sm text-foreground">
{balance.data.displayValue} {balance.data.symbol}
</span>
);
}
@@ -1,37 +0,0 @@
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import type { Wallet } from "./types";
import { ServerWalletsTableUI } from "./wallet-table-ui.client";
export function ServerWalletsTable({
wallets,
project,
teamSlug,
currentPage,
totalPages,
totalRecords,
managementAccessToken,
client,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
totalRecords: number;
currentPage: number;
totalPages: number;
client: ThirdwebClient;
}) {
return (
<ServerWalletsTableUI
client={client}
currentPage={currentPage}
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
totalPages={totalPages}
totalRecords={totalRecords}
wallets={wallets}
/>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import type { Project } from "@/api/project/projects";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { createSolanaAccount } from "../lib/vault.client";
export function CreateSolanaWallet(props: {
managementAccessToken: string | undefined;
project: Project;
teamSlug: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [label, setLabel] = useState("");
const router = useDashboardRouter();
const createMutation = useMutation({
mutationFn: async () => {
if (!props.managementAccessToken) {
throw new Error("No management access token");
}
const result = await createSolanaAccount({
managementAccessToken: props.managementAccessToken,
label: label.trim(),
projectId: props.project.id,
teamId: props.project.teamId,
});
if (!result.success || !result.data) {
throw result.error || new Error("Failed to create Solana wallet");
}
return result.data;
},
onSuccess: () => {
toast.success("Solana wallet created successfully");
setOpen(false);
setLabel("");
router.refresh();
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: "Failed to create Solana wallet",
);
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="default"
className="gap-2 rounded-full bg-foreground hover:bg-foreground/90"
disabled={props.disabled}
>
<PlusIcon className="size-4" />
Create Solana Wallet
</Button>
</DialogTrigger>
<DialogContent className="rounded-2xl">
<DialogHeader>
<DialogTitle>Create Solana Wallet</DialogTitle>
<DialogDescription>
Create a new Solana server wallet for your project
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
createMutation.mutate();
}}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="label">Wallet Label</Label>
<Input
id="label"
placeholder="e.g., Production Wallet"
value={label}
onChange={(e) => setLabel(e.target.value)}
required
/>
</div>
<Button
type="submit"
disabled={createMutation.isPending || !label.trim()}
>
{createMutation.isPending ? "Creating..." : "Create Wallet"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,218 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { KeyRoundIcon, Loader2Icon, ShieldCheckIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import type { Project } from "@/api/project/projects";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { upgradeAccessTokensForSolana } from "../../lib/vault.client";
const formSchema = z.object({
secretKey: z.string().min(1, "Secret key is required"),
});
type FormValues = z.infer<typeof formSchema>;
export function UpgradeSolanaPermissions(props: { project: Project }) {
const router = useDashboardRouter();
const [isComplete, setIsComplete] = useState(false);
// Check if this is an ejected vault
const engineCloudService = props.project.services.find(
(s) => s.name === "engineCloud",
);
const isEjectedVault = !engineCloudService?.encryptedAdminKey;
const form = useForm<FormValues>({
defaultValues: {
secretKey: "",
},
resolver: zodResolver(formSchema),
});
const upgradeMutation = useMutation({
mutationFn: async (data: FormValues) => {
return upgradeAccessTokensForSolana({
project: props.project,
projectSecretKey: data.secretKey,
});
},
onSuccess: (result) => {
if (!result.success) {
toast.error(result.error || "Failed to upgrade Solana permissions");
return;
}
setIsComplete(true);
toast.success("Solana permissions enabled successfully!");
// Refresh the page after a short delay to show updated state
setTimeout(() => {
router.refresh();
}, 1500);
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: "Failed to upgrade Solana permissions",
);
},
});
if (isComplete) {
return (
<Card className="border-success/50 bg-success/5">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-success/10">
<ShieldCheckIcon className="size-5 text-success" />
</div>
<div className="flex-1">
<CardTitle className="text-success">
Solana Access Enabled
</CardTitle>
<CardDescription>
Your project now has full Solana functionality
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-2 rounded-lg bg-background/50 p-3 border border-border/50">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
Refreshing page to load Solana wallets...
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-purple-500/10">
<KeyRoundIcon className="size-5 text-purple-500" />
</div>
<div>
<CardTitle>Enable Solana Functionality</CardTitle>
<CardDescription>
Upgrade your project to support Solana wallets and transactions
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertTitle>
{isEjectedVault
? "Why do you need my admin key?"
: "Why do you need my secret key?"}
</AlertTitle>
<AlertDescription className="mt-2 space-y-2 text-sm">
<p>
Your project was created before Solana support was added. To
enable Solana features, we need to update your access tokens with
new permissions.
</p>
{isEjectedVault ? (
<>
<p>
Since you're using an ejected vault, your admin key is used
to:
</p>
<ul className="ml-4 list-disc space-y-1">
<li>Create new access tokens with Solana permissions</li>
<li>Update your management token with the new permissions</li>
</ul>
<p className="font-medium">
Your admin key is never stored and is only used during this
one-time upgrade process.
</p>
</>
) : (
<>
<p>Your secret key is used to:</p>
<ul className="ml-4 list-disc space-y-1">
<li>Decrypt your existing admin credentials</li>
<li>Create new access tokens with Solana permissions</li>
<li>Re-encrypt and securely store the updated credentials</li>
</ul>
<p className="font-medium">
Your secret key is never stored and is only used during this
one-time upgrade process.
</p>
</>
)}
</AlertDescription>
</Alert>
<form
className="space-y-4"
onSubmit={form.handleSubmit((data) => upgradeMutation.mutate(data))}
>
<div className="space-y-2">
<Label htmlFor="secretKey">
{isEjectedVault ? "Vault Admin Key" : "Project Secret Key"}
</Label>
<Input
{...form.register("secretKey")}
id="secretKey"
placeholder={
isEjectedVault
? "Enter your vault admin key"
: "Enter your secret key"
}
type="password"
disabled={upgradeMutation.isPending}
className="font-mono"
/>
{form.formState.errors.secretKey && (
<p className="text-destructive text-sm">
{form.formState.errors.secretKey.message}
</p>
)}
<p className="text-muted-foreground text-xs">
{isEjectedVault
? "This is the admin key you received when you ejected your vault"
: "You can find your secret key in your project settings"}
</p>
</div>
<div className="flex justify-end gap-2">
<Button
type="submit"
disabled={upgradeMutation.isPending || !form.formState.isValid}
className="min-w-[140px]"
>
{upgradeMutation.isPending ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Upgrading...
</>
) : (
"Enable Solana"
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,200 @@
"use server";
import {
createVaultClient,
createSolanaAccount as vaultCreateSolanaAccount,
listSolanaAccounts as vaultListSolanaAccounts,
} from "@thirdweb-dev/vault-sdk";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import type { SolanaWallet } from "../wallet-table/types";
interface VaultSolanaAccountListItem {
id: string;
pubkey: string;
createdAt: string;
updatedAt: string;
metadata: {
projectId?: string;
type?: string;
label?: string;
} | null;
}
interface SolanaAccountResponse {
pubkey: string;
createdAt: string;
updatedAt: string;
}
export async function listSolanaAccounts(params: {
managementAccessToken: string;
page?: number;
limit?: number;
projectId?: string;
}): Promise<{
data: {
items: SolanaWallet[];
totalRecords: number;
};
error: Error | null;
success: boolean;
}> {
const { managementAccessToken, page = 1, limit = 100, projectId } = params;
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
return {
data: {
items: [],
totalRecords: 0,
},
error: new Error("Missing managementAccessToken or vault URL"),
success: false,
};
}
try {
const vaultClient = await createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
});
const response = await vaultListSolanaAccounts({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: page - 1, // Vault SDK uses 0-based pagination
pageSize: limit,
},
},
});
if (!response.success || !response.data) {
return {
data: {
items: [],
totalRecords: 0,
},
error: response.error
? new Error(JSON.stringify(response.error))
: new Error("Failed to fetch Solana accounts"),
success: false,
};
}
const items = (response.data.items || []) as VaultSolanaAccountListItem[];
// Filter by projectId and type, transform to SolanaWallet type
const wallets: SolanaWallet[] = items
.filter((item) => {
// Filter by projectId
if (projectId && item.metadata?.projectId !== projectId) {
return false;
}
// Only include server-wallet type
return !item.metadata?.type || item.metadata.type === "server-wallet";
})
.map((item) => ({
id: item.id,
publicKey: item.pubkey,
metadata: {
type: "server-wallet",
projectId: item.metadata?.projectId || projectId || "",
label: item.metadata?.label,
},
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}));
return {
data: {
items: wallets,
totalRecords: wallets.length, // Use filtered count since we're filtering by projectId
},
error: null,
success: true,
};
} catch (error) {
console.error("Failed to list Solana accounts", error);
return {
data: {
items: [],
totalRecords: 0,
},
error: error instanceof Error ? error : new Error("Unknown error"),
success: false,
};
}
}
export async function createSolanaAccount(params: {
managementAccessToken: string;
label: string;
projectId: string;
teamId: string;
}): Promise<{
data: SolanaAccountResponse | null;
error: Error | null;
success: boolean;
}> {
const { managementAccessToken, label, projectId, teamId } = params;
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
return {
data: null,
error: new Error("Missing managementAccessToken or vault URL"),
success: false,
};
}
try {
const vaultClient = await createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
});
const response = await vaultCreateSolanaAccount({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
metadata: {
label,
projectId,
teamId,
type: "server-wallet",
},
},
},
});
if (!response.success || !response.data) {
return {
data: null,
error: response.error
? new Error(JSON.stringify(response.error))
: new Error("Failed to create Solana account"),
success: false,
};
}
return {
data: {
pubkey: response.data.pubkey,
createdAt: response.data.createdAt,
updatedAt: response.data.updatedAt,
},
error: null,
success: true,
};
} catch (error) {
console.error("Failed to create Solana account", error);
return {
data: null,
error: error instanceof Error ? error : new Error("Unknown error"),
success: false,
};
}
}
@@ -0,0 +1,11 @@
export type SolanaWallet = {
id: string;
publicKey: string;
metadata: {
type: string;
projectId: string;
label?: string;
};
createdAt: string;
updatedAt: string;
};
@@ -1,25 +1,27 @@
import { ChevronLeftIcon } from "lucide-react";
import Link from "next/link";
export default function TransactionLayout({
export default async function TransactionLayout({
children,
params,
}: {
children: React.ReactNode;
params: { team_slug: string; project_slug: string };
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await params;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center gap-2">
<Link
className="flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
href={`/team/${params.team_slug}/${params.project_slug}/transactions`}
href={`/team/${team_slug}/${project_slug}/transactions`}
>
<ChevronLeftIcon className="size-4" />
Back to Transactions
</Link>
</div>
<div className="flex-1">{children}</div>
<div className="flex-1 space-y-6">{children}</div>
</div>
);
}
@@ -15,9 +15,12 @@ import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { loginRedirect } from "@/utils/redirects";
import type { Transaction } from "../../analytics/tx-table/types";
import {
getSingleSolanaTransaction,
getSingleTransaction,
getSolanaTransactionActivityLogs,
getTransactionActivityLogs,
} from "../../lib/analytics";
import { SolanaTransactionDetailsUI } from "./solana-transaction-details-ui";
import { TransactionDetailsUI } from "./transaction-details-ui";
type AbiItem =
@@ -195,17 +198,18 @@ export default async function TransactionPage({
redirect(`/team/${team_slug}`);
}
const [transactionData, activityLogs] = await Promise.all([
// Try fetching both EVM and Solana transactions
const [evmTransactionData, solanaTransactionData] = await Promise.all([
getSingleTransaction({
clientId: project.publishableKey,
teamId: project.teamId,
transactionId: id,
}),
getTransactionActivityLogs({
}).catch(() => null),
getSingleSolanaTransaction({
clientId: project.publishableKey,
teamId: project.teamId,
transactionId: id,
}),
}).catch(() => null),
]);
const client = getClientThirdwebClient({
@@ -213,23 +217,50 @@ export default async function TransactionPage({
teamId: project.teamId,
});
if (!transactionData) {
notFound();
// Determine which transaction type we have and fetch appropriate activity logs
if (solanaTransactionData) {
const activityLogs = await getSolanaTransactionActivityLogs({
clientId: project.publishableKey,
teamId: project.teamId,
transactionId: id,
});
// Render Solana transaction details
return (
<SolanaTransactionDetailsUI
activityLogs={activityLogs}
client={client}
project={project}
teamSlug={team_slug}
transaction={solanaTransactionData}
/>
);
}
// Decode transaction data on the server
const decodedTransactionData = await decodeTransactionData(transactionData);
if (evmTransactionData) {
const activityLogs = await getTransactionActivityLogs({
clientId: project.publishableKey,
teamId: project.teamId,
transactionId: id,
});
return (
<div className="space-y-6 p-2">
// Decode transaction data on the server for EVM
const decodedTransactionData =
await decodeTransactionData(evmTransactionData);
// Render EVM transaction details
return (
<TransactionDetailsUI
activityLogs={activityLogs}
client={client}
decodedTransactionData={decodedTransactionData}
project={project}
teamSlug={team_slug}
transaction={transactionData}
transaction={evmTransactionData}
/>
</div>
);
);
}
// No transaction found
notFound();
}
@@ -0,0 +1,459 @@
"use client";
import { format, formatDistanceToNowStrict } from "date-fns";
import {
ChevronDownIcon,
ChevronRightIcon,
ExternalLinkIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { stringify } from "thirdweb/utils";
import type { Project } from "@/api/project/projects";
import { SolanaAddress } from "@/components/blocks/solana-address";
import { Badge } from "@/components/ui/badge";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CodeClient } from "@/components/ui/code/code.client";
import { TabButtons } from "@/components/ui/tabs";
import { ToolTipLabel } from "@/components/ui/tooltip";
import type { SolanaTransaction } from "../../analytics/solana-tx-table/types";
import type { ActivityLogEntry } from "../../lib/analytics";
import { getSolanaNetworkName, getSolscanUrl } from "../../lib/solana-utils";
const solanaStatusDetails = {
CONFIRMED: { name: "Confirmed", variant: "success" as const },
FAILED: { name: "Failed", variant: "destructive" as const },
QUEUED: { name: "Queued", variant: "warning" as const },
SUBMITTED: { name: "Submitted", variant: "warning" as const },
};
export function SolanaTransactionDetailsUI({
transaction,
activityLogs,
}: {
transaction: SolanaTransaction;
teamSlug: string;
client: ThirdwebClient;
project: Project;
activityLogs: ActivityLogEntry[];
}) {
const [activeTab, setActiveTab] = useState<"overview" | "logs" | "raw">(
"overview",
);
// Extract relevant data from transaction
const {
id,
chainId,
signerAddress,
signature,
confirmedAt,
createdAt,
executionResult,
errorMessage,
} = transaction;
const status = (executionResult?.status ||
transaction.status ||
"QUEUED") as keyof typeof solanaStatusDetails;
// Parse network from chainId
const network = getSolanaNetworkName(chainId);
const networkDisplay = network.charAt(0).toUpperCase() + network.slice(1);
// Calculate time difference between creation and confirmation
const confirmationTime =
confirmedAt && createdAt
? new Date(confirmedAt).getTime() -
(createdAt instanceof Date ? createdAt : new Date(createdAt)).getTime()
: null;
// Get signature from executionResult if available
const txSignature =
(executionResult && "signature" in executionResult
? executionResult.signature
: signature) || null;
// Get slot and blockTime
const slot =
executionResult && "slot" in executionResult
? executionResult.slot
: transaction.confirmedAtSlot;
const blockTime =
executionResult && "blockTime" in executionResult
? executionResult.blockTime
: transaction.blockTime;
return (
<>
{/* Transaction ID Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="font-semibold text-3xl tracking-tight">
Transaction Details
</h1>
<div className="mt-2 flex items-center gap-2">
<span className="text-muted-foreground text-sm">Queue ID:</span>
<CopyTextButton
className="font-mono"
copyIconPosition="left"
textToCopy={id}
textToShow={id}
tooltip="Copy Queue ID"
variant="ghost"
/>
</div>
</div>
<Badge
className="gap-2 text-base"
variant={solanaStatusDetails[status].variant}
>
{solanaStatusDetails[status].name}
</Badge>
</div>
{/* Tabs */}
<TabButtons
tabs={[
{
name: "Overview",
onClick: () => setActiveTab("overview"),
isActive: activeTab === "overview",
},
{
name: "Activity Logs",
onClick: () => setActiveTab("logs"),
isActive: activeTab === "logs",
},
{
name: "Raw Data",
onClick: () => setActiveTab("raw"),
isActive: activeTab === "raw",
},
]}
/>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="grid gap-6 md:grid-cols-2">
{/* Transaction Information */}
<Card>
<CardHeader>
<CardTitle>Transaction Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<InfoRow label="Network" value={`Solana ${networkDisplay}`} />
<InfoRow
label="Status"
value={solanaStatusDetails[status].name}
/>
<InfoRow label="Signer Address">
<SolanaAddress address={signerAddress} shortenAddress={false} />
</InfoRow>
{txSignature && (
<InfoRow label="Signature">
<Link
href={getSolscanUrl(txSignature, chainId)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-link-foreground hover:underline text-sm"
>
<code className="font-mono">
{txSignature.slice(0, 12)}...{txSignature.slice(-12)}
</code>
<ExternalLinkIcon className="size-3" />
</Link>
</InfoRow>
)}
{slot && <InfoRow label="Slot" value={slot.toString()} />}
{errorMessage && (
<InfoRow label="Error">
<span className="text-destructive text-sm">
{errorMessage}
</span>
</InfoRow>
)}
</CardContent>
</Card>
{/* Timing Information */}
<Card>
<CardHeader>
<CardTitle>Timing Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<InfoRow label="Queued At">
<ToolTipLabel
label={format(
createdAt instanceof Date ? createdAt : new Date(createdAt),
"PP pp z",
)}
>
<span className="text-sm">
{formatDistanceToNowStrict(
createdAt instanceof Date
? createdAt
: new Date(createdAt),
{ addSuffix: true },
)}
</span>
</ToolTipLabel>
</InfoRow>
{confirmedAt && (
<>
<InfoRow label="Confirmed At">
<ToolTipLabel
label={format(new Date(confirmedAt), "PP pp z")}
>
<span className="text-sm">
{formatDistanceToNowStrict(new Date(confirmedAt), {
addSuffix: true,
})}
</span>
</ToolTipLabel>
</InfoRow>
{confirmationTime && (
<InfoRow
label="Confirmation Time"
value={`${(confirmationTime / 1000).toFixed(2)}s`}
/>
)}
</>
)}
{blockTime && (
<InfoRow
label="Block Time"
value={format(new Date(blockTime * 1000), "PP pp z")}
/>
)}
</CardContent>
</Card>
{/* Transaction Instructions */}
{transaction.transactionParams &&
transaction.transactionParams.instructions.length > 0 && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Instructions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{transaction.transactionParams.instructions.map(
(instruction, index) => (
<InstructionCard
key={`instruction-${instruction.programId}-${index}`}
index={index}
instruction={instruction}
/>
),
)}
</div>
</CardContent>
</Card>
)}
{/* Execution Details */}
{executionResult && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Execution Details</CardTitle>
</CardHeader>
<CardContent>
<CodeClient
code={stringify(executionResult, null, 2)}
lang="json"
/>
</CardContent>
</Card>
)}
</div>
)}
{/* Activity Logs Tab */}
{activeTab === "logs" && (
<Card>
<CardHeader>
<CardTitle>Activity Logs</CardTitle>
</CardHeader>
<CardContent>
{activityLogs.length === 0 ? (
<p className="text-muted-foreground text-sm">
No activity logs available
</p>
) : (
<div className="space-y-2">
{activityLogs.map((log) => (
<ActivityLogItem key={log.id} log={log} />
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Raw Data Tab */}
{activeTab === "raw" && (
<Card>
<CardHeader>
<CardTitle>Raw Transaction Data</CardTitle>
</CardHeader>
<CardContent>
<CodeClient code={stringify(transaction, null, 2)} lang="json" />
</CardContent>
</Card>
)}
</>
);
}
function InfoRow({
label,
value,
children,
}: {
label: string;
value?: string;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<dt className="text-muted-foreground text-sm font-medium">{label}</dt>
<dd className="text-sm">
{children || <span className="break-all">{value || "N/A"}</span>}
</dd>
</div>
);
}
function InstructionCard({
instruction,
index,
}: {
instruction: {
programId: string;
keys: Array<{
pubkey: string;
isSigner: boolean;
isWritable: boolean;
}>;
data: string;
};
index: number;
}) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<button
type="button"
className="flex w-full items-center justify-between"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="font-medium text-sm">Instruction {index + 1}</span>
{isExpanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</button>
{isExpanded && (
<div className="mt-4 space-y-3">
<div>
<p className="text-muted-foreground text-xs font-medium mb-1">
Program ID
</p>
<code className="text-xs font-mono break-all">
{instruction.programId}
</code>
</div>
<div>
<p className="text-muted-foreground text-xs font-medium mb-1">
Accounts ({instruction.keys.length})
</p>
<div className="space-y-2">
{instruction.keys.map((key, idx) => (
<div
key={`${key.pubkey}-${idx}`}
className="rounded border border-border bg-background p-2"
>
<code className="text-xs font-mono break-all">
{key.pubkey}
</code>
<div className="mt-1 flex gap-2">
{key.isSigner && (
<Badge variant="outline" className="text-xs">
Signer
</Badge>
)}
{key.isWritable && (
<Badge variant="outline" className="text-xs">
Writable
</Badge>
)}
</div>
</div>
))}
</div>
</div>
<div>
<p className="text-muted-foreground text-xs font-medium mb-1">
Data
</p>
<code className="text-xs font-mono break-all block bg-background p-2 rounded border border-border">
{instruction.data || "No data"}
</code>
</div>
</div>
)}
</div>
);
}
function ActivityLogItem({ log }: { log: ActivityLogEntry }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="rounded-lg border border-border p-3">
<button
type="button"
className="flex w-full items-center justify-between"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{log.eventType}
</Badge>
<span className="text-sm">{log.stageName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{format(new Date(log.timestamp), "PP pp")}
</span>
{isExpanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</div>
</button>
{isExpanded && log.payload && (
<div className="mt-3">
<CodeClient
code={
typeof log.payload === "string"
? log.payload
: stringify(log.payload, null, 2)
}
lang="json"
/>
</div>
)}
</div>
);
}
@@ -22,8 +22,8 @@ import { TabButtons } from "@/components/ui/tabs";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
import { statusDetails } from "../../analytics/tx-table/tx-table-ui";
import type { Transaction } from "../../analytics/tx-table/types";
import { statusDetails } from "../../analytics/tx-table/types";
import type { ActivityLogEntry } from "../../lib/analytics";
import type { DecodedTransactionData, DecodedTransactionResult } from "./page";
@@ -1,3 +1,4 @@
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getAuthToken } from "@/api/auth-token";
@@ -6,13 +7,20 @@ import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { ServerWalletsTable } from "../transactions/components/server-wallets-table.client";
import type { Wallet } from "../transactions/server-wallets/wallet-table/types";
import { listSolanaAccounts } from "../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../transactions/solana-wallets/wallet-table/types";
import { InAppWalletAnalytics } from "./analytics/chart";
import { InAppWalletsSummary } from "./analytics/chart/Summary";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
@@ -20,6 +28,8 @@ export default async function Page(props: {
to?: string;
type?: string;
interval?: string;
page?: string;
solana_page?: string;
}>;
}) {
const [searchParams, params] = await Promise.all([
@@ -40,12 +50,74 @@ export default async function Page(props: {
to: searchParams.to,
});
const project = await getProject(params.team_slug, params.project_slug);
const [vaultClient, project] = await Promise.all([
createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
}).catch(() => undefined),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
// Fetch server wallets with pagination (5 per page)
const pageSize = 5;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
// Fetch EVM wallets
const eoas =
vaultClient && managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
// Fetch Solana wallets
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
// Check for Solana permission errors
const isSolanaPermissionError = solanaAccounts.error?.message?.includes(
"AUTH_INSUFFICIENT_SCOPE",
);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
@@ -103,12 +175,50 @@ export default async function Page(props: {
authToken={authToken}
/>
<InAppWalletUsersPageContent
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
{/* Server Wallets Section (EVM + Solana) */}
<div className="flex flex-col gap-4">
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">
{eoas.error.message || "Failed to load EVM wallets"}
</p>
</div>
) : (
<ServerWalletsTable
client={client}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={eoas.data.items as Wallet[]}
managementAccessToken={managementAccessToken ?? undefined}
project={project}
solanaCurrentPage={solanCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
solanaPermissionError={isSolanaPermissionError || false}
/>
)}
</div>
{/* User Wallets Section */}
<div className="flex flex-col gap-4">
<h2 className="font-semibold text-2xl tracking-tight">
User wallets
</h2>
<InAppWalletUsersPageContent
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
</div>
</div>
</ProjectPage>
</ResponsiveSearchParamsProvider>
@@ -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}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More