Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
bc0295487b Version Packages (#8339)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-30 07:07:28 +07:00
FirekeeperandGitHub 90b779107d Add Solana wallet balance endpoint and UI integration (#8338) 2025-10-30 06:56:00 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
afe187de95 Version Packages (#8336)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-29 16:10:10 -07:00
759b93beff [SDK] Fix: move @storybook/react package to be a dev dependency (#8306)
Co-authored-by: Jonas Daniels <[email protected]>
2025-10-29 15:58:10 -07:00
Jonas DanielsandGitHub 8e357b3cb3 setup @thirdweb-dev/nexus package (#8332) 2025-10-29 15:55:12 -07:00
kumaryash90 6c318f83d6 Update implementations (#8334)
<!--

## 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 `MarketplaceV3` contract addresses and the `ZKSYNC_WETH` token addresses in the `implementations.ts` file.

### Detailed summary
- Updated `MarketplaceV3` address for network `232` from `0x9742f5ac11958cFAd151eBF0Fc31302fA409036E` to `0xB7387cF425eC8Da1b04b8E34De86245cb32bCA6D`.
- Updated `MarketplaceV3` address for network `37111` from `0x56Abb6a3f25DCcdaDa106191053b1CC54C196DEE` to `0xF8fd1016420c2B7832714241d36Efe55D41df126`.
- Updated `ZKSYNC_WETH` address for network `232` from `0xE5ecd226b3032910CEaa43ba92EE8232f8237553` to `0x6bDc36E20D267Ff0dd6097799f82e78907105e2F`.
- Updated `ZKSYNC_WETH` address for network `37111` from `0xaA91D645D7a6C1aeaa5988e0547267B77d33fe16` to `0xeee5a340Cdc9c179Db25dea45AcfD5FE8d4d3eB8`.

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

* **Chores**
  * Updated contract addresses for zkSync networks: MarketplaceV3 and WETH addresses replaced for chain 232 and chain 37111.
  * Added a changeset marking a patch release (release metadata).
  * No public API or exported signatures changed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-29 21:14:05 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
f790c9fae9 Version Packages (#8328)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-29 02:14:23 -07:00
Joaquim VergesandGitHub bcbb3f540a [X402] Simplify FacilitatorNetworkSchema to accept any string (#8331) 2025-10-29 02:11:13 -07:00
Joaquim VergesandGitHub 931c730a08 [Paywall] Support string price format when tokenAddress is not provided (#8330) 2025-10-28 12:09:59 -07:00
YashandGitHub e0c83121c2 add arc testnet to chains package (#8325) 2025-10-28 12:09:02 -07:00
MananTank a82a31e5ad Dashboard: Use monotone curve type for AreaChart to avoid clipping (#8329)
<!--

## 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 `type` property for charts from `natural` to `monotone` in various components to improve the visual representation of the data.

### Detailed summary

- In `empty-chart-state.tsx`, changed `type` from `natural` to `monotone`.
- In `area-chart.tsx`, changed `type` from `natural` to `monotone`.
- In `ErrorRate.tsx`, changed `type` from `natural` to `monotone`.

> ✨ 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 chart line rendering across multiple dashboard components for improved visual consistency. Area and bar charts now use refined curve interpolation for smoother data visualization.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-28 18:14:30 +00:00
MananTank 3d235e218a Dashboard: Update faucet amount for arc testnet (#8326)
<!--

## 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 adds a new entry for the `Arc testnet` in the `faucet.ts` file, enhancing the functionality to support an additional test network alongside the existing `Sophon testnet`.

### Detailed summary

- Added a comment for `Arc testnet`.
- Introduced a new entry `5042002: 1` for the `Arc testnet`.

> ✨ 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 faucet support for the Arc testnet, allowing users connected to this network to claim test funds.
  - The claim amount on Arc is set to 1, ensuring consistent and predictable claims on that network.
  - Existing networks and the default claim behavior remain unchanged, preserving current user flows while expanding testnet coverage.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-28 17:53:36 +00:00
MananTank c08fb1356e SDK: Fix Safe wallet connection issues with WalletConnect (#8327)
<!--

## 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 addresses issues with the Safe wallet connection when using `WalletConnect`, ensuring better handling of account retrieval.

### Detailed summary

- Updated the comment for `account` retrieval to clarify that it grabs the address from mainnet if available.
- Modified the `firstAccountOn` function to return the first account from the session if no matching account is found.

> ✨ 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**
  * Fixed Safe wallet connection issues with WalletConnect

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-28 17:19:37 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
68cca6f992 Version Packages (#8318)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-27 10:12:02 -07:00
Joaquim VergesandGitHub e0abf2b273 [SDK] Update x402 dependency and export additional types (#8319) 2025-10-27 10:02:00 -07:00
FirekeeperandGitHub abf0bbaf35 Update x402 default payment validity window (#8317) 2025-10-27 09:56:05 -07:00
MananTank 23cb3bd98c Dashboard: /pay/<id> page UI improvements, SDK: CheckoutWidget minor UI tweaks (#8310)
<!--

## 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 UI enhancements and minor adjustments in the `CheckoutWidget` and related components of the payment dashboard, improving styling, layout, and functionality.

### Detailed summary
- Minor UI adjustments in `CheckoutWidget`.
- Updated `formatTokenBalance.ts` to allow more flexible currency formatting.
- Added `trackingTight` prop to `Text` in `FiatValue.tsx`.
- Enhanced `PayPageWidget` with new class names for styling.
- Adjusted color values in SDK theme definitions.
- Improved layout and spacing in `WithHeader.tsx`.
- Modified text properties in `DirectPayment.tsx` for better readability.
- Created a new `PayIdPageHeader` component for improved header structure.
- Replaced complex JSX in `PayPage` with cleaner component structure, including `DotsBackgroundPattern`.

> ✨ 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**
  * Theme toggle added to Pay page headers; streamlined Pay page header and layout with decorative background.

* **Style**
  * Refined secondary/tertiary button and background colors, typography weights/letter spacing, spacing and divider styles.
  * Adjusted price and label styling, currency formatting precision, and visual spacing around branding.
  * Pay widget now uses a fallback image when no project image is provided.

* **Chores**
  * Added changelog entry documenting a patch release.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-27 16:11:49 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
dcef3f4fe7 Version Packages (#8315)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-26 01:25:18 -07:00
Joaquim VergesandGitHub bf46520a6b [SDK] Enhance compatibility with third party x402 libs (#8314) 2025-10-26 01:21:40 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
7defce7264 Version Packages (#8303)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-26 00:46:21 -07:00
Joaquim VergesandGitHub ae3a122cf1 [X402] Fix network comparison in decodePaymentRequest (#8313) 2025-10-26 00:44:27 -07:00
Jonas DanielsandGitHub 2412a6958c Handle null metadata in ERC721 and ERC1155 getNFT functions (#8312) 2025-10-24 15:22:14 -07:00
Jonas DanielsandGitHub bfd3be764c Filter out null-ish values from ERC721 and ERC1155 getNFTs() arrays (#8311) 2025-10-24 14:39:31 -07:00
Joaquim VergesandGitHub 46b9d2dd82 [Playground] Use production API URL (#8307) 2025-10-24 11:38:11 -07:00
MananTank 3e9eb49787 [MNY-289] SDK: Fix token selection UI loading state when wallet is connected to a chain that is not supported by tw bridge (#8304)
<!--

## 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 addresses an issue with the `Select Token` UI being stuck in a loading state when the wallet is connected to an unsupported chain by the `thirdweb Bridge`. It modifies the logic for selecting the default chain in various widgets.

### Detailed summary
- Updated the function from `getDefaultSelectedChain` to `findChain` for clarity.
- Improved logic to handle cases where `activeChainId` is undefined.
- Adjusted the selection of `selectedChain` to use the new `findChain` function, ensuring proper fallback options.

> ✨ 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
* Fixed Select Token components from remaining in loading state when the connected wallet chain is not supported by thirdweb Bridge. This affects BuyWidget, SwapWidget, and BridgeWidget.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-23 23:39:57 +00:00
MananTank 630edadcec Dashboard: Fix horizontal overflow in /bridge page (#8302)
<!--

## 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 styling of the `bridge-page` component by adding an `overflow-hidden` class to a `div`, which likely improves the layout and prevents overflow issues.

### Detailed summary

- Added `overflow-hidden` class to the `div` in the `bridge-page` component to manage overflow behavior.

> ✨ 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 bridge page container to refine content display behavior and ensure consistent visual presentation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-23 23:30:50 +00:00
MananTank f757e60846 [MNY-237] SDK: Update theme colors, minor UI tweaks (#8305)
<!--

## 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 theme colors and making minor UI tweaks across various React components in the `thirdweb` package.

### Detailed summary

- Updated `weight` from `600` to `500` in multiple components' `Text`.
- Adjusted `border` styles in `Container` components.
- Modified padding and margin in several layout elements.
- Enhanced UI elements with consistent `letterSpacing` and `fontSize`.
- Added new theme stories for `darkThemeObj` and `lightThemeObj`.

> ✨ 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 Storybook theme showcase displaying color tokens and design system palette
- **Bug Fixes**
    - Improved loading skeleton UI with better scrollable layout and visual consistency
- **Style**
    - Refactored theme system with explicit color objects
    - Updated button styling with adjusted border radius and padding
    - Refined typography (font weights) across UI components
    - Adjusted layout spacing and padding throughout Bridge and wallet components
    - Increased QR code size for improved visibility
- **Chores**
    - Added changeset documentation for patch release

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-23 23:21:57 +00:00
kumaryash90 d5d593956e Update factory (#8301)
<!--

## 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 patch for the `thirdweb` package, adding a new factory address for the `create2` deployment on the Neura testnet.

### Detailed summary
- Added a new factory address for `267`: `0x4e59b44847b379578588920cA78FbF26c0B4956C` to the `FACTORIES` record in `create-2-factory.ts`.

> ✨ 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 support for Neura testnet in Create2 factory deployments.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-23 17:05:22 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9a4b1cb147 Version Packages (#8300)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-22 16:42:56 -07:00
Alec AnanianandGitHub 85fe9491cd [SDK] Fix: update React Native Send Funds screen to allow decimal amounts (#8299) 2025-10-22 15:54:19 -07:00
Joaquim VergesandGitHub 899051f788 [SDK] Add EIP-5792 support for EIP1193.toProvider() (#8298) 2025-10-22 15:54:04 -07:00
Joaquim VergesandGitHub 5828b9bd45 [Docs] Add guide for using ConnectButton with wagmi application (#8294) 2025-10-22 15:42:12 -07:00
2b68a32eaa Update airdrop tokens reference link (#8197)
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: prithvish <[email protected]>
2025-10-22 15:41:34 -07:00
Manan TankandGitHub 66ae33ee8b [MNY-243] Update /pay page to render BuyWidget (#8297) 2025-10-22 15:39:56 -07:00
Manan TankandGitHub e9788096d6 [MNY-286] SDK: Do not require connecting wallet in BuyWidget if receiverAddress is set (#8296) 2025-10-22 15:39:27 -07:00
MananTank 706d696434 [MNY-285] Dashboard: Bridge page UI updates (#8295)
<!--

## 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 `bridgeStats` object to enhance the dashboard with dynamic statistics regarding supported chains, tokens, and routes. It updates various components to utilize these statistics, improving the accuracy of displayed information.

### Detailed summary
- Added `bridgeStats` object in `data.ts` with properties for supported chains, tokens, and routes.
- Updated text in `page.tsx` files to use dynamic values from `bridgeStats`.
- Introduced `AnimatedNumbers` component to display statistics with animations.
- Replaced static statistics with dynamic `DataSquare` components in `bridge-page.tsx`.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2025-10-22 21:57:02 +00:00
MananTank c36cfbb5c7 [MNY-257] Dashboard: Add query params in bridge page to configure token selection (#8293)
<!--

## 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 `Page` component in `apps/dashboard/src/app/bridge/page.tsx` by introducing asynchronous handling of search parameters and improving the logic for processing input and output chains and currencies.

### Detailed summary

- Added a `SearchParams` type to define search parameters.
- Updated `Page` to accept `searchParams` as a `Promise`.
- Implemented `onlyAddress` and `onlyNumber` functions for validation.
- Introduced a `parse` function to extract values from search parameters.
- Updated the `BridgePageUI` props for `buyTab` and `swapTab` to handle dynamic token data.

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

<!-- end pr-codex -->
2025-10-22 21:57:01 +00:00
MananTank 93a9ba9121 [PRO-72] Portal: Update hero subtext (#8292)
<!--

## 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 content of a paragraph in the `page.tsx` file to reflect a new description of the platform, enhancing its messaging and presentation.

### Detailed summary

- Changed the text within a `<p>` tag:
    - From: "Development framework for building onchain apps, games, and agents."
    - To: "Platform for building the next generation of internet products."
- Adjusted the class name from `max-w-md` to `max-w-lg` and added `text-pretty`.

> ✨ 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 hero section messaging to better reflect the platform's positioning and capabilities.
  * Enhanced text layout and formatting for improved readability on the homepage.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-22 21:53:45 +00:00
MananTank 6a7b556d75 [BLD-389] Dashboard: Project Wallet section UI improvements and refactor (#8291)
<!--

## 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 improving the handling of project wallets within the application. It removes unused components, simplifies props, and enhances the user interface for wallet management.

### Detailed summary

- Deleted unused files: `ProjectWalletSetup.client.tsx` and `ProjectWalletControls.client.tsx`.
- Updated `ProjectFTUX` to remove `managementAccessToken` prop.
- Simplified `CreateSolanaWallet` and `CreateServerWallet` components by removing unnecessary props.
- Enhanced styling in the `ProjectFTUX` story and the wallet-related components.
- Improved state management for wallet selection and transactions.
- Refactored `ProjectWalletSection` to streamline wallet creation and management processes.

> ✨ 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 comprehensive project wallet management interface with send, receive, view transactions, and change wallet actions through an interactive dashboard.

* **Refactor**
  * Reorganized wallet functionality into a dedicated module for improved code organization.
  * Simplified component interfaces by removing redundant authentication parameters throughout the dashboard.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-22 16:32:58 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e0ace736a8 Version Packages (#8281)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-21 18:31:35 -07:00
Jonas DanielsandGitHub b30369f3cc Change getContractMetadata() return type from any to unknown (#8289) 2025-10-21 17:43:43 -07:00
FirekeeperandGitHub f2121e9519 [Dashboard] Add userId support across wallet APIs and UI (#8290) 2025-10-22 06:35:10 +07:00
Joaquim VergesandGitHub 29d05a50bf [Dashboard] Add nexus to framer-rewrites.js (#8287) 2025-10-20 16:19:28 -07:00
Jonas DanielsandGitHub f00f559a12 Add force-dynamic to Stripe-related pages (#8288) 2025-10-20 16:19:16 -07:00
MananTank ceba6835dd SDK: Fix process not defined error in Vite (#8280)
<!--

## 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 addresses a `process not defined` error that occurs when using the `thirdweb/contract` import in Vite by ensuring that the `process` variable is only accessed when it is defined.

### Detailed summary

- Updated `PUBLISHED_PRIVATE_KEY` export in `packages/thirdweb/src/utils/any-evm/zksync/constants.ts` to check if `process` is defined before accessing `process.env.ZKSYNC_PUBLISHED_PRIVATE_KEY`.

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

- **Chores**
  - Improved robustness of environment configuration handling to avoid runtime issues in non-Node environments.

- **Bug Fixes**
  - Fixed a "process not defined" error that could occur when importing the library in Vite, included in a patch release.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-20 13:55:09 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
b33c1a30ac Version Packages (#8272)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-19 18:33:02 -07:00
Joaquim VergesandGitHub 72fbb1550f Resolve TokenIcon automatically when available (#8279) 2025-10-19 18:27:35 -07:00
MananTank 1e7df5ad3d [MNY-252] Dashboard: Update WalletAddress component UI (#8276)
<!--

## 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 `WalletAddress` component and its UI by improving the handling of social profiles, adding new story variants, and refining the UI elements for better user experience.

### Detailed summary
- Changed `retry` in `useSocialProfiles` from `3` to `false`.
- Added new Storybook stories for `WalletAddress` and `WalletAddressUI`.
- Introduced a `Skeleton` component for loading states.
- Refactored `WalletAddress` to use `useSocialProfiles` directly.
- Updated UI structure and styling for displaying social profiles.
- Improved the handling of address display and copy functionality.

> ✨ 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 Storybook examples showcasing Wallet Address states (loaded, loading, empty, invalid, zero address, ENS)

* **Refactor**
  * Split wallet address UI for clearer rendering and moved profile-driven rendering to a dedicated UI component
  * Updated copy button styling to use a unified copy control
  * Improved social profiles panel layout and presentation

* **Bug Fixes / Behavior**
  * Adjusted profile loading behavior to avoid automatic retry on failure, surfacing empty/error states sooner
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-19 10:54:54 +00:00
MananTank ca16ec783b [MNY-275] Surface the error from bridge API in SwapWidget UI (#8277)
<!--

## 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 improving error handling in the `swap-ui.tsx` component by providing more detailed error messages when fetching data fails.

### Detailed summary
- Added `multiline` property to a component.
- Updated error message for `preparedResultQuery.error` to display the specific error message if available, falling back to "Failed to get a quote".
- Retained existing error messages for `buyTokenQuery` and `sellTokenQuery`.

> ✨ 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**
  * Improved error display in the swap widget: error text now shows a more specific message when available, falls back to a generic failure message otherwise, and supports multiline wrapping so longer errors are fully visible to users.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-18 00:05:28 +00:00
MananTank b1a780832e [MNY-274] Dashboard: Generate swap token pages for popular tokens (#8278)
<!--

## 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 refactoring the `BuyAndSwapEmbed` component and related functionalities to improve the handling of buy and swap operations across different tokens and chains, enhancing the user experience in the bridge application.

### Detailed summary

- Removed `chain` and `tokenAddress` props from `BuyAndSwapEmbed`.
- Introduced `buyTab` and `swapTab` props to manage buy/sell token information.
- Updated `UniversalBridgeEmbed` to accept new props structure.
- Added token pair management in `slug-map.ts`.
- Created utility functions for token pair data retrieval.
- Refactored `BridgePageUI` to accommodate the new props and structure.
- Enhanced error handling and reporting in `BuyAndSwapEmbed`.

> ✨ 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 Bridge UI and dedicated token-pair pages with static routes, metadata, and token-pair utilities for cross-chain slugs.
  - Introduced a Bridge page UI with FAQ, data summary, and an embedded universal bridge widget.

- **Improvements**
  - Unified buy/sell input structure for embeds and prefills, simplifying embed wiring and page rendering.
  - More robust handling of chain/token data and reporting for buy/swap flows; removed legacy testnet flag usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 23:49:31 +00:00
MananTank 9bb65ad5b2 [PRO-48] Dashboard: Bridge page UI updates (#8274)
<!--

## 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 refactoring components related to FAQ sections, modifying link types for analytics, and enhancing the header components in the bridge application.

### Detailed summary
- Changed `linkType` in `reportBridgePageLinkClick` from `"integrate-bridge"` to `"bridge-docs"`.
- Renamed `FaqSection` to `FaqAccordion` and updated its implementation.
- Introduced a new `FaqSection` component wrapping `FaqAccordion`.
- Renamed `PageHeader` to `BridgePageHeader`.
- Updated `PillLink` references to use `FaqAccordion`.
- Enhanced styling and structure of header and FAQ sections.

> ✨ 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**
  * Theme toggle added to dashboard headers
  * Documentation quick-access added to headers and bridge area

* **UI/Layout Changes**
  * Redesigned bridge page with a new hero/heading and data pill layout
  * FAQ section refactored into an accordion-style component
  * Header navigation updated to include trending tokens and documentation controls
  * Removed legacy pill-style external link component
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 21:53:57 +00:00
MananTank db291d2d38 [MNY-268] Bridge Status UI updates (#8273)
<!--

## 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 UI components in the dashboard by improving styles for better text handling and introducing a new `LinkWithCopyButton` component for copying links and displaying tooltips.

### Detailed summary
- Updated `className` in `CopyTextButton` and `WalletAddress` for better text truncation.
- Introduced `LinkWithCopyButton` component for link handling and copying functionality.
- Adjusted styles in `BridgeStatus` and `TokenInfo` for improved UI consistency.
- Replaced `CopyTextButton` with `LinkWithCopyButton` for token address and transaction hash.

> ✨ 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 combined copy-and-open-link component with tooltip and temporary visual feedback for quick copying and opening external links.

* **Style**
  * Improved text truncation for wallet names, addresses and copy controls to prevent overflow.
  * Updated spacing, typography and section layout across the dashboard for clearer hierarchy.
  * Added external-link arrows for clearer link affordance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 20:23:13 +00:00
Jonas DanielsandGitHub 9dd0b17f15 Remove Chain Infrastructure section from team sidebar (#8275) 2025-10-17 15:21:34 -05:00
MananTank 12082fd5fd Dashboard: Fix autoconnect in /bridge page (#8271)
<!--

## 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 simplifies the `TWAutoConnect` component by removing the optional `accountAbstraction` prop and streamlining its usage in the `BridgeProviders` component.

### Detailed summary
- Removed the `accountAbstraction` prop from the `TWAutoConnect` component.
- Updated the `TWAutoConnect` function signature to only require the `client` prop.
- Integrated `TWAutoConnect` into the `BridgeProviders` component, passing the `thirdwebClient`.

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

* **Refactor**
  * Streamlined the auto-connect component API so it only requires the client, removing an unused configuration parameter.
  * Integrated the simplified auto-connect into the app provider stack to ensure automatic wallet/client initialization earlier in the startup sequence.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 16:38:49 +00:00
MananTank 5ff6063961 [MNY-269] SDK: autofocus token search input when token selector modal opens in BuyWidget, SwapWidget and BridgeWidget (#8266)
<!--

## 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 user experience by adding autofocus functionality to token selection inputs in various components and refactoring the `BuyWidget` and `SwapWidget` to utilize a common `Variant` component for rendering with different themes.

### Detailed summary
- Added `autoFocus` to token search inputs in `BuyWidget`, `SwapWidget`, and `BridgeWidget`.
- Refactored `BuyWidget` to use a `Variant` component for theme rendering.
- Refactored `SwapWidget` to use a `Variant` component for theme rendering.
- Introduced `autoFocusCrossIcon` prop in `Modal` component.
- Updated `SearchInput` to accept `autoFocus` prop.

> ✨ 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 search input now auto-focuses on desktop when opening token selector modals in Buy, Swap, and Bridge widgets.
  * Modal gained a configurable option to control initial focus of the close (cross) button.

* **Bug Fixes / UX**
  * Prevents unwanted focus on the modal close icon by default when configured, improving keyboard/voicing navigation.

* **Chores**
  * Storybook examples updated for consistent light/dark variants and layout metadata.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 14:31:13 +00:00
MananTank 3db3982fab [MNY-270] Remove in-app wallet options from bridge page connect modal (#8270)
<!--

## 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 centralized `appMetadata` constant for consistent branding across various components and refactors the code to utilize this constant, reducing redundancy and improving maintainability.

### Detailed summary
- Added `appMetadata` constant in `connect.ts`.
- Refactored `NebulaConnectButton` to use `appMetadata`.
- Updated `PageHeader` to include `wallets` prop in `PublicPageConnectButton`.
- Created `bridgeWallets` array in `UniversalBridgeEmbed` using `createWallet` with `appMetadata`.
- Modified multiple components to utilize `appMetadata` instead of hardcoded values.

> ✨ 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**
  * Wallet selection is now customizable for Buy, Swap, and Bridge flows.
  * App branding metadata centralized for consistent presentation across all wallet connection points.
  * Bridge now includes preset wallet options to streamline onboarding and autoconnect behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 03:46:41 +00:00
MananTank bf90104330 Dashboard: Remove playwright tests, checkly (#8269)
<!--

## 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 the removal of various test files and configurations related to the `dashboard` application, alongside updates to the `package.json` and `pnpm-lock.yaml` files to reflect changes in dependencies.

### Detailed summary
- Deleted multiple test files in `apps/dashboard/tests`.
- Removed configuration files: `setup.ts`, `README.md`, `checkly.config.ts`, and `playwright.config.ts`.
- Updated `package.json` to remove dependencies related to `checkly` and `playwright`.
- Adjusted `pnpm-lock.yaml` to reflect the removal of certain packages and their dependencies.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

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

<!-- end pr-codex -->

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

* **Chores**
  * Removed end-to-end testing infrastructure, configuration, helper utilities, test runner scripts, and related dev dependencies from the dashboard app.
* **Documentation**
  * Deleted the dashboard E2E testing README and related test documentation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-16 20:57:55 +00:00
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
292 changed files with 21958 additions and 7938 deletions
+9 -2
View File
@@ -188,9 +188,16 @@ jobs:
- name: Build Packages
run: pnpm build
- name: Report bundle size
- name: Report bundle size (thirdweb)
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
package_manager: pnpm
directory: packages/thirdweb
directory: packages/thirdweb
- name: Report bundle size (nexus)
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
package_manager: pnpm
directory: packages/nexus
-30
View File
@@ -1,30 +0,0 @@
import { defineConfig } from "checkly";
import { Frequency } from "checkly/constructs";
export default defineConfig({
checks: {
activated: true,
browserChecks: {
frequency: Frequency.EVERY_24H,
testMatch: "./tests/**/*.spec.ts",
},
checkMatch: "./**/*.check.ts",
frequency: Frequency.EVERY_24H,
ignoreDirectoriesMatch: [],
locations: ["us-east-1", "eu-west-1"],
muted: false,
playwrightConfig: {
use: {
baseURL: process.env.ENVIRONMENT_URL || "https://thirdweb.com",
},
},
runtimeId: "2023.09",
tags: ["website"],
},
cli: {
runLocation: "eu-west-1",
},
logicalId: "thirdweb-www",
projectName: "thirdweb.com",
repoUrl: "https://github.com/thirdweb-dev/dashboard",
});
+1
View File
@@ -11,6 +11,7 @@ module.exports = [
"/account-abstraction",
"/payments",
"/payments/x402",
"/nexus",
"/auth",
"/in-app-wallets",
"/transactions",
+2 -6
View File
@@ -2,6 +2,7 @@
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@number-flow/react": "^0.5.10",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
@@ -47,7 +48,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",
@@ -83,7 +83,6 @@
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.5",
"@next/eslint-plugin-next": "15.3.5",
"@playwright/test": "1.53.2",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
@@ -100,7 +99,6 @@
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"checkly": "6.0.1",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
@@ -123,13 +121,11 @@
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"playwright": "playwright test",
"postbuild": "next-sitemap",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
"typecheck": "tsc --noEmit",
"update-checkly": "npx checkly deploy"
"typecheck": "tsc --noEmit"
},
"version": "3.0.0"
}
-79
View File
@@ -1,79 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Run tests in files in parallel */
fullyParallel: true,
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
testDir: "./tests",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.ENVIRONMENT_URL || "http://127.0.0.1:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Run your local dev server before starting the tests */
webServer: process.env.CI
? undefined
: {
command: "pnpm run start",
reuseExistingServer: !process.env.CI,
url: "http://127.0.0.1:3000",
},
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+16 -1
View File
@@ -683,7 +683,22 @@ export function reportProductFeedback(properties: {
*
*/
export function reportBridgePageLinkClick(params: {
linkType: "integrate-bridge" | "trending-tokens";
linkType: "bridge-docs" | "trending-tokens";
}) {
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);
}
@@ -82,7 +82,7 @@ const CHAIN_PLAN_TO_INFRA = {
],
};
export async function getChainSubscriptions(slug: string) {
async function getChainSubscriptions(slug: string) {
const allSubscriptions = await getTeamSubscriptions(slug);
if (!allSubscriptions) {
return null;
@@ -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",
});
@@ -21,7 +21,7 @@ export async function getUniversalBridgeTokens(props: {
headers: {
"Content-Type": "application/json",
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
} as Record<string, string>,
},
method: "GET",
});
@@ -21,7 +21,7 @@ export async function addUniversalBridgeTokenRoute(props: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
} as Record<string, string>,
},
method: "POST",
});
@@ -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" />;
}
@@ -95,7 +95,7 @@ function SkeletonBarChart(props: {
fill={`url(#${fillAreaSkeletonId})`}
radius={8}
stroke="hsl(var(--muted-foreground))"
type="natural"
type="monotone"
/>
</AreaChart>
)}
@@ -1,9 +1,11 @@
/* eslint-disable no-restricted-syntax */
"use client";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Chain } from "thirdweb";
import { defineChain } from "thirdweb";
import { BuyWidget, SwapWidget } from "thirdweb/react";
import type { Wallet } from "thirdweb/wallets";
import {
reportAssetBuyCancelled,
reportAssetBuyFailed,
@@ -25,17 +27,46 @@ import {
import { cn } from "@/lib/utils";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { appMetadata } from "../../constants/connect";
import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
type PageType = "asset" | "bridge" | "chain";
export function BuyAndSwapEmbed(props: {
chain: Chain;
tokenAddress: string | undefined;
buyAmount: string | undefined;
export type BuyAndSwapEmbedProps = {
buyTab:
| {
buyToken:
| {
tokenAddress: string;
chainId: number;
amount?: string;
}
| undefined;
}
| undefined;
swapTab:
| {
sellToken:
| {
chainId: number;
tokenAddress: string;
amount?: string;
}
| undefined;
buyToken:
| {
chainId: number;
tokenAddress: string;
amount?: string;
}
| undefined;
}
| undefined;
pageType: PageType;
isTestnet: boolean | undefined;
}) {
wallets?: Wallet[];
};
export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
const { theme } = useTheme();
const [tab, setTab] = useState<"buy" | "swap">("swap");
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
@@ -84,24 +115,39 @@ export function BuyAndSwapEmbed(props: {
{tab === "buy" && (
<BuyWidget
amount={props.buyAmount || "1"}
chain={props.chain}
amount={props.buyTab?.buyToken?.amount || "1"}
chain={
props.buyTab?.buyToken?.chainId
? defineChain(props.buyTab.buyToken.chainId)
: undefined
}
tokenAddress={
props.buyTab?.buyToken?.tokenAddress as `0x${string}` | undefined
}
className="!rounded-2xl !border-none"
title=""
client={client}
connectOptions={{
autoConnect: false,
wallets: props.wallets,
appMetadata: appMetadata,
}}
onError={(e, quote) => {
const errorMessage = parseError(e);
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuyFailed({
buyTokenChainId:
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined,
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
@@ -114,21 +160,27 @@ export function BuyAndSwapEmbed(props: {
if (props.pageType === "asset") {
reportAssetBuyFailed({
assetType: "coin",
chainId: props.chain.id,
chainId: buyChainId,
error: errorMessage,
contractType: undefined,
is_testnet: props.isTestnet,
is_testnet: false,
});
}
}}
onCancel={(quote) => {
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuyCancelled({
buyTokenChainId:
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined,
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
@@ -141,24 +193,30 @@ export function BuyAndSwapEmbed(props: {
if (props.pageType === "asset") {
reportAssetBuyCancelled({
assetType: "coin",
chainId: props.chain.id,
chainId: buyChainId,
contractType: undefined,
is_testnet: props.isTestnet,
is_testnet: false,
});
}
}}
onSuccess={(quote) => {
onSuccess={({ quote }) => {
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuySuccessful({
buyTokenChainId:
quote.type === "buy"
? quote.intent.destinationChainId
: quote.type === "onramp"
? quote.intent.chainId
: undefined,
buyTokenChainId: buyChainId,
buyTokenAddress:
quote.type === "buy"
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote.type === "onramp"
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
@@ -167,14 +225,13 @@ export function BuyAndSwapEmbed(props: {
if (props.pageType === "asset") {
reportAssetBuySuccessful({
assetType: "coin",
chainId: props.chain.id,
chainId: buyChainId,
contractType: undefined,
is_testnet: props.isTestnet,
is_testnet: false,
});
}
}}
theme={themeObj}
tokenAddress={props.tokenAddress as `0x${string}`}
paymentMethods={["card"]}
/>
)}
@@ -184,18 +241,14 @@ export function BuyAndSwapEmbed(props: {
client={client}
theme={themeObj}
className="!rounded-2xl !border-none"
connectOptions={{
autoConnect: false,
wallets: props.wallets,
appMetadata: appMetadata,
}}
prefill={{
// buy this token by default
buyToken: {
chainId: props.chain.id,
tokenAddress: props.tokenAddress,
},
// sell the native token by default (but if buytoken is a native token, don't set)
sellToken: props.tokenAddress
? {
chainId: props.chain.id,
}
: undefined,
buyToken: props.swapTab?.buyToken,
sellToken: props.swapTab?.sellToken,
}}
onError={(error, quote) => {
const errorMessage = parseError(error);
@@ -208,7 +261,7 @@ export function BuyAndSwapEmbed(props: {
pageType: props.pageType,
});
}}
onSuccess={(quote) => {
onSuccess={({ quote }) => {
reportTokenSwapSuccessful({
buyTokenChainId: quote.intent.destinationChainId,
buyTokenAddress: quote.intent.destinationTokenAddress,
@@ -240,9 +293,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"
>
@@ -172,7 +172,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
key={key}
stackId={props.variant !== "stacked" ? undefined : "a"}
stroke={`var(--color-${key})`}
type="natural"
type="monotone"
/>
),
)}
@@ -5,25 +5,20 @@ import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { cn } from "@/lib/utils";
export function FaqSection(props: {
export function FaqAccordion(props: {
faqs: Array<{ title: string; description: string }>;
}) {
return (
<section>
<h2 className="text-2xl md:text-3xl font-semibold mb-4 tracking-tight">
Frequently asked questions
</h2>
<div className="flex flex-col">
{props.faqs.map((faq, faqIndex) => (
<FaqItem
key={faq.title}
title={faq.title}
description={faq.description}
className={cn(faqIndex === props.faqs.length - 1 && "border-b-0")}
/>
))}
</div>
</section>
<div className="flex flex-col">
{props.faqs.map((faq, faqIndex) => (
<FaqItem
key={faq.title}
title={faq.title}
description={faq.description}
className={cn(faqIndex === props.faqs.length - 1 && "border-b-0")}
/>
))}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,120 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import type { SocialProfile } from "thirdweb/react";
import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
import { WalletAddress, WalletAddressUI } from "./wallet-address";
const meta = {
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/WalletAddress",
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
args: {},
};
function Story() {
const client = storybookThirdwebClient as unknown as ThirdwebClient;
return (
<div className="container flex max-w-4xl flex-col gap-8 py-10">
<BadgeContainer label="Social Profiles Loaded">
<WalletAddressUI
address="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
client={client}
profiles={{
data: vitalikEth,
isPending: false,
}}
/>
</BadgeContainer>
<BadgeContainer label="No Social Profiles">
<WalletAddressUI
address="0x83Dd93fA5D8343094f850f90B3fb90088C1bB425"
client={client}
shortenAddress
profiles={{
data: [],
isPending: false,
}}
/>
</BadgeContainer>
<BadgeContainer label="Loading Social Profiles">
<WalletAddressUI
address="0x83Dd93fA5D8343094f850f90B3fb90088C1bB425"
client={client}
shortenAddress
profiles={{
data: [],
isPending: true,
}}
/>
</BadgeContainer>
<BadgeContainer label="Zero address">
<WalletAddressUI
address={ZERO_ADDRESS}
client={client}
profiles={{
data: [],
isPending: false,
}}
/>
</BadgeContainer>
<BadgeContainer label="Real Component - Invalid Address">
<WalletAddress address="not-an-address" client={client} />
</BadgeContainer>
<BadgeContainer label="Real Component - vitalik.eth">
<WalletAddress
address="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
client={client}
/>
</BadgeContainer>
</div>
);
}
const vitalikEth: SocialProfile[] = [
{
type: "ens",
name: "vitalik.eth",
bio: "mi pinxe lo crino tcati",
avatar: "https://euc.li/vitalik.eth",
metadata: {
name: "vitalik.eth",
address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
avatar: "https://euc.li/vitalik.eth",
description: "mi pinxe lo crino tcati",
url: "https://vitalik.ca",
},
},
{
type: "farcaster",
name: "vitalik.eth",
bio: undefined,
avatar:
"https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/b663cd63-fecf-4d0f-7f87-0e0b6fd42800/original",
metadata: {
fid: 5650,
bio: undefined,
pfp: "https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/b663cd63-fecf-4d0f-7f87-0e0b6fd42800/original",
username: "vitalik.eth",
addresses: [
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"0x96B6bB2bd2Eba3b4Fbefd7DbAC448ad7B6CBf279",
],
},
},
];
@@ -1,11 +1,10 @@
"use client";
import { CheckIcon, CircleSlashIcon, CopyIcon, XIcon } from "lucide-react";
import { CircleSlashIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
import { Img } from "@/components/blocks/Img";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
HoverCard,
@@ -13,41 +12,56 @@ import {
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useClipboard } from "@/hooks/useClipboard";
import { cn } from "@/lib/utils";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { CopyTextButton } from "../ui/CopyTextButton";
import { Skeleton } from "../ui/skeleton";
export function WalletAddress(props: {
type WalletAddressProps = {
address: string | undefined;
shortenAddress?: boolean;
className?: string;
iconClassName?: string;
client: ThirdwebClient;
fallbackIcon?: React.ReactNode;
}) {
// default back to zero address if no address provided
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
const [shortenedAddress, lessShortenedAddress] = useMemo(() => {
return [
props.shortenAddress !== false
? `${address.slice(0, 6)}...${address.slice(-4)}`
: address,
`${address.slice(0, 14)}...${address.slice(-12)}`,
];
}, [address, props.shortenAddress]);
};
export function WalletAddress(props: WalletAddressProps) {
const profiles = useSocialProfiles({
address: address,
address: props.address,
client: props.client,
});
const { onCopy, hasCopied } = useClipboard(address, 2000);
return (
<WalletAddressUI
{...props}
profiles={{
data: profiles.data || [],
isPending: profiles.isPending,
}}
/>
);
}
export function WalletAddressUI(
props: WalletAddressProps & {
profiles: {
data: SocialProfile[];
isPending: boolean;
};
},
) {
const address = props.address || ZERO_ADDRESS;
const shortenedAddress =
props.shortenAddress !== false
? `${address.slice(0, 6)}...${address.slice(-4)}`
: address;
if (!isAddress(address)) {
return (
<ToolTipLabel hoverable label={address}>
<span className="flex items-center gap-2 underline-offset-4 hover:underline">
<span className="flex items-center gap-2 underline-offset-4 hover:underline w-fit">
<div className="flex size-5 items-center justify-center rounded-full border bg-background">
<XIcon className="size-4 text-muted-foreground" />
</div>
@@ -78,7 +92,7 @@ export function WalletAddress(props: {
<HoverCardTrigger asChild tabIndex={-1}>
<Button
className={cn(
"flex flex-row items-center gap-2 px-0",
"flex flex-row items-center gap-2 px-0 max-w-full truncate",
props.className,
)}
onClick={(e) => e.stopPropagation()}
@@ -88,89 +102,82 @@ export function WalletAddress(props: {
<WalletAvatar
address={address}
iconClassName={props.iconClassName}
profiles={profiles.data || []}
profiles={props.profiles.data}
thirdwebClient={props.client}
fallbackIcon={props.fallbackIcon}
/>
)}
<span className="cursor-pointer font-mono">
{profiles.data?.[0]?.name || shortenedAddress}
<span className="cursor-pointer font-mono max-w-full truncate">
{props.profiles.data?.[0]?.name || shortenedAddress}
</span>
</Button>
</HoverCardTrigger>
<HoverCardContent
className="w-80 border-border"
className="w-[calc(100vw-2rem)] lg:w-[450px] border-border rounded-xl p-4 lg:p-6"
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">Wallet Address</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="rounded bg-muted p-2 text-center font-mono text-sm">
{lessShortenedAddress}
</p>
<h3 className="font-semibold text-lg">Social Profiles</h3>
{profiles.isPending ? (
<p className="text-muted-foreground text-sm">Loading profiles...</p>
) : !profiles.data?.length ? (
<p className="text-muted-foreground text-sm">No profiles found</p>
) : (
profiles.data?.map((profile) => {
const walletAvatarLink = resolveSchemeWithErrorHandler({
client: props.client,
uri: profile.avatar,
});
<div className="space-y-1">
<h3 className="font-medium text-sm">Wallet Address</h3>
return (
<div
className="flex flex-row items-center gap-2"
key={profile.type + profile.name}
>
{walletAvatarLink && (
<Avatar>
<AvatarImage
alt={profile.name}
className="object-cover"
src={walletAvatarLink}
/>
{profile.name && (
<AvatarFallback>
{profile.name.slice(0, 2)}
</AvatarFallback>
)}
</Avatar>
)}
<div className="flex w-full flex-col gap-1">
<div className="flex w-full flex-row items-center justify-between gap-4">
<h4 className="font-semibold text-md">{profile.name}</h4>
<Badge variant="outline">{profile.type}</Badge>
<CopyTextButton
textToShow={address}
textToCopy={address}
tooltip="Copy address"
copyIconPosition="right"
variant="ghost"
className="text-muted-foreground -translate-x-1.5"
/>
</div>
<div className="space-y-1 border-t pt-5 border-dashed">
<h3 className="font-medium text-sm">Social Profiles</h3>
{props.profiles.isPending ? (
<Skeleton className="h-4 w-[50%]" />
) : !props.profiles.data?.length ? (
<p className="text-muted-foreground text-sm">No profiles found</p>
) : (
<div className="!mt-2">
{props.profiles.data?.map((profile) => {
const walletAvatarLink = resolveSchemeWithErrorHandler({
client: props.client,
uri: profile.avatar,
});
return (
<div
className="flex flex-row items-center gap-3 py-2"
key={profile.type + profile.name}
>
<Avatar className="size-9">
<AvatarImage
alt={profile.name}
className="object-cover"
src={walletAvatarLink}
/>
{profile.name && (
<AvatarFallback>
{profile.name.slice(0, 2)}
</AvatarFallback>
)}
</Avatar>
<div className="space-y-0.5">
<h4 className="text-sm leading-none">{profile.name}</h4>
<span className="text-muted-foreground text-xs leading-none capitalize">
{profile.type === "ens" ? "ENS" : profile.type}
</span>
</div>
</div>
{profile.bio && (
<p className="line-clamp-1 whitespace-normal text-muted-foreground text-sm">
{profile.bio}
</p>
)}
</div>
</div>
);
})
)}
);
})}
</div>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
@@ -17,6 +17,7 @@ import { resetAnalytics } from "@/analytics/reset";
import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer";
import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal";
import { Button } from "@/components/ui/button";
import { appMetadata } from "@/constants/connect";
import { popularChains } from "@/constants/popularChains";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { useFavoriteChainIds } from "@/hooks/favorite-chains";
@@ -138,11 +139,7 @@ export const CustomConnectWallet = (props: {
return (
<>
<ConnectButton
appMetadata={{
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
}}
appMetadata={appMetadata}
chain={props.chain}
chains={allChainsV5}
client={client}
@@ -280,11 +277,7 @@ export function useCustomConnectModal() {
return useCallback(
(options: { chain?: Chain; client: ThirdwebClient }) => {
return connect({
appMetadata: {
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
},
appMetadata,
chain: options?.chain,
client: options.client,
privacyPolicyUrl: "/privacy-policy",
@@ -14,6 +14,15 @@ import {
} from "@/components/ui/select";
import type { SearchType } from "./types";
const searchTypeLabels: Record<SearchType, string> = {
email: "Email",
phone: "Phone",
id: "Auth Identifier",
address: "Address",
externalWallet: "External Wallet",
userId: "User Identifier",
};
export function AdvancedSearchInput(props: {
onSearch: (searchType: SearchType, query: string) => void;
onClear: () => void;
@@ -52,7 +61,8 @@ export function AdvancedSearchInput(props: {
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="id">ID</SelectItem>
<SelectItem value="id">Auth Identifier</SelectItem>
<SelectItem value="userId">User Identifier</SelectItem>
<SelectItem value="address">Address</SelectItem>
<SelectItem value="externalWallet">External Wallet</SelectItem>
</SelectContent>
@@ -62,7 +72,7 @@ export function AdvancedSearchInput(props: {
<div className="relative flex-1">
<Input
className="bg-background pl-9 border-r-0 rounded-r-none rounded-l-full"
placeholder={`Search by ${searchType}...`}
placeholder={`Search by ${searchTypeLabels[searchType]}...`}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
@@ -12,18 +12,25 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CopyTextButton } from "../ui/CopyTextButton";
const getUserIdentifier = (user: WalletUser) => {
const getAuthIdentifier = (user: WalletUser) => {
const mainDetail = user.linkedAccounts[0]?.details;
return (
mainDetail?.id ??
mainDetail?.email ??
mainDetail?.phone ??
mainDetail?.address ??
mainDetail?.id ??
user.id
mainDetail?.address
);
};
const truncateIdentifier = (value: string) => {
if (value.length <= 18) {
return value;
}
return `${value.slice(0, 8)}...${value.slice(-4)}`;
};
export function SearchResults(props: {
results: WalletUser[];
client: ThirdwebClient;
@@ -51,6 +58,8 @@ export function SearchResults(props: {
const mainDetail = user.linkedAccounts?.[0]?.details;
const email = mainDetail?.email as string | undefined;
const phone = mainDetail?.phone as string | undefined;
const authIdentifier = getAuthIdentifier(user);
const userIdentifier = user.id;
// Get external wallet addresses from linkedAccounts where type is 'siwe'
const externalWalletAccounts =
@@ -68,7 +77,34 @@ export function SearchResults(props: {
<p className="text-sm font-medium text-muted-foreground">
User Identifier
</p>
<p className="text-sm">{getUserIdentifier(user)}</p>
{userIdentifier ? (
<CopyTextButton
textToShow={truncateIdentifier(userIdentifier)}
textToCopy={userIdentifier}
tooltip="Copy User Identifier"
copyIconPosition="left"
variant="ghost"
/>
) : (
<p className="text-sm">N/A</p>
)}
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Auth Identifier
</p>
{authIdentifier ? (
<CopyTextButton
textToShow={truncateIdentifier(authIdentifier)}
textToCopy={authIdentifier}
tooltip="Copy Auth Identifier"
copyIconPosition="left"
variant="ghost"
/>
) : (
<p className="text-sm">N/A</p>
)}
</div>
{walletAddress && (
@@ -28,22 +28,42 @@ import { SearchResults } from "./SearchResults";
import { searchUsers } from "./searchUsers";
import type { SearchType } from "./types";
const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const getAuthIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const mainDetail = accounts[0]?.details;
return (
mainDetail?.id ??
mainDetail?.email ??
mainDetail?.phone ??
mainDetail?.address ??
mainDetail?.id
mainDetail?.address
);
};
const getExternalWallets = (accounts: WalletUser["linkedAccounts"]) => {
return accounts?.filter((account) => account.type === "siwe") || [];
const getPrimaryEmail = (accounts: WalletUser["linkedAccounts"]) => {
const emailFromPrimary = accounts[0]?.details?.email;
if (emailFromPrimary) {
return emailFromPrimary;
}
const emailAccount = accounts.find((account) => {
return typeof account.details?.email === "string" && account.details.email;
});
if (emailAccount && typeof emailAccount.details.email === "string") {
return emailAccount.details.email;
}
return undefined;
};
const columnHelper = createColumnHelper<WalletUser>();
const truncateIdentifier = (value: string) => {
if (value.length <= 18) {
return value;
}
return `${value.slice(0, 8)}...${value.slice(-4)}`;
};
export function InAppWalletUsersPageContent(
props: {
authToken: string;
@@ -56,9 +76,30 @@ export function InAppWalletUsersPageContent(
) {
const columns = useMemo(() => {
return [
columnHelper.accessor("id", {
cell: (cell) => {
const userId = cell.getValue();
if (!userId) {
return "N/A";
}
return (
<CopyTextButton
textToShow={truncateIdentifier(userId)}
textToCopy={userId}
tooltip="Copy User Identifier"
copyIconPosition="left"
variant="ghost"
/>
);
},
header: "User Identifier",
id: "user_identifier",
}),
columnHelper.accessor("linkedAccounts", {
cell: (cell) => {
const identifier = getUserIdentifier(cell.getValue());
const identifier = getAuthIdentifier(cell.getValue());
if (!identifier) {
return "N/A";
@@ -66,21 +107,17 @@ export function InAppWalletUsersPageContent(
return (
<CopyTextButton
textToShow={
identifier.length > 30
? `${identifier.slice(0, 30)}...`
: identifier
}
textToShow={truncateIdentifier(identifier)}
textToCopy={identifier}
tooltip="Copy User Identifier"
tooltip="Copy Auth Identifier"
copyIconPosition="left"
variant="ghost"
/>
);
},
enableColumnFilter: true,
header: "User Identifier",
id: "user_identifier",
header: "Auth Identifier",
id: "auth_identifier",
}),
columnHelper.accessor("wallets", {
cell: (cell) => {
@@ -94,33 +131,24 @@ export function InAppWalletUsersPageContent(
}),
columnHelper.accessor("linkedAccounts", {
cell: (cell) => {
const externalWallets = getExternalWallets(cell.getValue());
if (externalWallets.length === 0) {
return <span className="text-muted-foreground text-sm">None</span>;
const email = getPrimaryEmail(cell.getValue());
if (!email) {
return <span className="text-muted-foreground text-sm">N/A</span>;
}
return (
<div className="space-y-1">
{externalWallets.slice(0, 2).map((account) => {
const address = account.details?.address as string | undefined;
return address ? (
<div
key={`external-${address}-${account.details?.id}`}
className="text-xs"
>
<WalletAddress address={address} client={props.client} />
</div>
) : null;
})}
{externalWallets.length > 2 && (
<span className="text-muted-foreground text-xs">
+{externalWallets.length - 2} more
</span>
)}
</div>
<CopyTextButton
textToShow={email}
textToCopy={email}
tooltip="Copy Email"
copyIconPosition="left"
variant="ghost"
/>
);
},
header: "External Wallets",
id: "external_wallets",
header: "Email",
id: "email",
}),
columnHelper.accessor("wallets", {
cell: (cell) => {
@@ -230,20 +258,17 @@ export function InAppWalletUsersPageContent(
});
const csv = Papa.unparse(
usersWallets.map((row) => {
const externalWallets = getExternalWallets(row.linkedAccounts);
const externalWalletAddresses = externalWallets
.map((account) => account.details?.address)
.filter(Boolean)
.join(", ");
const email = getPrimaryEmail(row.linkedAccounts);
return {
address: row.wallets[0]?.address || "Uninitialized",
created: row.wallets[0]?.createdAt
? new Date(row.wallets[0].createdAt).toISOString()
: "Wallet not created yet",
external_wallets: externalWalletAddresses || "None",
email: email || "N/A",
login_methods: row.linkedAccounts.map((acc) => acc.type).join(", "),
user_identifier: getUserIdentifier(row.linkedAccounts),
auth_identifier: getAuthIdentifier(row.linkedAccounts) || "N/A",
user_identifier: row.id || "N/A",
};
}),
);
@@ -18,10 +18,10 @@ configure({
type APIWallet = ListUserWalletsResponses[200]["result"]["wallets"][0];
type APIProfile = APIWallet["profiles"][0];
// Transform API response to match existing WalletUser format
// Transform API response to match wallet user format
function transformToWalletUser(apiWallet: APIWallet): WalletUser {
return {
id: getProfileId(apiWallet.profiles[0]) || "",
id: apiWallet.userId || getProfileId(apiWallet.profiles[0]) || "",
linkedAccounts: apiWallet.profiles.map((profile) => {
// Create details object based on the profile data
let details:
@@ -105,6 +105,9 @@ export async function searchUsers(
case "externalWallet":
queryParams.externalWalletAddress = query;
break;
case "userId":
queryParams.userId = query;
break;
}
// Use the generated API function with Bearer authentication
@@ -3,4 +3,5 @@ export type SearchType =
| "phone"
| "id"
| "address"
| "externalWallet";
| "externalWallet"
| "userId";
@@ -41,7 +41,7 @@ export function CopyTextButton(props: {
<Button
aria-label={props.tooltip}
className={cn(
"flex h-auto w-auto gap-2 rounded-lg px-1.5 py-0.5 font-normal text-foreground",
"flex h-auto w-auto gap-2 rounded-lg px-1.5 py-0.5 font-normal text-foreground max-w-full truncate",
props.className,
)}
onClick={(e) => {
@@ -0,0 +1,45 @@
"use client";
import { ArrowUpRightIcon, CheckIcon, CopyIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { ToolTipLabel } from "./tooltip";
export function LinkWithCopyButton(props: {
href: string;
textToShow: string;
textToCopy: string;
copyTooltip: string;
className?: string;
}) {
const [isCopied, setIsCopied] = useState(false);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<div className={cn("flex items-center gap-1", props.className)}>
<ToolTipLabel label={props.copyTooltip}>
<Button
variant="ghost"
size="sm"
className="p-1 h-auto shrink-0 opacity-70 hover:opacity-100 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(props.textToCopy);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}}
>
<Icon className="size-3" />
</Button>
</ToolTipLabel>
<Link
href={props.href}
target="_blank"
className="text-sm text-muted-foreground hover:underline flex items-center gap-1 tabular-nums flex-1 truncate hover:text-foreground group"
>
<span className="max-w-full truncate">{props.textToShow}</span>
<ArrowUpRightIcon className="size-3.5 opacity-70 shrink-0 group-hover:opacity-100" />
</Link>
</div>
);
}
@@ -0,0 +1,5 @@
export const appMetadata = {
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
};
@@ -59,7 +59,10 @@ export async function fetchDashboardContractMetadata(
return {
contractType: compilerMetadata?.name || "",
image: contractMetadata.image || "",
image:
contractMetadata.image && typeof contractMetadata.image === "string"
? contractMetadata.image
: "",
name: contractName,
symbol: contractSymbol,
};
@@ -76,10 +76,10 @@ const fetchAccountList = ({
};
};
// Transform API response to match existing WalletUser format
// Transform API response to match the wallet user format
function transformToWalletUser(apiWallet: APIWallet): WalletUser {
return {
id: getProfileId(apiWallet.profiles[0]) || "",
id: apiWallet.userId || getProfileId(apiWallet.profiles[0]) || "",
linkedAccounts: apiWallet.profiles.map((profile) => {
// Create details object based on the profile data
let details:
+2
View File
@@ -5,6 +5,8 @@ const customClaimAmounts: Record<number, number> = {
631571: 0.1,
// Sophon testnet
531050104: 5,
// Arc testnet
5042002: 1,
};
const defaultClaimAmount = 0.01;
@@ -21,8 +21,8 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
primaryButtonText: "hsl(var(--inverted-foreground))",
primaryText: "hsl(var(--foreground))",
scrollbarBg: "hsl(var(--muted))",
secondaryButtonBg: "hsl(var(--secondary))",
secondaryButtonHoverBg: "hsl(var(--secondary)/80%)",
secondaryButtonBg: "hsl(var(--secondary)/70%)",
secondaryButtonHoverBg: "hsl(var(--secondary))",
secondaryButtonText: "hsl(var(--secondary-foreground))",
secondaryIconColor: "hsl(var(--secondary-foreground))",
secondaryIconHoverBg: "hsl(var(--accent))",
@@ -33,7 +33,7 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
separatorLine: "hsl(var(--border))",
skeletonBg: "hsl(var(--secondary-foreground)/15%)",
success: "hsl(var(--success-text))",
tertiaryBg: "hsl(var(--muted)/30%)",
tertiaryBg: "hsl(var(--muted)/50%)",
tooltipBg: "hsl(var(--popover))",
tooltipText: "hsl(var(--popover-foreground))",
},
@@ -1,18 +1,26 @@
"use client";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
import { defineDashboardChain } from "@/lib/defineDashboardChain";
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}
tokenAddress={undefined}
swapTab={{
sellToken: {
chainId: props.chain.chainId,
tokenAddress: NATIVE_TOKEN_ADDRESS,
},
buyToken: undefined,
}}
buyTab={{
buyToken: {
chainId: props.chain.chainId,
tokenAddress: NATIVE_TOKEN_ADDRESS,
},
}}
pageType="chain"
/>
</GridPatternEmbedContainer>
@@ -1,6 +1,6 @@
import { CircleAlertIcon } from "lucide-react";
import { getRawAccount } from "@/api/account/get-account";
import { FaqSection } from "@/components/blocks/faq-section";
import { FaqAccordion } from "@/components/blocks/faq-section";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getChain, getCustomChainMetadata } from "../../utils";
import { fetchChainSeo } from "./apis/chain-seo";
@@ -67,10 +67,21 @@ export default async function Page(props: Props) {
)}
{chainSeo?.faqs && chainSeo.faqs.length > 0 && (
<div className="py-10">
<FaqSection faqs={chainSeo.faqs} />
</div>
<FaqSection faqs={chainSeo.faqs} />
)}
</div>
);
}
function FaqSection(props: {
faqs: Array<{ title: string; description: string }>;
}) {
return (
<section className="py-10">
<h2 className="text-2xl md:text-3xl font-semibold mb-4 tracking-tight">
Frequently asked questions
</h2>
<FaqAccordion faqs={props.faqs} />
</section>
);
}
@@ -10,8 +10,7 @@ import * as ERC20Ext from "thirdweb/extensions/erc20";
import * as ERC721Ext from "thirdweb/extensions/erc721";
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import { download } from "thirdweb/storage";
import type { OverrideEntry } from "thirdweb/utils";
import { maxUint256 } from "thirdweb/utils";
import { isRecord, maxUint256, type OverrideEntry } from "thirdweb/utils";
import type { z } from "zod";
import type {
ClaimCondition as LegacyClaimCondition,
@@ -86,7 +85,7 @@ export async function getClaimPhasesInLegacyFormat(
]);
const snapshot = await fetchSnapshot(
condition.merkleRoot,
contractMetadata.merkle,
isRecord(contractMetadata.merkle) ? contractMetadata.merkle : {},
options.contract.client,
);
return {
@@ -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>
@@ -47,7 +47,10 @@ export default async function Image({
chainName: info.chainMetadata.name,
contractAddress: info.serverContract.address,
displayName: contractDisplayName,
logo: contractMetadata.image,
logo:
contractMetadata.image && typeof contractMetadata.image === "string"
? contractMetadata.image
: undefined,
});
} catch {
return contractOGImageTemplate({
@@ -2,6 +2,8 @@
import { useTheme } from "next-themes";
import { ConnectButton } from "thirdweb/react";
import type { Wallet } from "thirdweb/wallets";
import { appMetadata } from "@/constants/connect";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { getSDKTheme } from "@/utils/sdk-component-theme";
@@ -10,6 +12,7 @@ const client = getClientThirdwebClient();
export function PublicPageConnectButton(props: {
connectButtonClassName?: string;
wallets?: Wallet[];
}) {
const { theme } = useTheme();
const t = theme === "light" ? "light" : "dark";
@@ -17,11 +20,7 @@ export function PublicPageConnectButton(props: {
return (
<ConnectButton
appMetadata={{
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
}}
appMetadata={appMetadata}
autoConnect={false}
chains={allChainsV5}
client={client}
@@ -38,6 +37,7 @@ export function PublicPageConnectButton(props: {
}}
// we have an AutoConnect already added in root layout with AA configuration
theme={getSDKTheme(t)}
wallets={props.wallets}
/>
);
}
@@ -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,
@@ -89,16 +89,31 @@ export async function ERC20PublicPage(props: {
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
image={contractMetadata.image}
image={
contractMetadata.image &&
typeof contractMetadata.image === "string"
? contractMetadata.image
: undefined
}
isDashboardUser={isDashboardUser}
name={contractMetadata.name}
name={
contractMetadata.name && typeof contractMetadata.name === "string"
? contractMetadata.name
: // if we do not have a contract name fall back to the address
props.clientContract.address
}
socialUrls={
typeof contractMetadata.social_urls === "object" &&
contractMetadata.social_urls !== null
? contractMetadata.social_urls
: {}
}
symbol={contractMetadata.symbol}
symbol={
contractMetadata.symbol &&
typeof contractMetadata.symbol === "string"
? contractMetadata.symbol
: undefined
}
/>
</div>
</div>
@@ -143,21 +158,25 @@ export async function ERC20PublicPage(props: {
</div>
)}
{showBuyEmbed && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</GridPatternEmbedContainer>
</div>
)}
{showBuyEmbed &&
contractMetadata.name &&
typeof contractMetadata.name === "string" &&
contractMetadata.symbol &&
typeof contractMetadata.symbol === "string" && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</GridPatternEmbedContainer>
</div>
)}
<div className="container flex max-w-7xl grow flex-col pb-10">
<div className="flex grow flex-col gap-8">
@@ -167,12 +186,15 @@ export async function ERC20PublicPage(props: {
contractAddress={props.clientContract.address}
/>
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
{contractMetadata.symbol &&
typeof contractMetadata.symbol === "string" && (
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
)}
</div>
</div>
</div>
@@ -199,11 +221,21 @@ function BuyEmbed(props: {
if (!props.claimConditionMeta) {
return (
<BuyAndSwapEmbed
chain={props.clientContract.chain}
tokenAddress={props.clientContract.address}
buyAmount={undefined}
// chain={props.clientContract.chain}
swapTab={{
sellToken: {
chainId: props.clientContract.chain.id,
tokenAddress: props.clientContract.address,
},
buyToken: undefined,
}}
buyTab={{
buyToken: {
chainId: props.clientContract.chain.id,
tokenAddress: props.clientContract.address,
},
}}
pageType="asset"
isTestnet={props.chainMetadata.testnet}
/>
);
}
@@ -53,6 +53,13 @@ export async function NFTPublicPage(props: {
const _isTokenByIndexSupported =
props.type === "erc721" && isTokenByIndexSupported(functionSelectors);
// FIXME: this is technically a bad fallback but we gotta do what we gotta do
const contractMetadataWithNameAndSymbolFallback = {
...contractMetadata,
// fall back to the contract address if the name is not set
name: contractMetadata.name || props.clientContract.address,
symbol: contractMetadata.symbol || "",
};
const buyNFTDropCard = nftDropClaimParams ? (
<BuyNFTDropCardServer
chainMetadata={props.chainMetadata}
@@ -73,7 +80,7 @@ export async function NFTPublicPage(props: {
<NFTsGrid
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
collectionMetadata={contractMetadata}
collectionMetadata={contractMetadataWithNameAndSymbolFallback}
gridClassName={
buyNFTDropCard
? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
@@ -110,7 +117,7 @@ export async function NFTPublicPage(props: {
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
contractMetadata={contractMetadata}
contractMetadata={contractMetadataWithNameAndSymbolFallback}
isDashboardUser={isDashboardUser}
>
<ResponsiveLayout
@@ -138,7 +145,7 @@ export async function NFTPublicPage(props: {
<PageLoadTokenViewerSheet
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
collectionMetadata={contractMetadata}
collectionMetadata={contractMetadataWithNameAndSymbolFallback}
tokenByIndexSupported={_isTokenByIndexSupported}
tokenId={BigInt(props.tokenId)}
type={props.type}
@@ -37,7 +37,7 @@ export function TokenViewerSheet(
clientContract: ThirdwebContract;
chainMetadata: ChainMetadata;
collectionMetadata: {
name: string;
name: string | null;
image?: string;
};
type: "erc1155" | "erc721";
@@ -57,6 +57,11 @@ export function TokenViewerSheet(
) {
const tokenId = props.variant === "fetch-data" ? props.tokenId : props.nft.id;
const collectionMetadataWithNameFallback = {
...props.collectionMetadata,
name: props.collectionMetadata.name || props.clientContract.address,
};
return (
<Dialog
onOpenChange={(open) => {
@@ -78,7 +83,7 @@ export function TokenViewerSheet(
<FetchAndRenderTokenInfo
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
collectionMetadata={props.collectionMetadata}
collectionMetadata={collectionMetadataWithNameFallback}
tokenByIndexSupported={props.tokenByIndexSupported}
tokenId={props.tokenId}
type={props.type}
@@ -86,7 +91,7 @@ export function TokenViewerSheet(
) : (
<TokenInfoUI
chainMetadata={props.chainMetadata}
collectionMetadata={props.collectionMetadata}
collectionMetadata={collectionMetadataWithNameFallback}
contract={props.clientContract}
data={props.nft}
tokenId={tokenId}
@@ -84,7 +84,10 @@ export const SettingsMetadata = ({
console.error(err);
}
}
let image: string | undefined = metadata.data?.image;
let image: string | undefined =
metadata.data?.image && typeof metadata.data.image === "string"
? metadata.data.image
: undefined;
try {
image = image
? // eslint-disable-next-line no-restricted-syntax
@@ -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")}
@@ -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,413 @@
"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,
ArrowUpRightIcon,
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 { LinkWithCopyButton } from "@/components/ui/link-with-copy-button";
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-4">
<div className="space-y-1">
<p className="text-sm text-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="space-y-1">
<p className="text-sm text-foreground ">Payment ID</p>
<CopyTextButton
textToCopy={bridgeStatus.paymentId}
textToShow={bridgeStatus.paymentId}
tooltip="Payment ID"
className="text-sm -translate-x-1.5 tabular-nums text-muted-foreground"
copyIconPosition="left"
iconClassName="text-muted-foreground/70"
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-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);
const isNativeToken =
props.token.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase();
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="h-6" />
<div>
<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 flex items-center gap-1"
target="_blank"
>
{data}{" "}
<ArrowUpRightIcon className="size-3.5 text-muted-foreground/70 shrink-0" />
</Link>
)}
/>
</div>
</div>
</div>
<div className="h-8" />
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm text-foreground ">{props.addressLabel}</p>
<WalletAddress
address={props.walletAddress}
client={props.client}
className="py-0.5 h-auto text-sm [&>*]:!font-sans tabular-nums [&>*]:font-normal text-muted-foreground hover:text-foreground"
iconClassName="size-3"
shortenAddress={false}
/>
</div>
<div className="space-y-1">
<p className="text-sm text-foreground ">Token Address</p>
<LinkWithCopyButton
href={
isNativeToken
? `/${chainQuery.data?.slug || props.token.chainId}`
: `/${chainQuery.data?.slug || props.token.chainId}/${props.token.address}`
}
textToShow={props.token.address}
textToCopy={props.token.address}
copyTooltip="Copy Token Address"
className="-translate-x-1"
/>
</div>
<div className="space-y-1">
<p className="text-sm text-foreground ">Transaction Hash</p>
{props.txHash ? (
<LinkWithCopyButton
className="-translate-x-1"
href={`/${chainQuery.data?.slug || props.token.chainId}/tx/${props.txHash}`}
textToShow={`${props.txHash.slice(0, 12)}...${props.txHash.slice(-4)}`}
textToCopy={props.txHash}
copyTooltip="Copy Transaction Hash"
/>
) : (
<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-lg font-medium tracking-tight mb-4">Transactions</h3>
<div className="space-y-4">
{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="space-y-1">
<div className="flex items-center gap-2">
<Img
src={resolveSchemeWithErrorHandler({
client: props.client,
uri: chainQuery.data?.icon?.url,
})}
alt={chainQuery.data?.name}
className="size-3.5 rounded-full"
fallback={
<div className="size-3.5 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-foreground">{data}</p>}
/>
</div>
<LinkWithCopyButton
href={`/${chainQuery.data?.slug || props.chainId}/tx/${props.txHash}`}
textToShow={props.txHash}
textToCopy={props.txHash}
copyTooltip="Copy Transaction Hash"
className="-translate-x-0.5"
/>
</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>
);
}
@@ -1,5 +1,10 @@
"use client";
import { BookOpenIcon, MoonIcon, SunIcon } from "lucide-react";
import Link from "next/link";
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
import { useTheme } from "next-themes";
import { ClientOnly } from "@/components/blocks/client-only";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
@@ -9,7 +14,7 @@ export function PageHeader(props: { containerClassName?: string }) {
<div className="border-b">
<header
className={cn(
"container flex max-w-7xl justify-between py-3",
"container flex max-w-7xl justify-between py-4",
props.containerClassName,
)}
>
@@ -22,18 +27,49 @@ export function PageHeader(props: { containerClassName?: string }) {
</Link>
</div>
<div className="flex items-center gap-5">
<Link
href="https://portal.thirdweb.com/bridge"
target="_blank"
className="text-sm text-muted-foreground hover:text-foreground"
>
Docs
</Link>
<ToggleThemeButton />
<div className="flex items-center gap-4">
<div className="flex items-center gap-1 lg:gap-2">
<Link
href="https://portal.thirdweb.com/bridge"
target="_blank"
aria-label="View Documentation"
className="text-sm text-muted-foreground hover:text-foreground p-2 hover:bg-accent rounded-full transition-all duration-100"
>
<BookOpenIcon className="size-5" />
</Link>
<ToggleThemeButton />
</div>
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
</div>
</header>
</div>
);
}
function ToggleThemeButton(props: { className?: string }) {
const { setTheme, theme } = useTheme();
return (
<ClientOnly
ssr={<Skeleton className="size-[36px] rounded-full border bg-accent" />}
>
<Button
aria-label="Toggle theme"
className={cn(
"h-auto w-auto rounded-full p-2 text-muted-foreground hover:text-foreground",
props.className,
)}
onClick={() => {
setTheme(theme === "dark" ? "light" : "dark");
}}
variant="ghost"
>
{theme === "light" ? (
<SunIcon className="size-5 " />
) : (
<MoonIcon className="size-5 " />
)}
</Button>
</ClientOnly>
);
}
@@ -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";
@@ -122,6 +123,12 @@ export function TokensTable(props: {
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() ===
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { unstable_cache } from "next/cache";
import { Bridge } from "thirdweb";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { bridgeStats } from "../../../bridge/data";
import { PageHeader } from "./components/header";
import { TokenPage } from "./components/token-page";
@@ -36,7 +37,8 @@ export default async function Page() {
</h1>
<p className="text-muted-foreground">
85+ chains, 4500+ tokens and 9+ million routes supported
{bridgeStats.supportedChains} chains, {bridgeStats.supportedTokens}{" "}
tokens and {bridgeStats.supportedRoutes} routes supported
</p>
</div>
</div>
@@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
import { StripeRedirectErrorPage } from "../../_components/StripeRedirectErrorPage";
import { getPlanCancelUrl } from "../../utils/billing";
export const dynamic = "force-dynamic";
export default async function CancelPlanPage(props: {
params: Promise<{
team_id: string;
@@ -7,6 +7,8 @@ import {
getInvoicePaymentUrl,
} from "../../../utils/billing";
export const dynamic = "force-dynamic";
export default async function CheckoutPage(props: {
params: Promise<{
team_slug: string;
@@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
import { StripeRedirectErrorPage } from "../../_components/StripeRedirectErrorPage";
import { getBillingPortalUrl } from "../../utils/billing";
export const dynamic = "force-dynamic";
export default async function ManageBillingPage(props: {
params: Promise<{
team_slug: string;
@@ -1,16 +1,7 @@
"use client";
import type { ThirdwebClient } from "thirdweb";
import { AutoConnect } from "thirdweb/react";
import type { SmartWalletOptions } from "thirdweb/wallets";
export function TWAutoConnect(props: {
accountAbstraction?: SmartWalletOptions;
client: ThirdwebClient;
}) {
return (
<AutoConnect
accountAbstraction={props.accountAbstraction}
client={props.client}
/>
);
export function TWAutoConnect(props: { client: ThirdwebClient }) {
return <AutoConnect client={props.client} />;
}
@@ -76,7 +76,9 @@ export default async function DropPage({
]);
const thumbnail =
project.thumbnail || nft.metadata.image || contractMetadata.image || "";
project.thumbnail ||
nft.metadata.image ||
(typeof contractMetadata.image === "string" ? contractMetadata.image : "");
const displayName = contractMetadata.name || nft.metadata.name || "";
@@ -10,7 +10,6 @@ import {
HelpCircleIcon,
HomeIcon,
SettingsIcon,
WalletCardsIcon,
} from "lucide-react";
import { FullWidthSidebarLayout } from "@/components/blocks/full-width-sidebar-layout";
@@ -18,11 +17,6 @@ export function TeamSidebarLayout(props: {
layoutPath: string;
children: React.ReactNode;
ecosystems: Array<{ name: string; slug: string }>;
chainSubscriptions: Array<{
chainId: number;
chainName: string;
slug: string;
}>;
}) {
const { layoutPath, children } = props;
@@ -72,24 +66,6 @@ export function TeamSidebarLayout(props: {
{
separator: true,
} as const,
// infrastructure below here
// TODO: make this one link to an overview page that has a list of all the chains currently deployed
{
links: [
...props.chainSubscriptions.map((chainSubscription) => ({
href: `${layoutPath}/~/infrastructure/${chainSubscription.slug}`,
label: chainSubscription.chainName,
})),
{
href: `${layoutPath}/~/infrastructure/deploy`,
label: "Deploy Infrastructure",
},
],
subMenu: {
icon: WalletCardsIcon,
label: "Chain Infrastucture",
},
},
]}
footerSidebarLinks={[
{
@@ -4,13 +4,11 @@ import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { getProjects } from "@/api/project/projects";
import { fetchEcosystemList } from "@/api/team/ecosystems";
import { getTeamBySlug, getTeams } from "@/api/team/get-team";
import { getChainSubscriptions } from "@/api/team/team-subscription";
import { CustomChatButton } from "@/components/chat/CustomChatButton";
import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { siwaExamplePrompts } from "@/constants/siwa-example-prompts";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getChain } from "../../../(dashboard)/(chain)/utils";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
import { StaffModeNotice } from "./_components/StaffModeNotice";
import { TeamSidebarLayout } from "./TeamSidebarLayout";
@@ -21,52 +19,26 @@ export default async function TeamLayout(props: {
}) {
const params = await props.params;
const [
accountAddress,
account,
teams,
authToken,
team,
ecosystems,
chainSubscriptions,
] = await Promise.all([
getAuthTokenWalletAddress(),
getValidAccount(`/team/${params.team_slug}`),
getTeams(),
getAuthToken(),
getTeamBySlug(params.team_slug),
fetchEcosystemList(params.team_slug),
getChainSubscriptions(params.team_slug),
]);
const [accountAddress, account, teams, authToken, team, ecosystems] =
await Promise.all([
getAuthTokenWalletAddress(),
getValidAccount(`/team/${params.team_slug}`),
getTeams(),
getAuthToken(),
getTeamBySlug(params.team_slug),
fetchEcosystemList(params.team_slug),
]);
if (!teams || !accountAddress || !authToken || !team) {
redirect("/login");
}
const [teamsAndProjects, chainSidebarLinks] = await Promise.all([
Promise.all(
teams.map(async (team) => ({
projects: await getProjects(team.slug),
team,
})),
),
chainSubscriptions
? await Promise.all(
chainSubscriptions.map(async (chainSubscription) => {
if (!chainSubscription.chainId) {
throw new Error("Chain ID is required");
}
const chain = await getChain(chainSubscription.chainId);
return {
chainId: chain.chainId,
chainName: chain.name,
slug: chain.slug,
};
}),
).catch(() => [])
: [],
]);
const teamsAndProjects = await Promise.all(
teams.map(async (team) => ({
projects: await getProjects(team.slug),
team,
})),
);
const client = getClientThirdwebClient({
jwt: authToken,
@@ -92,9 +64,6 @@ export default async function TeamLayout(props: {
</div>
<TeamSidebarLayout
chainSubscriptions={chainSidebarLinks.sort(
(a, b) => a.chainId - b.chainId,
)}
ecosystems={ecosystems.map((ecosystem) => ({
name: ecosystem.name,
slug: ecosystem.slug,
@@ -11,6 +11,7 @@ import {
useActiveWalletConnectionStatus,
} from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { appMetadata } from "@/constants/connect";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { cn } from "@/lib/utils";
import { getSDKTheme } from "@/utils/sdk-component-theme";
@@ -65,11 +66,7 @@ export const NebulaConnectWallet = (props: {
const { customDetailsButton } = props;
return (
<ConnectButton
appMetadata={{
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
}}
appMetadata={appMetadata}
autoConnect={false}
chains={allChainsV5}
client={props.client}
@@ -2,6 +2,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
import { Button } from "@workspace/ui/components/button";
import { Spinner } from "@workspace/ui/components/spinner";
import { PlusIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
@@ -19,7 +22,6 @@ import {
type RouteDiscoveryValidationSchema,
routeDiscoveryValidationSchema,
} from "@/schema/validations";
import { RouteDiscoveryCard } from "./RouteDiscoveryCard";
export const RouteDiscovery = ({
project,
@@ -77,33 +79,21 @@ export const RouteDiscovery = ({
},
);
const errorText = form.getFieldState("tokenAddress").error?.message;
return (
<Form {...form}>
<form autoComplete="off" onSubmit={handleSubmit}>
<RouteDiscoveryCard
bottomText=""
errorText={form.getFieldState("tokenAddress").error?.message}
noPermissionText={undefined}
saveButton={
// Only show the submit button in the default state
{
disabled: !form.formState.isDirty,
isPending: submitDiscoveryMutation.isPending,
type: "submit",
variant: "outline",
}
}
>
<div>
<h3 className="font-semibold text-xl tracking-tight">
<div className="relative rounded-lg border border-border bg-card">
<div className="relative border-dashed border-b px-4 py-6 lg:px-6">
<h3 className="font-semibold text-xl tracking-tight mb-1">
Add a token to Bridge
</h3>
<p className="mt-1.5 mb-4 text-muted-foreground max-w-3xl text-sm text-pretty">
<p className="mb-4 text-muted-foreground max-w-3xl text-sm text-pretty">
Select your chain and input the token address to automatically
kick-off the token route discovery process. <br /> This may take
up to 20-40 minutes to complete.
</p>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 max-w-3xl">
<FormField
control={form.control}
@@ -142,7 +132,33 @@ export const RouteDiscovery = ({
/>
</div>
</div>
</RouteDiscoveryCard>
<div>
<div className="flex items-center justify-between gap-2 px-4 py-4 lg:px-6">
{errorText ? (
<p className="text-destructive-text text-sm">{errorText}</p>
) : (
<div />
)}
<Button
className="gap-1.5 rounded-full"
disabled={
!form.formState.isDirty || submitDiscoveryMutation.isPending
}
size="sm"
type="submit"
variant={"outline"}
>
{submitDiscoveryMutation.isPending ? (
<Spinner className="size-3" />
) : (
<PlusIcon className="size-4 text-muted-foreground" />
)}
Add Token
</Button>
</div>
</div>
</div>
</form>
</Form>
);
@@ -1,84 +0,0 @@
import type React from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner";
import { cn } from "@/lib/utils";
export function RouteDiscoveryCard(
props: React.PropsWithChildren<{
bottomText: React.ReactNode;
header?: {
description: string | undefined;
title: string;
};
errorText: string | undefined;
noPermissionText: string | undefined;
saveButton?: {
onClick?: () => void;
disabled: boolean;
isPending: boolean;
type?: "submit";
variant?:
| "ghost"
| "default"
| "primary"
| "destructive"
| "outline"
| "secondary";
className?: string;
label?: string;
};
}>,
) {
return (
<div className="relative rounded-lg border border-border bg-card">
<div
className={cn(
"relative border-border border-b px-4 py-6 lg:px-6",
props.noPermissionText && "cursor-not-allowed",
)}
>
{props.header && (
<>
<h3 className="font-semibold text-xl tracking-tight">
{props.header.title}
</h3>
{props.header.description && (
<p className="mt-1.5 mb-4 text-foreground text-sm">
{props.header.description}
</p>
)}
</>
)}
{props.children}
</div>
<div className="flex min-h-[60px] items-center justify-between gap-2 px-4 py-3 lg:px-6">
{props.noPermissionText ? (
<p className="text-muted-foreground text-sm">
{props.noPermissionText}
</p>
) : props.errorText ? (
<p className="text-destructive-text text-sm">{props.errorText}</p>
) : (
<p className="text-muted-foreground text-sm">{props.bottomText}</p>
)}
{props.saveButton && !props.noPermissionText && (
<Button
className={cn("gap-2", props.saveButton.className)}
disabled={props.saveButton.disabled || props.saveButton.isPending}
onClick={props.saveButton.onClick}
size="sm"
type={props.saveButton.type}
variant={props.saveButton.variant || "outline"}
>
{props.saveButton.isPending && <Spinner className="size-3" />}
{props.saveButton.label ||
(props.saveButton.isPending ? "Submit" : "Submit Token")}
</Button>
)}
</div>
</div>
);
}
@@ -11,6 +11,7 @@ import { PayAnalytics } from "../payments/components/PayAnalytics";
import { getUniversalBridgeFiltersFromSearchParams } from "../payments/components/time";
import { QuickStartSection } from "./QuickstartSection.client";
import { RouteDiscovery } from "./RouteDiscovery";
import { ViewTxStatus } from "./view-tx-status";
export default async function Page(props: {
params: Promise<{
@@ -84,7 +85,7 @@ export default async function Page(props: {
],
}}
>
<div className="flex flex-col gap-12">
<div className="flex flex-col gap-6">
<ResponsiveSearchParamsProvider value={searchParams}>
<PayAnalytics
client={client}
@@ -99,12 +100,16 @@ export default async function Page(props: {
<RouteDiscovery client={client} project={project} />
<QuickStartSection
projectSlug={params.project_slug}
teamSlug={params.team_slug}
clientId={project.publishableKey}
teamId={project.teamId}
/>
<ViewTxStatus client={client} />
<div className="pt-4">
<QuickStartSection
projectSlug={params.project_slug}
teamSlug={params.team_slug}
clientId={project.publishableKey}
teamId={project.teamId}
/>
</div>
</div>
</ProjectPage>
);
@@ -0,0 +1,72 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { ArrowUpRightIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function ViewTxStatus(props: { client: ThirdwebClient }) {
const [txHash, setTxHash] = useState("");
const [chainId, setChainId] = useState<number>(1);
const isValidTxHash = txHash.length === 66 && txHash.startsWith("0x");
const isEnabled = !!txHash && !!chainId && isValidTxHash;
return (
<div className="bg-card rounded-xl border">
<div className="p-4 lg:p-6 mb-3">
<h3 className="font-semibold text-xl tracking-tight mb-1">
View transaction status
</h3>
<p className="text-sm text-muted-foreground mb-4">
Select chain and enter the transaction hash to view the transaction
status.
</p>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 max-w-3xl">
<div className="space-y-2">
<Label>Chain</Label>
<SingleNetworkSelector
chainId={chainId}
onChange={setChainId}
client={props.client}
disableChainId
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="view-tx-hash">Transaction Hash</Label>
<Input
id="view-tx-hash"
value={txHash}
onChange={(e) => setTxHash(e.target.value)}
placeholder="0x.."
/>
{txHash && !isValidTxHash && (
<p className="text-sm text-destructive-text">
Invalid transaction hash
</p>
)}
</div>
</div>
</div>
<div className="px-4 lg:px-6 py-4 border-t border-dashed justify-end items-center flex">
<Button
asChild
disabled={!isEnabled}
size="sm"
variant={isEnabled ? "default" : "outline"}
className="gap-1.5 rounded-full"
>
<Link href={`/${chainId.toString()}/tx/${txHash}`} target="_blank">
View Transaction Status <ArrowUpRightIcon className="size-4" />
</Link>
</Button>
</div>
</div>
);
}
@@ -4,9 +4,14 @@ import { ProjectFTUX } from "./ProjectFTUX";
const meta = {
component: ProjectFTUX,
parameters: {
nextjs: {
appDirectory: true,
},
},
decorators: [
(Story) => (
<div className="container py-8 pb-20">
<div className="container py-8 pb-20 max-w-7xl">
<Story />
</div>
),
@@ -31,6 +36,5 @@ export const Default: Story = {
],
},
teamSlug: "bar",
managementAccessToken: undefined,
},
};
@@ -15,33 +15,12 @@ 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 { ProjectWalletControls } from "./ProjectWalletControls.client";
import { ProjectWalletSetup } from "./ProjectWalletSetup.client";
import { SecretKeySection } from "./SecretKeySection";
export async function ProjectFTUX(props: {
project: Project;
teamSlug: string;
wallet?: ProjectWalletSummary | undefined;
managementAccessToken: string | undefined;
}) {
const projectWallet = props.wallet ?? (await getProjectWallet(props.project));
export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
return (
<div className="flex flex-col gap-10">
<ProjectWalletSection
project={props.project}
teamSlug={props.teamSlug}
wallet={projectWallet}
managementAccessToken={props.managementAccessToken}
/>
<IntegrateAPIKeySection project={props.project} />
<ProductsSection
projectSlug={props.project.slug}
@@ -53,64 +32,6 @@ export async function ProjectFTUX(props: {
);
}
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 }) {
@@ -1,747 +0,0 @@
"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>
);
}
@@ -1,215 +0,0 @@
"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",
},
],
},
@@ -0,0 +1,736 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeftRightIcon,
EllipsisVerticalIcon,
RefreshCcwIcon,
SendIcon,
ShuffleIcon,
WalletIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { type ThirdwebClient, toWei } from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import { isAddress } from "thirdweb/utils";
import { z } from "zod";
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 { CopyTextButton } from "@/components/ui/CopyTextButton";
import {
Dialog,
DialogContent,
DialogDescription,
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 { Skeleton } from "@/components/ui/skeleton";
import type { 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 GetProjectServerWallets = (params: {
managementAccessToken: string;
projectId: string;
}) => Promise<ProjectWalletSummary[]>;
type ProjectWalletControlsProps = {
projectWallet: ProjectWalletSummary;
project: Project;
defaultChainId?: number;
teamSlug: string;
getProjectServerWallets: GetProjectServerWallets;
client: ThirdwebClient;
};
export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
const { projectWallet, 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 chain = useV5DashboardChain(selectedChainId);
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 props.getProjectServerWallets({
managementAccessToken,
projectId: project.id,
});
},
queryKey: ["project", project.id, "server-wallets", managementAccessToken],
staleTime: 60_000,
});
const balanceQuery = useWalletBalance({
address: projectWallet.address,
chain,
client: props.client,
});
const canChangeWallet =
serverWalletsQuery.data && serverWalletsQuery.data.length > 1;
const router = useDashboardRouter();
return (
<div>
<div className="rounded-xl border border-border bg-card relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Open wallet actions"
className="p-2 h-auto w-auto absolute right-4 top-4 z-10"
variant="ghost"
>
<EllipsisVerticalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 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>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() =>
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
)
}
>
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
View transactions
</DropdownMenuItem>
{canChangeWallet && (
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsChangeWalletOpen(true)}
>
<ShuffleIcon className="size-4 text-muted-foreground" />
Change project wallet
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="p-4 relative flex flex-col gap-5">
<div>
<p className="text-sm text-foreground mb-1">Wallet Address</p>
<CopyTextButton
textToShow={projectWallet.address}
textToCopy={projectWallet.address}
tooltip="Copy wallet address"
copyIconPosition="right"
variant="ghost"
className="text-muted-foreground -translate-x-1.5 py-0 h-auto"
/>
</div>
<div>
<p className="text-sm text-foreground mb-1">Wallet Label</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm break-all py-2 sm:py-0 text-muted-foreground">
{projectWallet.label || "N/A"}
</p>
</div>
</div>
</div>
<div className="p-4 border-t border-dashed flex justify-between items-center">
<div>
<p className="text-sm text-foreground mb-1">Balance</p>
<div className="flex items-center gap-1">
{balanceQuery.isFetching || !balanceQuery.data ? (
<Skeleton className="h-5 w-[100px]" />
) : (
<span className="text-sm text-muted-foreground">
{balanceQuery.data.displayValue} {balanceQuery.data.symbol}
</span>
)}
<Button
aria-label="Refresh balance"
className="p-1 h-auto w-auto"
disabled={balanceQuery.isFetching}
onClick={() => balanceQuery.refetch()}
variant="ghost"
>
<RefreshCcwIcon
className={cn(
"size-3 text-muted-foreground",
balanceQuery.isFetching && "animate-spin",
)}
/>
</Button>
</div>
</div>
<SingleNetworkSelector
chainId={selectedChainId}
className="w-fit rounded-full bg-background hover:bg-accent/50"
client={props.client}
disableDeprecated
disableChainId
onChange={setSelectedChainId}
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
align="end"
/>
</div>
</div>
<SendProjectWalletModal
chainId={selectedChainId}
client={props.client}
isManagedVault={isManagedVault}
label={projectWallet.label || ""}
onClose={() => setIsSendOpen(false)}
onSuccess={() => balanceQuery.refetch()}
open={isSendOpen}
publishableKey={project.publishableKey}
teamId={project.teamId}
walletAddress={projectWallet.address}
/>
<FundWalletModal
checkoutWidgetTitle="Fund Project Wallet"
client={props.client}
defaultChainId={selectedChainId}
description="Use your card or crypto to deposit into this server wallet"
onOpenChange={setIsReceiveOpen}
open={isReceiveOpen}
recipientAddress={projectWallet.address}
title="Fund project wallet"
/>
<Dialog onOpenChange={setIsChangeWalletOpen} open={isChangeWalletOpen}>
<DialogContent className="gap-0 p-0 overflow-hidden">
<ChangeProjectWalletDialogContent
isOpen={isChangeWalletOpen}
setIsOpen={setIsChangeWalletOpen}
serverWallets={{
data: serverWalletsQuery.data ?? [],
isPending: serverWalletsQuery.isLoading,
}}
projectWallet={projectWallet}
client={props.client}
project={project}
managementAccessToken={managementAccessToken}
/>
</DialogContent>
</Dialog>
</div>
);
}
function ChangeProjectWalletDialogContent(props: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
serverWallets: {
data: ProjectWalletSummary[];
isPending: boolean;
};
projectWallet: ProjectWalletSummary;
managementAccessToken: string | undefined;
project: Project;
client: ThirdwebClient;
}) {
const queryClient = useQueryClient();
const router = useDashboardRouter();
const [selectedWalletId, setSelectedWalletId] = useState<string | undefined>(
props.projectWallet.id,
);
const serverWallets = props.serverWallets.data;
const currentWalletAddressLower = props.projectWallet.address.toLowerCase();
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: props.project,
projectWalletAddress: wallet.address,
});
},
onError: (error) => {
const message =
error instanceof Error
? error.message
: "Failed to update project wallet";
toast.error(message);
},
onSuccess: async () => {
toast.success("Project wallet updated");
await queryClient.invalidateQueries({
queryKey: [
"project",
props.project.id,
"server-wallets",
props.managementAccessToken,
],
});
router.refresh();
},
});
return (
<div>
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Change Project Wallet</DialogTitle>
<DialogDescription>
Choose a server wallet to use as project wallet
</DialogDescription>
</DialogHeader>
<div className="px-4 pb-8 lg:px-6">
{props.serverWallets.isPending ? (
<div className="flex justify-center py-6">
<Spinner className="size-5" />
</div>
) : (
<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={props.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={props.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>
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
<Button
onClick={() => props.setIsOpen(false)}
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" />
)}
Set as project wallet
</Button>
</div>
</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>>;
type SendProjectWalletModalProps = {
open: boolean;
onClose: () => void;
onSuccess: () => void;
walletAddress: string;
publishableKey: string;
teamId: string;
chainId: number;
label: string;
client: ReturnType<typeof getClientThirdwebClient>;
isManagedVault: boolean;
};
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.";
function SendProjectWalletModal(props: SendProjectWalletModalProps) {
return (
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen) {
props.onClose();
}
}}
open={props.open}
>
<DialogContent className="gap-0 p-0 overflow-hidden">
<SendProjectWalletModalContent {...props} />
</DialogContent>
</Dialog>
);
}
function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
const {
onClose,
onSuccess,
walletAddress,
publishableKey,
teamId,
chainId,
label,
client,
isManagedVault,
} = props;
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"));
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 (
<div 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>
</div>
);
}
@@ -0,0 +1,126 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
import { projectStub } from "@/storybook/stubs";
import { storybookThirdwebClient } from "@/storybook/utils";
import { ProjectWalletSectionUI } from "./project-wallet";
const meta = {
component: ProjectWalletSectionUI,
title: "Project/ProjectWalletSection",
parameters: {
nextjs: {
appDirectory: true,
},
},
decorators: [
(Story) => (
<div className="container py-8 pb-20 max-w-7xl">
<Story />
</div>
),
],
} satisfies Meta<typeof ProjectWalletSectionUI>;
export default meta;
type Story = StoryObj<typeof meta>;
const projectWithManagedAccessToken = projectStub("foo", "bar");
projectWithManagedAccessToken.services = [
{
name: "engineCloud",
actions: [],
managementAccessToken: "managed-access-token",
},
];
const projectWithoutManagedAccessToken = projectStub("foo", "bar");
const projectWallet1: ProjectWalletSummary = {
id: "server-wallet-id",
address: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
label: "Project Wallet 1",
};
const projectWallet2: ProjectWalletSummary = {
id: "server-wallet-id-2",
address: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
label: "Project Wallet 2",
};
export const NoProjectWalletSetNoManagedAccessToken: Story = {
args: {
project: projectWithoutManagedAccessToken,
client: storybookThirdwebClient,
teamSlug: "bar",
projectWallet: undefined,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return [];
},
},
};
export const NoProjectWalletSetWithManagedAccessToken: Story = {
args: {
project: projectWithManagedAccessToken,
client: storybookThirdwebClient,
teamSlug: "bar",
projectWallet: undefined,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return [];
},
},
};
export const NoProjectWalletSetWithManagedAccessTokenAndServerWallets: Story = {
args: {
project: projectWithManagedAccessToken,
teamSlug: "bar",
client: storybookThirdwebClient,
projectWallet: undefined,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return [projectWallet1, projectWallet2];
},
},
};
export const NoProjectWalletSetLoading: Story = {
args: {
project: projectWithManagedAccessToken,
teamSlug: "bar",
client: storybookThirdwebClient,
projectWallet: undefined,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 100000));
return [projectWallet1, projectWallet2];
},
},
};
export const ProjectWalletSetMultipleServerWallets: Story = {
args: {
project: projectWithManagedAccessToken,
teamSlug: "bar",
client: storybookThirdwebClient,
projectWallet: projectWallet1,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return [projectWallet1, projectWallet2];
},
},
};
export const ProjectWalletSetSingleServerWallet: Story = {
args: {
project: projectWithManagedAccessToken,
teamSlug: "bar",
projectWallet: projectWallet1,
client: storybookThirdwebClient,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return [projectWallet1];
},
},
};
@@ -0,0 +1,331 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
import { ChevronDownIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets";
import type { Project } from "@/api/project/projects";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/Spinner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
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";
import { ProjectWalletDetailsSection } from "./project-wallet-details";
type GetProjectServerWallets = (params: {
managementAccessToken: string;
projectId: string;
}) => Promise<ProjectWalletSummary[]>;
function CreateProjectWalletSection(props: {
project: Project;
teamSlug: string;
getProjectServerWallets: GetProjectServerWallets;
}) {
const { project, teamSlug } = props;
const router = useDashboardRouter();
const queryClient = useQueryClient();
const client = useMemo(() => getClientThirdwebClient(), []);
const [selectedWalletId, setSelectedWalletId] = useState<string | undefined>(
undefined,
);
const [isSelectDialogOpen, setIsSelectDialogOpen] = useState(false);
const managementAccessToken =
props.project.services?.find((service) => service.name === "engineCloud")
?.managementAccessToken ?? undefined;
const serverWalletsQuery = useQuery({
enabled: Boolean(managementAccessToken),
queryFn: async () => {
if (!managementAccessToken) {
return [] as ProjectWalletSummary[];
}
return props.getProjectServerWallets({
managementAccessToken,
projectId: project.id,
});
},
queryKey: ["project", project.id, "server-wallets", managementAccessToken],
refetchOnWindowFocus: false,
});
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,
],
});
setIsSelectDialogOpen(false);
router.refresh();
},
});
const canSelectExisting =
Boolean(managementAccessToken) && serverWallets.length > 0;
return (
<div className="rounded-xl border p-4 bg-card">
<div className="rounded-full p-2 border bg-background inline-flex mb-4">
<XIcon className="size-3.5 text-muted-foreground" />
</div>
<h2 className="text-sm font-medium text-foreground mb-0.5">
No Project Wallet set
</h2>
<p className="text-sm text-muted-foreground">
Set a project wallet to set the default sender in thirdweb API.
</p>
<div className="mt-4 space-y-5 text-foreground">
{serverWalletsQuery.isLoading ? (
<Skeleton className="h-[36px] max-w-lg rounded-full" />
) : canSelectExisting ? (
<div className="flex items-center gap-3">
<Button
className="bg-background rounded-full gap-2"
variant="outline"
size="sm"
onClick={() => {
setSelectedWalletId(effectiveSelectedWalletId);
setIsSelectDialogOpen(true);
}}
type="button"
>
<ChevronDownIcon className="size-4 text-muted-foreground" />
Select a server wallet
</Button>
<CreateServerWallet
project={project}
teamSlug={teamSlug}
setAsProjectWallet
button={{ size: "sm", iconClassName: "size-3.5" }}
/>
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setIsSelectDialogOpen(false);
}
}}
open={isSelectDialogOpen}
>
<DialogContent className="gap-0 p-0 overflow-hidden">
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Select project wallet</DialogTitle>
<DialogDescription>
Choose a 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 === 0 ? (
<p className="text-sm text-muted-foreground">
No server wallets found.
</p>
) : (
<div className="flex flex-col gap-3">
<Select
onValueChange={(value) => setSelectedWalletId(value)}
value={selectedWalletId ?? ""}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select server wallet" />
</SelectTrigger>
<SelectContent>
{serverWallets.map((wallet) => (
<SelectItem key={wallet.id} value={wallet.id}>
<div className="flex items-center gap-3">
<WalletAddress
address={wallet.address}
client={client}
className="h-auto py-1"
/>
<span className="text-sm text-muted-foreground">
{wallet.label}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
<Button
onClick={() => {
setIsSelectDialogOpen(false);
}}
type="button"
variant="outline"
disabled={setDefaultWalletMutation.isPending}
>
Cancel
</Button>
<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>
</div>
</DialogContent>
</Dialog>
</div>
) : (
<div className="space-y-3 text-sm text-muted-foreground">
<CreateServerWallet
project={project}
teamSlug={teamSlug}
setAsProjectWallet
button={{ size: "sm", iconClassName: "size-3.5" }}
/>
</div>
)}
</div>
</div>
);
}
export function ProjectWalletSectionUI(props: {
project: Project;
teamSlug: string;
projectWallet: ProjectWalletSummary | undefined;
getProjectServerWallets: GetProjectServerWallets;
client: ThirdwebClient;
}) {
return (
<div>
<div className="mb-3">
<h2 className="mb-1 font-semibold text-xl tracking-tight">
Project Wallet
</h2>
<p className="text-muted-foreground text-sm">
The server wallet to be used as the default sender in{" "}
<UnderlineLink
href="https://portal.thirdweb.com/reference"
target="_blank"
>
thirdweb API
</UnderlineLink>
</p>
</div>
{props.projectWallet ? (
<ProjectWalletDetailsSection
project={props.project}
projectWallet={props.projectWallet}
client={props.client}
teamSlug={props.teamSlug}
getProjectServerWallets={props.getProjectServerWallets}
/>
) : (
<CreateProjectWalletSection
project={props.project}
teamSlug={props.teamSlug}
getProjectServerWallets={props.getProjectServerWallets}
/>
)}
</div>
);
}
export function ProjectWalletSection(props: {
project: Project;
teamSlug: string;
projectWallet: ProjectWalletSummary | undefined;
client: ThirdwebClient;
}) {
return (
<ProjectWalletSectionUI
project={props.project}
teamSlug={props.teamSlug}
client={props.client}
projectWallet={props.projectWallet}
getProjectServerWallets={listProjectServerWallets}
/>
);
}
@@ -59,7 +59,6 @@ export function ErrorRate({ datapoints }: { datapoints: ResultItem[] }) {
key={tag}
stackId="a"
stroke={`var(--color-${tag})`}
type="natural"
/>,
);
}
@@ -39,10 +39,8 @@ import type {
import { loginRedirect } from "@/utils/redirects";
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
import {
ProjectFTUX,
ProjectWalletSection,
} from "./components/ProjectFTUX/ProjectFTUX";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import { ProjectWalletSection } from "./components/project-wallet/project-wallet";
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
import { TransactionsChartCardAsync } from "./components/Transactions";
import { ProjectHighlightsCard } from "./overview/highlights-card";
@@ -112,9 +110,6 @@ export default async function ProjectOverviewPage(props: PageProps) {
});
const projectWallet = await getProjectWallet(project);
const managementAccessToken =
project.services?.find((service) => service.name === "engineCloud")
?.managementAccessToken ?? undefined;
return (
<ResponsiveSearchParamsProvider value={searchParams}>
@@ -135,14 +130,17 @@ export default async function ProjectOverviewPage(props: PageProps) {
actions: null,
}}
>
<ProjectWalletSection
project={project}
teamSlug={params.team_slug}
projectWallet={projectWallet}
client={client}
/>
<div className="h-10" />
{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}
@@ -155,12 +153,7 @@ export default async function ProjectOverviewPage(props: PageProps) {
/>
</div>
) : (
<ProjectFTUX
project={project}
teamSlug={params.team_slug}
wallet={projectWallet}
managementAccessToken={managementAccessToken}
/>
<ProjectFTUX project={project} teamSlug={params.team_slug} />
)}
</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;
},
@@ -16,6 +16,7 @@ export function QuickStartSection(props: {
clientId: string;
teamId: string;
projectWalletAddress?: string;
authToken: string;
}) {
return (
<section>
@@ -46,6 +47,7 @@ export function QuickStartSection(props: {
clientId={props.clientId}
projectWalletAddress={props.projectWalletAddress}
teamId={props.teamId}
authToken={props.authToken}
>
<Button
className="w-full gap-2 group text-foreground bg-background"
@@ -49,6 +49,7 @@ export function CreatePaymentLinkButton(
clientId: string;
teamId: string;
projectWalletAddress?: string;
authToken: string;
}>,
) {
const [open, setOpen] = useState(false);
@@ -62,6 +63,7 @@ export function CreatePaymentLinkButton(
projectWalletAddress={props.projectWalletAddress}
setOpen={setOpen}
teamId={props.teamId}
authToken={props.authToken}
/>
</DialogContent>
</Dialog>
@@ -73,6 +75,7 @@ function CreatePaymentLinkDialogContent(props: {
teamId: string;
setOpen: (open: boolean) => void;
projectWalletAddress?: string;
authToken: string;
}) {
const client = getClientThirdwebClient();
@@ -125,6 +128,7 @@ function CreatePaymentLinkDialogContent(props: {
amount: toUnits(values.amount.toString(), token.decimals),
},
title: values.title,
authToken: props.authToken,
});
return result;
@@ -44,6 +44,7 @@ export function PaymentLinksTable(props: {
clientId: string;
teamId: string;
projectWalletAddress?: string;
authToken: string;
}) {
return (
<section>
@@ -57,6 +58,7 @@ export function PaymentLinksTable(props: {
clientId={props.clientId}
projectWalletAddress={props.projectWalletAddress}
teamId={props.teamId}
authToken={props.authToken}
/>
</section>
);
@@ -66,12 +68,14 @@ 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],
@@ -83,6 +87,7 @@ function PaymentLinksTableInner(props: {
return await Promise.all(
paymentLinks.map(async (paymentLink) => {
const { data } = await getPayments({
authToken: props.authToken,
clientId: props.clientId,
teamId: props.teamId,
paymentLinkId: paymentLink.id,
@@ -134,6 +139,7 @@ function PaymentLinksTableInner(props: {
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" />
@@ -228,6 +234,7 @@ function PaymentLinksTableInner(props: {
clientId={props.clientId}
teamId={props.teamId}
paymentLinkId={paymentLink.id}
authToken={props.authToken}
>
<Button size="icon" variant="ghost">
<TrashIcon className="size-4" strokeWidth={1} />
@@ -277,6 +284,7 @@ function DeletePaymentLinkButton(
paymentLinkId: string;
clientId: string;
teamId: string;
authToken: string;
}>,
) {
const [open, setOpen] = useState(false);
@@ -287,6 +295,7 @@ function DeletePaymentLinkButton(
clientId: props.clientId,
teamId: props.teamId,
paymentLinkId: id,
authToken: props.authToken,
});
return null;
},
@@ -56,6 +56,7 @@ export default async function Page(props: {
clientId={project.publishableKey}
projectWalletAddress={projectWallet?.address}
teamId={project.teamId}
authToken={authToken}
>
<Button className="gap-1.5 rounded-full" size="sm">
<PlusIcon className="size-4" />
@@ -147,6 +148,7 @@ export default async function Page(props: {
clientId={project.publishableKey}
projectWalletAddress={projectWallet?.address}
teamId={project.teamId}
authToken={authToken}
/>
<QuickStartSection
projectSlug={params.project_slug}
@@ -154,6 +156,7 @@ export default async function Page(props: {
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>
@@ -27,6 +27,7 @@ interface PayConfigProps {
teamSlug: string;
fees: Fee;
projectWalletAddress?: string;
authToken: string;
}
export const PayConfig: React.FC<PayConfigProps> = (props) => {
@@ -48,6 +49,7 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
feeBps: values.developerFeeBPS,
feeRecipient: values.payoutAddress,
teamId: props.teamId,
authToken: props.authToken,
});
},
});
@@ -37,6 +37,7 @@ export default async function Page(props: {
let fees = await getFees({
clientId: project.publishableKey,
teamId: team.id,
authToken: authToken,
}).catch(() => {
return {
createdAt: "",
@@ -69,6 +70,7 @@ export default async function Page(props: {
teamId={team.id}
projectWalletAddress={projectWallet?.address}
teamSlug={team_slug}
authToken={authToken}
/>
</div>
);
@@ -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>
@@ -3,17 +3,20 @@ import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
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 { 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;
}
@@ -25,11 +28,12 @@ export const EngineChecklist: React.FC<Props> = (props) => {
const steps: Step[] = [];
steps.push({
children: (
<CreateServerWalletStep
managementAccessToken={props.managementAccessToken}
project={props.project}
teamSlug={props.teamSlug}
/>
<div className="mt-4 flex flex-row gap-4">
<CreateServerWallet
project={props.project}
teamSlug={props.teamSlug}
/>
</div>
),
completed: props.wallets.length > 0 || props.hasTransactions,
description:
@@ -57,7 +61,6 @@ export const EngineChecklist: React.FC<Props> = (props) => {
});
return steps;
}, [
props.managementAccessToken,
props.project,
props.wallets,
props.hasTransactions,
@@ -85,6 +88,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;
}
@@ -96,19 +112,3 @@ export const EngineChecklist: React.FC<Props> = (props) => {
/>
);
};
function CreateServerWalletStep(props: {
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
}) {
return (
<div className="mt-4 flex flex-row gap-4">
<CreateServerWallet
managementAccessToken={props.managementAccessToken}
project={props.project}
teamSlug={props.teamSlug}
/>
</div>
);
}
@@ -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,447 +0,0 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useId, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import type {
Transaction,
TransactionStatus,
TransactionsResponse,
} from "./types";
// TODO - add Status selector dropdown here
export function TransactionsTableUI(props: {
getData: (params: {
page: number;
status: TransactionStatus | undefined;
id: string | undefined;
from: string | undefined;
}) => Promise<TransactionsResponse>;
project: Project;
teamSlug: string;
wallets?: Wallet[];
client: ThirdwebClient;
}) {
const router = useDashboardRouter();
const [autoUpdate, setAutoUpdate] = useState(true);
const [status, setStatus] = useState<TransactionStatus | undefined>(
undefined,
);
const [id, setId] = useState<string | undefined>(undefined);
const [from, setFrom] = useState<string | undefined>(undefined);
const [page, setPage] = useState(1);
const pageSize = 10;
const transactionsQuery = useQuery({
placeholderData: keepPreviousData,
queryFn: () => props.getData({ page, status, id, from }),
queryKey: ["transactions", props.project.id, page, status, id, from],
refetchInterval: autoUpdate ? 4_000 : false,
});
const transactions = transactionsQuery.data?.transactions ?? [];
const totalCount = transactionsQuery.data?.pagination.totalCount ?? 0;
const totalPages = Math.ceil(totalCount / pageSize);
const showPagination = totalCount > pageSize;
const showSkeleton =
(transactionsQuery.isPlaceholderData && transactionsQuery.isFetching) ||
(transactionsQuery.isLoading && !transactionsQuery.isPlaceholderData);
const autoUpdateId = useId();
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="font-semibold text-2xl tracking-tight">
Transaction History
</h2>
<p className="text-muted-foreground text-sm">
Transactions sent from server wallets
</p>
</div>
<div className="flex shrink-0 items-center gap-2 border-border border-t pt-4 lg:border-none lg:pt-0">
<Label htmlFor={autoUpdateId}>Auto Update</Label>
<Switch
checked={autoUpdate}
id={autoUpdateId}
onCheckedChange={(v) => setAutoUpdate(!!v)}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<Input
className="max-w-[250px]"
onChange={(e) => {
const value = e.target.value.trim();
setId(value || undefined);
setPage(1);
}}
placeholder="Filter by Queue ID"
value={id || ""}
/>
<Input
className="max-w-[250px]"
onChange={(e) => {
const value = e.target.value.trim();
setFrom(value || undefined);
setPage(1);
}}
placeholder="Filter by wallet address"
value={from || ""}
/>
<div>
<StatusSelector
setStatus={(v) => {
setStatus(v);
// reset page
setPage(1);
}}
status={status}
/>
</div>
</div>
</div>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue ID</TableHead>
<TableHead>Chain</TableHead>
<TableHead>Status</TableHead>
<TableHead>From</TableHead>
<TableHead>Tx Hash</TableHead>
<TableHead>Queued</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{showSkeleton
? new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
<SkeletonRow key={i} />
))
: transactions.map((tx) => (
<TableRow
className="cursor-pointer hover:bg-accent/50"
key={`${tx.id}${tx.chainId}`}
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions/tx/${tx.id}`,
);
}}
>
{/* Queue ID */}
<TableCell className="font-medium">
<CopyAddressButton
address={tx.id ? tx.id : ""}
className="text-muted-foreground"
copyIconPosition="left"
variant="ghost"
/>
</TableCell>
{/* Chain Id */}
<TableCell>
<TxChainCell
chainId={tx.chainId || undefined}
client={props.client}
/>
</TableCell>
{/* Status */}
<TableCell>
<TxStatusCell transaction={tx} />
</TableCell>
{/* From Address */}
<TableCell>
{tx.from ? (
<WalletAddress
address={tx.from}
client={props.client}
/>
) : (
"N/A"
)}
</TableCell>
{/* Tx Hash */}
<TableCell>
<TxHashCell transaction={tx} />
</TableCell>
{/* Queued At */}
<TableCell>
<TxQueuedAtCell transaction={tx} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!showSkeleton && transactions.length === 0 && (
<div className="flex min-h-[200px] items-center justify-center">
No transactions found
</div>
)}
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
);
}
export const statusDetails = {
CONFIRMED: {
name: "Confirmed",
type: "success",
},
FAILED: {
name: "Failed",
type: "destructive",
},
QUEUED: {
name: "Queued",
type: "warning",
},
SUBMITTED: {
name: "Submitted",
type: "warning",
},
} as const;
function StatusSelector(props: {
status: TransactionStatus | undefined;
setStatus: (value: TransactionStatus | undefined) => void;
}) {
const statuses = Object.keys(statusDetails) as TransactionStatus[];
return (
<Select
onValueChange={(v) => {
if (v === "all") {
props.setStatus(undefined);
} else {
props.setStatus(v as TransactionStatus);
}
}}
value={props.status || "all"}
>
<SelectTrigger className="min-w-[160px] rounded-full">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All Transactions</SelectItem>
{statuses.map((item) => {
return (
<SelectItem key={item} value={item}>
{statusDetails[item].name}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
);
}
function SkeletonRow() {
return (
<TableRow className="h-[73px]">
<TableCell>
<Skeleton className="h-6 w-[152px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[155px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[58px] rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[145px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[160px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[108px]" />
</TableCell>
</TableRow>
);
}
function TxChainCell(props: {
chainId: string | undefined;
client: ThirdwebClient;
}) {
const { chainId } = props;
const { idToChain } = useAllChainsData();
if (!chainId) {
return "N/A";
}
const chain = idToChain.get(Number.parseInt(chainId));
if (!chain) {
return `Chain ID: ${chainId}`;
}
return (
<div className="flex items-center gap-2">
<ChainIconClient
className="size-5"
client={props.client}
src={chain.icon?.url}
/>
<div className="max-w-[150px] truncate">
{chain.name ?? `Chain ID: ${chainId}`}
</div>
</div>
);
}
function TxStatusCell(props: { transaction: Transaction }) {
const { transaction } = props;
const { errorMessage } = transaction;
const minedAt = transaction.confirmedAt;
const status =
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
const onchainStatus =
transaction.executionResult &&
"onchainStatus" in transaction.executionResult
? transaction.executionResult.onchainStatus
: null;
const tooltip =
onchainStatus !== "REVERTED"
? errorMessage
: status === "CONFIRMED" && minedAt
? `Completed ${format(new Date(minedAt), "PP pp")}`
: undefined;
return (
<ToolTipLabel hoverable label={tooltip}>
<Badge className="gap-2" variant={statusDetails[status].type}>
{statusDetails[status].name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
);
}
function TxHashCell(props: { transaction: Transaction }) {
const { idToChain } = useAllChainsData();
const { chainId, transactionHash } = props.transaction;
if (!transactionHash) {
return "N/A";
}
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
const explorer = chain?.explorers?.[0];
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
if (!explorer) {
return (
<CopyTextButton
className="font-mono text-muted-foreground text-sm"
copyIconPosition="left"
textToCopy={transactionHash}
textToShow={shortHash}
tooltip="Copy transaction hash"
variant="ghost"
/>
);
}
return (
<Button
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
variant="ghost"
>
<Link
href={`${explorer.url}/tx/${transactionHash}`}
onClick={(e) => {
e.stopPropagation();
}}
rel="noopener noreferrer"
target="_blank"
>
{shortHash}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
);
}
function TxQueuedAtCell(props: { transaction: Transaction }) {
const value = props.transaction.createdAt;
if (!value) {
return;
}
const date = new Date(value);
return (
<ToolTipLabel label={format(date, "PP pp z")}>
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
@@ -1,72 +0,0 @@
"use client";
import type { ThirdwebClient } from "thirdweb";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import { TransactionsTableUI } from "./tx-table-ui";
import type { TransactionStatus, TransactionsResponse } from "./types";
export function TransactionsTable(props: {
project: Project;
wallets?: Wallet[];
teamSlug: string;
client: ThirdwebClient;
}) {
return (
<TransactionsTableUI
client={props.client}
getData={async ({ page, status, id, from }) => {
return await getTransactions({
page,
project: props.project,
status,
id,
from,
});
}}
project={props.project}
teamSlug={props.teamSlug}
wallets={props.wallets}
/>
);
}
async function getTransactions({
project,
page,
status,
id,
from,
}: {
project: Project;
page: number;
status: TransactionStatus | undefined;
id: string | undefined;
from: string | undefined;
}) {
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
{
headers: {
"Content-Type": "application/json",
"x-client-id": project.publishableKey,
"x-team-id": project.teamId,
},
method: "GET",
pathname: `/v1/transactions`,
searchParams: {
limit: "20",
page: page.toString(),
status: status ?? undefined,
id: id ?? undefined,
from: from ?? undefined,
},
},
);
if (!transactions.ok) {
throw new Error(transactions.error);
}
return transactions.data.result;
}
@@ -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,824 @@
"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 {
Select,
SelectContent,
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 { 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 { fetchSolanaBalance } from "../lib/getSolanaBalance";
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;
client: ThirdwebClient;
solanaPermissionError?: boolean;
authToken: string;
}
export function ServerWalletsTable(props: ServerWalletsTableProps) {
const {
evmWallets,
solanaWallets,
project,
teamSlug,
evmTotalRecords,
evmCurrentPage,
evmTotalPages,
solanaTotalRecords,
solanaCurrentPage,
solanaTotalPages,
client,
solanaPermissionError,
authToken,
} = props;
const [activeChain, setActiveChain] = useState<WalletChain>("evm");
const [selectedChainId, setSelectedChainId] = useState<number>(1);
const [selectedSolanaChain, setSelectedSolanaChain] = useState<
"solana:mainnet" | "solana:devnet"
>("solana:mainnet");
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 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
project={project}
teamSlug={teamSlug}
disabled={solanaPermissionError}
/>
<Select
value={selectedSolanaChain}
onValueChange={(value) =>
setSelectedSolanaChain(
value as "solana:mainnet" | "solana:devnet",
)
}
>
<SelectTrigger className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50">
<SelectValue placeholder="Select network" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="solana:mainnet" className="rounded-lg">
Solana Mainnet
</SelectItem>
<SelectItem value="solana:devnet" className="rounded-lg">
Solana Devnet
</SelectItem>
</SelectContent>
</Select>
</>
)}
</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}
authToken={authToken}
chainId={selectedSolanaChain}
/>
))}
</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,
authToken,
chainId,
}: {
wallet: SolanaWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
authToken: string;
chainId: "solana:mainnet" | "solana:devnet";
}) {
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}
authToken={authToken}
clientId={project.publishableKey}
chainId={chainId}
/>
</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,
authToken,
clientId,
chainId,
}: {
publicKey: string;
authToken: string;
clientId: string;
chainId: "solana:mainnet" | "solana:devnet";
}) {
const balance = useQuery({
queryFn: async () => {
return await fetchSolanaBalance({
publicKey,
authToken,
clientId,
chainId,
});
},
queryKey: ["solanaWalletBalance", publicKey, chainId],
});
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,60 @@
import { configure, getSolanaWalletBalance } from "@thirdweb-dev/api";
import { THIRDWEB_API_HOST } from "@/constants/urls";
// Configure the API client to use the correct base URL
configure({
override: {
baseUrl: THIRDWEB_API_HOST,
},
});
export async function fetchSolanaBalance({
publicKey,
authToken,
clientId,
chainId = "solana:mainnet",
}: {
publicKey: string;
authToken: string;
clientId: string;
chainId?: "solana:mainnet" | "solana:devnet";
}): Promise<{
displayValue: string;
symbol: string;
value: string;
decimals: number;
} | null> {
try {
const response = await getSolanaWalletBalance({
path: {
address: publicKey,
},
query: {
chainId,
},
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
},
});
if (response.error || !response.data) {
console.error(
"Error fetching Solana balance:",
response.error || "No data returned",
);
return null;
}
return {
displayValue: response.data.result.displayValue,
symbol: "SOL",
value: response.data.result.value,
decimals: response.data.result.decimals,
};
} catch (error) {
console.error("Error fetching Solana balance:", error);
return null;
}
}
@@ -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,6 +1,6 @@
"use client";
import { encrypt } from "@thirdweb-dev/service-utils";
import { decrypt, encrypt } from "@thirdweb-dev/service-utils";
import {
createAccessToken,
createEoa,
@@ -514,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: [
{
@@ -535,7 +558,7 @@ export async function createWalletAccessToken(props: {
},
},
],
type: "eoa:read",
type: "solana:read",
},
{
requiredMetadataPatterns: [
@@ -558,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",
},
],
},
@@ -633,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: [
{
@@ -657,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,
@@ -123,43 +156,66 @@ export default async function TransactionsAnalyticsPage(props: {
isManagedVault={isManagedVault}
client={client}
hasTransactions={hasTransactions}
managementAccessToken={managementAccessToken ?? undefined}
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}
managementAccessToken={managementAccessToken ?? undefined}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={eoas.data.items as Wallet[]}
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}
authToken={authToken}
/>
)}
</div>
@@ -15,18 +15,26 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { createProjectServerWallet } from "../../lib/vault.client";
export default function CreateServerWallet(props: {
export function CreateServerWallet(props: {
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
setAsProjectWallet?: boolean;
button?: {
size?: "default" | "sm" | "lg";
iconClassName?: string;
};
}) {
const router = useDashboardRouter();
const [label, setLabel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const managementAccessToken =
props.project.services?.find((service) => service.name === "engineCloud")
?.managementAccessToken ?? undefined;
const createEoaMutation = useMutation({
mutationFn: async ({
managementAccessToken,
@@ -53,12 +61,12 @@ export default function CreateServerWallet(props: {
});
const handleCreateServerWallet = async () => {
if (!props.managementAccessToken) {
if (!managementAccessToken) {
router.push(`/team/${props.teamSlug}/${props.project.slug}/vault`);
} else {
await createEoaMutation.mutateAsync({
label,
managementAccessToken: props.managementAccessToken,
managementAccessToken: managementAccessToken,
});
}
};
@@ -68,21 +76,32 @@ export default function CreateServerWallet(props: {
return (
<>
<Button
className="gap-1.5 rounded-full bg-background"
className="gap-1.5 rounded-full bg-background text-foreground"
variant="outline"
size={props.button?.size}
onClick={() =>
props.managementAccessToken
managementAccessToken
? setModalOpen(true)
: router.push(`/team/${props.teamSlug}/${props.project.slug}/vault`)
}
>
{isLoading ? (
<Spinner className="size-4" />
<Spinner
className={cn(
"size-4 text-muted-foreground",
props.button?.iconClassName,
)}
/>
) : (
<PlusIcon className="size-4 text-muted-foreground" />
<PlusIcon
className={cn(
"size-4 text-muted-foreground",
props.button?.iconClassName,
)}
/>
)}
{props.managementAccessToken
{managementAccessToken
? isLoading
? "Creating..."
: "Create Server Wallet"
@@ -1,519 +0,0 @@
"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 { 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 { ToolTipLabel } from "@/components/ui/tooltip";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { updateDefaultProjectWallet } from "../../lib/vault.client";
import CreateServerWallet from "../components/create-server-wallet.client";
import type { Wallet } from "./types";
export function ServerWalletsTableUI({
wallets,
project,
teamSlug,
managementAccessToken,
totalRecords,
currentPage,
totalPages,
client,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
totalRecords: number;
currentPage: number;
totalPages: number;
client: ThirdwebClient;
}) {
const [selectedChainId, setSelectedChainId] = useState<number>(1);
const [showSmartAccount, setShowSmartAccount] = useState(false);
const queryClient = useQueryClient();
return (
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<WalletProductIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
Server Wallets
</h2>
<p className="text-muted-foreground text-sm">
Create and manage server wallets for your project
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<div className="flex flex-row gap-2.5">
<CreateServerWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
/>
<SingleNetworkSelector
chainId={selectedChainId}
onChange={setSelectedChainId}
client={client}
disableChainId
className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50"
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
/>
</div>
<div className="flex items-center gap-2.5">
<Label
className="text-sm text-muted-foreground"
htmlFor="showSmartAccount"
>
Show ERC4337 Smart Account
</Label>
<Switch
id="showSmartAccount"
checked={showSmartAccount}
onCheckedChange={setShowSmartAccount}
size="sm"
/>
</div>
</div>
</div>
<TableContainer className="rounded-none border-x-0 border-b-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Label</TableHead>
<TableHead className="w-[280px]">
{showSmartAccount
? "Smart Account Address"
: "Wallet Address"}
</TableHead>
<TableHead className="min-w-[80px]">
<div className="flex w-[180px] items-center gap-1.5">
Balance
{wallets.length > 0 && (
<ToolTipLabel
contentClassName="capitalize font-normal tracking-normal leading-normal"
label="Refresh Balance"
>
<Button
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
onClick={async (e) => {
const buttonEl = e.currentTarget;
buttonEl.setAttribute("data-pending", "true");
await queryClient.invalidateQueries({
queryKey: ["walletBalance", selectedChainId],
});
buttonEl.setAttribute("data-pending", "false");
}}
size="sm"
variant="ghost"
>
<RefreshCcwIcon className="size-4" />
</Button>
</ToolTipLabel>
)}
</div>
</TableHead>
<TableHead>Created </TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wallets.map((wallet) => (
<ServerWalletTableRow
key={wallet.id}
wallet={wallet}
project={project}
teamSlug={teamSlug}
client={client}
chainId={selectedChainId}
showSmartAccount={showSmartAccount}
/>
))}
</TableBody>
</Table>
{wallets.length === 0 && (
<div className="py-24 flex flex-col items-center justify-center px-4 text-center gap-4">
<div className="p-2 rounded-full bg-background border border-border">
<XIcon className="size-5 text-muted-foreground" />
</div>
<p className="text-muted-foreground">No server wallets found</p>
</div>
)}
</TableContainer>
{totalPages > 1 && (
<div className="flex flex-col items-center border-t p-6">
<div className="mb-4 text-muted-foreground text-sm">
Found {totalRecords} server wallets
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
currentPage > 1 ? currentPage - 1 : 1
}`}
legacyBehavior
passHref
>
<PaginationPrevious
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</Link>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={`page-${pageNumber}`}>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?page=${pageNumber}`}
passHref
>
<PaginationLink isActive={currentPage === pageNumber}>
{pageNumber}
</PaginationLink>
</Link>
</PaginationItem>
),
)}
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
currentPage < totalPages ? currentPage + 1 : totalPages
}`}
passHref
>
<PaginationNext
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</Link>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
</div>
);
}
function ServerWalletTableRow(props: {
wallet: Wallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
chainId: number;
showSmartAccount: boolean;
}) {
const { wallet, project, teamSlug, client, chainId, showSmartAccount } =
props;
const chain = useV5DashboardChain(chainId);
const smartAccountAddressQuery = useQuery({
queryFn: async () => {
const smartAccountAddress = await predictSmartAccountAddress({
adminAddress: wallet.address,
chain: chain,
client: client,
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
});
return smartAccountAddress;
},
enabled: showSmartAccount,
queryKey: ["smart-account-address", wallet.address, chainId],
});
// Get the project wallet address
const engineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const defaultWalletAddress = engineCloudService?.projectWalletAddress;
const isDefaultWallet =
defaultWalletAddress &&
wallet.address.toLowerCase() === defaultWalletAddress.toLowerCase();
return (
<TableRow key={wallet.id}>
{/* Label */}
<TableCell>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
)}
>
{wallet.metadata.label || "N/A"}
</span>
{isDefaultWallet && (
<Badge variant="success" className="text-xs">
default
</Badge>
)}
</div>
</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);
const router = useDashboardRouter();
const setAsDefaultMutation = useMutation({
mutationFn: async () => {
await updateDefaultProjectWallet({
project: props.project,
projectWalletAddress: props.wallet.address,
});
},
onSuccess: () => {
toast.success("Wallet set as project wallet");
router.refresh();
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to set default wallet",
);
},
});
return (
<>
<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>
)}
<DropdownMenuItem
onClick={() => setAsDefaultMutation.mutate()}
disabled={setAsDefaultMutation.isPending}
className="flex items-center gap-2 h-9 rounded-lg"
>
<CheckIcon className="size-4 text-muted-foreground" />
Set as default
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{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}
/>
);
}

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