diff --git a/.changeset/afraid-seals-sit.md b/.changeset/afraid-seals-sit.md new file mode 100644 index 0000000000..fc0f651b03 --- /dev/null +++ b/.changeset/afraid-seals-sit.md @@ -0,0 +1,6 @@ +--- +'minifront': minor +'@repo/ui': minor +--- + +fix auctions source diff --git a/.changeset/afraid-trains-compare.md b/.changeset/afraid-trains-compare.md new file mode 100644 index 0000000000..c0ff8e93b6 --- /dev/null +++ b/.changeset/afraid-trains-compare.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/perspective': minor +--- + +Add additional transaction type classifications diff --git a/.changeset/old-jobs-type.md b/.changeset/old-jobs-type.md new file mode 100644 index 0000000000..5ba74c1b1c --- /dev/null +++ b/.changeset/old-jobs-type.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/wasm': minor +--- + +Properly derive DelegatorVoteView from perspective diff --git a/.changeset/seven-gifts-play.md b/.changeset/seven-gifts-play.md deleted file mode 100644 index 5cc944405f..0000000000 --- a/.changeset/seven-gifts-play.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@penumbra-zone/wasm': patch ---- - -Bug fix with get_all_notes not respecting None asset id + delegator voting tests diff --git a/.changeset/small-queens-drum.md b/.changeset/small-queens-drum.md deleted file mode 100644 index b3c1022afb..0000000000 --- a/.changeset/small-queens-drum.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@penumbra-zone/client': patch ---- - -fix manifest checking function diff --git a/.github/workflows/deploy-ui-preview-main.yml b/.github/workflows/deploy-ui-preview-main.yml new file mode 100644 index 0000000000..c29d4c0221 --- /dev/null +++ b/.github/workflows/deploy-ui-preview-main.yml @@ -0,0 +1,32 @@ +# Deploys the static website for the UI storybook to "preview" environment, +# on every merge into main branch. +name: Deploy UI to preview +on: + workflow_dispatch: + push: + branches: + - main +jobs: + build_and_deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install + working-directory: packages/ui + + - name: Build static site + run: pnpm build-storybook + working-directory: packages/ui + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_PENUMBRA_UI }} + channelId: live + target: preview + entryPoint: packages/ui + projectId: penumbra-ui diff --git a/.github/workflows/deploy-ui-preview-pr.yml b/.github/workflows/deploy-ui-preview-pr.yml new file mode 100644 index 0000000000..b0dfaf5dba --- /dev/null +++ b/.github/workflows/deploy-ui-preview-pr.yml @@ -0,0 +1,38 @@ +# Deploys the static website for the UI storybook to a temporary environment, +# with an ephemeral URL posted to the PR for sharing/review. +name: Deploy UI to temporary URL +on: + workflow_dispatch: + pull_request: + paths: + # Only deploy an ephemeral Storybook preview for PRs that make changes to + # the UI package. + - 'packages/ui/src/**' +permissions: + checks: write + contents: read + pull-requests: write +jobs: + build_and_preview: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install + working-directory: packages/ui + + - name: Build static site + run: pnpm build-storybook + working-directory: packages/ui + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_PENUMBRA_UI }} + target: preview + entryPoint: packages/ui + projectId: penumbra-ui diff --git a/.github/workflows/deploy-ui-release.yml b/.github/workflows/deploy-ui-release.yml new file mode 100644 index 0000000000..e2beb38011 --- /dev/null +++ b/.github/workflows/deploy-ui-release.yml @@ -0,0 +1,34 @@ +# Deploys the static website for the UI storybook to final prod website, +# on every tag push into main branch. +name: Deploy UI to stable channel +on: + # Support ad-hoc runs + workflow_dispatch: + # Run automatically on tag push + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' +jobs: + build_and_deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install + working-directory: packages/ui + + - name: Build static site + run: pnpm build-storybook + working-directory: packages/ui + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_PENUMBRA_UI }} + channelId: live + target: stable + entryPoint: packages/ui + projectId: penumbra-ui diff --git a/.gitignore b/.gitignore index 86257dc813..4faea8352a 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,7 @@ packages/*/penumbra-zone-*.tgz packages/*/repo-*-*.tgz packages/*/package -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo + +# Storybook builds +storybook-static diff --git a/README.md b/README.md index be176a0843..6a55e22f3d 100644 --- a/README.md +++ b/README.md @@ -75,34 +75,10 @@ pnpm dev ``` You now have a local copy of Minifront available at -[`https://localhost:5173`](https://localhost:5173) and an unbundled Prax is -available at [`apps/extension/dist`](apps/extension/dist), ready to be loaded -into your browser. +[`https://localhost:5173`](https://localhost:5173). Minifront will hot-reload. -If you're working on Prax, Chrome will show extension page changes after a -manual refresh, but cannot reload the extension worker scripts or content -scripts. For worker script changes, you must manually reload the extension. For -content script changes, you must also manually reload pages hosting the injected -scripts. - -#### Loading your unbundled build of Prax into Chrome - -After building Prax, you can load it into Chrome. - -It's recommended to use a dedicated browser profile for development, not your -personal profile. - -1. Go to the Extensions page [`chrome://extensions`](chrome://extensions) -2. Enable _Developer Mode_ by clicking the toggle switch at the top right -3. Click the button _Load unpacked extension_ at the top and locate your cloned - repository. Select the extension's build output directory - [`apps/extension/dist`](../apps/extension/dist). -4. Activate the extension to enter onboarding. - - You may set a blank password. - - You can pin the Prax extension button to your toolbar for quick access. - ## Security If you believe you've found a security-related issue with Penumbra, diff --git a/apps/minifront/CHANGELOG.md b/apps/minifront/CHANGELOG.md index c5843e374e..3d0ad07b3e 100644 --- a/apps/minifront/CHANGELOG.md +++ b/apps/minifront/CHANGELOG.md @@ -1,5 +1,91 @@ # minifront +## 6.12.3 + +### Patch Changes + +- Updated dependencies [3477bef] +- Updated dependencies [16147fe] + - @penumbra-zone/types@17.0.1 + - @penumbra-zone/perspective@18.0.0 + - @penumbra-zone/crypto-web@16.0.1 + - @repo/ui@7.2.1 + +## 6.12.2 + +### Patch Changes + +- Updated dependencies [a788eff] +- Updated dependencies [54a5d66] + - @penumbra-zone/transport-dom@7.4.0 + - @penumbra-zone/client@14.0.0 + - @repo/ui@7.2.0 + +## 6.12.1 + +### Patch Changes + +- Updated dependencies [86c1bbe] + - @penumbra-zone/perspective@17.0.0 + - @penumbra-zone/getters@12.1.0 + - @repo/ui@7.1.0 + - @penumbra-zone/types@17.0.0 + - @penumbra-zone/crypto-web@16.0.0 + +## 6.12.0 + +### Minor Changes + +- 0233722: added proxying timestampByHeight + +### Patch Changes + +- 52fdce2: Add decimal part validation (its length cannot exeed the exponent of a selected token) +- Updated dependencies [e0f4258] +- Updated dependencies [0233722] +- Updated dependencies [978efe6] +- Updated dependencies [af04e2a] +- Updated dependencies [26bd932] + - @penumbra-zone/zquery@3.0.1 + - @penumbra-zone/types@16.1.0 + - @penumbra-zone/client@13.0.0 + - @penumbra-zone/transport-dom@7.3.0 + - @repo/ui@7.0.3 + - @penumbra-zone/crypto-web@15.0.0 + - @penumbra-zone/perspective@16.0.0 + +## 6.11.4 + +### Patch Changes + +- Updated dependencies [22bf02c] + - @penumbra-zone/protobuf@5.5.0 + - @penumbra-zone/client@12.0.0 + - @penumbra-zone/getters@12.0.0 + - @penumbra-zone/perspective@15.0.0 + - @penumbra-zone/types@16.0.0 + - @repo/ui@7.0.2 + - @penumbra-zone/crypto-web@14.0.0 + +## 6.11.3 + +### Patch Changes + +- Updated dependencies [3aaead1] + - @penumbra-zone/crypto-web@13.0.1 + - @penumbra-zone/types@15.1.1 + - @repo/ui@7.0.1 + - @penumbra-zone/perspective@14.0.2 + +## 6.11.2 + +### Patch Changes + +- Updated dependencies [ab09596] + - @penumbra-zone/client@11.1.1 + - @penumbra-zone/perspective@14.0.1 + - @repo/ui@7.0.0 + ## 6.11.1 ### Patch Changes diff --git a/apps/minifront/package.json b/apps/minifront/package.json index 6dc448ee4b..7e28cf3095 100644 --- a/apps/minifront/package.json +++ b/apps/minifront/package.json @@ -1,6 +1,6 @@ { "name": "minifront", - "version": "6.11.1", + "version": "6.12.3", "private": true, "license": "(MIT OR Apache-2.0)", "type": "module", diff --git a/apps/minifront/src/components/ibc/ibc-in/destination-addr.tsx b/apps/minifront/src/components/ibc/ibc-in/destination-addr.tsx index 435ad877a5..7ae54f024e 100644 --- a/apps/minifront/src/components/ibc/ibc-in/destination-addr.tsx +++ b/apps/minifront/src/components/ibc/ibc-in/destination-addr.tsx @@ -16,7 +16,7 @@ export const DestinationAddr = () => { // Set initial account to trigger address loading useEffect(() => { setAccount(0); - }, []); + }, [setAccount]); return (
diff --git a/apps/minifront/src/components/ibc/ibc-out/ibc-out-form.tsx b/apps/minifront/src/components/ibc/ibc-out/ibc-out-form.tsx index 592a5ccaa3..15f0a56551 100644 --- a/apps/minifront/src/components/ibc/ibc-out/ibc-out-form.tsx +++ b/apps/minifront/src/components/ibc/ibc-out/ibc-out-form.tsx @@ -64,6 +64,11 @@ export const IbcOutForm = () => { issue: 'insufficient funds', checkFn: () => validationErrors.amountErr, }, + { + type: 'error', + issue: 'invalid decimal length', + checkFn: () => validationErrors.exponentErr, + }, ]} balances={filteredBalances} /> diff --git a/apps/minifront/src/components/send/send-form/index.tsx b/apps/minifront/src/components/send/send-form/index.tsx index 38e7690990..9d2098f14e 100644 --- a/apps/minifront/src/components/send/send-form/index.tsx +++ b/apps/minifront/src/components/send/send-form/index.tsx @@ -24,6 +24,7 @@ export const SendForm = () => { memo, fee, feeTier, + assetFeeMetadata, setAmount, setSelection, setRecipient, @@ -82,6 +83,11 @@ export const SendForm = () => { issue: 'insufficient funds', checkFn: () => validationErrors.amountErr, }, + { + type: 'error', + issue: 'invalid decimal length', + checkFn: () => validationErrors.exponentErr, + }, ]} balances={transferableBalancesResponses?.data ?? []} loading={transferableBalancesResponses?.loading} @@ -97,6 +103,7 @@ export const SendForm = () => { feeTier={feeTier} stakingAssetMetadata={stakingTokenMetadata.data} setFeeTier={setFeeTier} + assetFeeMetadata={assetFeeMetadata} /> = { [EduPanel.IBC_WITHDRAW]: 'IBC to a connected chain. Note that if the chain is a transparent chain, the transaction will be visible to others.', [EduPanel.SWAP]: - 'Shielded swaps between any kind of cryptoasset, with sealed-bid, batch pricing and no frontrunning. Only the batch totals are revealed, providing long-term privacy. Penumbra has no MEV, because transactions do not leak data about user activity.', + 'Shielded batch swaps between any kind of cryptoasset, using an intelligent batch pricing mechanism. While the assets and amounts are publicly revealed, user identities and specific transaction details remain hidden, ensuring long-term privacy.', [EduPanel.SWAP_AUCTION]: "Offer a specific quantity of cryptocurrency at decreasing prices until all the tokens are sold. Buyers can place bids at the price they're willing to pay, with the auction concluding when all tokens are sold or when the auction time expires. This mechanism allows for price discovery based on market demand, with participants potentially acquiring tokens at prices lower than initially offered.", [EduPanel.STAKING]: diff --git a/apps/minifront/src/components/shared/gas-fee.tsx b/apps/minifront/src/components/shared/gas-fee.tsx index e1b5f81c20..fcb7993e5d 100644 --- a/apps/minifront/src/components/shared/gas-fee.tsx +++ b/apps/minifront/src/components/shared/gas-fee.tsx @@ -1,14 +1,14 @@ import { Fee, FeeTier_Tier, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1/fee_pb.js'; +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1/fee_pb'; import { SegmentedPicker, SegmentedPickerOption } from '@repo/ui/components/ui/segmented-picker'; import { InputBlock } from './input-block'; import { ValueViewComponent } from '@repo/ui/components/ui/value'; import { Metadata, ValueView, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; const FEE_TIER_OPTIONS: SegmentedPickerOption[] = [ { @@ -30,23 +30,23 @@ export const GasFee = ({ feeTier, stakingAssetMetadata, setFeeTier, + assetFeeMetadata, }: { fee: Fee | undefined; feeTier: FeeTier_Tier; stakingAssetMetadata?: Metadata; + assetFeeMetadata?: Metadata; setFeeTier: (feeTier: FeeTier_Tier) => void; }) => { - if (!stakingAssetMetadata) { - return null; - } + // If the metadata for the fee asset is undefined, fallback to using the bundled staking asset metadata. + const feeMetadata = assetFeeMetadata ?? stakingAssetMetadata; const feeValueView = new ValueView({ valueView: { case: 'knownAssetId', value: { amount: fee?.amount ?? { hi: 0n, lo: 0n }, - // TODO: once https://github.com/penumbra-zone/web/pull/1468 is merged, change this to metadata: assetFeeMedata - metadata: stakingAssetMetadata, + metadata: feeMetadata, }, }, }); diff --git a/apps/minifront/src/components/shared/input-token.tsx b/apps/minifront/src/components/shared/input-token.tsx index ae263de4b2..0709732068 100644 --- a/apps/minifront/src/components/shared/input-token.tsx +++ b/apps/minifront/src/components/shared/input-token.tsx @@ -1,9 +1,12 @@ +import { useMemo } from 'react'; +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; +import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; +import { getMetadataFromBalancesResponseOptional } from '@penumbra-zone/getters/balances-response'; +import { BalanceValueView } from '@repo/ui/components/ui/balance-value-view'; import { cn } from '@repo/ui/lib/utils'; import BalanceSelector from './selectors/balance-selector'; import { Validation } from './validation-result'; import { InputBlock } from './input-block'; -import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; -import { BalanceValueView } from '@repo/ui/components/ui/balance-value-view'; import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; import { NumberInput } from './number-input'; @@ -36,6 +39,10 @@ export default function InputToken({ onInputChange, loading, }: InputTokenProps) { + const tokenExponent = useMemo(() => { + return getDisplayDenomExponent.optional()(getMetadataFromBalancesResponseOptional(selection)); + }, [selection]); + const setInputToBalanceMax = () => { const match = balances.find(b => b.balanceView?.equals(selection?.balanceView)); if (match?.balanceView) { @@ -52,6 +59,7 @@ export default function InputToken({ { +export interface NumberInputProps extends InputProps { + /** If present, prevents users from entering the fractional number part longer than `maxExponent` */ + maxExponent?: number; +} + +export const NumberInput: FC = ({ maxExponent, ...props }) => { const inputRef = useWheelPrevent(); - return ; + const onKeyDown: KeyboardEventHandler = event => { + if (maxExponent === 0 && (event.key === '.' || event.key === ',')) { + event.preventDefault(); + return; + } + + if ( + typeof maxExponent !== 'undefined' && + typeof props.value === 'string' && + !Number.isNaN(Number(event.key)) + ) { + const fraction = `${props.value}${event.key}`.split('.')[1]?.length; + if (fraction && fraction > maxExponent) { + event.preventDefault(); + return; + } + } + props.onKeyDown?.(event); + }; + + return ; }; diff --git a/apps/minifront/src/components/shared/number-input/use-wheel-prevent.ts b/apps/minifront/src/components/shared/number-input/use-wheel-prevent.ts index 4a0d3975a3..7b80f9f3cc 100644 --- a/apps/minifront/src/components/shared/number-input/use-wheel-prevent.ts +++ b/apps/minifront/src/components/shared/number-input/use-wheel-prevent.ts @@ -15,11 +15,9 @@ export const useWheelPrevent = () => { }; useEffect(() => { - inputRef.current?.addEventListener('wheel', onWheel); - - return () => { - inputRef.current?.removeEventListener('wheel', onWheel); - }; + const ac = new AbortController(); + inputRef.current?.addEventListener('wheel', onWheel, { signal: ac.signal, passive: false }); + return () => ac.abort(); }, []); return inputRef; diff --git a/apps/minifront/src/components/shared/selectors/balance-item.tsx b/apps/minifront/src/components/shared/selectors/balance-item.tsx index 53f637cb08..87b9c75caf 100644 --- a/apps/minifront/src/components/shared/selectors/balance-item.tsx +++ b/apps/minifront/src/components/shared/selectors/balance-item.tsx @@ -36,7 +36,7 @@ export const BalanceItem = ({ asset, value, onSelect }: BalanceItemProps) => { return metadataFromValue?.equals(metadataFromAsset); } return false; - }, [asset, value]); + }, [asset, metadataFromAsset, metadataFromValue, value]); return ( onSelect(asset)}> diff --git a/apps/minifront/src/components/staking/account/use-staking-tokens-and-filter.ts b/apps/minifront/src/components/staking/account/use-staking-tokens-and-filter.ts index ae677f6407..fb70736ca8 100644 --- a/apps/minifront/src/components/staking/account/use-staking-tokens-and-filter.ts +++ b/apps/minifront/src/components/staking/account/use-staking-tokens-and-filter.ts @@ -87,7 +87,7 @@ export const useStakingTokensAndFilter = ( (prev, curr) => toAccountSwitcherFilter(prev, curr, stakingTokenMetadata), [], ); - }, [balancesByAccount]); + }, [balancesByAccount, stakingTokenMetadata]); const stakingTokens = stakingTokensByAccount.get(account); diff --git a/apps/minifront/src/components/swap/auction-list/get-filtered-auction-infos.test.ts b/apps/minifront/src/components/swap/auction-list/get-filtered-auction-infos.test.ts index e875313167..a0882abcbb 100644 --- a/apps/minifront/src/components/swap/auction-list/get-filtered-auction-infos.test.ts +++ b/apps/minifront/src/components/swap/auction-list/get-filtered-auction-infos.test.ts @@ -5,6 +5,7 @@ import { DutchAuction, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1/auction_pb.js'; import { AuctionInfo } from '../../../fetchers/auction-infos'; +import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; const MOCK_AUCTION_1 = new DutchAuction({ description: { @@ -16,10 +17,12 @@ const MOCK_AUCTION_1 = new DutchAuction({ }, }); const MOCK_AUCTION_ID_1 = new AuctionId({ inner: new Uint8Array([1]) }); + const MOCK_AUCTION_INFO_1: AuctionInfo = { auction: MOCK_AUCTION_1, id: MOCK_AUCTION_ID_1, localSeqNum: 0n, + addressIndex: new AddressIndex({ account: 0 }), }; const MOCK_AUCTION_2 = new DutchAuction({ @@ -36,6 +39,7 @@ const MOCK_AUCTION_INFO_2: AuctionInfo = { auction: MOCK_AUCTION_2, id: MOCK_AUCTION_ID_2, localSeqNum: 0n, + addressIndex: new AddressIndex({ account: 0 }), }; const MOCK_AUCTION_3 = new DutchAuction({ @@ -52,6 +56,7 @@ const MOCK_AUCTION_INFO_3: AuctionInfo = { auction: MOCK_AUCTION_3, id: MOCK_AUCTION_ID_3, localSeqNum: 0n, + addressIndex: new AddressIndex({ account: 0 }), }; const MOCK_AUCTION_4 = new DutchAuction({ @@ -68,6 +73,7 @@ const MOCK_AUCTION_INFO_4: AuctionInfo = { auction: MOCK_AUCTION_4, id: MOCK_AUCTION_ID_4, localSeqNum: 0n, + addressIndex: new AddressIndex({ account: 0 }), }; const MOCK_FULL_SYNC_HEIGHT = 15n; diff --git a/apps/minifront/src/components/swap/auction-list/index.tsx b/apps/minifront/src/components/swap/auction-list/index.tsx index 3761445930..5c89a6a25c 100644 --- a/apps/minifront/src/components/swap/auction-list/index.tsx +++ b/apps/minifront/src/components/swap/auction-list/index.tsx @@ -13,6 +13,7 @@ import { useAuctionInfos } from '../../../state/swap/dutch-auction'; import { useStatus } from '../../../state/status'; import { byStartHeightAscending } from './helpers'; import { Filters } from './filters'; +import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; const auctionListSelector = (state: AllSlices) => ({ endAuction: state.swap.dutchAuction.endAuction, @@ -22,18 +23,22 @@ const auctionListSelector = (state: AllSlices) => ({ const getButtonProps = ( auctionId: AuctionId, - endAuction: (auctionId: AuctionId) => Promise, - withdraw: (auctionId: AuctionId, seqNum: bigint) => Promise, + addressIndex: AddressIndex, + endAuction: (auctionId: AuctionId, addressIndex: AddressIndex) => Promise, + withdraw: (auctionId: AuctionId, seqNum: bigint, addressIndex: AddressIndex) => Promise, localSeqNum?: bigint, ): | { buttonType: 'end' | 'withdraw'; onClickButton: VoidFunction } | { buttonType: undefined; onClickButton: undefined } => { if (localSeqNum === 0n) { - return { buttonType: 'end', onClickButton: () => void endAuction(auctionId) }; + return { buttonType: 'end', onClickButton: () => void endAuction(auctionId, addressIndex) }; } if (localSeqNum === 1n) { - return { buttonType: 'withdraw', onClickButton: () => void withdraw(auctionId, localSeqNum) }; + return { + buttonType: 'withdraw', + onClickButton: () => void withdraw(auctionId, localSeqNum, addressIndex), + }; } return { buttonType: undefined, onClickButton: undefined }; @@ -87,7 +92,14 @@ export const AuctionList = () => { inputMetadata={auctionInfo.inputMetadata} outputMetadata={auctionInfo.outputMetadata} fullSyncHeight={status?.fullSyncHeight} - {...getButtonProps(auctionInfo.id, endAuction, withdraw, auctionInfo.localSeqNum)} + addressIndex={auctionInfo.addressIndex} + {...getButtonProps( + auctionInfo.id, + auctionInfo.addressIndex, + endAuction, + withdraw, + auctionInfo.localSeqNum, + )} renderButtonPlaceholder />
diff --git a/apps/minifront/src/components/swap/swap-form/index.tsx b/apps/minifront/src/components/swap/swap-form/index.tsx index d97dc76d07..4f03b08e8e 100644 --- a/apps/minifront/src/components/swap/swap-form/index.tsx +++ b/apps/minifront/src/components/swap/swap-form/index.tsx @@ -10,6 +10,7 @@ import { SimulateSwap } from './simulate-swap'; import { LayoutGroup } from 'framer-motion'; import { useId } from 'react'; import { submitButtonDisabledSelector } from '../../../state/swap'; +import { PriceHistory } from './price-history'; const swapFormSelector = (state: AllSlices) => ({ onSubmit: @@ -49,6 +50,8 @@ export const SwapForm = () => { )} + + + } + > + {candles.data.length ? ( +
Since block {String(startBlock)}
+ ) : ( +
No recent price data
+ )} + + + ); +}; diff --git a/apps/minifront/src/components/swap/swap-form/simulate-swap.tsx b/apps/minifront/src/components/swap/swap-form/simulate-swap.tsx index f96ad56e8f..c6c0003423 100644 --- a/apps/minifront/src/components/swap/swap-form/simulate-swap.tsx +++ b/apps/minifront/src/components/swap/swap-form/simulate-swap.tsx @@ -16,7 +16,7 @@ export const SimulateSwap = ({ layoutId }: { layoutId: string }) => { return ( void simulateSwap()} />} layoutId={layoutId} > diff --git a/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx b/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx index b319bd4bb6..e637ba7405 100644 --- a/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx +++ b/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx @@ -2,19 +2,20 @@ import { BalanceValueView } from '@repo/ui/components/ui/balance-value-view'; import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; import { Box } from '@repo/ui/components/ui/box'; -import { CandlestickPlot } from '@repo/ui/components/ui/candlestick-plot'; import { joinLoHiAmount } from '@penumbra-zone/types/amount'; -import { getAmount, getBalanceView } from '@penumbra-zone/getters/balances-response'; +import { + getAmount, + getBalanceView, + getMetadataFromBalancesResponseOptional, +} from '@penumbra-zone/getters/balances-response'; import { ArrowRight } from 'lucide-react'; -import { useEffect } from 'react'; -import { getBlockDate } from '../../../fetchers/block-date'; +import { useMemo } from 'react'; import { AllSlices } from '../../../state'; import { useStoreShallow } from '../../../utils/use-store-shallow'; import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; import { getAddressIndex } from '@penumbra-zone/getters/address-view'; import { AssetSelector } from '../../shared/selectors/asset-selector'; import BalanceSelector from '../../shared/selectors/balance-selector'; -import { useStatus } from '../../../state/status'; import { zeroValueView } from '../../../utils/zero-value-view'; import { isValidAmount } from '../../../state/helpers'; import { NonNativeFeeWarning } from '../../shared/non-native-fee-warning'; @@ -26,6 +27,7 @@ import { swappableAssetsSelector, swappableBalancesResponsesSelector, } from '../../../state/swap/helpers'; +import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; const getAssetOutBalance = ( balancesResponses: BalancesResponse[] = [], @@ -52,7 +54,6 @@ const tokenSwapInputSelector = (state: AllSlices) => ({ setAssetOut: state.swap.setAssetOut, amount: state.swap.amount, setAmount: state.swap.setAmount, - priceHistory: state.swap.priceHistory, reverse: state.swap.reverse, }); @@ -63,31 +64,14 @@ const tokenSwapInputSelector = (state: AllSlices) => ({ * amount. */ export const TokenSwapInput = () => { - const status = useStatus(); - const latestKnownBlockHeight = status.data?.latestKnownBlockHeight ?? 0n; const balancesResponses = useBalancesResponses({ select: swappableBalancesResponsesSelector }); const swappableAssets = useAssets({ select: swappableAssetsSelector }); - const { amount, setAmount, assetIn, setAssetIn, assetOut, setAssetOut, priceHistory, reverse } = + const { amount, setAmount, assetIn, setAssetIn, assetOut, setAssetOut, reverse } = useStoreShallow(tokenSwapInputSelector); const assetOutBalance = getAssetOutBalance(balancesResponses?.data, assetIn, assetOut); - - useEffect(() => { - if (!assetIn || !assetOut) { - return; - } else { - return priceHistory.load(); - } - }, [assetIn, assetOut]); - - useEffect(() => { - if (!priceHistory.candles.length) { - return; - } else if (latestKnownBlockHeight % 10n) { - return; - } else { - return priceHistory.load(); - } - }, [priceHistory, latestKnownBlockHeight]); + const assetInExponent = useMemo(() => { + return getDisplayDenomExponent.optional()(getMetadataFromBalancesResponseOptional(assetIn)); + }, [assetIn]); const maxAmount = getAmount.optional()(assetIn); const maxAmountAsString = maxAmount ? joinLoHiAmount(maxAmount).toString() : undefined; @@ -108,6 +92,7 @@ export const TokenSwapInput = () => { variant='transparent' placeholder='Enter an amount...' max={maxAmountAsString} + maxExponent={assetInExponent} step='any' className={'font-bold leading-10 md:h-8 md:text-xl xl:h-10 xl:text-3xl'} onChange={e => { @@ -172,19 +157,6 @@ export const TokenSwapInput = () => { -
- {priceHistory.startMetadata && priceHistory.endMetadata && priceHistory.candles.length ? ( - - ) : null} -
- ({ claimSwap: state.unclaimedSwaps.claimSwap, - isInProgress: state.unclaimedSwaps.isInProgress, }); export const UnclaimedSwaps = () => { const unclaimedSwaps = useUnclaimedSwaps(); - const { claimSwap, isInProgress } = useStoreShallow(unclaimedSwapsSelector); + const { claimSwap } = useStoreShallow(unclaimedSwapsSelector); + const [claim, setClaim] = useState([]); + + // Internally track the IDs of the claimed swaps. + const handleClaim = async (id: string, swap: SwapRecord) => { + setClaim(prev => [...prev, id]); + try { + await claimSwap(id, swap); + } catch (error) { + setClaim(prev => prev.filter(claimId => claimId !== id)); + } + }; return !unclaimedSwaps.data?.length ? (
@@ -24,6 +36,7 @@ export const UnclaimedSwaps = () => { Unclaimed Swaps {unclaimedSwaps.data.map(({ swap, asset1, asset2 }) => { const id = uint8ArrayToBase64(getSwapRecordCommitment(swap).inner); + const isClaiming = claim.includes(id); return (
@@ -39,10 +52,10 @@ export const UnclaimedSwaps = () => {
); diff --git a/apps/minifront/src/components/tx-details/tx-viewer.tsx b/apps/minifront/src/components/tx-details/tx-viewer.tsx index 531f26721e..7b9da59626 100644 --- a/apps/minifront/src/components/tx-details/tx-viewer.tsx +++ b/apps/minifront/src/components/tx-details/tx-viewer.tsx @@ -56,8 +56,15 @@ export const TxViewer = ({ txInfo }: { txInfo?: TransactionInfo }) => { return (
Transaction View
-
- {txInfo?.id && uint8ArrayToHex(txInfo.id.inner)} +
+
+ {txInfo?.id && uint8ArrayToHex(txInfo.id.inner)} +
+
+ block {txInfo?.height.toString()} +
diff --git a/apps/minifront/src/fetchers/auction-infos.ts b/apps/minifront/src/fetchers/auction-infos.ts index 44172e2f6a..b2d30f2c0d 100644 --- a/apps/minifront/src/fetchers/auction-infos.ts +++ b/apps/minifront/src/fetchers/auction-infos.ts @@ -5,6 +5,7 @@ import { import { viewClient } from '../clients'; import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; import { getInputAssetId, getOutputAssetId } from '@penumbra-zone/getters/dutch-auction'; +import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; export interface AuctionInfo { id: AuctionId; @@ -12,6 +13,7 @@ export interface AuctionInfo { localSeqNum: bigint; inputMetadata?: Metadata; outputMetadata?: Metadata; + addressIndex: AddressIndex; } export const getAuctionInfos = async function* ({ @@ -20,7 +22,7 @@ export const getAuctionInfos = async function* ({ queryLatestState?: boolean; } = {}): AsyncGenerator { for await (const response of viewClient.auctions({ queryLatestState, includeInactive: true })) { - if (!response.auction || !response.id) { + if (!response.auction || !response.id || !response.noteRecord?.addressIndex) { continue; } @@ -47,6 +49,7 @@ export const getAuctionInfos = async function* ({ localSeqNum: response.localSeq, inputMetadata: inputMetadata?.denomMetadata, outputMetadata: outputMetadata?.denomMetadata, + addressIndex: response.noteRecord.addressIndex, }; } }; diff --git a/apps/minifront/src/fetchers/block-date.ts b/apps/minifront/src/fetchers/block-date.ts index 4f13e3d5d5..a14b609436 100644 --- a/apps/minifront/src/fetchers/block-date.ts +++ b/apps/minifront/src/fetchers/block-date.ts @@ -1,9 +1,9 @@ -import { tendermintClient } from '../clients'; +import { sctClient } from '../clients'; export const getBlockDate = async ( height: bigint, signal?: AbortSignal, ): Promise => { - const { block } = await tendermintClient.getBlockByHeight({ height }, { signal }); - return block?.header?.time?.toDate(); + const { timestamp } = await sctClient.timestampByHeight({ height }, { signal }); + return timestamp?.toDate(); }; diff --git a/apps/minifront/src/fetchers/registry.ts b/apps/minifront/src/fetchers/registry.ts index 38004b88cf..4b735ecb0b 100644 --- a/apps/minifront/src/fetchers/registry.ts +++ b/apps/minifront/src/fetchers/registry.ts @@ -2,6 +2,7 @@ import { Chain, ChainRegistryClient, Registry } from '@penumbra-labs/registry'; import { useQuery } from '@tanstack/react-query'; import { getChainId } from './chain-id'; import { getAssetMetadataById } from './assets'; +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; export const chainRegistryClient = new ChainRegistryClient(); @@ -34,6 +35,20 @@ export const getStakingTokenMetadata = async () => { return stakingAssetsMetadata; }; +export const getAssetTokenMetadata = async (assetId: AssetId) => { + const chainId = await getChainId(); + if (!chainId) { + throw new Error('Could not fetch chain id'); + } + + const assetTokenMetadata = await getAssetMetadataById(assetId); + + if (!assetTokenMetadata) { + throw new Error('Could not fetch asset token metadata'); + } + return assetTokenMetadata; +}; + export const getChains = async (): Promise => { const chainId = await getChainId(); if (!chainId) { diff --git a/apps/minifront/src/fetchers/transactions.ts b/apps/minifront/src/fetchers/transactions.ts deleted file mode 100644 index 6234da0dae..0000000000 --- a/apps/minifront/src/fetchers/transactions.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { viewClient } from '../clients'; -import { getTransactionClassificationLabel } from '@penumbra-zone/perspective/transaction/classify'; -import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; - -export interface TransactionSummary { - height: number; - hash: string; - description: string; -} - -export const getAllTransactions = async (): Promise => { - const responses = await Array.fromAsync(viewClient.transactionInfo({})); - return responses - .map(tx => { - return { - height: Number(tx.txInfo?.height ?? 0n), - hash: tx.txInfo?.id?.inner ? uint8ArrayToHex(tx.txInfo.id.inner) : 'unknown', - description: getTransactionClassificationLabel(tx.txInfo?.view), - }; - }) - .sort((a, b) => b.height - a.height); -}; diff --git a/apps/minifront/src/state/helpers.ts b/apps/minifront/src/state/helpers.ts index 077383505f..535fd11f72 100644 --- a/apps/minifront/src/state/helpers.ts +++ b/apps/minifront/src/state/helpers.ts @@ -21,7 +21,11 @@ import { TransactionClassification } from '@penumbra-zone/perspective/transactio import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; import { fromValueView } from '@penumbra-zone/types/amount'; import { BigNumber } from 'bignumber.js'; -import { getValueViewCaseFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; +import { + getMetadataFromBalancesResponseOptional, + getValueViewCaseFromBalancesResponse, +} from '@penumbra-zone/getters/balances-response'; +import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; /** * Handles the common use case of planning, building, and broadcasting a @@ -185,8 +189,32 @@ export const amountMoreThanBalance = ( return Boolean(amountInDisplayDenom) && BigNumber(amountInDisplayDenom).gt(balanceAmt); }; +/** + * Checks if the entered amount fraction part is longer than the asset's exponent + */ +export const isIncorrectDecimal = ( + asset: BalancesResponse, + /** + * The amount that a user types into the interface will always be in the + * display denomination -- e.g., in `penumbra`, not in `upenumbra`. + */ + amountInDisplayDenom: string, +): boolean => { + if (!asset.balanceView) { + throw new Error('Missing balanceView'); + } + + const exponent = getDisplayDenomExponent.optional()( + getMetadataFromBalancesResponseOptional(asset), + ); + const fraction = amountInDisplayDenom.split('.')[1]?.length; + return typeof exponent !== 'undefined' && typeof fraction !== 'undefined' && fraction > exponent; +}; + export const isValidAmount = (amount: string, assetIn?: BalancesResponse) => - Number(amount) >= 0 && (!assetIn || !amountMoreThanBalance(assetIn, amount)); + Number(amount) >= 0 && + (!assetIn || !amountMoreThanBalance(assetIn, amount)) && + (!assetIn || !isIncorrectDecimal(assetIn, amount)); export const isKnown = (balancesResponse: BalancesResponse) => getValueViewCaseFromBalancesResponse.optional()(balancesResponse) === 'knownAssetId'; diff --git a/apps/minifront/src/state/ibc-out.ts b/apps/minifront/src/state/ibc-out.ts index b5a86af43b..ef8c577f0c 100644 --- a/apps/minifront/src/state/ibc-out.ts +++ b/apps/minifront/src/state/ibc-out.ts @@ -14,7 +14,7 @@ import { } from '@penumbra-zone/getters/value-view'; import { getAddressIndex } from '@penumbra-zone/getters/address-view'; import { toBaseUnit } from '@penumbra-zone/types/lo-hi'; -import { amountMoreThanBalance, planBuildBroadcast } from './helpers'; +import { amountMoreThanBalance, isIncorrectDecimal, planBuildBroadcast } from './helpers'; import { getAssetId } from '@penumbra-zone/getters/metadata'; import { assetPatterns } from '@penumbra-zone/types/assets'; import { bech32, bech32m } from 'bech32'; @@ -233,6 +233,9 @@ export const ibcValidationErrors = (state: AllSlices) => { amountErr: !state.ibcOut.selection ? false : amountMoreThanBalance(state.ibcOut.selection, state.ibcOut.amount), + exponentErr: !state.ibcOut.selection + ? false + : isIncorrectDecimal(state.ibcOut.selection, state.ibcOut.amount), }; }; diff --git a/apps/minifront/src/state/send/index.ts b/apps/minifront/src/state/send/index.ts index 0928354103..c88af693ea 100644 --- a/apps/minifront/src/state/send/index.ts +++ b/apps/minifront/src/state/send/index.ts @@ -5,15 +5,15 @@ import { TransactionPlannerRequest, TransactionPlannerRequest_Output, TransactionPlannerRequest_Spend, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; import { BigNumber } from 'bignumber.js'; -import { MemoPlaintext } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb.js'; -import { amountMoreThanBalance, plan, planBuildBroadcast } from '../helpers'; +import { MemoPlaintext } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb'; +import { amountMoreThanBalance, isIncorrectDecimal, plan, planBuildBroadcast } from '../helpers'; import { Fee, FeeTier_Tier, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1/fee_pb.js'; +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1/fee_pb'; import { getAssetIdFromValueView, getDisplayDenomExponentFromValueView, @@ -23,6 +23,8 @@ import { toBaseUnit } from '@penumbra-zone/types/lo-hi'; import { isAddress } from '@penumbra-zone/bech32m/penumbra'; import { transferableBalancesResponsesSelector } from './helpers'; import { PartialMessage } from '@bufbuild/protobuf'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { getAssetTokenMetadata } from '../../fetchers/registry'; export interface SendSlice { selection: BalancesResponse | undefined; @@ -41,6 +43,7 @@ export interface SendSlice { txInProgress: boolean; isSendingMax: boolean; setIsSendingMax: (isSendingMax: boolean) => void; + assetFeeMetadata: Metadata | undefined; } export const createSendSlice = (): SliceCreator => (set, get) => { @@ -53,6 +56,7 @@ export const createSendSlice = (): SliceCreator => (set, get) => { feeTier: FeeTier_Tier.LOW, txInProgress: false, isSendingMax: false, + assetFeeMetadata: undefined, setAmount: amount => { set(state => { state.send.amount = amount; @@ -84,18 +88,25 @@ export const createSendSlice = (): SliceCreator => (set, get) => { if (!amount || !recipient || !selection) { set(state => { state.send.fee = undefined; + state.send.assetFeeMetadata = undefined; }); return; } const txPlan = await plan(assembleRequest(get().send)); const fee = txPlan.transactionParameters?.fee; + + // Fetch the asset metadata for the fee if assetId is defined; otherwise, set it to undefined. + // The undefined case occurs when the fee uses the native staking token. + const feeAssetMetadata = fee?.assetId ? await getAssetTokenMetadata(fee.assetId) : undefined; + if (!fee?.amount) { return; } set(state => { state.send.fee = fee; + state.send.assetFeeMetadata = feeAssetMetadata; }); }, setFeeTier: feeTier => { @@ -166,6 +177,7 @@ const assembleRequest = ({ export interface SendValidationFields { recipientErr: boolean; amountErr: boolean; + exponentErr: boolean; memoErr: boolean; } @@ -178,6 +190,7 @@ export const sendValidationErrors = ( return { recipientErr: Boolean(recipient) && !isAddress(recipient), amountErr: !asset ? false : amountMoreThanBalance(asset, amount), + exponentErr: !asset ? false : isIncorrectDecimal(asset, amount), // The memo cannot exceed 512 bytes // return address uses 80 bytes // so 512-80=432 bytes for memo text diff --git a/apps/minifront/src/state/swap/dutch-auction/index.ts b/apps/minifront/src/state/swap/dutch-auction/index.ts index b618634258..24303a7e86 100644 --- a/apps/minifront/src/state/swap/dutch-auction/index.ts +++ b/apps/minifront/src/state/swap/dutch-auction/index.ts @@ -12,6 +12,7 @@ import { ZQueryState, createZQuery } from '@penumbra-zone/zquery'; import { AuctionInfo, getAuctionInfos } from '../../../fetchers/auction-infos'; import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; import { bech32mAuctionId } from '@penumbra-zone/bech32m/pauctid'; +import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; /** * Multipliers to use with the output of the swap simulation, to determine @@ -33,8 +34,12 @@ interface Actions { setMinOutput: (minOutput: string) => void; setMaxOutput: (maxOutput: string) => void; onSubmit: () => Promise; - endAuction: (auctionId: AuctionId) => Promise; - withdraw: (auctionId: AuctionId, currentSeqNum: bigint) => Promise; + endAuction: (auctionId: AuctionId, addressIndex: AddressIndex) => Promise; + withdraw: ( + auctionId: AuctionId, + currentSeqNum: bigint, + addressIndex: AddressIndex, + ) => Promise; reset: VoidFunction; setFilter: (filter: Filter) => void; estimate: () => Promise; @@ -174,15 +179,19 @@ export const createDutchAuctionSlice = (): SliceCreator => (s } }, - endAuction: async auctionId => { - const req = new TransactionPlannerRequest({ dutchAuctionEndActions: [{ auctionId }] }); + endAuction: async (auctionId, addressIndex) => { + const req = new TransactionPlannerRequest({ + dutchAuctionEndActions: [{ auctionId }], + source: addressIndex, + }); await planBuildBroadcast('dutchAuctionEnd', req); get().swap.dutchAuction.auctionInfos.revalidate(); }, - withdraw: async (auctionId, currentSeqNum) => { + withdraw: async (auctionId, currentSeqNum, addressIndex) => { const req = new TransactionPlannerRequest({ dutchAuctionWithdrawActions: [{ auctionId, seq: currentSeqNum + 1n }], + source: addressIndex, }); await planBuildBroadcast('dutchAuctionWithdraw', req); get().swap.dutchAuction.auctionInfos.revalidate(); diff --git a/apps/minifront/src/state/swap/helpers.ts b/apps/minifront/src/state/swap/helpers.ts index 4870021473..58b9b97d26 100644 --- a/apps/minifront/src/state/swap/helpers.ts +++ b/apps/minifront/src/state/swap/helpers.ts @@ -4,6 +4,7 @@ import { } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; import { CandlestickData, + CandlestickDataResponse, SimulateTradeRequest, SimulateTradeResponse, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb.js'; @@ -18,7 +19,6 @@ import { toBaseUnit } from '@penumbra-zone/types/lo-hi'; import { BigNumber } from 'bignumber.js'; import { SwapSlice } from '.'; import { dexClient, simulationClient } from '../../clients'; -import { PriceHistorySlice } from './price-history'; import { assetPatterns } from '@penumbra-zone/types/assets'; import { fromBaseUnitAmount } from '@penumbra-zone/types/amount'; import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; @@ -50,11 +50,32 @@ export const sendSimulateTradeRequest = ({ return simulationClient.simulateTrade(req); }; +/** + * Due to the way price data is recorded, symmetric comparisons do not return + * symmetric data. to get the complete picture, a client must combine both + * datasets. + * 1. query the intended comparison direction (start token -> end token) + * 2. query the inverse comparison direction (end token -> start token) + * 3. flip the inverse data (reciprocal values, high becomes low) + * 4. combine the data (use the highest high, lowest low, sum volumes) + */ +export const sendComplementaryCandlestickDataRequests = async ( + startMetadata?: Metadata, + endMetadata?: Metadata, + limit?: bigint, + startHeight?: bigint, +) => + Promise.all([ + sendCandlestickDataRequest(startMetadata, endMetadata, limit, startHeight), + sendCandlestickDataRequest(endMetadata, startMetadata, limit, startHeight), + ]).then(([direct, inverse]) => ({ direct, inverse })); + export const sendCandlestickDataRequest = async ( - { startMetadata, endMetadata }: Pick, - limit: bigint, - signal?: AbortSignal, -): Promise => { + startMetadata?: Metadata, + endMetadata?: Metadata, + limit?: bigint, + startHeight?: bigint, +): Promise => { const start = startMetadata?.penumbraAssetId; const end = endMetadata?.penumbraAssetId; @@ -65,21 +86,70 @@ export const sendCandlestickDataRequest = async ( throw new Error('Asset pair equivalent'); } - try { - const { data } = await dexClient.candlestickData( - { - pair: { start, end }, - limit, + return dexClient.candlestickData({ pair: { start, end }, limit, startHeight }); +}; + +export const combinedCandlestickDataSelector = ( + zQueryState: AbridgedZQueryState<{ + direct: CandlestickDataResponse; + inverse: CandlestickDataResponse; + }>, +) => { + if (!zQueryState.data) { + return { ...zQueryState, data: undefined }; + } else { + const direct = zQueryState.data.direct.data; + const corrected = zQueryState.data.inverse.data.map( + // flip inverse data to match orientation of direct data + inverseCandle => { + const correctedCandle = inverseCandle.clone(); + // comparative values are reciprocal + correctedCandle.open = 1 / inverseCandle.open; + correctedCandle.close = 1 / inverseCandle.close; + // high and low swap places + correctedCandle.high = 1 / inverseCandle.low; + correctedCandle.low = 1 / inverseCandle.high; + return correctedCandle; }, - { signal }, ); - return data; - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - return; - } else { - throw err; - } + + // combine data at each height into a single candle + const combinedCandles = Array.from( + // collect candles at each height + Map.groupBy([...direct, ...corrected], ({ height }) => height), + ).map(([height, candlesAtHeight]) => { + // TODO: open/close don't diverge much, and when they do it seems to be due + // to inadequate number precision. it might be better to just pick one, but + // it's not clear which one is 'correct' + const combinedCandleAtHeight = candlesAtHeight.reduce( + (acc, cur) => { + // sum volumes + acc.directVolume += cur.directVolume; + acc.swapVolume += cur.swapVolume; + + // highest high, lowest low + acc.high = Math.max(acc.high, cur.high); + acc.low = Math.min(acc.low, cur.low); + + // these accumulate to be averaged + acc.open += cur.open; + acc.close += cur.close; + return acc; + }, + new CandlestickData({ height, low: Infinity, high: -Infinity }), + ); + + // average accumulated open/close + combinedCandleAtHeight.open /= candlesAtHeight.length; + combinedCandleAtHeight.close /= candlesAtHeight.length; + + return combinedCandleAtHeight; + }); + + return { + ...zQueryState, + data: combinedCandles.sort((a, b) => Number(a.height - b.height)), + }; } }; diff --git a/apps/minifront/src/state/swap/price-history.ts b/apps/minifront/src/state/swap/price-history.ts index 427b3675a6..b17abd4fc9 100644 --- a/apps/minifront/src/state/swap/price-history.ts +++ b/apps/minifront/src/state/swap/price-history.ts @@ -1,48 +1,63 @@ -import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; -import { CandlestickData } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb.js'; -import { getMetadataFromBalancesResponseOptional } from '@penumbra-zone/getters/balances-response'; -import { AllSlices, SliceCreator } from '..'; -import { sendCandlestickDataRequest } from './helpers'; +import { CandlestickDataResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb.js'; +import { PRICE_RELEVANCE_THRESHOLDS } from '@penumbra-zone/types/assets'; +import { createZQuery, ZQueryState } from '@penumbra-zone/zquery'; +import { AllSlices, SliceCreator, useStore } from '..'; +import { sendCandlestickDataRequest, sendComplementaryCandlestickDataRequests } from './helpers'; interface Actions { - load: (ac?: AbortController) => AbortController['abort']; + /** + * History limit becomes the maximum width of the chart domain (block height). + */ + setHistoryLimit: (limit: bigint) => void; + /** + * Setting history start will cause the chart domain to begin at the specified + * block height and extend towards the present. Setting history start to + * `undefined` or `0n` will cause the chart domain to end at the present block + * height and extend towards the past. + */ + setHistoryStart: (blockHeight?: bigint) => void; } +export const { candles, useCandles, useRevalidateCandles } = createZQuery({ + name: 'candles', + fetch: sendComplementaryCandlestickDataRequests, + getUseStore: () => useStore, + get: state => state.swap.priceHistory.candles, + set: setter => { + const newState = setter(useStore.getState().swap.priceHistory.candles); + useStore.setState(state => { + state.swap.priceHistory.candles = newState; + }); + }, +}); + interface State { - candles: CandlestickData[]; - endMetadata?: Metadata; - startMetadata?: Metadata; + candles: ZQueryState< + { direct: CandlestickDataResponse; inverse: CandlestickDataResponse }, + Parameters + >; + historyLimit: bigint; + historyStart?: bigint; } export type PriceHistorySlice = Actions & State; -const INITIAL_STATE: State = { - candles: [], +const INITIAL_STATE: Omit = { + candles, + historyLimit: PRICE_RELEVANCE_THRESHOLDS.default, }; -export const createPriceHistorySlice = (): SliceCreator => (set, get) => ({ +export const createPriceHistorySlice = (): SliceCreator => set => ({ ...INITIAL_STATE, - load: (ac = new AbortController()): AbortController['abort'] => { - const { assetIn, assetOut } = get().swap; - const startMetadata = getMetadataFromBalancesResponseOptional(assetIn); - const endMetadata = assetOut; - void sendCandlestickDataRequest( - { startMetadata, endMetadata }, - // there's no UI to set limit yet, and most ranges don't always happen to - // include price records. 2500 at least scales well when there is data - 2500n, - ac.signal, - ).then(data => { - if (data) { - set(({ swap }) => { - swap.priceHistory.startMetadata = startMetadata; - swap.priceHistory.endMetadata = endMetadata; - swap.priceHistory.candles = data; - }); - } + setHistoryLimit: blocks => { + set(state => { + state.swap.priceHistory.historyLimit = blocks; + }); + }, + setHistoryStart: blockHeight => { + set(state => { + state.swap.priceHistory.historyStart = blockHeight; }); - - return () => ac.abort('Returned slice abort'); }, }); diff --git a/apps/minifront/vite.config.ts b/apps/minifront/vite.config.ts index 2bd319b53b..195904f20a 100644 --- a/apps/minifront/vite.config.ts +++ b/apps/minifront/vite.config.ts @@ -6,9 +6,11 @@ import basicSsl from '@vitejs/plugin-basic-ssl'; import { commitInfoPlugin } from './src/utils/commit-info-vite-plugin'; import polyfillNode from 'vite-plugin-node-stdlib-browser'; -export default defineConfig({ - define: { 'globalThis.__DEV__': 'import.meta.env.DEV' }, - clearScreen: false, - base: './', - plugins: [polyfillNode(), react(), basicSsl(), commitInfoPlugin()], +export default defineConfig(({ mode }) => { + return { + define: { 'globalThis.__DEV__': mode !== 'production' }, + clearScreen: false, + base: './', + plugins: [polyfillNode(), react(), basicSsl(), commitInfoPlugin()], + }; }); diff --git a/apps/node-status/CHANGELOG.md b/apps/node-status/CHANGELOG.md index 32ca2ba294..33201e1fd3 100644 --- a/apps/node-status/CHANGELOG.md +++ b/apps/node-status/CHANGELOG.md @@ -1,5 +1,47 @@ # node-status +## 4.1.13 + +### Patch Changes + +- 3477bef: bugfix: injecting globalThis.**DEV** correctly on prod builds + - @repo/ui@7.2.1 + +## 4.1.12 + +### Patch Changes + +- Updated dependencies [54a5d66] + - @repo/ui@7.2.0 + +## 4.1.11 + +### Patch Changes + +- Updated dependencies [86c1bbe] + - @repo/ui@7.1.0 + +## 4.1.10 + +### Patch Changes + +- Updated dependencies [26bd932] + - @repo/ui@7.0.3 + +## 4.1.9 + +### Patch Changes + +- Updated dependencies [22bf02c] + - @penumbra-zone/protobuf@5.5.0 + - @repo/ui@7.0.2 + +## 4.1.8 + +### Patch Changes + +- @repo/ui@7.0.1 + ## 4.1.7 ### Patch Changes diff --git a/apps/node-status/package.json b/apps/node-status/package.json index 13b5f62fd9..085840b7bf 100644 --- a/apps/node-status/package.json +++ b/apps/node-status/package.json @@ -1,6 +1,6 @@ { "name": "node-status", - "version": "4.1.7", + "version": "4.1.13", "private": true, "license": "(MIT OR Apache-2.0)", "type": "module", diff --git a/apps/node-status/vite.config.ts b/apps/node-status/vite.config.ts index 638eefa3d3..874ec2cbc6 100644 --- a/apps/node-status/vite.config.ts +++ b/apps/node-status/vite.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -export default defineConfig({ - define: { 'globalThis.__DEV__': 'import.meta.env.DEV' }, - clearScreen: false, - plugins: [react()], +export default defineConfig(({ mode }) => { + return { + define: { 'globalThis.__DEV__': mode !== 'production' }, + clearScreen: false, + plugins: [react()], + }; }); diff --git a/docs/custody.md b/docs/custody.md deleted file mode 100644 index 69b3eb6019..0000000000 --- a/docs/custody.md +++ /dev/null @@ -1,24 +0,0 @@ -# Custody - -### Password - -new API -in-memory password key in zustand -await session.set('passwordKey', key); -await local.set('passwordKeyPrint', keyPrint.toJson()); - -When setting up for the first time, you'll need to set a password. - -This password is hashed via `PBKDF2`, see [keyStretchingHash() function](../packages/crypto/src/encryption.ts). -It utilizes a pseudorandom function along with a salt value and iteration. The use of a salt provides protection against pre-computed attacks such as rainbow tables, and the iteration count slows down brute-force attacks. - -The password `Key` (private key material) is stored in `chrome.storage.session` used for deriving spending keys for wallets later. - -The password `KeyPrint` (public hash/salt) is stored in `chrome.storage.local` to later validate logins when session storage is wiped. - -### New wallet - -Upon importing or generating a new seed phrase, it is: - -- Encrypted using `AES-GCM`, see [encrypt() function](../packages/crypto/src/encryption.ts) -- `Box` (nonce/ciphertext) is stored in `chrome.storage.local` for later decryption diff --git a/docs/deployment.md b/docs/deployment.md index 29bdb8740e..5ce33eaed4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,19 +1,5 @@ # Deployment Workflows -### Prax Chrome Extension - -Create a [github issue deployment issue](https://github.com/penumbra-zone/web/issues/new?template=deployment.md&title=Publish+vX.X.X+extension+%2B+web+app) to track deployment progress and steps. - -Upon a new [git tag](https://github.com/penumbra-zone/web/releases/tag/v4.2.0) being pushed to the repo, -a [workflow](../.github/workflows/extension-publish.yml) is kicked off. It then requests permission to -continue from [github group](https://github.com/orgs/penumbra-zone/teams/penumbra-labs) and, after approval, -bundles the extension into a .zip which gets put in the Chrome Webstore review queue. It typically takes -1-3 days to go live. The publication status can be monitored in the [Chrome Developer Dashboard](https://chrome.google.com/webstore/devconsole/aabc0949-93db-4e77-ad9f-e6ca1d132501?hl=en). - -### Web app - -Manually run [Deploy Firebase Dapp](https://github.com/penumbra-zone/web/actions/workflows/deploy-firebase-dapp.yml) github action on main branch. - ### NPM Packages The packages in this repo are published using [changesets](https://github.com/changesets/changesets). diff --git a/docs/extension-services.md b/docs/extension-services.md index 1f6f69a933..383ecfbd50 100644 --- a/docs/extension-services.md +++ b/docs/extension-services.md @@ -1,100 +1,3 @@ -# Extension Services - -Prax uses a custom transport for `@connectrpc/connect` to provide -Protobuf-specified services via a DOM channel `MessagePort` and the Chrome -extension runtime. - -Interestingly, this transport should be generally applicable to any -Protobuf-specified interface, including all auto-generated clients and server -stubs from the buf registry. - -If you are interested in using the transport for -your own project, the generic packages are available in -`@penumbra-zone/transport-dom` and `@penumbra-zone/transport-chrome`. - -You may use locally generated service types, or simply install the [appropriate -packages from the buf -registry](https://buf.build/penumbra-zone/penumbra/sdks/main). If you are using -npm, buf's [npm-specific guide](https://buf.build/docs/bsr/generated-sdks/npm) -is recommended reading. - -## Clients - -Each channel transport can be used as a page-level singleton servicing multiple -clients. Developers using React queriers may be interested in -`@connectrpc/connect-query`. - -Creation is fully synchronous from the constructor's perspective, and the client -is immediately useable, but requests are delayed until init actually completes. - -### Connection to Prax - -For developing a dapp that connects to Prax, you may use the convenience functions in `@penumbra-zone/client`. - -```ts -import { createPraxClient } from '@penumbra-zone/client'; -import { ViewService } from '@penumbra-zone/protobuf'; - -const viewClient = createPraxClient(ViewService); -``` - -An incredibly simple use might be something like this. - -```ts -import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; - -const { address } = await viewClient.addressByIndex({}); -console.log(bech32mAddress(address)); -``` - -### More control - -Other providers may be available, and you can configure the transport however -you'd like. Use of the client is identical. - -```ts -import { getAnyPenumbraPort } from '@penumbra-zone/client'; -import { ViewService } from '@penumbra-zone/protobuf'; -import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; - -const channelTransport = createChannelTransport({ - getPort: getAnyPenumbraPort, - jsonOptions: { typeRegistry: createRegistry(ViewService) }, -}); - -const viewClient = createPromiseClient(ViewService, channelTransport); -const { address } = await viewClient.addressByIndex({}); -console.log(bech32mAddress(address)); -``` - -### The actual interface - -These are just convenience methods to this interface. A global record, on which -arbitrary strings identify providers, with a simple interface to connect or -request permission to connect. - -If you're developing a wallet, injection of a record here will allow you to -expose your wallet to potentially interested web apps. - - - -```ts -export const PenumbraSymbol = Symbol.for('penumbra'); - -export interface PenumbraProvider { - readonly connect: () => Promise; - readonly request: () => Promise; - readonly isConnected: () => boolean | undefined; - readonly manifest: string; -} - -declare global { - interface Window { - readonly [PenumbraSymbol]?: undefined | Readonly>; - } -} -``` - ## Service Implementation Services in this repository should eventually be re-useable, but you can also diff --git a/docs/publishing.md b/docs/publishing.md deleted file mode 100644 index eff06f0b32..0000000000 --- a/docs/publishing.md +++ /dev/null @@ -1,54 +0,0 @@ -# Publishing Extension - -Publishing a new version of the extension should be a very careful process. -The extension is a hot wallet and custodies the user's encrypted seed phrase. -If the publishing pipeline was compromised, a bad actor could upload malicious code. - -### Access to publish - -#### #1 - Penumbra Labs [google group](https://groups.google.com/a/penumbralabs.xyz/g/chrome-extension-publishers) - -This entity is a [group publisher](https://developer.chrome.com/docs/webstore/group-publishers/). Members of the -group have publish permissions. Note: For a group member to publish updates, that member must register as a Chrome Web -Store developer and pay the one-time registration fee. -Package uploads are done through -the [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole/). - -#### #2 - Github CI/CD - -Upon a github release, the `penumbra-zone/penumbra-labs` github team will be pinged for a review of the release. -Any one of the members can approve it. Upon doing so, the pipeline will trigger packaging and uploading the extension -code on the main branch. -See github action [here](../.github/workflows/extension-publish.yml). - -Two versions of the extension will be uploaded: - -- **Prax Wallet BETA**: A private beta version of the extension, used to garner feedback from external contributors ( - i.e. passionate discord members). Users can be added to this list in - the [prax-beta-testers google group](https://groups.google.com/a/penumbralabs.xyz/g/prax-beta-testers). -- **Prax Wallet**: The public, production version of the extension. - -After the pipeline has run, one of the chrome publishers with dashboard access, should take these actions: - -1. Submit the **BETA** version for approval with immediate publishing. There is no risks on this end for breakage. After - approval, test extensively for bugs. -2. Submit the **PRODUCTION** version for approval but _without_ publishing (should uncheck box that says _"Publish Prax - wallet automatically after it has passed review"_). Once approved and BETA version is validated, it can be published - and go live instantly. - -##### Credentials - -The credentials for this have been generated in -the [penumbra-web google cloud project](https://console.cloud.google.com/apis/credentials?project=penumbra-web&supportedpurview=project). -If the one who generated the credentials has been removed from the ownership google group (from #1 above), -new credentials need to be generated for -the [ext-publish](https://github.com/penumbra-zone/web/settings/environments/1654975857/edit) github environment: - -- GOOGLE_CLIENT_ID -- GOOGLE_CLIENT_SECRET -- GOOGLE_REFRESH_TOKEN - -These can be generated by following -the [chrome webstore api guide](https://developer.chrome.com/docs/webstore/using_webstore_api/). - -Note: there is a Chrome review process that typically takes 1-2 days. diff --git a/docs/state-management.md b/docs/state-management.md deleted file mode 100644 index 80ad866118..0000000000 --- a/docs/state-management.md +++ /dev/null @@ -1,50 +0,0 @@ -# State management - -## Web extension - -The extension has three types of state: - -### In-memory state - -We use [Zustand](https://github.com/pmndrs/zustand) for this. It is based on simplified flux principles and is similar to Redux. -We chose Zustand given its minimalistic, no-boilerplate, hooks-integrated approach. We use `immer` middleware for easier state mutations. - -Can be found here: [apps/extension/src/state/](../apps/extension/src/state/). See examples in that folder on how to create your own slice and add to the store. - -On refresh, this state is wiped and only the persisted state [apps/extension/src/state/persist.ts](../apps/extension/src/state/persist.ts) is rehyrated. - -Be sure to test store functionality! Example using `vitest` here: [apps/extension/src/state/password.test.ts](../apps/extension/src/state/password.test.ts). - -### Session state - -Meant to be used for short-term persisted data. Holds data in memory for the duration of a browser session. - -Sourced from `chrome.storage.session`. Some helpers fns: - -- Clear all state: `chrome.storage.session.clear()` -- See all state: `chrome.storage.session.get().then(console.log)` - -See `apps/extension/src/state/password.ts` for an example of how to do typesafe storage that is synced with Zustand. -Also, be sure to rehydrate Zustand state here: [apps/extension/src/state/persist.ts](../apps/extension/src/state/persist.ts). - -### Local state - -Same API as above, except uses `chrome.storage.local`. -Meant to be used for long-term persisted data. It is cleared when the extension is removed. - -### Migrations - -If your persisted state changes in a breaking way, it's important to write a migration. Steps: - -1. Create a new version in the respective storage file. Example: [SessionStorageVersion](../apps/extension/src/storage/session.ts). -2. Write the migration functions. Should have a data structure that looks like: - -```typescript - { - "seedPhrase": { // storage key - "V1": (old) => old.split(' ') // old version: migrate fn - } - } -``` - -3. See [apps/extension/src/storage/migration.test.ts](../apps/extension/src/storage/migration.test.ts) for an example. Make sure you add types to your migration function! diff --git a/docs/testing.md b/docs/testing.md index 88165426a8..331c7b234f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,6 @@ much as we can. If we do, our app will be far more resilient to changes. Different kinds of testing examples: - Unit tests: [packages/crypto/src/encryption.test.ts](../packages/crypto/src/encryption.test.ts) -- Zustand store tests: [apps/extension/src/state/password.test.ts](../apps/extension/src/state/password.test.ts) - Zod tests to validate types: [packages/wasm/src/client.test.ts](../packages/wasm/src/keys.test.ts) ### Vitest diff --git a/docs/web-workers.md b/docs/web-workers.md deleted file mode 100644 index 97477e26ea..0000000000 --- a/docs/web-workers.md +++ /dev/null @@ -1,14 +0,0 @@ -# Web workers - -The heavy lifting of requests happen in the service worker. Unfortunately, it's an odd runtime environment -and doesn't have access to the same apis as a normal web app. Here is what we have confirmed: - -- Using web-workers directly from a service worker. Not supported ❌ -- Using wasm-threads (wasm-rayon-bindgen). Not supported as it uses web workers underneath the hood ❌ -- Using offscreen api. Works ✅ - -The offscreen api workaround solution was [recommended by Google engineers](https://bugs.chromium.org/p/chromium/issues/detail?id=1219164). -It works by opening an invisible window and issue commands to it to access the full web api. -If it sounds hacky, it's because it is. Here is an [example code](https://github.com/GoogleChrome/chrome-extensions-samples/blob/f608c65e61c2fbf3749ccba88ddce6fafd65e71f/functional-samples/cookbook.offscreen-dom/background.js) of it in use. - -Note: It doesn't look like [comlink](https://github.com/GoogleChromeLabs/comlink) works via this method. diff --git a/eslint.config.js b/eslint.config.js index 2d462681d0..99c7c3c2b4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -74,15 +74,13 @@ export default tseslint.config( rules: { ...react.configs.recommended.rules, ...react_hooks.configs.recommended.rules, + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/rules-of-hooks': 'error', }, }, { name: 'custom:react-wishlist-improvements', rules: { - // these were from a broken plugin. should be enabled and fixed. - 'react-hooks/exhaustive-deps': 'off', - 'react-hooks/rules-of-hooks': 'off', - // this plugin was formerly included, but was never actually applied. 'react-refresh/only-export-components': 'off', @@ -156,7 +154,6 @@ export default tseslint.config( 'error', { requireDefaultForNonUnion: true }, ], - curly: ['error', 'all'], eqeqeq: ['error', 'smart'], }, }, @@ -236,7 +233,9 @@ export default tseslint.config( '**/*.story.@(ts|tsx|js|jsx|mjs|cjs)', ], rules: { + '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/prefer-promise-reject-errors': 'off', 'react/display-name': 'off', }, }, @@ -258,4 +257,9 @@ export default tseslint.config( // disable rules covered by prettier prettier, + + { + name: 'custom:prettier-would-disable', + rules: { curly: ['error', 'all'] }, + }, ); diff --git a/package.json b/package.json index 15df073274..7d2c2c3a58 100644 --- a/package.json +++ b/package.json @@ -17,15 +17,13 @@ "dev": "turbo dev:compile dev:app", "dev:app": "turbo dev:compile dev:app", "dev:compile": "turbo dev:compile", - "dev:pack": "turbo dev:pack --concurrency 16", + "dev:pack": "rm -fv ./packages/*/penumbra-zone-*.tgz && turbo dev:pack --concurrency 16", "format": "turbo format", "format:prettier": "prettier --write .", - "format:pretty-quick": "pretty-quick", "format:syncpack": "syncpack format", "lint": "turbo lint", "lint:fix": "turbo lint -- --fix", "lint:prettier": "prettier --check .", - "lint:pretty-quick": "pretty-quick --check", "lint:rust": "turbo lint:rust", "lint:strict": "turbo lint:strict", "lint:syncpack": "syncpack lint", @@ -79,8 +77,7 @@ "eslint-plugin-vitest": "^0.5.4", "jsdom": "^24.0.0", "playwright": "^1.44.0", - "prettier": "^3.2.5", - "pretty-quick": "^4.0.0", + "prettier": "^3.3.3", "syncpack": "^12.3.2", "tailwindcss": "^3.4.3", "tailwindcss-animate": "^1.0.7", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 633632869a..9836d45660 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,40 @@ # @penumbra-zone/client +## 14.0.0 + +### Minor Changes + +- a788eff: Update default timeouts to better support build times + +### Patch Changes + +- Updated dependencies [a788eff] + - @penumbra-zone/transport-dom@7.4.0 + +## 13.0.0 + +### Minor Changes + +- 978efe6: PenumbraManifest refers to chrome.runtime.ManifestV3 + +### Patch Changes + +- Updated dependencies [af04e2a] + - @penumbra-zone/transport-dom@7.3.0 + +## 12.0.0 + +### Patch Changes + +- Updated dependencies [22bf02c] + - @penumbra-zone/protobuf@5.5.0 + +## 11.1.1 + +### Patch Changes + +- ab09596: fix manifest checking function + ## 11.1.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 556514720c..f6b2bd663b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/client", - "version": "11.1.0", + "version": "14.0.0", "license": "(MIT OR Apache-2.0)", "description": "Package for connecting to any Penumbra extension, including Prax.", "type": "module", @@ -37,7 +37,8 @@ "devDependencies": { "@connectrpc/connect": "^1.4.0", "@penumbra-zone/protobuf": "workspace:*", - "@penumbra-zone/transport-dom": "workspace:*" + "@penumbra-zone/transport-dom": "workspace:*", + "@types/chrome": "^0.0.268" }, "peerDependencies": { "@connectrpc/connect": "^1.4.0", diff --git a/packages/client/src/assert.ts b/packages/client/src/assert.ts index 072e5adf04..e52cf0990e 100644 --- a/packages/client/src/assert.ts +++ b/packages/client/src/assert.ts @@ -51,6 +51,9 @@ export const assertProviderConnected = (providerOrigin?: string) => { * Given a specific origin, identify the relevant injection, and confirm its * manifest is actually present or throw. An `undefined` origin is accepted but * will throw. + * + * The manifest will be fetched and returned as parsed json. The `signal` + * parameter may be used to abort the fetch. */ export const assertProviderManifest = async (providerOrigin?: string, signal?: AbortSignal) => { // confirm the provider injection is present diff --git a/packages/client/src/create.ts b/packages/client/src/create.ts index c2301c04a5..e1ff5d533f 100644 --- a/packages/client/src/create.ts +++ b/packages/client/src/create.ts @@ -77,7 +77,12 @@ export const createPenumbraChannelTransport = async ( export const createPenumbraClientSync =

( service: P, requireProvider?: string, -) => createPromiseClient(service, createPenumbraChannelTransportSync(requireProvider)); + transportOptions?: Omit, +) => + createPromiseClient( + service, + createPenumbraChannelTransportSync(requireProvider, transportOptions), + ); /** * Asynchronously create a client for `service` from the specified provider, or diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1c573c8f0b..11e53c1568 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5,20 +5,21 @@ import { PenumbraSymbol } from './symbol.js'; declare global { interface Window { - /** Records injected upon this global should identify themselves by a field - * name matching the origin of the provider. */ + /** Records injected upon this global should be identified by a name matching + * the origin segment of their manifest href `PenumbraProvider['manifest']`. */ readonly [PenumbraSymbol]?: undefined | Readonly>; } } -/** Synchronously return the specified provider, without verifying anything. */ -export const getPenumbraUnsafe = (penumbraOrigin: string) => +/** Return the specified provider, without verifying anything. */ +export const getPenumbraUnsafe = (penumbraOrigin: string): PenumbraProvider | undefined => window[PenumbraSymbol]?.[penumbraOrigin]; /** Return the specified provider after confirming presence of its manifest. */ -export const getPenumbra = (penumbraOrigin: string) => assertProvider(penumbraOrigin); +export const getPenumbra = (penumbraOrigin: string): Promise => + assertProvider(penumbraOrigin); -/** Return the specified provider's manifest. */ +/** Fetch the specified provider's manifest. */ export const getPenumbraManifest = async ( penumbraOrigin: string, signal?: AbortSignal, @@ -30,14 +31,14 @@ export const getPenumbraManifest = async ( return manifestJson; }; -export const getAllPenumbraManifests = (): Record< - keyof (typeof window)[typeof PenumbraSymbol], - Promise -> => +/** Fetch all manifests for all providers available on the page. */ +export const getAllPenumbraManifests = ( + signal?: AbortSignal, +): Record> => Object.fromEntries( Object.keys(assertGlobalPresent()).map(providerOrigin => [ providerOrigin, - getPenumbraManifest(providerOrigin), + getPenumbraManifest(providerOrigin, signal), ]), ); diff --git a/packages/client/src/manifest.ts b/packages/client/src/manifest.ts index 8ad98c51b2..5649d6c046 100644 --- a/packages/client/src/manifest.ts +++ b/packages/client/src/manifest.ts @@ -1,43 +1,24 @@ -/** Currently, Penumbra manifests are chrome extension manifest v3. There's no type - * guard because manifest format is enforced by chrome. This type only describes - * fields we're interested in as a client. +/// + +/** + * Currently, Penumbra manifests are expected to be chrome extension manifest + * v3. This type just requires a few fields of ManifestV3 that apps might use + * to display provider information to the user. * * @see https://developer.chrome.com/docs/extensions/reference/manifest#keys + * + * For chrome extensions, the extension `id` will be the host of the extension + * origin. The `id` is added to the manifest by the chrome store, so will be + * missing from a locally-built extension in development. Developers may + * configure a public `key` field to ensure the `id` field matches in + * development builds, but `id` will still not be present in the manifest. + * + * If necessary, `id` could be calculated from your key. + * + * @see https://web.archive.org/web/20120606044635/http://supercollider.dk/2010/01/calculating-chrome-extension-id-from-your-private-key-233 */ -export interface PenumbraManifest { - /** - * manifest id is present in production, but generally not in dev, because - * they are inserted by chrome store tooling. chrome extension id are simple - * hashes of the 'key' field, an extension-specific public key. - * - * developers may configure a public key in dev, and the extension id will - * match appropriately, but will not be present in the manifest. - * - * the extension id is also part of the extension's origin URI. - * - * @see https://developer.chrome.com/docs/extensions/reference/manifest/key - * @see https://web.archive.org/web/20120606044635/http://supercollider.dk/2010/01/calculating-chrome-extension-id-from-your-private-key-233 - */ - id?: string; - key?: string; - - // these are required - name: string; - version: string; - description: string; - - // these are optional, but might be nice to have - homepage_url?: string; - options_ui?: { page: string }; - options_page?: string; - - // icons are not indexed by number, but by a stringified number. they may be - // any square size but the power-of-two sizes are typical. the chrome store - // requires a '128' icon. - icons: Record<`${number}`, string> & { - ['128']: string; - }; -} +export type PenumbraManifest = Partial & + Required>; export const isPenumbraManifest = (mf: unknown): mf is PenumbraManifest => mf !== null && diff --git a/packages/crypto/CHANGELOG.md b/packages/crypto/CHANGELOG.md index 95817c6fe5..eeb7f5747f 100644 --- a/packages/crypto/CHANGELOG.md +++ b/packages/crypto/CHANGELOG.md @@ -1,5 +1,39 @@ # @penumbra-zone/crypto-web +## 16.0.1 + +### Patch Changes + +- Updated dependencies [3477bef] + - @penumbra-zone/types@17.0.1 + +## 16.0.0 + +### Patch Changes + +- @penumbra-zone/types@17.0.0 + +## 15.0.0 + +### Patch Changes + +- Updated dependencies [0233722] + - @penumbra-zone/types@16.1.0 + +## 14.0.0 + +### Patch Changes + +- @penumbra-zone/types@16.0.0 + +## 13.0.1 + +### Patch Changes + +- 3aaead1: Move the "default" option in package.json exports field to the last +- Updated dependencies [3aaead1] + - @penumbra-zone/types@15.1.1 + ## 13.0.0 ### Patch Changes diff --git a/packages/crypto/package.json b/packages/crypto/package.json index a96052ddfc..1cec8d8170 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/crypto-web", - "version": "13.0.0", + "version": "16.0.1", "license": "(MIT OR Apache-2.0)", "type": "module", "engine": { @@ -25,8 +25,8 @@ "publishConfig": { "exports": { "./*": { - "default": "./dist/*.js", - "types": "./dist/*.d.ts" + "types": "./dist/*.d.ts", + "default": "./dist/*.js" } } }, diff --git a/packages/getters/CHANGELOG.md b/packages/getters/CHANGELOG.md index 373f299d17..56cc56dc29 100644 --- a/packages/getters/CHANGELOG.md +++ b/packages/getters/CHANGELOG.md @@ -1,5 +1,18 @@ # @penumbra-zone/getters +## 12.1.0 + +### Minor Changes + +- 86c1bbe: Add support for delegate vote action views + +## 12.0.0 + +### Patch Changes + +- Updated dependencies [22bf02c] + - @penumbra-zone/protobuf@5.5.0 + ## 11.0.0 ### Minor Changes diff --git a/packages/getters/package.json b/packages/getters/package.json index 24bf63445a..a3e2b35696 100644 --- a/packages/getters/package.json +++ b/packages/getters/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/getters", - "version": "11.0.0", + "version": "12.1.0", "license": "(MIT OR Apache-2.0)", "description": "Convenience getters for the deeply nested optionals of Penumbra's protobuf types", "type": "module", diff --git a/packages/getters/src/delegator-vote-view.ts b/packages/getters/src/delegator-vote-view.ts new file mode 100644 index 0000000000..865751e50b --- /dev/null +++ b/packages/getters/src/delegator-vote-view.ts @@ -0,0 +1,6 @@ +import { createGetter } from './utils/create-getter.js'; +import { DelegatorVoteView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/governance/v1/governance_pb.js'; + +export const getDelegatorVoteBody = createGetter( + (view?: DelegatorVoteView) => view?.delegatorVote.value?.delegatorVote?.body, +); diff --git a/packages/getters/src/swap-view.ts b/packages/getters/src/swap-view.ts index d0148172ff..2c5764d749 100644 --- a/packages/getters/src/swap-view.ts +++ b/packages/getters/src/swap-view.ts @@ -97,6 +97,6 @@ export const getClaimTx = createGetter((swapView?: SwapView) => */ export const getAddressView = createGetter((swapView?: SwapView) => swapView?.swapView.case === 'visible' - ? swapView.swapView.value.output1?.address ?? swapView.swapView.value.output2?.address + ? (swapView.swapView.value.output1?.address ?? swapView.swapView.value.output2?.address) : undefined, ); diff --git a/packages/perspective/CHANGELOG.md b/packages/perspective/CHANGELOG.md index d0b4b0507c..859191732b 100644 --- a/packages/perspective/CHANGELOG.md +++ b/packages/perspective/CHANGELOG.md @@ -1,5 +1,56 @@ # @penumbra-zone/perspective +## 18.0.0 + +### Minor Changes + +- 16147fe: Add support for DelegatorVotePlan -> DelegatorVoteView + +### Patch Changes + +- Updated dependencies [3477bef] +- Updated dependencies [d6ce325] + - @penumbra-zone/wasm@20.1.0 + +## 17.0.0 + +### Minor Changes + +- 86c1bbe: Add support for delegate vote action views + +### Patch Changes + +- Updated dependencies [4e30796] +- Updated dependencies [86c1bbe] + - @penumbra-zone/wasm@20.0.0 + - @penumbra-zone/getters@12.1.0 + +## 16.0.0 + +### Patch Changes + +- @penumbra-zone/wasm@19.0.0 + +## 15.0.0 + +### Patch Changes + +- @penumbra-zone/getters@12.0.0 +- @penumbra-zone/wasm@18.0.0 + +## 14.0.2 + +### Patch Changes + +- @penumbra-zone/wasm@17.0.2 + +## 14.0.1 + +### Patch Changes + +- Updated dependencies [1a57749] + - @penumbra-zone/wasm@17.0.1 + ## 14.0.0 ### Patch Changes diff --git a/packages/perspective/package.json b/packages/perspective/package.json index f7695d92f0..a9192f058a 100644 --- a/packages/perspective/package.json +++ b/packages/perspective/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/perspective", - "version": "14.0.0", + "version": "18.0.0", "license": "(MIT OR Apache-2.0)", "description": "Tools for assuming different perspectives of Penumbra transactions", "type": "module", diff --git a/packages/perspective/src/plan/view-action-plan.ts b/packages/perspective/src/plan/view-action-plan.ts index f72618eb6c..51da3ce9d8 100644 --- a/packages/perspective/src/plan/view-action-plan.ts +++ b/packages/perspective/src/plan/view-action-plan.ts @@ -34,6 +34,10 @@ import { ActionDutchAuctionWithdrawView, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1/auction_pb.js'; import { PartialMessage } from '@bufbuild/protobuf'; +import { + DelegatorVotePlan, + DelegatorVoteView, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/governance/v1/governance_pb.js'; const getValueView = async ( value: Value | undefined, @@ -223,6 +227,30 @@ const getSwapClaimView = async ( }); }; +const getDelegatorVoteView = async ( + votePlan: DelegatorVotePlan, + denomMetadataByAssetId: (id: AssetId) => Promise, + fullViewingKey: FullViewingKey, +): Promise => { + return new DelegatorVoteView({ + delegatorVote: { + case: 'visible', + value: { + note: await getNoteView(votePlan.stakedNote, denomMetadataByAssetId, fullViewingKey), + delegatorVote: { + body: { + proposal: votePlan.proposal, + startPosition: votePlan.startPosition, + vote: votePlan.vote, + value: votePlan.stakedNote?.value, + unbondedAmount: votePlan.unbondedAmount, + }, + }, + }, + }, + }); +}; + export const viewActionPlan = (denomMetadataByAssetId: (id: AssetId) => Promise, fullViewingKey: FullViewingKey) => async (actionPlan: ActionPlan): Promise => { @@ -334,6 +362,23 @@ export const viewActionPlan = actionView: actionPlan.action, }); + case 'delegatorVote': + return new ActionView({ + actionView: { + case: 'delegatorVote', + value: await getDelegatorVoteView( + actionPlan.action.value, + denomMetadataByAssetId, + fullViewingKey, + ), + }, + }); + + case 'validatorVote': + return new ActionView({ + actionView: actionPlan.action, + }); + case undefined: throw new Error('No action case in action plan'); default: diff --git a/packages/perspective/src/transaction/classification.ts b/packages/perspective/src/transaction/classification.ts index bf9d4b3bd9..47824db83b 100644 --- a/packages/perspective/src/transaction/classification.ts +++ b/packages/perspective/src/transaction/classification.ts @@ -9,21 +9,27 @@ export type TransactionClassification = | 'send' /** The transaction is a receive from an external account. */ | 'receive' - /** The transaction contains a `swap` action. */ + /** The transactions below are one that contain the respective action. */ | 'swap' - /** The transaction contains a `swapClaim` action. */ | 'swapClaim' - /** The transaction contains a `delegate` action. */ | 'delegate' - /** The transaction contains an `undelegate` action. */ | 'undelegate' - /** The transaction contains an `undelegateClaim` action. */ | 'undelegateClaim' - /** The transaction contains an `ics20Withdrawal` action. */ | 'ics20Withdrawal' - /** The transaction contains an `actionDutchAuctionSchedule` action. */ | 'dutchAuctionSchedule' - /** The transaction contains an `actionDutchAuctionEnd` action. */ | 'dutchAuctionEnd' - /** The transaction contains an `actionDutchAuctionWithdraw` action. */ - | 'dutchAuctionWithdraw'; + | 'dutchAuctionWithdraw' + | 'delegatorVote' + | 'validatorVote' + | 'validatorDefinition' + | 'ibcRelayAction' + | 'proposalSubmit' + | 'proposalWithdraw' + | 'proposalDepositClaim' + | 'positionOpen' + | 'positionClose' + | 'positionWithdraw' + | 'positionRewardClaim' + | 'communityPoolSpend' + | 'communityPoolOutput' + | 'communityPoolDeposit'; diff --git a/packages/perspective/src/transaction/classify.test.ts b/packages/perspective/src/transaction/classify.test.ts index 9473fbb11e..b7cd9061d7 100644 --- a/packages/perspective/src/transaction/classify.test.ts +++ b/packages/perspective/src/transaction/classify.test.ts @@ -375,7 +375,8 @@ describe('classifyTransaction()', () => { }, { actionView: { - case: 'delegatorVote', + // @ts-expect-error Simulating an unexpected case + case: 'daoGovernanceVote', value: {}, }, }, diff --git a/packages/perspective/src/transaction/classify.ts b/packages/perspective/src/transaction/classify.ts index b04ed83a0c..ac70e7d3a6 100644 --- a/packages/perspective/src/transaction/classify.ts +++ b/packages/perspective/src/transaction/classify.ts @@ -36,6 +36,48 @@ export const classifyTransaction = (txv?: TransactionView): TransactionClassific if (allActionCases.has('actionDutchAuctionWithdraw')) { return 'dutchAuctionWithdraw'; } + if (allActionCases.has('delegatorVote')) { + return 'delegatorVote'; + } + if (allActionCases.has('validatorVote')) { + return 'validatorVote'; + } + if (allActionCases.has('validatorDefinition')) { + return 'validatorDefinition'; + } + if (allActionCases.has('ibcRelayAction')) { + return 'ibcRelayAction'; + } + if (allActionCases.has('proposalSubmit')) { + return 'proposalSubmit'; + } + if (allActionCases.has('proposalWithdraw')) { + return 'proposalWithdraw'; + } + if (allActionCases.has('proposalDepositClaim')) { + return 'proposalDepositClaim'; + } + if (allActionCases.has('positionOpen')) { + return 'positionOpen'; + } + if (allActionCases.has('positionClose')) { + return 'positionClose'; + } + if (allActionCases.has('positionWithdraw')) { + return 'positionWithdraw'; + } + if (allActionCases.has('positionRewardClaim')) { + return 'positionRewardClaim'; + } + if (allActionCases.has('communityPoolSpend')) { + return 'communityPoolSpend'; + } + if (allActionCases.has('communityPoolDeposit')) { + return 'communityPoolDeposit'; + } + if (allActionCases.has('communityPoolOutput')) { + return 'communityPoolOutput'; + } const hasOpaqueSpend = txv.bodyView?.actionViews.some( a => a.actionView.case === 'spend' && a.actionView.value.spendView.case === 'opaque', @@ -89,7 +131,6 @@ export const classifyTransaction = (txv?: TransactionView): TransactionClassific } if (isInternal) { - // TODO: fill this in with classification of swaps, swapclaims, etc. return 'unknownInternal'; } @@ -112,6 +153,20 @@ export const TRANSACTION_LABEL_BY_CLASSIFICATION: Record diff --git a/packages/perspective/src/translators/action-view.ts b/packages/perspective/src/translators/action-view.ts index a5c14fee2c..e259b804ac 100644 --- a/packages/perspective/src/translators/action-view.ts +++ b/packages/perspective/src/translators/action-view.ts @@ -5,6 +5,7 @@ import { asOpaqueOutputView, asReceiverOutputView } from './output-view.js'; import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; import { asOpaqueSwapView } from './swap-view.js'; import { asOpaqueSwapClaimView } from './swap-claim-view.js'; +import { asOpaqueDelegatorVoteView } from './delegator-vote-view.js'; export const asPublicActionView: Translator = actionView => { switch (actionView?.actionView.case) { @@ -40,6 +41,14 @@ export const asPublicActionView: Translator = actionView => { }, }); + case 'delegatorVote': + return new ActionView({ + actionView: { + case: 'delegatorVote', + value: asOpaqueDelegatorVoteView(actionView.actionView.value), + }, + }); + // Currently defaulting to displaying that all data is public as it's better // to err on communicating private data as public than the other way around // TODO: Do proper audit of what data for each action is public diff --git a/packages/perspective/src/translators/delegator-vote-view.test.ts b/packages/perspective/src/translators/delegator-vote-view.test.ts new file mode 100644 index 0000000000..13db661612 --- /dev/null +++ b/packages/perspective/src/translators/delegator-vote-view.test.ts @@ -0,0 +1,60 @@ +import { asOpaqueDelegatorVoteView } from './delegator-vote-view.js'; +import { describe, expect, test } from 'vitest'; +import { + DelegatorVote, + DelegatorVoteView, + DelegatorVoteView_Opaque, + DelegatorVoteView_Visible, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/governance/v1/governance_pb.js'; +import { NoteView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/shielded_pool/v1/shielded_pool_pb.js'; +import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; + +describe('asOpaqueDelegatorVoteView', () => { + test('when passed `undefined` returns an empty, opaque delegator vote view', () => { + const expected = new DelegatorVoteView({ + delegatorVote: { + case: 'opaque', + value: new DelegatorVoteView_Opaque({ + delegatorVote: undefined, + }), + }, + }); + + expect(asOpaqueDelegatorVoteView(undefined)).toEqual(expected); + }); + + test('when passed an already-opaque delegator vote view returns the delegator vote view as-is', () => { + const opaqueDelegatorVoteView = new DelegatorVoteView({ + delegatorVote: { + case: 'opaque', + value: new DelegatorVoteView_Opaque({ + delegatorVote: new DelegatorVote({ body: { proposal: 123n } }), + }), + }, + }); + expect( + asOpaqueDelegatorVoteView(opaqueDelegatorVoteView).equals(opaqueDelegatorVoteView), + ).toBeTruthy(); + }); + + test('returns an opaque version of the delegator vote view', () => { + const visibleDelegatorVoteView = new DelegatorVoteView({ + delegatorVote: { + case: 'visible', + value: new DelegatorVoteView_Visible({ + delegatorVote: new DelegatorVote({ body: { proposal: 123n } }), + note: new NoteView({ value: new ValueView() }), + }), + }, + }); + + const result = asOpaqueDelegatorVoteView(visibleDelegatorVoteView); + + expect(result.delegatorVote.case).toBe('opaque'); + expect( + result.delegatorVote.value?.delegatorVote?.equals( + visibleDelegatorVoteView.delegatorVote.value?.delegatorVote, + ), + ).toBeTruthy(); + }); +}); diff --git a/packages/perspective/src/translators/delegator-vote-view.ts b/packages/perspective/src/translators/delegator-vote-view.ts new file mode 100644 index 0000000000..d676f62b38 --- /dev/null +++ b/packages/perspective/src/translators/delegator-vote-view.ts @@ -0,0 +1,20 @@ +import { Translator } from './types.js'; +import { + DelegatorVoteView, + DelegatorVoteView_Opaque, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/governance/v1/governance_pb.js'; + +export const asOpaqueDelegatorVoteView: Translator = delegatorVoteView => { + if (delegatorVoteView?.delegatorVote.case === 'opaque') { + return delegatorVoteView; + } + + return new DelegatorVoteView({ + delegatorVote: { + case: 'opaque', + value: new DelegatorVoteView_Opaque({ + delegatorVote: delegatorVoteView?.delegatorVote.value?.delegatorVote, + }), + }, + }); +}; diff --git a/packages/protobuf/CHANGELOG.md b/packages/protobuf/CHANGELOG.md index 1039062852..e54670666e 100644 --- a/packages/protobuf/CHANGELOG.md +++ b/packages/protobuf/CHANGELOG.md @@ -1,5 +1,11 @@ # @penumbra-zone/protobuf +## 5.5.0 + +### Minor Changes + +- 22bf02c: Add additional query services to PenumbraService + ## 5.4.0 ### Minor Changes diff --git a/packages/protobuf/package.json b/packages/protobuf/package.json index 1408445b00..a62fa531e0 100644 --- a/packages/protobuf/package.json +++ b/packages/protobuf/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/protobuf", - "version": "5.4.0", + "version": "5.5.0", "license": "(MIT OR Apache-2.0)", "description": "Exports a `@bufbuild/protobuf` type registry with all message types necessary to communicate with a Penumbra extension", "type": "module", diff --git a/packages/protobuf/src/penumbra-core.ts b/packages/protobuf/src/penumbra-core.ts index 9fb4dc18f3..25faeb0a86 100644 --- a/packages/protobuf/src/penumbra-core.ts +++ b/packages/protobuf/src/penumbra-core.ts @@ -1,11 +1,13 @@ export { QueryService as AppService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/app/v1/app_connect.js'; export { QueryService as AuctionService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/auction/v1/auction_connect.js'; +export { QueryService as CommunityPoolService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/community_pool/v1/community_pool_connect.js'; export { QueryService as CompactBlockService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/compact_block/v1/compact_block_connect.js'; export { QueryService as DexService, SimulationService, } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/dex/v1/dex_connect.js'; +export { QueryService as FeeService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/fee/v1/fee_connect.js'; export { QueryService as GovernanceService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/governance/v1/governance_connect.js'; export { QueryService as SctService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/sct/v1/sct_connect.js'; export { QueryService as ShieldedPoolService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/shielded_pool/v1/shielded_pool_connect.js'; diff --git a/packages/protobuf/src/web.ts b/packages/protobuf/src/web.ts index 74f365b0c3..9f5244cb5a 100644 --- a/packages/protobuf/src/web.ts +++ b/packages/protobuf/src/web.ts @@ -1,6 +1,14 @@ import type { IbcChannelService, IbcClientService, IbcConnectionService } from './ibc-core.js'; import type { CustodyService, ViewService } from './penumbra.js'; -import type { DexService, SctService, SimulationService, StakeService } from './penumbra-core.js'; +import { + CommunityPoolService, + DexService, + FeeService, + GovernanceService, + SctService, + SimulationService, + StakeService, +} from './penumbra-core.js'; import type { TendermintProxyService } from './penumbra-proxy.js'; export type PenumbraService = @@ -13,4 +21,7 @@ export type PenumbraService = | typeof SimulationService | typeof StakeService | typeof TendermintProxyService - | typeof ViewService; + | typeof ViewService + | typeof GovernanceService + | typeof CommunityPoolService + | typeof FeeService; diff --git a/packages/query/CHANGELOG.md b/packages/query/CHANGELOG.md index 4586546204..28b68fba76 100644 --- a/packages/query/CHANGELOG.md +++ b/packages/query/CHANGELOG.md @@ -1,5 +1,68 @@ # @penumbra-zone/query +## 19.0.0 + +### Patch Changes + +- 3477bef: bugfix: injecting globalThis.**DEV** correctly on prod builds +- Updated dependencies [3477bef] +- Updated dependencies [d6ce325] + - @penumbra-zone/types@17.0.1 + - @penumbra-zone/wasm@20.1.0 + - @penumbra-zone/crypto-web@16.0.1 + +## 18.0.0 + +### Patch Changes + +- Updated dependencies [4e30796] +- Updated dependencies [86c1bbe] + - @penumbra-zone/wasm@20.0.0 + - @penumbra-zone/getters@12.1.0 + - @penumbra-zone/types@17.0.0 + - @penumbra-zone/crypto-web@16.0.0 + +## 17.0.0 + +### Minor Changes + +- 0233722: added proxying timestampByHeight + +### Patch Changes + +- Updated dependencies [0233722] + - @penumbra-zone/types@16.1.0 + - @penumbra-zone/crypto-web@15.0.0 + - @penumbra-zone/wasm@19.0.0 + +## 16.0.0 + +### Patch Changes + +- Updated dependencies [22bf02c] + - @penumbra-zone/protobuf@5.5.0 + - @penumbra-zone/getters@12.0.0 + - @penumbra-zone/wasm@18.0.0 + - @penumbra-zone/types@16.0.0 + - @penumbra-zone/crypto-web@14.0.0 + +## 15.0.2 + +### Patch Changes + +- 3aaead1: Move the "default" option in package.json exports field to the last +- Updated dependencies [3aaead1] + - @penumbra-zone/crypto-web@13.0.1 + - @penumbra-zone/types@15.1.1 + - @penumbra-zone/wasm@17.0.2 + +## 15.0.1 + +### Patch Changes + +- Updated dependencies [1a57749] + - @penumbra-zone/wasm@17.0.1 + ## 15.0.0 ### Minor Changes diff --git a/packages/query/package.json b/packages/query/package.json index 5851c98034..8eadb66958 100644 --- a/packages/query/package.json +++ b/packages/query/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/query", - "version": "15.0.0", + "version": "19.0.0", "license": "(MIT OR Apache-2.0)", "type": "module", "engine": { @@ -25,8 +25,8 @@ "publishConfig": { "exports": { "./*": { - "default": "./dist/*.js", - "types": "./dist/*.d.ts" + "types": "./dist/*.d.ts", + "default": "./dist/*.js" } } }, diff --git a/packages/query/src/queriers/sct.ts b/packages/query/src/queriers/sct.ts new file mode 100644 index 0000000000..a3645e587e --- /dev/null +++ b/packages/query/src/queriers/sct.ts @@ -0,0 +1,19 @@ +import { PromiseClient } from '@connectrpc/connect'; +import { createClient } from './utils.js'; +import { SctService } from '@penumbra-zone/protobuf'; +import { SctQuerierInterface } from '@penumbra-zone/types/querier'; +import { + TimestampByHeightRequest, + TimestampByHeightResponse, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/sct/v1/sct_pb.js'; + +export class SctQuerier implements SctQuerierInterface { + private readonly client: PromiseClient; + + constructor({ grpcEndpoint }: { grpcEndpoint: string }) { + this.client = createClient(grpcEndpoint, SctService); + } + timestampByHeight(req: TimestampByHeightRequest): Promise { + return this.client.timestampByHeight(req); + } +} diff --git a/packages/query/src/root-querier.ts b/packages/query/src/root-querier.ts index f39a1b1603..1445ef87ad 100644 --- a/packages/query/src/root-querier.ts +++ b/packages/query/src/root-querier.ts @@ -7,6 +7,7 @@ import { CnidariumQuerier } from './queriers/cnidarium.js'; import { StakeQuerier } from './queriers/staking.js'; import type { RootQuerierInterface } from '@penumbra-zone/types/querier'; import { AuctionQuerier } from './queriers/auction.js'; +import { SctQuerier } from './queriers/sct.js'; // Given the amount of query services, this root querier aggregates them all // to make it easier for consumers @@ -16,6 +17,7 @@ export class RootQuerier implements RootQuerierInterface { readonly tendermint: TendermintQuerier; readonly shieldedPool: ShieldedPoolQuerier; readonly ibcClient: IbcClientQuerier; + readonly sct: SctQuerier; readonly stake: StakeQuerier; readonly cnidarium: CnidariumQuerier; readonly auction: AuctionQuerier; @@ -26,6 +28,7 @@ export class RootQuerier implements RootQuerierInterface { this.tendermint = new TendermintQuerier({ grpcEndpoint }); this.shieldedPool = new ShieldedPoolQuerier({ grpcEndpoint }); this.ibcClient = new IbcClientQuerier({ grpcEndpoint }); + this.sct = new SctQuerier({ grpcEndpoint }); this.stake = new StakeQuerier({ grpcEndpoint }); this.cnidarium = new CnidariumQuerier({ grpcEndpoint }); this.auction = new AuctionQuerier({ grpcEndpoint }); diff --git a/packages/query/vitest.config.ts b/packages/query/vitest.config.ts index c1651dd515..0c2847ad53 100644 --- a/packages/query/vitest.config.ts +++ b/packages/query/vitest.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vitest/config'; -export default defineConfig({ - define: { 'globalThis.__DEV__': 'import.meta.env.DEV' }, +export default defineConfig(({ mode }) => { + return { + define: { 'globalThis.__DEV__': mode !== 'production' }, + }; }); diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 8b2d794f71..7ec933f7d6 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,48 @@ # @penumbra-zone/react +## 1.1.0 + +### Minor Changes + +- a788eff: Update default timeouts to better support build times + +### Patch Changes + +- Updated dependencies [a788eff] + - @penumbra-zone/transport-dom@7.4.0 + - @penumbra-zone/client@14.0.0 + +## 1.0.4 + +### Patch Changes + +- b65f9bb: remove reference to window +- Updated dependencies [978efe6] +- Updated dependencies [af04e2a] + - @penumbra-zone/client@13.0.0 + - @penumbra-zone/transport-dom@7.3.0 + +## 1.0.3 + +### Patch Changes + +- Updated dependencies [22bf02c] + - @penumbra-zone/protobuf@5.5.0 + - @penumbra-zone/client@12.0.0 + +## 1.0.2 + +### Patch Changes + +- 1a269d4: encourage client-side execution with input prop that cannot be obtained server-side + +## 1.0.1 + +### Patch Changes + +- Updated dependencies [ab09596] + - @penumbra-zone/client@11.1.1 + ## 1.0.0 ### Major Changes diff --git a/packages/react/README.md b/packages/react/README.md index 72f42d9da0..94223e32ad 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -10,22 +10,38 @@ you're writing a Penumbra dapp in React. npm config set @buf:registry https://buf.build/gen/npm/v1 ``` +## This is a client-side package + +The components in this package interact with a browser extension, so can only be +executed in a browser, not in any server-side rendering context. To encourage +this, `` uses the `penumbra` input prop which may only +be obtained client-side. It's recommended to use methods from +`@penumbra-zone/client` to obtain this value, as described below. + ## Overview If a user has a Penumbra provider in their browser, it may be present (injected) in the record at the window global `window[Symbol.for('penumbra')]` identified by a URL origin at which the provider can serve a manifest. For example, Prax -Wallet's origin is `chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe`, so its provider record may be accessed like +Wallet's origin is `chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe`, so its +provider record may be accessed like ```ts const prax: PenumbraProvider | undefined = window[Symbol.for('penumbra')]?.['chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe']; ``` -So, use of `` with an `origin` prop identifying your -preferred extension, or `injection` prop identifying the actual page injection -from your preferred extension, will result in automatic progress towards a -successful connection. +or with helpers available from `@penumbra-zone/client`, like + +```ts +import { assertProvider } from '@penumbra-zone/client'; +const prax = assertProvider('chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe'); +``` + +Use of `` with a `penumbra` prop identifying your +provider will result in automatic progress towards a successful connection. +Connection requires user approval, so it's recommended provide UI on your page +controlling the `makeApprovalRequest` prop. Hooks `usePenumbraTransport` and `usePenumbraService` will promise a transport or client that inits when the configured provider becomes connected, or rejects @@ -40,8 +56,10 @@ client may time out. ## `` This wrapping component will provide a context available to all child components -that is directly accessible by `usePenumbra`, or additionally by -`usePenumbraTransport` or `usePenumbraService`. +that is directly accessible by `usePenumbra`, or by `usePenumbraTransport` or +`usePenumbraService`. Accepts a `makeApprovalRequest` prop, off by default, to +configure conditional use of the `request` method of the Penumbra interface, +which may trigger a popup or require user interaction. ### Unary requests may use `@connectrpc/connect-query` @@ -55,7 +73,8 @@ A wrapping component: ```tsx import { Outlet } from 'react-router-dom'; -import { PenumbraProvider } from '@penumbra-zone/react'; +import { assertProvider } from '@penumbra-zone/client'; +import { PenumbraContextProvider } from '@penumbra-zone/react'; import { usePenumbraTransportSync } from '@penumbra-zone/react/hooks/use-penumbra-transport'; import { TransportProvider } from '@connectrpc/connect-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -64,13 +83,13 @@ const praxOrigin = 'chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe'; const queryClient = new QueryClient(); export const PenumbraDappPage = () => ( - + - + ); ``` @@ -142,9 +161,7 @@ generally robust and should asynchronously progress towards an active connection if possible, even if steps are performed slightly 'out-of-order'. This package's exported `` component handles this state -and all of these transitions for you. Use of `` with an -`origin` or `provider` prop will result in automatic progress towards a -`Connected` state. +and all of these transitions for you. During this progress, the context exposes an explicit status, so you may easily condition your layout and display. You can access this status via @@ -160,7 +177,7 @@ working client is available. ### State chart This flowchart reads from top (page load) to bottom (page unload). Each labelled -chart node is a possible value of `PenumbraProviderState`. Diamond-shaped nodes +chart node is a possible value of `PenumbraState`. Diamond-shaped nodes are conditions described by the surrounding path labels. There are more possible transitions than diagrammed here - for instance once diff --git a/packages/react/package.json b/packages/react/package.json index 22eedeb6e4..d9c87c5413 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/react", - "version": "1.0.0", + "version": "1.1.0", "license": "(MIT OR Apache-2.0)", "description": "React package for connecting to any Penumbra extension, including Prax.", "type": "module", diff --git a/packages/react/src/components/penumbra-context-provider.tsx b/packages/react/src/components/penumbra-context-provider.tsx index 2883ecd9f6..71091701c3 100644 --- a/packages/react/src/components/penumbra-context-provider.tsx +++ b/packages/react/src/components/penumbra-context-provider.tsx @@ -1,5 +1,4 @@ import { getPenumbraManifest, PenumbraProvider, PenumbraState } from '@penumbra-zone/client'; -import { assertProviderRecord } from '@penumbra-zone/client/assert'; import { isPenumbraStateEvent } from '@penumbra-zone/client/event'; import { PenumbraManifest } from '@penumbra-zone/client/manifest'; import { jsonOptions } from '@penumbra-zone/protobuf'; @@ -10,21 +9,19 @@ import { import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { PenumbraContext, penumbraContext } from '../penumbra-context.js'; -type PenumbraContextProviderProps = { +interface PenumbraContextProviderProps { children?: ReactNode; - origin: string; + penumbra?: PenumbraProvider; makeApprovalRequest?: boolean; transportOpts?: Omit; -} & ({ provider: PenumbraProvider } | { origin: string }); +} export const PenumbraContextProvider = ({ children, - origin: providerOrigin, + penumbra, makeApprovalRequest = false, transportOpts, }: PenumbraContextProviderProps) => { - const penumbra = assertProviderRecord(providerOrigin); - const [providerConnected, setProviderConnected] = useState(); const [providerManifest, setProviderManifest] = useState(); const [providerPort, setProviderPort] = useState(); @@ -53,25 +50,25 @@ export const PenumbraContextProvider = ({ // fetch manifest to confirm presence of provider useEffect(() => { - // require origin. skip if failure or manifest present - if (!providerOrigin || (failure ?? providerManifest)) { + // require provider manifest uri, skip if failure or manifest present + if (!penumbra?.manifest || (failure ?? providerManifest)) { return; } // abortable effect const ac = new AbortController(); - void getPenumbraManifest(providerOrigin, ac.signal) + void getPenumbraManifest(new URL(penumbra.manifest).origin, ac.signal) .then(manifestJson => ac.signal.aborted || setProviderManifest(manifestJson)) .catch(setFailure); return () => ac.abort(); - }, [failure, penumbra, providerManifest, providerOrigin, setFailure, setProviderManifest]); + }, [failure, penumbra?.manifest, providerManifest, setFailure, setProviderManifest]); // attach state event listener useEffect(() => { - // require manifest. unnecessary if failed - if (!providerManifest || failure) { + // require penumbra, manifest. unnecessary if failed + if (!penumbra || !providerManifest || failure) { return; } @@ -81,7 +78,7 @@ export const PenumbraContextProvider = ({ 'penumbrastate', (evt: Event) => { if (isPenumbraStateEvent(evt)) { - if (evt.detail.origin !== providerOrigin) { + if (evt.detail.origin !== new URL(penumbra.manifest).origin) { setFailure(new Error('State change from unexpected origin')); } else if (evt.detail.state !== penumbra.state()) { console.warn('State change not verifiable'); @@ -94,12 +91,12 @@ export const PenumbraContextProvider = ({ { signal: ac.signal }, ); return () => ac.abort(); - }, [failure, penumbra, penumbra.addEventListener, providerManifest, providerOrigin, setFailure]); + }, [failure, providerManifest, setFailure, penumbra]); // request effect useEffect(() => { - // require manifest, no failures - if (providerManifest && !failure) { + // require penumbra, manifest, no failures + if (penumbra?.request && providerManifest && !failure) { switch (providerState) { case PenumbraState.Present: if (makeApprovalRequest) { @@ -110,20 +107,12 @@ export const PenumbraContextProvider = ({ break; } } - }, [ - failure, - makeApprovalRequest, - penumbra, - penumbra.request, - providerManifest, - providerState, - setFailure, - ]); + }, [failure, makeApprovalRequest, penumbra, providerManifest, providerState, setFailure]); // connect effect useEffect(() => { // require manifest, no failures - if (providerManifest && !failure) { + if (penumbra && providerManifest && !failure) { switch (providerState) { case PenumbraState.Present: if (!makeApprovalRequest) { @@ -143,21 +132,13 @@ export const PenumbraContextProvider = ({ break; } } - }, [ - failure, - makeApprovalRequest, - penumbra, - penumbra.connect, - providerManifest, - providerState, - setFailure, - ]); + }, [failure, makeApprovalRequest, penumbra, providerManifest, providerState, setFailure]); const createdContext: PenumbraContext = useMemo( () => ({ failure, manifest: providerManifest, - origin: providerOrigin, + origin: penumbra?.manifest && new URL(penumbra.manifest).origin, // require manifest to forward state state: providerManifest && providerState, @@ -171,8 +152,8 @@ export const PenumbraContextProvider = ({ : undefined, transportOpts, - // require manifest and no failures to forward injected methods - ...(providerManifest && !failure + // require penumbra, manifest and no failures to forward injected things + ...(penumbra && providerManifest && !failure ? { port: providerConnected && providerPort, connect: penumbra.connect, @@ -186,14 +167,9 @@ export const PenumbraContextProvider = ({ }), [ failure, - penumbra.addEventListener, - penumbra.connect, - penumbra.disconnect, - penumbra.removeEventListener, - penumbra.request, + penumbra, providerConnected, providerManifest, - providerOrigin, providerPort, providerState, transportOpts, diff --git a/packages/react/src/hooks/use-penumbra-transport.ts b/packages/react/src/hooks/use-penumbra-transport.ts index 65671dd0d7..79a4067887 100644 --- a/packages/react/src/hooks/use-penumbra-transport.ts +++ b/packages/react/src/hooks/use-penumbra-transport.ts @@ -11,7 +11,7 @@ export const usePenumbraTransport = () => usePenumbra().transport; /** This method immediately returns a new, unshared Transport to the surrounding * Penumbra context. This transport will always create synchronously, but may * time out and reject all requests if the Penumbra context does not provide a - * port within your configured defaultTimeoutMs (defaults to 10 seconds). */ + * port within your configured defaultTimeoutMs (defaults to 60 seconds). */ export const usePenumbraTransportSync = (opts?: Omit) => { const penumbra = usePenumbra(); const { port, failure, state } = penumbra; diff --git a/packages/react/src/penumbra-context.ts b/packages/react/src/penumbra-context.ts index 3a300c2412..85c0489514 100644 --- a/packages/react/src/penumbra-context.ts +++ b/packages/react/src/penumbra-context.ts @@ -1,17 +1,10 @@ import type { Transport } from '@connectrpc/connect'; -import { - PenumbraProvider, - PenumbraSymbol, - type PenumbraManifest, - type PenumbraState, -} from '@penumbra-zone/client'; +import { PenumbraProvider, type PenumbraManifest, type PenumbraState } from '@penumbra-zone/client'; import type { ChannelTransportOptions } from '@penumbra-zone/transport-dom/create'; import { createContext } from 'react'; -const penumbraGlobal = window[PenumbraSymbol]; - export type PenumbraContext = Partial> & { - origin?: keyof NonNullable; + origin?: string; manifest?: PenumbraManifest; port?: MessagePort | false; failure?: Error; diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 885e36dc3c..80057ff1aa 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -4,7 +4,9 @@ "composite": true, "jsx": "react-jsx", "module": "Node16", - "noEmit": true, + "outDir": "dist", + "preserveWatchOutput": true, + "rootDir": "src", "target": "ESNext" }, "extends": "@tsconfig/strictest/tsconfig.json", diff --git a/packages/services/CHANGELOG.md b/packages/services/CHANGELOG.md index 04e81d7bc5..27c8b055d0 100644 --- a/packages/services/CHANGELOG.md +++ b/packages/services/CHANGELOG.md @@ -1,5 +1,99 @@ # @penumbra-zone/router +## 22.0.0 + +### Patch Changes + +- Updated dependencies [3477bef] +- Updated dependencies [d6ce325] +- Updated dependencies [16147fe] + - @penumbra-zone/query@19.0.0 + - @penumbra-zone/types@17.0.1 + - @penumbra-zone/wasm@20.1.0 + - @penumbra-zone/perspective@18.0.0 + - @penumbra-zone/crypto-web@16.0.1 + - @penumbra-zone/storage@18.0.0 + +## 21.0.0 + +### Minor Changes + +- a788eff: Update default timeouts to better support build times + +### Patch Changes + +- Updated dependencies [a788eff] + - @penumbra-zone/transport-dom@7.4.0 + +## 20.0.0 + +### Patch Changes + +- Updated dependencies [4e30796] +- Updated dependencies [86c1bbe] + - @penumbra-zone/wasm@20.0.0 + - @penumbra-zone/perspective@17.0.0 + - @penumbra-zone/getters@12.1.0 + - @penumbra-zone/query@18.0.0 + - @penumbra-zone/storage@17.0.0 + - @penumbra-zone/types@17.0.0 + - @penumbra-zone/crypto-web@16.0.0 + +## 19.0.0 + +### Minor Changes + +- 0233722: added proxying timestampByHeight + +### Patch Changes + +- Updated dependencies [0233722] +- Updated dependencies [af04e2a] + - @penumbra-zone/query@17.0.0 + - @penumbra-zone/types@16.1.0 + - @penumbra-zone/transport-dom@7.3.0 + - @penumbra-zone/crypto-web@15.0.0 + - @penumbra-zone/storage@16.0.0 + - @penumbra-zone/wasm@19.0.0 + - @penumbra-zone/perspective@16.0.0 + +## 18.0.0 + +### Patch Changes + +- Updated dependencies [22bf02c] + - @penumbra-zone/protobuf@5.5.0 + - @penumbra-zone/getters@12.0.0 + - @penumbra-zone/query@16.0.0 + - @penumbra-zone/wasm@18.0.0 + - @penumbra-zone/perspective@15.0.0 + - @penumbra-zone/storage@15.0.0 + - @penumbra-zone/types@16.0.0 + - @penumbra-zone/crypto-web@14.0.0 + +## 17.0.2 + +### Patch Changes + +- 3aaead1: Move the "default" option in package.json exports field to the last +- Updated dependencies [3aaead1] + - @penumbra-zone/storage@14.0.2 + - @penumbra-zone/crypto-web@13.0.1 + - @penumbra-zone/query@15.0.2 + - @penumbra-zone/types@15.1.1 + - @penumbra-zone/wasm@17.0.2 + - @penumbra-zone/perspective@14.0.2 + +## 17.0.1 + +### Patch Changes + +- Updated dependencies [1a57749] + - @penumbra-zone/wasm@17.0.1 + - @penumbra-zone/perspective@14.0.1 + - @penumbra-zone/query@15.0.1 + - @penumbra-zone/storage@14.0.1 + ## 17.0.0 ### Minor Changes diff --git a/packages/services/package.json b/packages/services/package.json index 8b0a1be5e0..b3ef68f151 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/services", - "version": "17.0.0", + "version": "22.0.0", "license": "(MIT OR Apache-2.0)", "type": "module", "engine": { @@ -25,12 +25,12 @@ "publishConfig": { "exports": { "./ctx/*": { - "default": "./dist/ctx/*.js", - "types": "./dist/ctx/*.d.ts" + "types": "./dist/ctx/*.d.ts", + "default": "./dist/ctx/*.js" }, "./*": { - "default": "./dist/*/index.js", - "types": "./dist/*/index.d.ts" + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" } } }, diff --git a/packages/services/src/sct-service/index.ts b/packages/services/src/sct-service/index.ts index c142f40861..c44692c119 100644 --- a/packages/services/src/sct-service/index.ts +++ b/packages/services/src/sct-service/index.ts @@ -1,9 +1,11 @@ import type { ServiceImpl } from '@connectrpc/connect'; import type { SctService } from '@penumbra-zone/protobuf'; import { epochByHeight } from './epoch-by-height.js'; +import { timestampByHeight } from './timestamp-by-height.js'; export type Impl = ServiceImpl; -export const sctImpl: Omit = { +export const sctImpl: Omit = { epochByHeight, + timestampByHeight, }; diff --git a/packages/services/src/sct-service/timestamp-by-height.test.ts b/packages/services/src/sct-service/timestamp-by-height.test.ts new file mode 100644 index 0000000000..b3fd29a8e2 --- /dev/null +++ b/packages/services/src/sct-service/timestamp-by-height.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { MockServices } from '../test-utils.js'; +import { createContextValues, createHandlerContext, HandlerContext } from '@connectrpc/connect'; +import { SctService } from '@penumbra-zone/protobuf'; +import { servicesCtx } from '../ctx/prax.js'; +import type { ServicesInterface } from '@penumbra-zone/types/services'; +import { + TimestampByHeightRequest, + TimestampByHeightResponse, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/sct/v1/sct_pb.js'; +import { Timestamp } from '@bufbuild/protobuf'; +import { timestampByHeight } from './timestamp-by-height.js'; + +describe('TimestampByHeight request handler', () => { + let mockServices: MockServices; + let mockSctQuerierTimestampByHeight: Mock; + let mockCtx: HandlerContext; + const mockTimestampByHeighResponse = new TimestampByHeightResponse({ + timestamp: Timestamp.now(), + }); + + beforeEach(() => { + vi.resetAllMocks(); + + mockSctQuerierTimestampByHeight = vi.fn().mockResolvedValue(mockTimestampByHeighResponse); + + mockServices = { + getWalletServices: vi.fn(() => + Promise.resolve({ + querier: { + sct: { timestampByHeight: mockSctQuerierTimestampByHeight }, + }, + }), + ) as MockServices['getWalletServices'], + } satisfies MockServices; + + mockCtx = createHandlerContext({ + service: SctService, + method: SctService.methods.timestampByHeight, + protocolName: 'mock', + requestMethod: 'MOCK', + url: '/mock', + contextValues: createContextValues().set(servicesCtx, () => + Promise.resolve(mockServices as unknown as ServicesInterface), + ), + }); + }); + + it("returns the response from the sct querier's `timestampByHeight` method", async () => { + const req = new TimestampByHeightRequest({ + height: 729n, + }); + const result = await timestampByHeight(req, mockCtx); + + expect(mockSctQuerierTimestampByHeight).toHaveBeenCalledWith(req); + expect(result as TimestampByHeightResponse).toEqual(mockTimestampByHeighResponse); + }); +}); diff --git a/packages/services/src/sct-service/timestamp-by-height.ts b/packages/services/src/sct-service/timestamp-by-height.ts new file mode 100644 index 0000000000..6551a54142 --- /dev/null +++ b/packages/services/src/sct-service/timestamp-by-height.ts @@ -0,0 +1,9 @@ +import { Impl } from './index.js'; +import { servicesCtx } from '../ctx/prax.js'; + +export const timestampByHeight: Impl['timestampByHeight'] = async (req, ctx) => { + const services = await ctx.values.get(servicesCtx)(); + const { querier } = await services.getWalletServices(); + + return querier.sct.timestampByHeight(req); +}; diff --git a/packages/services/src/test-utils.ts b/packages/services/src/test-utils.ts index 5356f21450..6393d5f248 100644 --- a/packages/services/src/test-utils.ts +++ b/packages/services/src/test-utils.ts @@ -35,6 +35,7 @@ export interface IndexedDbMock { getAuctionOutstandingReserves?: Mock; hasStakingAssetBalance?: Mock; stakingTokenAssetId?: Mock; + upsertAuction?: Mock; } export interface AuctionMock { @@ -58,10 +59,14 @@ export interface ViewServerMock { export interface MockQuerier { auction?: AuctionMock; tendermint?: TendermintMock; + sct?: SctMock; shieldedPool?: ShieldedPoolMock; stake?: StakeMock; } +export interface SctMock { + timestampByHeight?: Mock; +} export interface StakeMock { validatorPenalty?: Mock; } diff --git a/packages/services/src/view-service/fees.test.ts b/packages/services/src/view-service/fees.test.ts index e7863d8805..04228c7d59 100644 --- a/packages/services/src/view-service/fees.test.ts +++ b/packages/services/src/view-service/fees.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; import { + SwapRecord, TransactionPlannerRequest, TransactionPlannerRequest_ActionDutchAuctionEnd, TransactionPlannerRequest_ActionDutchAuctionSchedule, @@ -8,25 +9,73 @@ import { TransactionPlannerRequest_Output, TransactionPlannerRequest_Swap, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; -import { AuctionId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1/auction_pb.js'; +import { + AuctionId, + DutchAuctionDescription, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1/auction_pb.js'; +import { extractAltFee } from './fees.js'; +import { StateCommitment } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/crypto/tct/v1/tct_pb.js'; +import { IndexedDbMock } from '../test-utils.js'; +import { uint8ArrayToBase64 } from '@penumbra-zone/types/base64'; +import { IndexedDbInterface } from '@penumbra-zone/types/indexed-db'; -// TODO: Need to properly write tests the coverage describe('extractAltFee', () => { - it('extracts the fee from outputs', () => { - const umAssetId = new AssetId({ altBaseDenom: 'UM' }); + let mockIndexedDb: IndexedDbMock; + + beforeEach(() => { + vi.clearAllMocks(); + + mockIndexedDb = { + getSwapByCommitment: vi.fn(), + upsertAuction: vi.fn(), + saveAssetsMetadata: vi.fn(), + getAuction: vi.fn(), + }; + }); + + it('extracts the staking asset fee from outputs', async () => { + const umAssetId = new AssetId({ + inner: new Uint8Array([ + 41, 234, 156, 47, 51, 113, 246, 164, 135, 231, 233, 92, 36, 112, 65, 244, 163, 86, 249, 131, + 235, 6, 78, 93, 43, 59, 207, 50, 44, 169, 106, 16, + ]), + }); const request = new TransactionPlannerRequest({ outputs: [ - new TransactionPlannerRequest_Output({ + { value: { assetId: umAssetId }, - }), + }, + ], + }); + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(umAssetId)).toBeTruthy(); + }); + + it('extracts the alternative asset fee from outputs', async () => { + const umAssetId = new AssetId({ + inner: new Uint8Array([ + 29, 109, 132, 171, 117, 25, 85, 32, 109, 182, 133, 48, 82, 47, 204, 82, 209, 59, 174, 189, + 148, 83, 191, 212, 31, 157, 52, 111, 42, 123, 56, 7, + ]), + }); + const request = new TransactionPlannerRequest({ + outputs: [ + { + value: { assetId: umAssetId }, + }, ], }); - const outputAsset = request.outputs.map(o => o.value?.assetId).find(Boolean); - expect(outputAsset!.equals(umAssetId)).toBeTruthy(); + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(umAssetId)).toBeTruthy(); }); - it('skips over outputs that do not have assetIds', () => { - const umAssetId = new AssetId({ altBaseDenom: 'UM' }); + it('skips over outputs that do not have assetIds', async () => { + const umAssetId = new AssetId({ + inner: new Uint8Array([ + 41, 234, 156, 47, 51, 113, 246, 164, 135, 231, 233, 92, 36, 112, 65, 244, 163, 86, 249, 131, + 235, 6, 78, 93, 43, 59, 207, 50, 44, 169, 106, 16, + ]), + }); const request = new TransactionPlannerRequest({ outputs: [ new TransactionPlannerRequest_Output({}), @@ -35,11 +84,11 @@ describe('extractAltFee', () => { }), ], }); - const outputAsset = request.outputs.map(o => o.value?.assetId).find(Boolean); - expect(outputAsset!.equals(umAssetId)).toBeTruthy(); + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(umAssetId)).toBeTruthy(); }); - it('prioritizes outputs over all else', () => { + it('prioritizes outputs over all else', async () => { const outputAssetId = new AssetId({ altBaseDenom: 'output' }); const swapAssetId = new AssetId({ altBaseDenom: 'swap' }); const auctionScheduleAssetId = new AssetId({ altBaseDenom: 'auction-schedule' }); @@ -74,12 +123,19 @@ describe('extractAltFee', () => { ], }); - const outputAsset = request.outputs.map(o => o.value?.assetId).find(Boolean); - expect(outputAsset!.equals(outputAssetId)).toBeTruthy(); + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(outputAssetId)).toBeTruthy(); }); - it('extracts the fee from swaps', () => { - const swapAssetId = new AssetId({ altBaseDenom: 'swap' }); + it('extracts the staking asset fee from swaps', async () => { + mockIndexedDb.getSwapByCommitment?.mockResolvedValue(mockSwapNativeStakingToken); + + const swapAssetId = new AssetId({ + inner: new Uint8Array([ + 41, 234, 156, 47, 51, 113, 246, 164, 135, 231, 233, 92, 36, 112, 65, 244, 163, 86, 249, 131, + 235, 6, 78, 93, 43, 59, 207, 50, 44, 169, 106, 16, + ]), + }); const request = new TransactionPlannerRequest({ swaps: [ new TransactionPlannerRequest_Swap({ @@ -88,7 +144,271 @@ describe('extractAltFee', () => { ], }); - const swapAsset = request.swaps.map(assetIn => assetIn.value?.assetId).find(Boolean); - expect(swapAsset!.equals(swapAssetId)).toBeTruthy(); + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(swapAssetId)).toBeTruthy(); }); + + it('extracts the alternative asset fee from swaps', async () => { + mockIndexedDb.getSwapByCommitment?.mockResolvedValue(mockSwapAlternativeToken); + + const swapAssetId = new AssetId({ + inner: new Uint8Array([ + 29, 109, 132, 171, 117, 25, 85, 32, 109, 182, 133, 48, 82, 47, 204, 82, 209, 59, 174, 189, + 148, 83, 191, 212, 31, 157, 52, 111, 42, 123, 56, 7, + ]), + }); + const request = new TransactionPlannerRequest({ + swaps: [ + { + value: { assetId: swapAssetId }, + }, + ], + }); + + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(swapAssetId)).toBeTruthy(); + }); + + it('extracts the staking asset fee from swap claims', async () => { + mockIndexedDb.getSwapByCommitment?.mockResolvedValue(mockSwapNativeStakingToken); + + const swapAssetId = new AssetId({ + inner: new Uint8Array([ + 41, 234, 156, 47, 51, 113, 246, 164, 135, 231, 233, 92, 36, 112, 65, 244, 163, 86, 249, 131, + 235, 6, 78, 93, 43, 59, 207, 50, 44, 169, 106, 16, + ]), + }); + + const request = new TransactionPlannerRequest({ + swapClaims: [ + { + swapCommitment: mockSwapCommitmentNativeStakingToken, + }, + ], + }); + + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(swapAssetId)).toBeTruthy(); + }); + + it('extracts the alternative asset fee from swap claims', async () => { + mockIndexedDb.getSwapByCommitment?.mockResolvedValue(mockSwapAlternativeToken); + + const swapAssetId = new AssetId({ + inner: new Uint8Array([ + 29, 109, 132, 171, 117, 25, 85, 32, 109, 182, 133, 48, 82, 47, 204, 82, 209, 59, 174, 189, + 148, 83, 191, 212, 31, 157, 52, 111, 42, 123, 56, 7, + ]), + }); + + const request = new TransactionPlannerRequest({ + swapClaims: [ + { + swapCommitment: mockSwapCommitmentAlternativeToken, + }, + ], + }); + + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(swapAssetId)).toBeTruthy(); + }); + + it('extracts the asset fee from dutchAuctionScheduleActions', async () => { + const auctionScheduleAssetId = new AssetId({ + inner: new Uint8Array([ + 29, 109, 132, 171, 117, 25, 85, 32, 109, 182, 133, 48, 82, 47, 204, 82, 209, 59, 174, 189, + 148, 83, 191, 212, 31, 157, 52, 111, 42, 123, 56, 7, + ]), + }); + + const request = new TransactionPlannerRequest({ + dutchAuctionScheduleActions: [ + { + description: { + input: { + amount: { hi: 0n, lo: 0n }, + assetId: auctionScheduleAssetId, + }, + }, + }, + ], + }); + + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(auctionScheduleAssetId)).toBeTruthy(); + }); + + it('extracts the asset fee from dutchAuctionEndActions', async () => { + const auctionScheduleAssetId = new AssetId({ + inner: new Uint8Array([ + 29, 109, 132, 171, 117, 25, 85, 32, 109, 182, 133, 48, 82, 47, 204, 82, 209, 59, 174, 189, + 148, 83, 191, 212, 31, 157, 52, 111, 42, 123, 56, 7, + ]), + }); + + const auction = new DutchAuctionDescription({ + input: { + assetId: auctionScheduleAssetId, + }, + }); + + mockIndexedDb.getAuction?.mockResolvedValueOnce({ + auction, + noteCommitment: mockAuctionEndCommitment, + seqNum: 0n, + }); + + const request = new TransactionPlannerRequest({ + dutchAuctionEndActions: [ + { + auctionId: { inner: new Uint8Array([]) }, + }, + ], + }); + + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(auctionScheduleAssetId)).toBeTruthy(); + }); + + it('extracts the asset fee from dutchAuctionWithdrawAuctions', async () => { + const auctionScheduleAssetId = new AssetId({ + inner: new Uint8Array([ + 29, 109, 132, 171, 117, 25, 85, 32, 109, 182, 133, 48, 82, 47, 204, 82, 209, 59, 174, 189, + 148, 83, 191, 212, 31, 157, 52, 111, 42, 123, 56, 7, + ]), + }); + + const auction = new DutchAuctionDescription({ + input: { + assetId: auctionScheduleAssetId, + }, + }); + + mockIndexedDb.getAuction?.mockResolvedValueOnce({ + auction, + noteCommitment: mockAuctionEndCommitment, + seqNum: 0n, + }); + + const request = new TransactionPlannerRequest({ + dutchAuctionWithdrawActions: [ + { + auctionId: { inner: new Uint8Array([]) }, + }, + ], + }); + + const result = await extractAltFee(request, mockIndexedDb as unknown as IndexedDbInterface); + expect(result.equals(auctionScheduleAssetId)).toBeTruthy(); + }); +}); + +const mockAuctionEndCommitment = StateCommitment.fromJson({ + inner: 'A6VBVkrk+s18q+Sjhl8uEGfS3i0dwF1FrkNm8Db6VAA=', +}); + +const mockSwapCommitmentNativeStakingToken = StateCommitment.fromJson({ + inner: 'A6VBVkrk+s18q+Sjhl8uEGfS3i0dwF1FrkNm8Db6VAA=', +}); + +const mockSwapCommitmentAlternativeToken = StateCommitment.fromJson({ + inner: 'B6VBVkrk+s18q+Sjhl8uEGfS3i0dwF1FrkNm8Db6VAA=', +}); + +const mockSwapNativeStakingToken = SwapRecord.fromJson({ + swapCommitment: { inner: 'A6VBVkrk+s18q+Sjhl8uEGfS3i0dwF1FrkNm8Db6VAA=' }, + swap: { + tradingPair: { + asset1: { inner: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=' }, + asset2: { inner: 'HW2Eq3UZVSBttoUwUi/MUtE7rr2UU7/UH500byp7OAc=' }, + }, + delta1I: {}, + delta2I: { lo: '1000000' }, + claimFee: { + amount: { hi: '0', lo: '0' }, + assetId: { + inner: uint8ArrayToBase64( + new Uint8Array([ + 41, 234, 156, 47, 51, 113, 246, 164, 135, 231, 233, 92, 36, 112, 65, 244, 163, 86, 249, + 131, 235, 6, 78, 93, 43, 59, 207, 50, 44, 169, 106, 16, + ]), + ), + }, + }, + claimAddress: { + inner: + '2VQ9nQKqga8RylgOq+wAY3/Hmxg96mGnI+Te/BRnXWpr5bSxpLShbpOmzO4pPULf+tGjaBum6InyEpipJ+8wk+HufrvSBa43H9o2ir5WPbk=', + }, + rseed: 'RPuhZ9q2F3XHbTcDPRTHnJjJaMxv8hes4TzJuMbsA/k=', + }, + position: '2383742304257', + nullifier: { inner: 'dE7LbhBDgDXHiRvreFyCllcKOOQeuIVsbn2aw8uKhww=' }, + outputData: { + delta1: {}, + delta2: { lo: '1000000' }, + lambda1: { lo: '2665239' }, + lambda2: {}, + unfilled1: {}, + unfilled2: {}, + height: '356591', + tradingPair: { + asset1: { inner: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=' }, + asset2: { inner: 'HW2Eq3UZVSBttoUwUi/MUtE7rr2UU7/UH500byp7OAc=' }, + }, + epochStartingHeight: '356050', + }, + source: { + transaction: { + id: '9e1OaxysQAzHUUKsroXMNRCzlPxd6hBWLrqURgNBrmE=', + }, + }, +}); + +const mockSwapAlternativeToken = SwapRecord.fromJson({ + swapCommitment: { inner: 'B6VBVkrk+s18q+Sjhl8uEGfS3i0dwF1FrkNm8Db6VAA=' }, + swap: { + tradingPair: { + asset1: { inner: 'HW2Eq3UZVSBttoUwUi/MUtE7rr2UU7/UH500byp7OAc=' }, + asset2: { inner: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=' }, + }, + delta1I: {}, + delta2I: { lo: '1000000' }, + claimFee: { + amount: { hi: '0', lo: '0' }, + assetId: { + inner: uint8ArrayToBase64( + new Uint8Array([ + 29, 109, 132, 171, 117, 25, 85, 32, 109, 182, 133, 48, 82, 47, 204, 82, 209, 59, 174, + 189, 148, 83, 191, 212, 31, 157, 52, 111, 42, 123, 56, 7, + ]), + ), + }, + }, + claimAddress: { + inner: + '2VQ9nQKqga8RylgOq+wAY3/Hmxg96mGnI+Te/BRnXWpr5bSxpLShbpOmzO4pPULf+tGjaBum6InyEpipJ+8wk+HufrvSBa43H9o2ir5WPbk=', + }, + rseed: 'RPuhZ9q2F3XHbTcDPRTHnJjJaMxv8hes4TzJuMbsA/k=', + }, + position: '2383742304258', + nullifier: { inner: 'eE7LbhBDgDXHiRvreFyCllcKOOQeuIVsbn2aw8uKhww=' }, + outputData: { + delta1: {}, + delta2: { lo: '1000000' }, + lambda1: { lo: '2665239' }, + lambda2: {}, + unfilled1: {}, + unfilled2: {}, + height: '356591', + tradingPair: { + asset1: { inner: 'HW2Eq3UZVSBttoUwUi/MUtE7rr2UU7/UH500byp7OAc=' }, + asset2: { inner: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=' }, + }, + epochStartingHeight: '356051', + }, + source: { + transaction: { + id: '8e1OaxysQAzHUUKsroXMNRCzlPxd6hBWLrqURgNBrmE=', + }, + }, }); diff --git a/packages/services/src/view-service/fees.ts b/packages/services/src/view-service/fees.ts index 98470a7c02..3326aabe49 100644 --- a/packages/services/src/view-service/fees.ts +++ b/packages/services/src/view-service/fees.ts @@ -39,5 +39,30 @@ export const extractAltFee = async ( return swaps?.swap?.claimFee?.assetId ?? indexedDb.stakingTokenAssetId; } + const auctionScheduleAsset = request.dutchAuctionScheduleActions + .map(a => a.description?.input) + .find(Boolean); + if (auctionScheduleAsset?.assetId) { + return auctionScheduleAsset.assetId; + } + + const auctionEndAsset = request.dutchAuctionEndActions.map(a => a.auctionId).find(Boolean); + if (auctionEndAsset) { + const endAuction = await indexedDb.getAuction(auctionEndAsset); + if (endAuction.auction?.input?.assetId) { + return endAuction.auction.input.assetId; + } + } + + const auctionWithdrawAsset = request.dutchAuctionWithdrawActions + .map(a => a.auctionId) + .find(Boolean); + if (auctionWithdrawAsset) { + const withdrawAuction = await indexedDb.getAuction(auctionWithdrawAsset); + if (withdrawAuction.auction?.input?.assetId) { + return withdrawAuction.auction.input.assetId; + } + } + throw new Error('Could not extract alternative fee assetId from TransactionPlannerRequest'); }; diff --git a/packages/services/src/view-service/transaction-planner/assert-transaction-source.ts b/packages/services/src/view-service/transaction-planner/assert-transaction-source.ts new file mode 100644 index 0000000000..b4e28a1f98 --- /dev/null +++ b/packages/services/src/view-service/transaction-planner/assert-transaction-source.ts @@ -0,0 +1,12 @@ +import { TransactionPlannerRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; +import { Code, ConnectError } from '@connectrpc/connect'; + +export const assertTransactionSource = (transactionPlannerRequest: TransactionPlannerRequest) => { + // Ensure that a source is provided in the transaction request. + if (!transactionPlannerRequest.source) { + throw new ConnectError( + 'Source is required in the TransactionPlannerRequest', + Code.InvalidArgument, + ); + } +}; diff --git a/packages/services/src/view-service/transaction-planner/index.test.ts b/packages/services/src/view-service/transaction-planner/index.test.ts index a6e8a8130c..5dbad0f01f 100644 --- a/packages/services/src/view-service/transaction-planner/index.test.ts +++ b/packages/services/src/view-service/transaction-planner/index.test.ts @@ -18,6 +18,7 @@ import { AssetId, Value, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; +import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; const mockPlanTransaction = vi.hoisted(() => vi.fn()); vi.mock('@penumbra-zone/wasm/planner', () => ({ @@ -60,7 +61,9 @@ describe('TransactionPlanner request handler', () => { .set(fvkCtx, () => Promise.resolve(testFullViewingKey)), }); - req = new TransactionPlannerRequest({}); + req = new TransactionPlannerRequest({ + source: new AddressIndex({ account: 0 }), + }); }); test('should throw if request is not valid', async () => { diff --git a/packages/services/src/view-service/transaction-planner/index.ts b/packages/services/src/view-service/transaction-planner/index.ts index 9c06b451e1..a575fa6763 100644 --- a/packages/services/src/view-service/transaction-planner/index.ts +++ b/packages/services/src/view-service/transaction-planner/index.ts @@ -6,6 +6,7 @@ import { assertSwapAssetsAreNotTheSame } from './assert-swap-assets-are-not-the- import { TransactionPlannerRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; import { fvkCtx } from '../../ctx/full-viewing-key.js'; import { extractAltFee } from '../fees.js'; +import { assertTransactionSource } from './assert-transaction-source.js'; export const transactionPlanner: Impl['transactionPlanner'] = async (req, ctx) => { assertValidRequest(req); @@ -66,4 +67,5 @@ export const transactionPlanner: Impl['transactionPlanner'] = async (req, ctx) = */ const assertValidRequest = (req: TransactionPlannerRequest): void => { assertSwapAssetsAreNotTheSame(req); + assertTransactionSource(req); }; diff --git a/packages/services/src/view-service/util/custody-authorize.ts b/packages/services/src/view-service/util/custody-authorize.ts index ed0b81c963..5c8d0bb185 100644 --- a/packages/services/src/view-service/util/custody-authorize.ts +++ b/packages/services/src/view-service/util/custody-authorize.ts @@ -13,7 +13,8 @@ export const custodyAuthorize = async ( if (!custodyClient) { throw new ConnectError('Cannot access custody service', Code.FailedPrecondition); } - const { data } = await custodyClient.authorize({ plan }); + // authorization awaits user interaction, so timeout is disabled + const { data } = await custodyClient.authorize({ plan }, { timeoutMs: 0 }); if (!data) { throw new ConnectError('No authorization data', Code.PermissionDenied); } diff --git a/packages/storage/CHANGELOG.md b/packages/storage/CHANGELOG.md index a727ed9959..df8beed250 100644 --- a/packages/storage/CHANGELOG.md +++ b/packages/storage/CHANGELOG.md @@ -1,5 +1,56 @@ # @penumbra-zone/storage +## 18.0.0 + +### Patch Changes + +- Updated dependencies [3477bef] +- Updated dependencies [d6ce325] + - @penumbra-zone/types@17.0.1 + - @penumbra-zone/wasm@20.1.0 + +## 17.0.0 + +### Patch Changes + +- Updated dependencies [4e30796] +- Updated dependencies [86c1bbe] + - @penumbra-zone/wasm@20.0.0 + - @penumbra-zone/getters@12.1.0 + - @penumbra-zone/types@17.0.0 + +## 16.0.0 + +### Patch Changes + +- Updated dependencies [0233722] + - @penumbra-zone/types@16.1.0 + - @penumbra-zone/wasm@19.0.0 + +## 15.0.0 + +### Patch Changes + +- @penumbra-zone/getters@12.0.0 +- @penumbra-zone/wasm@18.0.0 +- @penumbra-zone/types@16.0.0 + +## 14.0.2 + +### Patch Changes + +- 3aaead1: Move the "default" option in package.json exports field to the last +- Updated dependencies [3aaead1] + - @penumbra-zone/types@15.1.1 + - @penumbra-zone/wasm@17.0.2 + +## 14.0.1 + +### Patch Changes + +- Updated dependencies [1a57749] + - @penumbra-zone/wasm@17.0.1 + ## 14.0.0 ### Major Changes diff --git a/packages/storage/package.json b/packages/storage/package.json index 987d7cffd8..2e2c4be925 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/storage", - "version": "14.0.0", + "version": "18.0.0", "license": "(MIT OR Apache-2.0)", "type": "module", "engine": { @@ -25,8 +25,8 @@ "publishConfig": { "exports": { "./indexed-db": { - "default": "./dist/indexed-db/index.js", - "types": "./dist/indexed-db/index.d.ts" + "types": "./dist/indexed-db/index.d.ts", + "default": "./dist/indexed-db/index.js" } } }, diff --git a/packages/transport-chrome/CHANGELOG.md b/packages/transport-chrome/CHANGELOG.md index 99add2d58e..72ed55f4b9 100644 --- a/packages/transport-chrome/CHANGELOG.md +++ b/packages/transport-chrome/CHANGELOG.md @@ -1,5 +1,33 @@ # @penumbra-zone/transport-chrome +## 7.0.0 + +### Minor Changes + +- a788eff: Update default timeouts to better support build times + +### Patch Changes + +- Updated dependencies [a788eff] + - @penumbra-zone/transport-dom@7.4.0 + +## 6.0.0 + +### Minor Changes + +- af04e2a: respect transport abort controls + +### Patch Changes + +- Updated dependencies [af04e2a] + - @penumbra-zone/transport-dom@7.3.0 + +## 5.0.3 + +### Patch Changes + +- 3aaead1: Move the "default" option in package.json exports field to the last + ## 5.0.2 ### Patch Changes diff --git a/packages/transport-chrome/package.json b/packages/transport-chrome/package.json index bab6fad00d..fae6926b1f 100644 --- a/packages/transport-chrome/package.json +++ b/packages/transport-chrome/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/transport-chrome", - "version": "5.0.2", + "version": "7.0.0", "license": "(MIT OR Apache-2.0)", "description": "Tools for adapting `@penumbra-zone/transport` to Chrome's extension runtime messaging API", "type": "module", @@ -26,8 +26,8 @@ "publishConfig": { "exports": { "./*": { - "default": "./dist/*.js", - "types": "./dist/*.d.ts" + "types": "./dist/*.d.ts", + "default": "./dist/*.js" } } }, diff --git a/packages/transport-chrome/src/session-client.ts b/packages/transport-chrome/src/session-client.ts index 8dd90760d4..01b8fbd513 100644 --- a/packages/transport-chrome/src/session-client.ts +++ b/packages/transport-chrome/src/session-client.ts @@ -15,6 +15,7 @@ */ import { + isTransportAbort, isTransportError, isTransportMessage, isTransportStream, @@ -44,7 +45,7 @@ const localErrorJson = (err: unknown, relevantMessage?: unknown) => typeof err === 'function' ? err.name : typeof err === 'object' - ? (Object.getPrototypeOf(err) as unknown)?.constructor?.name ?? String(err) + ? ((Object.getPrototypeOf(err) as unknown)?.constructor?.name ?? String(err)) : typeof err, ), value: err, @@ -102,10 +103,10 @@ export class CRSessionClient { try { if (ev.data === false) { this.disconnectService(); - } else if (isTransportMessage(ev.data)) { + } else if (isTransportAbort(ev.data) || isTransportMessage(ev.data)) { this.servicePort.postMessage(ev.data); } else if (isTransportStream(ev.data)) { - this.servicePort.postMessage(this.requestChannelStream(ev.data)); + this.servicePort.postMessage(this.makeChannelStreamRequest(ev.data)); } else { console.warn('Unknown item from client', ev.data); } @@ -135,7 +136,7 @@ export class CRSessionClient { return [{ requestId, stream }, [stream]] satisfies [TransportStream, [Transferable]]; }; - private requestChannelStream = ({ requestId, stream }: TransportStream) => { + private makeChannelStreamRequest = ({ requestId, stream }: TransportStream) => { const channel = nameConnection(this.prefix, ChannelLabel.STREAM); const sinkListener = (p: chrome.runtime.Port) => { if (p.name !== channel) { diff --git a/packages/transport-chrome/src/session-manager.ts b/packages/transport-chrome/src/session-manager.ts index cfb21f07a5..64aa4339ec 100644 --- a/packages/transport-chrome/src/session-manager.ts +++ b/packages/transport-chrome/src/session-manager.ts @@ -5,15 +5,15 @@ import { isTransportInitChannel, TransportInitChannel } from './message.js'; import { PortStreamSink, PortStreamSource } from './stream.js'; import { ChannelHandlerFn } from '@penumbra-zone/transport-dom/adapter'; import { + isTransportAbort, isTransportMessage, TransportEvent, TransportMessage, TransportStream, } from '@penumbra-zone/transport-dom/messages'; -interface CRSession { +interface CRSession extends AbortController { clientId: string; - acont: AbortController; port: chrome.runtime.Port; origin: string; } @@ -43,6 +43,7 @@ interface CRSession { export class CRSessionManager { private static singleton?: CRSessionManager; private sessions = new Map(); + private requests = new Map(); private constructor( private prefix: string, @@ -61,6 +62,19 @@ export class CRSessionManager { */ public static init = (prefix: string, handler: ChannelHandlerFn) => { CRSessionManager.singleton ??= new CRSessionManager(prefix, handler); + return CRSessionManager.singleton.sessions; + }; + + public static killOrigin = (targetOrigin: string) => { + if (CRSessionManager.singleton) { + CRSessionManager.singleton.sessions.forEach(session => { + if (session.origin === targetOrigin) { + session.abort(targetOrigin); + } + }); + } else { + throw new Error('No session manager'); + } }; /** @@ -100,31 +114,31 @@ export class CRSessionManager { if (this.sessions.has(clientId)) { throw new Error(`Session collision: ${clientId}`); } - const session = { + + const session: CRSession = Object.assign(new AbortController(), { clientId, - acont: new AbortController(), origin: sender.origin, port: port, - }; + }); this.sessions.set(clientId, session); - session.acont.signal.addEventListener('abort', () => port.disconnect()); - port.onDisconnect.addListener(() => session.acont.abort('Disconnect')); + session.signal.addEventListener('abort', () => port.disconnect()); + port.onDisconnect.addListener(() => session.abort('Disconnect')); port.onMessage.addListener((i, p) => { - void (async () => { - try { - if (isTransportMessage(i)) { - p.postMessage(await this.clientMessageHandler(session.acont.signal, i)); - } else if (isTransportInitChannel(i)) { - console.warn('Client streaming unimplemented', this.acceptChannelStreamRequest(i)); - } else { - console.warn('Unknown item in transport', i); - } - } catch (e) { - session.acont.abort(e); + try { + if (isTransportAbort(i)) { + this.requests.get(i.requestId)?.abort(); + } else if (isTransportMessage(i)) { + void this.clientMessageHandler(session, i).then(res => p.postMessage(res)); + } else if (isTransportInitChannel(i)) { + console.warn('Client streaming unimplemented', this.acceptChannelStreamRequest(i)); + } else { + console.warn('Unknown item in transport', i); } - })(); + } catch (e) { + session.abort(e); + } }); }; @@ -137,13 +151,19 @@ export class CRSessionManager { * representing an error. */ private clientMessageHandler( - signal: AbortSignal, + session: CRSession, { requestId, message }: TransportMessage, ): Promise { - return this.handler(message) + if (this.requests.has(requestId)) { + throw new Error(`Request collision: ${requestId}`); + } + const requestController = new AbortController(); + session.signal.addEventListener('abort', () => requestController.abort()); + this.requests.set(requestId, requestController); + return this.handler(message, AbortSignal.any([session.signal, requestController.signal])) .then(response => response instanceof ReadableStream - ? this.responseChannelStream(signal, { + ? this.responseChannelStream(requestController.signal, { requestId, stream: response as unknown, } as TransportStream) @@ -152,7 +172,8 @@ export class CRSessionManager { .catch((error: unknown) => ({ requestId, error: errorToJson(ConnectError.from(error), undefined), - })); + })) + .finally(() => this.requests.delete(requestId)); } /** diff --git a/packages/transport-dom/CHANGELOG.md b/packages/transport-dom/CHANGELOG.md index 3d5afbbaae..6c6851ba56 100644 --- a/packages/transport-dom/CHANGELOG.md +++ b/packages/transport-dom/CHANGELOG.md @@ -1,5 +1,17 @@ # @penumbra-zone/transport-dom +## 7.4.0 + +### Minor Changes + +- a788eff: Update default timeouts to better support build times + +## 7.3.0 + +### Minor Changes + +- af04e2a: respect transport abort controls + ## 7.2.2 ### Patch Changes diff --git a/packages/transport-dom/package.json b/packages/transport-dom/package.json index b2b9c453d2..09665f2ae5 100644 --- a/packages/transport-dom/package.json +++ b/packages/transport-dom/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/transport-dom", - "version": "7.2.2", + "version": "7.4.0", "license": "(MIT OR Apache-2.0)", "type": "module", "engine": { diff --git a/packages/transport-dom/src/ReadableStream.from.ts b/packages/transport-dom/src/ReadableStream.from.ts index bb31b10eb0..5b7308b6ea 100644 --- a/packages/transport-dom/src/ReadableStream.from.ts +++ b/packages/transport-dom/src/ReadableStream.from.ts @@ -14,8 +14,10 @@ const ReadableStreamWithFrom: typeof ReadableStream & { from: ReadableStreamFrom 'from' in ReadableStream ? (ReadableStream as typeof ReadableStream & { from: ReadableStreamFrom }) : Object.assign(ReadableStream, { - from(iterable: Iterable | AsyncIterable): ReadableStream { - if (Symbol.iterator in iterable) { + from(iterable: ReadableStream | Iterable | AsyncIterable): ReadableStream { + if (iterable instanceof ReadableStream) { + return iterable; + } else if (Symbol.iterator in iterable) { const it = iterable[Symbol.iterator](); return new ReadableStream({ pull(cont) { diff --git a/packages/transport-dom/src/adapter.ts b/packages/transport-dom/src/adapter.ts index 477e84157d..f5c78848e7 100644 --- a/packages/transport-dom/src/adapter.ts +++ b/packages/transport-dom/src/adapter.ts @@ -10,9 +10,11 @@ import { } from '@connectrpc/connect'; import { Any, + AnyMessage, JsonReadOptions, JsonValue, JsonWriteOptions, + MessageType, MethodInfo, MethodKind, ServiceType, @@ -33,10 +35,14 @@ import ReadableStream from './ReadableStream.from.js'; // hopefully also simplifies transport call soon type MethodType = MethodInfo & { service: { typeName: string } }; -type ChannelRequest = JsonValue; +type ChannelRequest = JsonValue | ReadableStream; type ChannelResponse = JsonValue | ReadableStream; -export type ChannelHandlerFn = (r: ChannelRequest) => Promise; +export type ChannelHandlerFn = ( + request: ChannelRequest, + signal?: AbortSignal, + timeoutMs?: number, +) => Promise; export type ChannelContextFn = ( h: UniversalServerRequest, ) => Promise; @@ -145,7 +151,7 @@ export const connectChannelAdapter = (opt: ChannelAdapterOptions): ChannelHandle ); // TODO: alternatively, we could have the channelClient provide a requestPath - const I_MethodType = new Map( + const methodTypesByName = new Map( router.handlers.map(({ method, service }) => [ method.I.typeName, { ...method, service: { typeName: service.typeName } }, @@ -164,11 +170,28 @@ export const connectChannelAdapter = (opt: ChannelAdapterOptions): ChannelHandle httpClient: injectRequestContext, }); - return async function channelHandler(message: ChannelRequest) { - const request = Any.fromJson(message, jsonOptions).unpack(jsonOptions.typeRegistry)!; - const requestType = request.getType(); + const deserializeRequest = ( + message: ChannelRequest, + ): { requestType: MessageType; request: AnyMessage | ReadableStream } => { + if (message instanceof ReadableStream) { + throw new ConnectError('Streaming request unimplemented', ConnectErrorCode.Unimplemented); + } else { + const request = Any.fromJson(message, jsonOptions).unpack(jsonOptions.typeRegistry); + if (!request) { + throw new ConnectError('Invalid request', ConnectErrorCode.InvalidArgument); + } + return { requestType: request.getType(), request }; + } + }; - const methodType = I_MethodType.get(requestType.typeName); + return async function channelHandler( + message: ChannelRequest, + signal?: AbortSignal, + timeoutMs?: number, + ) { + const { request, requestType } = deserializeRequest(message); + + const methodType = methodTypesByName.get(requestType.typeName); if (!methodType) { throw new ConnectError(`Method ${requestType.typeName} not found`, ConnectErrorCode.NotFound); } @@ -180,8 +203,8 @@ export const connectChannelAdapter = (opt: ChannelAdapterOptions): ChannelHandle // only uses service.typeName, so this cast is ok methodType.service as ServiceType, methodType satisfies MethodInfo, - undefined, // TODO abort - undefined, // TODO timeout + signal, + timeoutMs, undefined, // TODO headers request, ); @@ -191,21 +214,35 @@ export const connectChannelAdapter = (opt: ChannelAdapterOptions): ChannelHandle // only uses service.typeName, so this cast is ok methodType.service as ServiceType, methodType satisfies MethodInfo, - undefined, // TODO abort - undefined, // TODO timeout + signal, + timeoutMs, undefined, // TODO headers createAsyncIterable([request]), ); break; + case MethodKind.BiDiStreaming: + case MethodKind.ClientStreaming: + response = await transport.stream( + // only uses service.typeName, so this cast is ok + methodType.service as ServiceType, + methodType satisfies MethodInfo, + signal, + timeoutMs, + undefined, // TODO headers + request as never, + ); + break; default: throw new ConnectError( - `Unimplemented method kind ${methodType.kind}`, + `Unexpected method kind for ${requestType.typeName}`, ConnectErrorCode.Unimplemented, ); } if (response.stream) { - return ReadableStream.from(response.message).pipeThrough(new MessageToJson(jsonOptions)); + return ReadableStream.from(response.message).pipeThrough(new MessageToJson(jsonOptions), { + signal, + }); } else { return Any.pack(response.message).toJson(jsonOptions); } diff --git a/packages/transport-dom/src/create.test.ts b/packages/transport-dom/src/create.test.ts index fc3dcb7a2d..d0af3262f6 100644 --- a/packages/transport-dom/src/create.test.ts +++ b/packages/transport-dom/src/create.test.ts @@ -1,46 +1,48 @@ -import { describe, expect, it } from 'vitest'; - -import { createChannelTransport } from './create.js'; -import { ElizaService } from '@buf/connectrpc_eliza.connectrpc_es/connectrpc/eliza/v1/eliza_connect.js'; import { + ConverseRequest, + ConverseResponse, IntroduceRequest, + IntroduceResponse, SayRequest, SayResponse, } from '@buf/connectrpc_eliza.bufbuild_es/connectrpc/eliza/v1/eliza_pb.js'; -import { createRegistry } from '@bufbuild/protobuf'; -import { TransportMessage } from './messages.js'; +import { ElizaService } from '@buf/connectrpc_eliza.connectrpc_es/connectrpc/eliza/v1/eliza_connect.js'; +import { Any, createRegistry, type PlainMessage } from '@bufbuild/protobuf'; +import type { Transport } from '@connectrpc/connect'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { type ChannelTransportOptions, createChannelTransport } from './create.js'; +import { + isTransportAbort, + isTransportMessage, + type TransportEvent, + type TransportMessage, + type TransportStream, +} from './messages.js'; import ReadableStream from './ReadableStream.from.js'; +const PRINT_TEST_TIMES = false; + const typeRegistry = createRegistry(ElizaService); -describe('createChannelClient', () => { - it('should return a transport', () => { - const { port2 } = new MessageChannel(); +describe('message transport', () => { + let port1: MessagePort; + let port2: MessagePort; + let transportOptions: ChannelTransportOptions; + let transport: Transport; - const transportOptions = { + beforeEach(() => { + ({ port1, port2 } = new MessageChannel()); + transportOptions = { getPort: () => Promise.resolve(port2), - defaultTimeoutMs: 5000, jsonOptions: { typeRegistry }, }; - - const transport = createChannelTransport(transportOptions); - - expect(transport).toBeDefined(); + transport = createChannelTransport(transportOptions); }); it('should send and receive unary messages', async () => { - const { port1, port2 } = new MessageChannel(); - - const transportOptions = { - getPort: () => Promise.resolve(port2), - defaultTimeoutMs: 5000, - jsonOptions: { typeRegistry }, - }; - - const transport = createChannelTransport(transportOptions); - - const input = new SayRequest({ sentence: 'hello' }); + const input: PlainMessage = { sentence: 'hello' }; + const response: PlainMessage = { sentence: 'world' }; const unaryRequest = transport.unary( ElizaService, @@ -48,159 +50,595 @@ describe('createChannelClient', () => { undefined, undefined, undefined, - input, + new SayRequest(input), ); - const otherEnd = new Promise((resolve, reject) => { + const otherEnd = new Promise((resolve, reject) => { port1.onmessage = (event: MessageEvent) => { try { const { requestId, message } = event.data as TransportMessage; expect(requestId).toBeTypeOf('string'); expect(message).toMatchObject({ - sentence: 'hello', + ...input, '@type': 'type.googleapis.com/connectrpc.eliza.v1.SayRequest', }); port1.postMessage({ requestId, message: { - sentence: 'world', + ...response, '@type': 'type.googleapis.com/connectrpc.eliza.v1.SayResponse', }, }); - resolve(true); + resolve(); } catch (e) { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(e); } }; }); - await expect(otherEnd).resolves.toBe(true); - - await expect(unaryRequest).resolves.toBeTruthy(); - const { message: unaryResponse } = await unaryRequest; - expect(new SayResponse({ sentence: 'world' }).equals(unaryResponse)).toBeTruthy(); + await expect(otherEnd).resolves.not.toThrow(); + await expect(unaryRequest.then(({ message }) => message)).resolves.toMatchObject(response); }); it('should send and receive streaming requests', async () => { + const input: PlainMessage = { name: 'Prax' }; + const responses: PlainMessage[] = [ + { sentence: 'Yo' }, + { sentence: 'This' }, + { sentence: 'Streams' }, + ]; + + const streamRequest = transport.stream( + ElizaService, + ElizaService.methods.introduce, + undefined, + undefined, + undefined, + ReadableStream.from([new IntroduceRequest(input)]), + ); + + const otherEnd = new Promise((resolve, reject) => { + port1.onmessage = (event: MessageEvent) => { + try { + const { requestId, message } = event.data as TransportMessage; + + expect(requestId).toBeTypeOf('string'); + expect(message).toMatchObject({ + name: 'Prax', + '@type': 'type.googleapis.com/connectrpc.eliza.v1.IntroduceRequest', + }); + + const stream = ReadableStream.from( + responses.map(r => Any.pack(new IntroduceResponse(r)).toJson({ typeRegistry })), + ); + + port1.postMessage({ requestId, stream }, [stream]); + + resolve(); + } catch (e) { + reject(e); + } + }; + }); + + await expect(otherEnd).resolves.not.toThrow(); + await expect(streamRequest).resolves.toMatchObject({ stream: true }); + await expect( + streamRequest.then(({ message }) => Array.fromAsync(message)), + ).resolves.toMatchObject(responses); + }); + + it('should require streaming requests to contain at least one message', async () => { + const streamRequest = transport.stream( + ElizaService, + ElizaService.methods.introduce, + undefined, + undefined, + undefined, + (async function* () {})(), + ); + + await expect(streamRequest).rejects.toThrow(); + }); + + it('should require server-streaming requests to contain only one message', async () => { + const inputs: PlainMessage[] = [{ name: 'Ananke' }, { name: 'Harpalyke' }]; + + const streamRequest = transport.stream( + ElizaService, + ElizaService.methods.introduce, + undefined, + undefined, + undefined, + ReadableStream.from(inputs.map(i => new IntroduceRequest(i))), + ); + + await expect(streamRequest).rejects.toThrow(); + }); + + it('should handle bidirectional streaming requests', async () => { const { port1, port2 } = new MessageChannel(); const transportOptions = { getPort: () => Promise.resolve(port2), - defaultTimeoutMs: 5000, jsonOptions: { typeRegistry }, }; const transport = createChannelTransport(transportOptions); - const input = new IntroduceRequest({ name: 'Prax' }); + const inputs: PlainMessage[] = [ + { sentence: 'homomorphic?' }, + { sentence: 'gemini double text' }, + ]; + const responses: PlainMessage[] = [ + { sentence: 'no' }, + { sentence: 'im bi' }, + { sentence: 'directional' }, + ]; const streamRequest = transport.stream( ElizaService, - ElizaService.methods.introduce, + ElizaService.methods.converse, + undefined, + undefined, + undefined, + ReadableStream.from(inputs), + ); + + const otherEnd = new Promise((resolve, reject) => { + port1.onmessage = async (event: MessageEvent) => { + try { + const { requestId, stream: inputStream } = event.data as TransportStream; + + expect(requestId).toBeTypeOf('string'); + await expect(Array.fromAsync(inputStream)).resolves.toMatchObject(inputs); + + const responseStream = ReadableStream.from( + responses.map(r => Any.pack(new ConverseResponse(r)).toJson({ typeRegistry })), + ); + + port1.postMessage({ requestId, stream: responseStream }, [responseStream]); + + resolve(); + } catch (e) { + reject(e); + } + }; + }); + + await expect(otherEnd).resolves.not.toThrow(); + await expect(streamRequest).resolves.toMatchObject({ stream: true }); + await expect( + streamRequest.then(({ message }) => Array.fromAsync(message)), + ).resolves.toMatchObject(responses); + }); +}); + +describe('transport timeouts', () => { + let port1: MessagePort; + let port2: MessagePort; + let transportOptions: ChannelTransportOptions; + const defaultTimeoutMs = 200; + let transport: Transport; + + beforeEach(() => { + performance.clearMarks(); + ({ port1, port2 } = new MessageChannel()); + transportOptions = { + getPort: () => Promise.resolve(port2), + jsonOptions: { typeRegistry }, + defaultTimeoutMs, + }; + }); + + it('should time out unary requests', async () => { + transport = createChannelTransport(transportOptions); + + const input = { sentence: 'hello' }; + const response = { sentence: '.........hello' }; + + const unaryRequest = transport.unary( + ElizaService, + ElizaService.methods.say, undefined, undefined, undefined, - ReadableStream.from([input]), + new SayRequest(input), ); - const otherEnd = new Promise((resolve, reject) => { + const otherEnd = new Promise((resolve, reject) => { port1.onmessage = (event: MessageEvent) => { try { const { requestId, message } = event.data as TransportMessage; expect(requestId).toBeTypeOf('string'); expect(message).toMatchObject({ - name: 'Prax', - '@type': 'type.googleapis.com/connectrpc.eliza.v1.IntroduceRequest', + ...input, + '@type': 'type.googleapis.com/connectrpc.eliza.v1.SayRequest', }); - const stream = ReadableStream.from([ - { - sentence: 'Yo', - '@type': 'type.googleapis.com/connectrpc.eliza.v1.IntroduceResponse', - }, - { - sentence: 'This', - '@type': 'type.googleapis.com/connectrpc.eliza.v1.IntroduceResponse', - }, - { - sentence: 'Streams', - '@type': 'type.googleapis.com/connectrpc.eliza.v1.IntroduceResponse', - }, - ]); + setTimeout(() => { + port1.postMessage({ + requestId, + message: { + ...response, + '@type': 'type.googleapis.com/connectrpc.eliza.v1.SayResponse', + }, + }); + resolve(); + }, defaultTimeoutMs * 2); + } catch (e) { + reject(e); + } + }; + }); + + await expect(unaryRequest).rejects.toThrow('[deadline_exceeded]'); + await expect(otherEnd).resolves.not.toThrow(); + }); + + it('should time out unary requests at a specified custom time', async () => { + transport = createChannelTransport(transportOptions); + const customTimeoutMs = 100; - port1.postMessage( - { + const input: PlainMessage = { sentence: 'hello' }; + const response: PlainMessage = { sentence: '.........hello' }; + + const unaryRequest = transport.unary( + ElizaService, + ElizaService.methods.say, + undefined, + customTimeoutMs, + undefined, + new SayRequest(input), + ); + + const otherEnd = new Promise((resolve, reject) => { + port1.onmessage = (event: MessageEvent) => { + try { + const { requestId, message } = event.data as TransportMessage; + + expect(requestId).toBeTypeOf('string'); + expect(message).toMatchObject({ + ...input, + '@type': 'type.googleapis.com/connectrpc.eliza.v1.SayRequest', + }); + + setTimeout(() => { + port1.postMessage({ requestId, - stream, - }, - [stream], + message: { + ...response, + '@type': 'type.googleapis.com/connectrpc.eliza.v1.SayResponse', + }, + }); + resolve(); + }, defaultTimeoutMs / 2); + } catch (e) { + reject(e); + } + }; + }); + + await expect(unaryRequest).rejects.toThrow('[deadline_exceeded]'); + await expect(otherEnd).resolves.not.toThrow(); + }); + + it('should time out streaming requests', async () => { + transport = createChannelTransport(transportOptions); + + const input: PlainMessage = { name: 'hello' }; + const responses: PlainMessage[] = [ + { sentence: 'this wont send before timeout' }, + ]; + + const streamRequest = transport.stream( + ElizaService, + ElizaService.methods.introduce, + undefined, + undefined, + undefined, + ReadableStream.from([new IntroduceRequest(input)]), + ); + + const otherEnd = new Promise((resolve, reject) => { + port1.onmessage = (event: MessageEvent) => { + try { + const { requestId, message } = event.data as TransportMessage; + + expect(requestId).toBeTypeOf('string'); + expect(message).toMatchObject({ + ...input, + '@type': 'type.googleapis.com/connectrpc.eliza.v1.IntroduceRequest', + }); + + const stream = ReadableStream.from( + responses.map(r => Any.pack(new IntroduceResponse(r)).toJson({ typeRegistry })), ); - resolve(true); + setTimeout(() => { + port1.postMessage({ requestId, stream }, [stream]); + resolve(); + }, defaultTimeoutMs * 2); } catch (e) { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(e); } }; }); - await expect(otherEnd).resolves.toBe(true); + await expect(streamRequest.then(({ message }) => message)).rejects.toThrow( + '[deadline_exceeded]', + ); + await expect(otherEnd).resolves.not.toThrow(); + }); - await expect(streamRequest).resolves.toMatchObject({ stream: true }); - const { message: streamResponse } = await streamRequest; + it('should not time out streaming responses that are already streaming', async () => { + transport = createChannelTransport(transportOptions); + + const input: PlainMessage = { name: 'hello' }; + const responses: PlainMessage[] = [ + { sentence: 'thiswillarrivebeforetimeout!!!' }, + { sentence: 'and so will this,' }, + { sentence: 'but this one is right on the edge' }, + { sentence: '.....and this will arrive waaaaaay after timeout' }, + ]; + + const streamRequest = transport.stream( + ElizaService, + ElizaService.methods.introduce, + undefined, + undefined, + undefined, + ReadableStream.from([new IntroduceRequest(input)]), + ); + + const streamDone = Promise.withResolvers(); + + const otherEnd = new Promise((resolve, reject) => { + port1.onmessage = (event: MessageEvent) => { + try { + const { requestId, message } = event.data as TransportMessage; - const res = Array.fromAsync(streamResponse); - await expect(res).resolves.toBeTruthy(); + expect(requestId).toBeTypeOf('string'); + expect(message).toMatchObject({ + ...input, + '@type': 'type.googleapis.com/connectrpc.eliza.v1.IntroduceRequest', + }); + + const stream = ReadableStream.from( + (async function* ( + streamFinished: PromiseWithResolvers['resolve'], + streamFailed: PromiseWithResolvers['reject'], + ) { + performance.mark('stream'); + try { + for (const [i, r] of responses.entries()) { + await new Promise(resolve => setTimeout(resolve, defaultTimeoutMs / 3)); + performance.measure(`chunk ${i}`, 'stream'); + yield Any.pack(new IntroduceResponse(r)).toJson({ typeRegistry }); + } + streamFinished(); + } catch (e) { + streamFailed(e); + } + performance.measure('end', 'stream'); + })(streamDone.resolve, streamDone.reject), + ); + + port1.postMessage({ requestId, stream }, [stream]); + resolve(); + } catch (e) { + reject(e); + } + }; + }); + + await expect(otherEnd).resolves.not.toThrow(); + await expect( + streamRequest.then(({ message }) => Array.fromAsync(message)), + ).resolves.not.toThrow(); + await expect(streamDone.promise).resolves.not.toThrow(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (PRINT_TEST_TIMES) { + console.log('measure', [ + { defaultTimeoutMs }, + ...performance + .getEntriesByType('measure') + .map(({ name, duration }) => ({ name, duration })), + ]); + } }); +}); - it('should require streaming requests to contain at least one message', async () => { - const { port2 } = new MessageChannel(); - const transportOptions = { +describe('transport aborts', () => { + let port1: MessagePort; + let port2: MessagePort; + let transportOptions: ChannelTransportOptions; + let transport: Transport; + let ac: AbortController; + const defaultTimeoutMs = 200; + + beforeEach(() => { + ({ port1, port2 } = new MessageChannel()); + transportOptions = { getPort: () => Promise.resolve(port2), - defaultTimeoutMs: 5000, jsonOptions: { typeRegistry }, + defaultTimeoutMs, }; + transport = createChannelTransport(transportOptions); + ac = new AbortController(); + }); - const transport = createChannelTransport(transportOptions); + it('should cancel unary requests if missing reason', async () => { + const input: PlainMessage = { sentence: 'hello' }; + + ac.abort(); + + const unaryRequest = transport.unary( + ElizaService, + ElizaService.methods.say, + ac.signal, + undefined, + undefined, + new SayRequest(input), + ); + + const gotRequest = Promise.withResolvers(); + const gotAbort = Promise.withResolvers(); + + port1.onmessage = (event: MessageEvent) => { + const tev = event.data as TransportEvent; + expect(tev.requestId).toBeTypeOf('string'); + + if (isTransportMessage(tev)) { + expect(tev.message).toMatchObject(input); + gotRequest.resolve(); + } else if (isTransportAbort(tev)) { + expect(tev.abort).toBe(true); + gotAbort.resolve(); + } else { + throw new Error('unexpected event'); + } + }; + + await expect(unaryRequest).rejects.toThrow('[canceled]'); + await expect(Promise.all([gotRequest, gotAbort])).resolves.not.toThrow(); + }); + + it('should abort unary requests with propagating reason', async () => { + const input: PlainMessage = { sentence: 'hello' }; + + ac.abort('some reason'); + + const unaryRequest = transport.unary( + ElizaService, + ElizaService.methods.say, + ac.signal, + undefined, + undefined, + new SayRequest(input), + ); + + const gotRequest = Promise.withResolvers(); + const gotAbort = Promise.withResolvers(); + + port1.onmessage = (event: MessageEvent) => { + const tev = event.data as TransportEvent; + expect(tev.requestId).toBeTypeOf('string'); + + if (isTransportMessage(tev)) { + expect(tev.message).toMatchObject(input); + gotRequest.resolve(); + } else if (isTransportAbort(tev)) { + expect(tev.abort).toBe(true); + gotAbort.resolve(); + } else { + throw new Error('unexpected event'); + } + }; + + await expect(unaryRequest).rejects.toThrow('some reason'); + await expect(unaryRequest).rejects.toThrow('[aborted]'); + await expect(Promise.all([gotRequest, gotAbort])).resolves.not.toThrow(); + }); + + it('can cancel streaming requests before they begin', async () => { + const input: PlainMessage = { + name: 'and now for something completely different', + }; + + ac.abort('another reason'); const streamRequest = transport.stream( ElizaService, ElizaService.methods.introduce, + ac.signal, undefined, undefined, - undefined, - // eslint-disable-next-line @typescript-eslint/no-empty-function - (async function* () {})(), + ReadableStream.from([new IntroduceRequest(input)]), ); - await expect(streamRequest).rejects.toThrow(); + const gotRequest = Promise.withResolvers(); + const gotAbort = Promise.withResolvers(); + + port1.onmessage = (event: MessageEvent) => { + const tev = event.data as TransportEvent; + expect(tev.requestId).toBeTypeOf('string'); + + if (isTransportMessage(tev)) { + expect(tev.message).toMatchObject(input); + gotRequest.resolve(); + } else if (isTransportAbort(tev)) { + expect(tev.abort).toBe(true); + gotAbort.resolve(); + } else { + throw new Error('unexpected event'); + } + }; + + await expect(streamRequest).rejects.toThrow('another reason'); + await expect(streamRequest).rejects.toThrow('[aborted]'); + await expect(Promise.all([gotRequest, gotAbort])).resolves.not.toThrow(); }); - it('should require server-streaming requests to contain only one message', async () => { - const { port2 } = new MessageChannel(); - const transportOptions = { - getPort: () => Promise.resolve(port2), - defaultTimeoutMs: 5000, - jsonOptions: { typeRegistry }, + it('can cancel streaming requests already in progress', async () => { + const input: PlainMessage = { + name: 'and now for something remarkably similar', }; - const transport = createChannelTransport(transportOptions); + const responses: PlainMessage[] = [ + { sentence: 'something remarkably similar' }, + { sentence: 'something remarkably similar' }, + { sentence: 'something remarkably similar' }, + { sentence: 'something remarkably similar' }, + { sentence: 'something remarkably similar' }, + ]; - const input = new IntroduceRequest({ name: 'Prax' }); + setTimeout(() => ac.abort('a bad reason'), defaultTimeoutMs / 2); const streamRequest = transport.stream( ElizaService, ElizaService.methods.introduce, + ac.signal, undefined, undefined, - undefined, - ReadableStream.from([input, input]), + ReadableStream.from([new IntroduceRequest(input)]), ); - await expect(streamRequest).rejects.toThrow(); + const gotRequest = Promise.withResolvers(); + const gotAbort = Promise.withResolvers(); + + port1.onmessage = (event: MessageEvent) => { + const tev = event.data as TransportEvent; + const { requestId } = tev; + expect(requestId).toBeTypeOf('string'); + + if (isTransportMessage(tev)) { + expect(tev.message).toMatchObject(input); + gotRequest.resolve(); + const stream = ReadableStream.from( + (async function* () { + for (const r of responses) { + await new Promise(resolve => setTimeout(resolve, defaultTimeoutMs / 3)); + yield Any.pack(new IntroduceResponse(r)).toJson({ typeRegistry }); + } + })(), + ); + port1.postMessage({ requestId, stream }, [stream]); + } else if (isTransportAbort(tev)) { + expect(tev.abort).toBe(true); + gotAbort.resolve(); + } else { + throw new Error('unexpected event'); + } + }; + + await expect(streamRequest).resolves.not.toThrow(); + await expect(streamRequest.then(({ message }) => Array.fromAsync(message))).rejects.toThrow( + 'a bad reason', + ); + await expect(Promise.all([gotRequest, gotAbort])).resolves.not.toThrow(); }); }); diff --git a/packages/transport-dom/src/create.ts b/packages/transport-dom/src/create.ts index 2e854ef4ef..5f12ed46c6 100644 --- a/packages/transport-dom/src/create.ts +++ b/packages/transport-dom/src/create.ts @@ -18,6 +18,7 @@ import { isTransportEvent, isTransportMessage, isTransportStream, + TransportAbort, TransportEvent, TransportMessage, TransportStream, @@ -45,15 +46,43 @@ export interface ChannelTransportOptions getPort: () => PromiseLike; } +/** + * For use with `ConnectError.from`, in `rejectOnSignal`. Identifies an + * appropriate error code for an unknown throw. + * - ConnectError.from forwards exising ConnectError codes, ignoring this + * - ConnectError.from uses `Code.Canceled` for an 'AbortError', ignoring this + * - We want to apply `Code.DeadlineExceeded` for any 'TimeoutError' + * - All others should use `Code.Aborted` + */ +const codeForError = (r?: unknown) => { + if (r instanceof DOMException && r.name === 'TimeoutError') { + return Code.DeadlineExceeded; + } else { + return Code.Aborted; + } +}; + +const rejectOnSignal = (...signals: (AbortSignal | undefined)[]) => { + return new Promise((_, reject) => { + const signal = AbortSignal.any(signals.filter(s => s instanceof AbortSignal)); + signal.addEventListener('abort', () => + reject(ConnectError.from(signal.reason, codeForError(signal.reason))), + ); + if (signal.aborted) { + reject(ConnectError.from(signal.reason, codeForError(signal.reason))); + } + }); +}; + export const createChannelTransport = ({ getPort, jsonOptions, - defaultTimeoutMs = 10_000, + defaultTimeoutMs = 60_000, }: ChannelTransportOptions): Transport => { const pending = new Map void>(); // this is used to recover errors that couldn't be thrown at a caller - const listenerError = Promise.withResolvers(); + const transportFailure = new AbortController(); // port returned by the penumbra global let port: MessagePort | undefined; @@ -66,45 +95,44 @@ export const createChannelTransport = ({ * @returns A promise that resolves when the channel is acquired. */ const connect = async () => { - const initTimeout = new Promise( - (_, reject) => - defaultTimeoutMs && - setTimeout( - reject, - defaultTimeoutMs, - new ConnectError('Channel connection request timed out', Code.Unavailable), - ), - ); - - const gotPort = await Promise.race([getPort(), initTimeout]); + const connectionPort = await Promise.race([ + getPort(), + rejectOnSignal( + defaultTimeoutMs > 0 ? AbortSignal.timeout(defaultTimeoutMs) : undefined, + ).catch(() => + Promise.reject(new ConnectError('Channel connection request timed out', Code.Unavailable)), + ), + ]); - gotPort.addEventListener('message', transportListener); - gotPort.start(); + connectionPort.addEventListener('message', transportListener); + connectionPort.addEventListener('messageerror', (ev: MessageEvent) => + transportFailure.abort(ConnectError.from(ev.data)), + ); + connectionPort.start(); - return gotPort; + return connectionPort; }; const transportListener = ({ data }: MessageEvent) => { - if (!data) { - // likely 'false' indicating a disconnect - listenerError.reject(new ConnectError('Connection closed', Code.Unavailable)); + if (data === false) { + // 'false' indicating a disconnect + transportFailure.abort(new ConnectError('Connection closed', Code.Unavailable)); } else if (isTransportEvent(data)) { - // this is a response to a specific request. the port may be shared, so it - // may contain a requestId we don't know about. the response may be - // successful, or contain an error conveyed only to the caller. - const respond = pending.get(data.requestId); - if (respond) { - respond(data); - } + // this is a response to a specific request. the port may be shared, so + // it's okay if it contains a requestId we don't know about. the response + // may be successful, or contain an error conveyed only to the caller. + pending.get(data.requestId)?.(data); } else if (isTransportError(data)) { // this is a channel-level error, corresponding to no specific request. - // this will fail this transport, and every client using this transport. - // every transport sharing this port will fail independently. - listenerError.reject( + // it will fail this transport, and every client using this transport, and + // every transport using this channel. every transport sharing this port + // will fail independently, but the rejection created here will be + // delivered to every subsequent request attempted on this transport. + transportFailure.abort( errorFromJson(data.error, data.metadata, new ConnectError('Transport failed')), ); } else { - listenerError.reject( + transportFailure.abort( new ConnectError( 'Unknown item in transport', Code.Unimplemented, @@ -120,37 +148,64 @@ export const createChannelTransport = ({ async unary = AnyMessage, O extends Message = AnyMessage>( service: ServiceType, method: MethodInfo, - _signal: AbortSignal | undefined, // TODO - _timeoutMs: number | undefined, // TODO + signal: AbortSignal | undefined, + timeoutMs: number | undefined = defaultTimeoutMs, header: HeadersInit | undefined, input: PartialMessage, ): Promise> { + transportFailure.signal.throwIfAborted(); port ??= await connect(); const requestId = crypto.randomUUID(); - const { promise: response, resolve, reject } = Promise.withResolvers(); - pending.set(requestId, (tev: TransportEvent) => { - if (isTransportMessage(tev, requestId)) { - resolve(tev); - } else if (isTransportError(tev)) { - reject(errorFromJson(tev.error, tev.metadata, new ConnectError('Unary failed'))); - } else { - reject(ConnectError.from(tev)); - } - }); + const requestFailure = new AbortController(); - const message = Any.pack(new method.I(input)).toJson(jsonOptions); - port.postMessage({ requestId, message, header }); + const response = Promise.race([ + rejectOnSignal( + transportFailure.signal, + requestFailure.signal, + timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined, + signal, + ), + new Promise((resolve, reject) => { + pending.set(requestId, (tev: TransportEvent) => { + if (isTransportMessage(tev, requestId)) { + resolve(tev); + } else if (isTransportError(tev, requestId)) { + reject(errorFromJson(tev.error, tev.metadata, new ConnectError('Unary failed'))); + } else { + reject(ConnectError.from(tev)); + } + }); + }), + ]).finally(() => pending.delete(requestId)); - const success = Promise.race([response, listenerError.promise]); + if (!signal?.aborted) { + try { + switch (method.kind) { + case MethodKind.Unary: + { + const message = Any.pack(new method.I(input)).toJson(jsonOptions); + signal?.addEventListener('abort', () => + port?.postMessage({ requestId, abort: true } satisfies TransportAbort), + ); + port.postMessage({ requestId, message, header } satisfies TransportMessage); + } + break; + default: + throw new ConnectError('MethodKind not supported', Code.Unimplemented); + } + } catch (e) { + requestFailure.abort(e); + } + } return { service, method, stream: false, - header: new Headers((await success).header), - trailer: new Headers((await success).trailer), - message: await success.then(({ message }) => { + header: new Headers((await response).header), + trailer: new Headers((await response).trailer), + message: await response.then(({ message }) => { const o = new method.O(); Any.fromJson(message, jsonOptions).unpackTo(o); return o; @@ -161,63 +216,100 @@ export const createChannelTransport = ({ async stream = AnyMessage, O extends Message = AnyMessage>( service: ServiceType, method: MethodInfo, - _signal: AbortSignal | undefined, // TODO - _timeoutMs: number | undefined, // TODO + signal: AbortSignal | undefined, + timeoutMs: number | undefined = defaultTimeoutMs, header: HeadersInit | undefined, input: AsyncIterable>, ): Promise> { + transportFailure.signal.throwIfAborted(); port ??= await connect(); const requestId = crypto.randomUUID(); - const { promise: response, resolve, reject } = Promise.withResolvers(); - pending.set(requestId, (tev: TransportEvent) => { - if (isTransportStream(tev, requestId)) { - resolve(tev); - } else if (isTransportError(tev)) { - reject(errorFromJson(tev.error, tev.metadata, new ConnectError('Stream failed'))); - } else { - reject(ConnectError.from(tev)); - } - }); - - if (method.kind === MethodKind.ServerStreaming) { - const iter = input[Symbol.asyncIterator](); - const [{ value } = { value: null }, { done }] = [await iter.next(), await iter.next()]; - if (done && typeof value === 'object' && value != null) { - const message = Any.pack(new method.I(value as object)).toJson(jsonOptions); - port.postMessage({ requestId, message, header } satisfies TransportMessage); - } else { - throw new ConnectError( - 'MethodKind.ServerStreaming expects a single request message', - Code.OutOfRange, - ); + + const requestFailure = new AbortController(); + + const response = Promise.race([ + rejectOnSignal( + transportFailure.signal, + requestFailure.signal, + timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined, + signal, + ), + new Promise((resolve, reject) => { + pending.set(requestId, (tev: TransportEvent) => { + if (isTransportStream(tev, requestId)) { + resolve(tev); + } else if (isTransportError(tev, requestId)) { + reject(errorFromJson(tev.error, tev.metadata, new ConnectError('Stream failed'))); + } else { + reject(ConnectError.from(tev)); + } + }); + }), + ]).finally(() => pending.delete(requestId)); + + if (!signal?.aborted) { + try { + switch (method.kind) { + case MethodKind.ServerStreaming: + // send as a single message + { + // consume the input stream, which should have only one message + const iter = input[Symbol.asyncIterator](); + const [{ value } = { value: null }, { done }] = [ + await iter.next(), + await iter.next(), + ]; + // confirm the input stream ended after one message with content + if (done && typeof value === 'object' && value !== null) { + const message = Any.pack(new method.I(value as object)).toJson(jsonOptions); + port.postMessage({ requestId, message, header } satisfies TransportMessage); + } else { + throw new ConnectError( + 'MethodKind.ServerStreaming expects a single request message', + Code.OutOfRange, + ); + } + } + break; + case MethodKind.ClientStreaming: + case MethodKind.BiDiStreaming: + // send as an actual stream + { + const stream: ReadableStream = ReadableStream.from(input).pipeThrough( + new TransformStream({ + transform: (chunk: PartialMessage, cont) => + cont.enqueue(Any.pack(new method.I(chunk)).toJson(jsonOptions)), + }), + ); + port.postMessage({ requestId, stream, header } satisfies TransportStream, [stream]); + } + break; + default: + throw new ConnectError('MethodKind not supported', Code.Unimplemented); + } + } catch (e) { + requestFailure.abort(e); } - } else { - const stream: ReadableStream = ReadableStream.from(input).pipeThrough( - new TransformStream({ - transform: (chunk: PartialMessage, cont) => - cont.enqueue(Any.pack(new method.I(chunk)).toJson(jsonOptions)), - }), - ); - port.postMessage({ requestId, stream, header } satisfies TransportStream, [stream]); } - const success = await Promise.race([response, listenerError.promise]); - return { service, method, stream: true, - header: new Headers(success.header), - trailer: new Headers(success.trailer), - message: success.stream.pipeThrough( - new TransformStream({ - transform: (chunk, cont) => { - const o = new method.O(); - Any.fromJson(chunk, jsonOptions).unpackTo(o); - cont.enqueue(o); - }, - }), + header: new Headers((await response).header), + trailer: new Headers((await response).trailer), + message: await response.then(({ stream }) => + stream.pipeThrough( + new TransformStream({ + transform: (chunk, cont) => { + const o = new method.O(); + Any.fromJson(chunk, jsonOptions).unpackTo(o); + cont.enqueue(o); + }, + }), + { signal }, + ), ), }; }, diff --git a/packages/transport-dom/src/messages.ts b/packages/transport-dom/src/messages.ts index 1b200db618..089ab73a03 100644 --- a/packages/transport-dom/src/messages.ts +++ b/packages/transport-dom/src/messages.ts @@ -2,7 +2,8 @@ import type { JsonValue } from '@bufbuild/protobuf'; // transport meta -export interface TransportError extends Partial { +export interface TransportError extends Partial { + requestId: I extends string ? string : string | undefined; error: JsonValue; metadata?: HeadersInit; } @@ -18,6 +19,10 @@ export interface TransportEvent { //contextValues?: object; } +export interface TransportAbort extends TransportEvent { + abort: true; +} + export interface TransportMessage extends TransportEvent { message: JsonValue; } @@ -31,7 +36,8 @@ export interface TransportStream extends TransportEvent typeof o === 'object' && o !== null; -export const isTransportError = (e: unknown): e is TransportError => isObj(e) && 'error' in e; +export const isTransportError = (e: unknown, id?: I): e is TransportError => + isObj(e) && 'error' in e && (!id || ('requestId' in e && e.requestId === id)); export const isTransportData = (t: unknown): t is TransportData => isTransportMessage(t) || isTransportStream(t); @@ -49,3 +55,6 @@ export const isTransportMessage = ( export const isTransportStream = (s: unknown, id?: I): s is TransportStream => isTransportEvent(s, id) && 'stream' in s && s.stream instanceof ReadableStream; + +export const isTransportAbort = (a: unknown, id?: I): a is TransportAbort => + isTransportEvent(a, id) && 'abort' in a && a.abort === true; diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index 1ad8d34ac9..420926ecd6 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,36 @@ # @penumbra-zone/types +## 17.0.1 + +### Patch Changes + +- 3477bef: bugfix: injecting globalThis.**DEV** correctly on prod builds + +## 17.0.0 + +### Patch Changes + +- Updated dependencies [86c1bbe] + - @penumbra-zone/getters@12.1.0 + +## 16.1.0 + +### Minor Changes + +- 0233722: added proxying timestampByHeight + +## 16.0.0 + +### Patch Changes + +- @penumbra-zone/getters@12.0.0 + +## 15.1.1 + +### Patch Changes + +- 3aaead1: Move the "default" option in package.json exports field to the last + ## 15.1.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 7481bd5184..0b346c61a4 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@penumbra-zone/types", - "version": "15.1.0", + "version": "17.0.1", "license": "(MIT OR Apache-2.0)", "type": "module", "engine": { @@ -19,18 +19,18 @@ "dist" ], "exports": { - "./*": "./src/*.ts", - "./internal-msg/*": "./src/internal-msg/*.ts" + "./internal-msg/*": "./src/internal-msg/*.ts", + "./*": "./src/*.ts" }, "publishConfig": { "exports": { "./*": { - "default": "./dist/*.js", - "types": "./dist/*.d.ts" + "types": "./dist/*.d.ts", + "default": "./dist/*.js" }, "./internal-msg/*": { - "default": "./dist/internal-msg/*.js", - "types": "./dist/internal-msg/*.d.ts" + "types": "./dist/internal-msg/*.d.ts", + "default": "./dist/internal-msg/*.js" } } }, diff --git a/packages/types/src/querier.ts b/packages/types/src/querier.ts index a9a9d3e2fe..48fa38d21b 100644 --- a/packages/types/src/querier.ts +++ b/packages/types/src/querier.ts @@ -21,6 +21,10 @@ import { AuctionId, DutchAuction, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1/auction_pb.js'; +import { + TimestampByHeightRequest, + TimestampByHeightResponse, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/sct/v1/sct_pb.js'; export interface RootQuerierInterface { app: AppQuerierInterface; @@ -29,6 +33,7 @@ export interface RootQuerierInterface { shieldedPool: ShieldedPoolQuerierInterface; ibcClient: IbcClientQuerierInterface; stake: StakeQuerierInterface; + sct: SctQuerierInterface; cnidarium: CnidariumQuerierInterface; auction: AuctionQuerierInterface; } @@ -74,3 +79,7 @@ export interface CnidariumQuerierInterface { export interface AuctionQuerierInterface { auctionStateById(id: AuctionId): Promise; } + +export interface SctQuerierInterface { + timestampByHeight(req: TimestampByHeightRequest): Promise; +} diff --git a/packages/types/vitest.config.ts b/packages/types/vitest.config.ts index c1651dd515..0c2847ad53 100644 --- a/packages/types/vitest.config.ts +++ b/packages/types/vitest.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vitest/config'; -export default defineConfig({ - define: { 'globalThis.__DEV__': 'import.meta.env.DEV' }, +export default defineConfig(({ mode }) => { + return { + define: { 'globalThis.__DEV__': mode !== 'production' }, + }; }); diff --git a/packages/ui/.firebaserc b/packages/ui/.firebaserc new file mode 100644 index 0000000000..2b58d9aa15 --- /dev/null +++ b/packages/ui/.firebaserc @@ -0,0 +1,19 @@ +{ + "projects": { + "default": "penumbra-ui" + }, + "targets": { + "penumbra-ui": { + "hosting": { + "preview": [ + "penumbra-ui-preview" + ], + "stable": [ + "penumbra-ui" + ] + } + } + }, + "etags": {}, + "dataconnectEmulatorConfig": {} +} \ No newline at end of file diff --git a/packages/ui/.storybook/main.js b/packages/ui/.storybook/main.js index f6b4950b43..c267f58f80 100644 --- a/packages/ui/.storybook/main.js +++ b/packages/ui/.storybook/main.js @@ -10,7 +10,18 @@ function getAbsolutePath(value) { /** @type { import('@storybook/react-vite').StorybookConfig } */ const config = { - stories: ['../stories/**/*.mdx', '../components/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + stories: [ + { + directory: '../src', + files: '**/@(*.stories.@(js|jsx|mjs|ts|tsx)|*.mdx)', + titlePrefix: 'UI library', + }, + { + directory: '../components', + files: '**/@(*.stories.@(js|jsx|mjs|ts|tsx)|*.mdx)', + titlePrefix: 'Deprecated', + }, + ], addons: [ getAbsolutePath('@storybook/addon-links'), getAbsolutePath('@storybook/addon-essentials'), diff --git a/packages/ui/.storybook/manager.js b/packages/ui/.storybook/manager.js new file mode 100644 index 0000000000..50803930bf --- /dev/null +++ b/packages/ui/.storybook/manager.js @@ -0,0 +1,10 @@ +import { addons } from '@storybook/manager-api'; +import penumbraTheme from './penumbraTheme'; + +addons.setConfig({ + showToolbar: true, + theme: penumbraTheme, + sidebar: { + collapsedRoots: ['Deprecated'], + }, +}); diff --git a/packages/ui/.storybook/penumbraTheme.js b/packages/ui/.storybook/penumbraTheme.js new file mode 100644 index 0000000000..70bf9ffcfc --- /dev/null +++ b/packages/ui/.storybook/penumbraTheme.js @@ -0,0 +1,20 @@ +import { create } from '@storybook/theming/create'; +import logo from './public/logo.svg'; + +const penumbraTheme = create({ + appBg: 'black', + appContentBg: 'black', + appPreviewBg: 'black', + barBg: 'black', + base: 'dark', + brandImage: logo, + brandTitle: 'Penumbra UI library', + colorPrimary: '#8d5728', + colorSecondary: '#629994', + fontBase: 'Poppins', + fontCode: '"Iosevka Term",monospace', + textColor: 'white', + textMutedColor: '#e3e3e3', +}); + +export default penumbraTheme; diff --git a/packages/ui/.storybook/preview.js b/packages/ui/.storybook/preview.js deleted file mode 100644 index 79924a5324..0000000000 --- a/packages/ui/.storybook/preview.js +++ /dev/null @@ -1,16 +0,0 @@ -import '../styles/globals.css'; - -/** @type { import('@storybook/react').Preview } */ -const preview = { - parameters: { - actions: { argTypesRegex: '^on[A-Z].*' }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - }, -}; - -export default preview; diff --git a/packages/ui/.storybook/preview.jsx b/packages/ui/.storybook/preview.jsx new file mode 100644 index 0000000000..1df3ae07b8 --- /dev/null +++ b/packages/ui/.storybook/preview.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import globalsCssUrl from '../styles/globals.css?url'; +import penumbraTheme from './penumbraTheme'; +import { ThemeProvider } from '../src/ThemeProvider'; +import styled from 'styled-components'; + +const Wrapper = styled.div` + color: ${props => props.theme.color.text.primary}; +`; + +/** @type { import('@storybook/react').Preview } */ +const preview = { + decorators: [ + (Story, { title }) => { + const isDeprecatedComponent = title.startsWith('Deprecated/'); + + if (isDeprecatedComponent) { + return ( + <> + + + + ); + } + + return ( + + + + + + ); + }, + ], + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + docs: { + theme: penumbraTheme, + }, + }, +}; + +export default preview; diff --git a/packages/ui/.storybook/public/logo.svg b/packages/ui/.storybook/public/logo.svg new file mode 100644 index 0000000000..0293e288a9 --- /dev/null +++ b/packages/ui/.storybook/public/logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 22e59ab93a..56b11706cb 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,49 @@ # @penumbra-zone/ui +## 7.2.1 + +### Patch Changes + +- Updated dependencies [3477bef] + - @penumbra-zone/types@17.0.1 + +## 7.2.0 + +### Minor Changes + +- 54a5d66: Add Button/ButtonGroup/SegmentedPicker components + +## 7.1.0 + +### Minor Changes + +- 86c1bbe: Add support for delegate vote action views + +### Patch Changes + +- @penumbra-zone/types@17.0.0 + +## 7.0.3 + +### Patch Changes + +- 26bd932: Shows the green checkmark icon for all filled dutch auctions +- Updated dependencies [0233722] + - @penumbra-zone/types@16.1.0 + +## 7.0.2 + +### Patch Changes + +- @penumbra-zone/types@16.0.0 + +## 7.0.1 + +### Patch Changes + +- Updated dependencies [3aaead1] + - @penumbra-zone/types@15.1.1 + ## 7.0.0 ### Major Changes diff --git a/packages/ui/components/readme.mdx b/packages/ui/components/readme.mdx new file mode 100644 index 0000000000..9f92a02883 --- /dev/null +++ b/packages/ui/components/readme.mdx @@ -0,0 +1,10 @@ +import { Meta } from '@storybook/blocks'; +import * as ToasterStories from './ui/toaster/toaster.stories'; + + + +# Deprecated Penumbra UI components + +The `components/ui` directory contains deprecated Penumbra UI components. These will eventually all be replaced by components in the `src` directory; but until then, they're still here for reference and use when needed. + +Note that there are not Storybook stories for all deprecated components. To find deprecated components not listed here, please see the `components/ui` directory of the `@penumbra-zone/ui` package. diff --git a/packages/ui/components/ui/candlestick-plot/index.tsx b/packages/ui/components/ui/candlestick-plot/index.tsx index a25500393e..6962fc5281 100644 --- a/packages/ui/components/ui/candlestick-plot/index.tsx +++ b/packages/ui/components/ui/candlestick-plot/index.tsx @@ -43,10 +43,11 @@ interface CandlestickPlotProps { width?: number; height?: number; candles: CandlestickData[]; - latestKnownBlockHeight?: number; + blockDomain: [bigint, bigint]; startMetadata: Metadata; endMetadata: Metadata; getBlockDate: GetBlockDateFn; + scaleMargin?: number; } interface CandlestickTooltipProps { @@ -66,8 +67,9 @@ export const CandlestickPlot = withTooltip) => { - const { parentRef, width: w, height: h } = useParentSize({ debounceTime: 150 }); + const { parentRef, width: w, height: h } = useParentSize(); - const { maxPrice, minPrice } = useMemo( - () => - candles.reduce( - (acc, d) => ({ - minPrice: Math.min(acc.minPrice, lowPrice(d)), - maxPrice: Math.max(acc.maxPrice, highPrice(d)), - }), - { minPrice: Infinity, maxPrice: -Infinity }, - ), - [candles], - ); - const maxSpread = maxPrice - minPrice; + const { blockScale, priceScale } = useMemo(() => { + const { maxPrice, minPrice } = candles.reduce( + (acc, d) => ({ + minPrice: Math.min(acc.minPrice, lowPrice(d)), + maxPrice: Math.max(acc.maxPrice, highPrice(d)), + }), + { minPrice: Infinity, maxPrice: 0 }, + ); + + const maxSpread = maxPrice - minPrice; + + const blockScale = scaleLinear({ + range: [scaleMargin, w], + domain: [Number(startBlock), Number(endBlock)], + }); + + const priceScale = scaleLinear({ + range: [h - scaleMargin, 0], + domain: [Math.max(0, minPrice - maxSpread / 4), maxPrice], + }); + + return { priceScale, blockScale }; + }, [candles, scaleMargin, w, startBlock, endBlock, h]); const useTooltip = useCallback( (d: CandlestickData) => ({ onMouseOver: () => { showTooltip({ tooltipTop: priceScale(midPrice(d)), - tooltipLeft: blockScale(blockHeight(d)), + tooltipLeft: blockScale(blockHeight(d)) / 2, tooltipData: d, }); }, @@ -105,31 +118,10 @@ export const CandlestickPlot = withTooltip({ - range: [50, w - 5], - domain: [startBlock, latestKnownBlockHeight ?? endBlock], - }); - - const priceScale = scaleLinear({ - range: [h, 0], - domain: [minPrice - maxSpread / 2, maxPrice + maxSpread / 2], - }); + const blockWidth = w / Number(endBlock - startBlock); return ( <> @@ -142,14 +134,14 @@ export const CandlestickPlot = withTooltip @@ -210,8 +202,8 @@ export const CandlestickPlot = withTooltip ac.abort('Abort tooltip date query'); - }, [data]); + }, [data, getBlockDate]); const endBase = endMetadata.denomUnits.filter(d => !d.exponent)[0]!; const startBase = startMetadata.denomUnits.filter(d => !d.exponent)[0]!; diff --git a/packages/ui/components/ui/dutch-auction-component/expanded-details/index.tsx b/packages/ui/components/ui/dutch-auction-component/expanded-details/index.tsx index 1662c7cbf3..a0401d3033 100644 --- a/packages/ui/components/ui/dutch-auction-component/expanded-details/index.tsx +++ b/packages/ui/components/ui/dutch-auction-component/expanded-details/index.tsx @@ -11,18 +11,21 @@ import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; import { cn } from '../../../../lib/utils'; import { AuctionIdComponent } from '../../auction-id-component'; import { motion } from 'framer-motion'; +import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; export const ExpandedDetails = ({ auctionId, dutchAuction, inputMetadata, outputMetadata, + addressIndex, fullSyncHeight, }: { auctionId?: AuctionId; dutchAuction: DutchAuction; inputMetadata?: Metadata; outputMetadata?: Metadata; + addressIndex?: AddressIndex; fullSyncHeight?: bigint; }) => { const { description } = dutchAuction; @@ -104,6 +107,12 @@ export const ExpandedDetails = ({ )} + + {addressIndex && ( + + {addressIndex.account} + + )}

); }; diff --git a/packages/ui/components/ui/dutch-auction-component/index.tsx b/packages/ui/components/ui/dutch-auction-component/index.tsx index b52d089b33..8f16149cac 100644 --- a/packages/ui/components/ui/dutch-auction-component/index.tsx +++ b/packages/ui/components/ui/dutch-auction-component/index.tsx @@ -10,12 +10,14 @@ import { useState } from 'react'; import { cn } from '../../../lib/utils'; import { ExpandedDetails } from './expanded-details'; import { AnimatePresence, motion } from 'framer-motion'; +import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js'; interface BaseProps { auctionId?: AuctionId; dutchAuction: DutchAuction; inputMetadata?: Metadata; outputMetadata?: Metadata; + addressIndex?: AddressIndex; fullSyncHeight?: bigint; /** * If this will be in a list of other ``s, and some @@ -42,6 +44,7 @@ export const DutchAuctionComponent = ({ dutchAuction, inputMetadata, outputMetadata, + addressIndex, fullSyncHeight, buttonType, onClickButton, @@ -103,6 +106,7 @@ export const DutchAuctionComponent = ({ inputMetadata={inputMetadata} outputMetadata={outputMetadata} fullSyncHeight={fullSyncHeight} + addressIndex={addressIndex} /> {renderButtonPlaceholder &&
} diff --git a/packages/ui/components/ui/dutch-auction-component/progress-bar/indicator/index.tsx b/packages/ui/components/ui/dutch-auction-component/progress-bar/indicator/index.tsx index ab6d31497c..c48cf78140 100644 --- a/packages/ui/components/ui/dutch-auction-component/progress-bar/indicator/index.tsx +++ b/packages/ui/components/ui/dutch-auction-component/progress-bar/indicator/index.tsx @@ -1,8 +1,9 @@ +import { useMemo } from 'react'; import { DutchAuction } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1/auction_pb.js'; import { CircleArrowRight, CircleCheck, CircleX } from 'lucide-react'; -import { getProgress } from './get-progress'; import { getDescription } from '@penumbra-zone/getters/dutch-auction'; import { isZero } from '@penumbra-zone/types/amount'; +import { getProgress } from './get-progress'; export const Indicator = ({ dutchAuction, @@ -13,16 +14,25 @@ export const Indicator = ({ }) => { const description = getDescription(dutchAuction); const seqNum = dutchAuction.state?.seq; - if (seqNum === undefined) { - return null; - } - const auctionEnded = - (!!seqNum && seqNum > 0n) || (!!fullSyncHeight && fullSyncHeight >= description.endHeight); - const endedUnfulfilled = - auctionEnded && - !!dutchAuction.state?.inputReserves && - !isZero(dutchAuction.state.inputReserves); + const stateIcon = useMemo(() => { + const auctionEnded = + (!!seqNum && seqNum > 0n) || (!!fullSyncHeight && fullSyncHeight >= description.endHeight); + const isFilled = + !!dutchAuction.state?.inputReserves && isZero(dutchAuction.state.inputReserves); + const isUnfilled = + !!dutchAuction.state?.inputReserves && !isZero(dutchAuction.state.inputReserves); + + if (auctionEnded && isUnfilled) { + return ; + } + + if (isFilled || auctionEnded) { + return ; + } + + return ; + }, [seqNum, fullSyncHeight, description.endHeight, dutchAuction.state]); const progress = getProgress( description.startHeight, @@ -31,15 +41,13 @@ export const Indicator = ({ seqNum, ); + if (seqNum === undefined) { + return null; + } + return (
- {endedUnfulfilled ? ( - - ) : auctionEnded ? ( - - ) : ( - - )} + {stateIcon}
); }; diff --git a/packages/ui/components/ui/identicon/generate.ts b/packages/ui/components/ui/identicon/generate.ts index e87741de4b..500c56120c 100644 --- a/packages/ui/components/ui/identicon/generate.ts +++ b/packages/ui/components/ui/identicon/generate.ts @@ -1,12 +1,12 @@ // Inspired by: https://github.com/vercel/avatar -import djb2a from 'djb2a'; import color from 'tinycolor2'; +import Murmur from 'murmurhash3js'; // Deterministically getting a gradient from a string for use as an identicon export const generateGradient = (str: string) => { // Get first color - const hash = djb2a(str); + const hash = Murmur.x86.hash32(str); const c = color({ h: hash % 360, s: 0.95, l: 0.5 }); const tetrad = c.tetrad(); // 4 colors spaced around the color wheel, the first being the input @@ -22,11 +22,10 @@ export const generateGradient = (str: string) => { export const generateSolidColor = (str: string) => { // Get color - const hash = djb2a(str); + const hash = Murmur.x86.hash32(str); const c = color({ h: hash % 360, s: 0.95, l: 0.5 }) .saturate(0) .darken(20); - return { bg: c.toHexString(), // get readable text color diff --git a/packages/ui/components/ui/tx/action-view.tsx b/packages/ui/components/ui/tx/action-view.tsx index f36f9c69e6..c8055c1eed 100644 --- a/packages/ui/components/ui/tx/action-view.tsx +++ b/packages/ui/components/ui/tx/action-view.tsx @@ -12,11 +12,14 @@ import { ActionDutchAuctionScheduleViewComponent } from './actions-views/action- import { ActionDutchAuctionEndComponent } from './actions-views/action-dutch-auction-end'; import { ActionDutchAuctionWithdrawViewComponent } from './actions-views/action-dutch-auction-withdraw-view'; import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; +import { DelegatorVoteComponent } from './actions-views/delegator-vote.tsx'; +import { ValidatorVoteComponent } from './actions-views/validator-vote.tsx'; -const CASE_TO_LABEL: Record = { - daoDeposit: 'DAO Deposit', - daoOutput: 'DAO Output', - daoSpend: 'DAO Spend', +type Case = Exclude; + +const CASE_TO_LABEL: Record = { + output: 'Output', + spend: 'Spend', delegate: 'Delegate', delegatorVote: 'Delegator Vote', ibcRelayAction: 'IBC Relay Action', @@ -34,9 +37,15 @@ const CASE_TO_LABEL: Record = { undelegateClaim: 'Undelegate Claim', validatorDefinition: 'Validator Definition', validatorVote: 'Validator Vote', + actionDutchAuctionEnd: 'Dutch Auction End', + actionDutchAuctionSchedule: 'Dutch Auction Schedule', + actionDutchAuctionWithdraw: 'Dutch Auction Withdraw', + communityPoolDeposit: 'Community Pool Deposit', + communityPoolOutput: 'Community Pool Output', + communityPoolSpend: 'Community Pool Spend', }; -const getLabelForActionCase = (actionCase: string | undefined): string => { +const getLabelForActionCase = (actionCase: ActionView['actionView']['case']): string => { if (!actionCase) { return ''; } @@ -90,6 +99,12 @@ export const ActionViewComponent = ({ case 'actionDutchAuctionWithdraw': return ; + case 'delegatorVote': + return ; + + case 'validatorVote': + return ; + case 'validatorDefinition': return ; @@ -102,12 +117,6 @@ export const ActionViewComponent = ({ case 'proposalWithdraw': return ; - case 'validatorVote': - return ; - - case 'delegatorVote': - return ; - case 'proposalDepositClaim': return ; diff --git a/packages/ui/components/ui/tx/actions-views/action-dutch-auction-schedule-view.tsx b/packages/ui/components/ui/tx/actions-views/action-dutch-auction-schedule-view.tsx index f6fd38c9ea..f7c334ca8f 100644 --- a/packages/ui/components/ui/tx/actions-views/action-dutch-auction-schedule-view.tsx +++ b/packages/ui/components/ui/tx/actions-views/action-dutch-auction-schedule-view.tsx @@ -18,6 +18,7 @@ export const ActionDutchAuctionScheduleViewComponent = ({ dutchAuction={new DutchAuction({ description: value.action?.description })} inputMetadata={value.inputMetadata} outputMetadata={value.outputMetadata} + auctionId={value.auctionId} /> } /> diff --git a/packages/ui/components/ui/tx/actions-views/delegator-vote.tsx b/packages/ui/components/ui/tx/actions-views/delegator-vote.tsx new file mode 100644 index 0000000000..6c800a80f4 --- /dev/null +++ b/packages/ui/components/ui/tx/actions-views/delegator-vote.tsx @@ -0,0 +1,127 @@ +import { ViewBox } from '../viewbox'; +import { ValueViewComponent } from '../../value'; +import { getAddress } from '@penumbra-zone/getters/note-view'; +import { ActionDetails } from './action-details'; +import { + DelegatorVoteBody, + DelegatorVoteView, + Vote, + Vote_Vote, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/governance/v1/governance_pb'; +import { getDelegatorVoteBody } from '@penumbra-zone/getters/delegator-vote-view'; +import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb.js'; +import { + Metadata, + ValueView, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; +import { AddressViewComponent } from '../../address-view'; +import { base64ToUint8Array } from '@penumbra-zone/types/base64'; + +// TODO: This is sad, but at the moment, we aren't provided the metadata to have a rich display. +// Given the high-priority of getting action view support, this is added. +// We should properly implement ValueViews into the protos for DelegatorVote action view and delete this code. +const umMetadata = new Metadata({ + denomUnits: [ + { + denom: 'penumbra', + exponent: 6, + }, + { + denom: 'upenumbra', + exponent: 0, + }, + ], + base: 'upenumbra', + display: 'penumbra', + symbol: 'UM', + penumbraAssetId: { + inner: base64ToUint8Array('KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA='), + }, + images: [ + { + svg: 'https://raw.githubusercontent.com/prax-wallet/registry/main/images/um.svg', + }, + ], +}); + +const umValueView = (amount?: Amount) => { + return new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount, + metadata: umMetadata, + }, + }, + }); +}; + +export const DelegatorVoteComponent = ({ value }: { value: DelegatorVoteView }) => { + const body = getDelegatorVoteBody.optional()(value); + + if (value.delegatorVote.case === 'visible') { + const note = value.delegatorVote.value.note; + const address = getAddress.optional()(note); + + return ( + + + + + + + } + /> + ); + } + + if (value.delegatorVote.case === 'opaque') { + return ( + + + + } + /> + ); + } + + return
Invalid DelegatorVoteView
; +}; + +export const VoteToString = (vote: Vote): string => { + switch (vote.vote) { + case Vote_Vote.UNSPECIFIED: + return 'UNSPECIFIED'; + case Vote_Vote.ABSTAIN: + return 'ABSTAIN'; + case Vote_Vote.YES: + return 'YES'; + case Vote_Vote.NO: + return 'NO'; + } +}; + +const VoteBodyDetails = ({ body }: { body?: DelegatorVoteBody }) => { + return ( + <> + {!!body?.proposal && ( + {Number(body.proposal)} + )} + + {!!body?.vote && ( + {VoteToString(body.vote)} + )} + {!!body?.unbondedAmount && ( + + + + )} + + ); +}; diff --git a/packages/ui/components/ui/tx/actions-views/validator-vote.tsx b/packages/ui/components/ui/tx/actions-views/validator-vote.tsx new file mode 100644 index 0000000000..c9e8c91707 --- /dev/null +++ b/packages/ui/components/ui/tx/actions-views/validator-vote.tsx @@ -0,0 +1,41 @@ +import { ViewBox } from '../viewbox'; +import { ActionDetails } from './action-details'; +import { ValidatorVote } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/governance/v1/governance_pb'; +import { VoteToString } from './delegator-vote.tsx'; +import { bech32mIdentityKey } from '@penumbra-zone/bech32m/penumbravalid'; +import { bech32mGovernanceId } from '@penumbra-zone/bech32m/penumbragovern'; + +export const ValidatorVoteComponent = ({ value }: { value: ValidatorVote }) => { + return ( + + {!!value.body?.proposal && ( + {Number(value.body.proposal)} + )} + + {!!value.body?.vote && ( + {VoteToString(value.body.vote)} + )} + + {!!value.body?.reason?.reason && ( + {value.body.reason.reason} + )} + + {!!value.body?.identityKey && ( + + {bech32mIdentityKey(value.body.identityKey)} + + )} + + {!!value.body?.governanceKey && ( + + {bech32mGovernanceId(value.body.governanceKey)} + + )} + + } + /> + ); +}; diff --git a/packages/ui/components/ui/tx/index.tsx b/packages/ui/components/ui/tx/index.tsx index ab7bc2ee85..0bcb2d93b5 100644 --- a/packages/ui/components/ui/tx/index.tsx +++ b/packages/ui/components/ui/tx/index.tsx @@ -49,7 +49,7 @@ const useFeeMetadata = (txv: TransactionView, getMetadata: MetadataFetchFn) => { setIsLoading(false); }) .catch((e: unknown) => setError(e)); - }, [txv, getMetadata, setFeeValueView]); + }, [txv, getMetadata, setFeeValueView, amount]); return { feeValueView, isLoading, error }; }; diff --git a/packages/ui/firebase.json b/packages/ui/firebase.json new file mode 100644 index 0000000000..e1ec9099b2 --- /dev/null +++ b/packages/ui/firebase.json @@ -0,0 +1,14 @@ +{ + "hosting": [ + { + "target": "preview", + "public": "storybook-static", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + }, + { + "target": "stable", + "public": "storybook-static", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + } + ] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 15b9142709..2c3fe9c810 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,10 +1,11 @@ { "name": "@repo/ui", - "version": "7.0.0", + "version": "7.2.1", "private": true, "license": "(MIT OR Apache-2.0)", "type": "module", "scripts": { + "build-storybook": "storybook build", "lint": "eslint components lib", "lint:fix": "eslint components lib --fix", "lint:strict": "tsc --noEmit && eslint components lib --max-warnings 0", @@ -25,9 +26,9 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", - "@mui/material": "^5.15.18", "@penumbra-labs/registry": "10.0.0", "@penumbra-zone/bech32m": "workspace:*", + "@penumbra-zone/perspective": "workspace:*", "@penumbra-zone/types": "workspace:*", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", @@ -59,29 +60,32 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "0.2.0", - "djb2a": "^2.0.0", "framer-motion": "^11.2.4", "humanize-duration": "^3.32.0", "lucide-react": "^0.378.0", + "murmurhash3js": "^3.0.1", "react-dom": "^18.3.1", "react-loader-spinner": "^6.1.6", "react-router-dom": "^6.23.1", "sonner": "1.4.3", + "styled-components": "^6.1.11", "tailwind-merge": "^2.3.0", "tinycolor2": "^1.6.0" }, "devDependencies": { "@penumbra-zone/getters": "workspace:*", - "@penumbra-zone/perspective": "workspace:*", "@storybook/addon-essentials": "^8.1.1", "@storybook/addon-interactions": "^8.1.1", "@storybook/addon-links": "^8.1.1", "@storybook/addon-postcss": "^2.0.0", "@storybook/blocks": "^8.1.1", + "@storybook/manager-api": "^8.1.11", "@storybook/preview-api": "^8.1.1", "@storybook/react": "^8.1.1", "@storybook/react-vite": "8.1.1", + "@storybook/theming": "^8.1.11", "@types/humanize-duration": "^3.27.4", + "@types/murmurhash3js": "^3.0.7", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", "@types/tinycolor2": "^1.4.6", diff --git a/packages/ui/src/Button/helpers.test.ts b/packages/ui/src/Button/helpers.test.ts new file mode 100644 index 0000000000..d8aab34473 --- /dev/null +++ b/packages/ui/src/Button/helpers.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { getBackgroundColor } from './helpers'; +import { DefaultTheme } from 'styled-components'; + +describe('getBackgroundColor()', () => { + const theme = { + color: { primary: { main: '#aaa' }, neutral: { main: '#ccc' }, destructive: { main: '#f00' } }, + } as DefaultTheme; + + describe('when `priority` is `primary`', () => { + it('returns the primary color for the `accent` action type', () => { + expect(getBackgroundColor('accent', 'primary', theme)).toBe('#aaa'); + }); + + it('returns the neutral color for the `default` action type', () => { + expect(getBackgroundColor('default', 'primary', theme)).toBe('#ccc'); + }); + + it('returns the corresponding color for other action types', () => { + expect(getBackgroundColor('destructive', 'primary', theme)).toBe('#f00'); + }); + }); + + describe('when `priority` is `secondary`', () => { + it('returns `transparent`', () => { + expect(getBackgroundColor('accent', 'secondary', theme)).toBe('transparent'); + }); + }); +}); diff --git a/packages/ui/src/Button/helpers.ts b/packages/ui/src/Button/helpers.ts new file mode 100644 index 0000000000..9a8129aa25 --- /dev/null +++ b/packages/ui/src/Button/helpers.ts @@ -0,0 +1,23 @@ +import { DefaultTheme } from 'styled-components'; +import { Priority, ActionType } from '../utils/button'; + +export const getBackgroundColor = ( + actionType: ActionType, + priority: Priority, + theme: DefaultTheme, +): string => { + if (priority === 'secondary') { + return 'transparent'; + } + + switch (actionType) { + case 'accent': + return theme.color.primary.main; + + case 'default': + return theme.color.neutral.main; + + default: + return theme.color[actionType].main; + } +}; diff --git a/packages/ui/src/Button/index.stories.tsx b/packages/ui/src/Button/index.stories.tsx new file mode 100644 index 0000000000..3ade62476f --- /dev/null +++ b/packages/ui/src/Button/index.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button } from '.'; +import { ArrowLeftRight, Check } from 'lucide-react'; + +const meta: Meta = { + component: Button, + tags: ['autodocs', '!dev'], + argTypes: { + icon: { + control: 'select', + options: ['None', 'Check', 'ArrowLeftRight'], + mapping: { None: undefined, Check, ArrowLeftRight }, + }, + onClick: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + size: 'sparse', + children: 'Save', + actionType: 'default', + disabled: false, + icon: Check, + iconOnly: false, + }, +}; diff --git a/packages/ui/src/Button/index.test.tsx b/packages/ui/src/Button/index.test.tsx new file mode 100644 index 0000000000..bdd3c72529 --- /dev/null +++ b/packages/ui/src/Button/index.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '.'; +import { fireEvent, render } from '@testing-library/react'; +import { ThemeProvider } from '../ThemeProvider'; +import { Check } from 'lucide-react'; + +describe(', { + wrapper: ThemeProvider, + }); + + fireEvent.click(getByText('Click me')); + + expect(onClick).toHaveBeenCalledOnce(); + }); + + describe('when `iconOnly` is falsey', () => { + it('renders `children` as the button text', () => { + const { queryByText } = render(, { wrapper: ThemeProvider }); + + expect(queryByText('Label')).toBeTruthy(); + }); + }); + + describe('when `iconOnly` is `true`', () => { + it('renders `children` as the button label', () => { + const { queryByText, queryByLabelText } = render( + , + { wrapper: ThemeProvider }, + ); + + expect(queryByText('Label')).toBeNull(); + expect(queryByLabelText('Label')).toBeTruthy(); + }); + + it('renders `children` as the `title`', () => { + const { queryByTitle } = render( + , + { wrapper: ThemeProvider }, + ); + + expect(queryByTitle('Label')).toBeTruthy(); + }); + }); +}); diff --git a/packages/ui/src/Button/index.tsx b/packages/ui/src/Button/index.tsx new file mode 100644 index 0000000000..097fc305b0 --- /dev/null +++ b/packages/ui/src/Button/index.tsx @@ -0,0 +1,181 @@ +import { MouseEventHandler, useContext } from 'react'; +import styled, { css, DefaultTheme } from 'styled-components'; +import { asTransientProps } from '../utils/asTransientProps'; +import { Size, Priority, ActionType, buttonInteractions } from '../utils/button'; +import { getBackgroundColor } from './helpers'; +import { button } from '../utils/typography'; +import { LucideIcon } from 'lucide-react'; +import { ButtonPriorityContext } from '../utils/ButtonPriorityContext'; + +const dense = css` + border-radius: ${props => props.theme.borderRadius.full}; + padding-left: ${props => props.theme.spacing(props.$iconOnly ? 2 : 4)}; + padding-right: ${props => props.theme.spacing(props.$iconOnly ? 2 : 4)}; + height: 32px; + min-width: 32px; +`; + +const sparse = css` + border-radius: ${props => props.theme.borderRadius.sm}; + padding-left: ${props => props.theme.spacing(4)}; + padding-right: ${props => props.theme.spacing(4)}; + height: 48px; + width: ${props => (props.$iconOnly ? '48px' : '100%')}; +`; + +const outlineColorByActionType: Record = { + default: 'neutralFocusOutline', + accent: 'primaryFocusOutline', + unshield: 'unshieldFocusOutline', + destructive: 'destructiveFocusOutline', +}; + +const borderColorByActionType: Record< + ActionType, + 'neutral' | 'primary' | 'unshield' | 'destructive' +> = { + default: 'neutral', + accent: 'primary', + unshield: 'unshield', + destructive: 'destructive', +}; + +interface StyledButtonProps { + $iconOnly?: boolean; + $actionType: ActionType; + $priority: Priority; + $size: Size; + $getFocusOutlineColor: (theme: DefaultTheme) => string; + $getBorderRadius: (theme: DefaultTheme) => string; +} + +const StyledButton = styled.button` + ${button} + + background-color: ${props => getBackgroundColor(props.$actionType, props.$priority, props.theme)}; + border: none; + outline: ${props => + props.$priority === 'secondary' + ? `1px solid ${props.theme.color[borderColorByActionType[props.$actionType]].main}` + : 'none'}; + outline-offset: -1px; + display: flex; + gap: ${props => props.theme.spacing(2)}; + align-items: center; + justify-content: center; + color: ${props => props.theme.color.neutral.contrast}; + cursor: pointer; + overflow: hidden; + position: relative; + + ${props => (props.$size === 'dense' ? dense : sparse)} + + ${buttonInteractions} + + &::after { + outline-offset: -2px; + } +`; + +interface BaseButtonProps { + type?: HTMLButtonElement['type']; + /** + * The button label. If `iconOnly` is `true`, this will be used as the + * `aria-label` attribute. + */ + children: string; + /** + * Set to `sparse` for more loosely arranged layouts, such as when this is the + * submit button for a form. Use `dense` when, e.g., this button appears next + * to every item in a dense list of data. + * + * Default: `sparse`. + */ + size?: Size; + /** + * What type of action is this button related to? Leave as `default` for most + * buttons, set to `accent` for the single most important action on a given + * page, set to `unshield` for actions that will unshield the user's funds, + * and set to `destructive` for destructive actions. + * + * Default: `default` + */ + actionType?: ActionType; + disabled?: boolean; + onClick?: MouseEventHandler; +} + +interface IconOnlyProps { + /** + * When `true`, will render just an icon button. The label text passed via + * `children` will be used as the `aria-label`. + */ + iconOnly: true; + /** + * The icon import from `lucide-react` to render. If `iconOnly` is `true`, no + * label will be rendered -- just the icon. Otherwise, the icon will be + * rendered to the left of the label. + * + * ```tsx + * import { ChevronRight } from 'lucide-react'; + * + * + * ``` + */ + icon: LucideIcon; +} + +interface RegularProps { + /** + * When `true`, will render just an icon button. The label text passed via + * `children` will be used as the `aria-label`. + */ + iconOnly?: false; + /** + * The icon import from `lucide-react` to render. If `iconOnly` is `true`, no + * label will be rendered -- just the icon. Otherwise, the icon will be + * rendered to the left of the label. + * + * ```tsx + * import { ChevronRight } from 'lucide-react'; + * + * + * ``` + */ + icon?: LucideIcon; +} + +export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps); + +/** A component for all your button needs! */ +export const Button = ({ + children, + disabled = false, + onClick, + icon: IconComponent, + iconOnly, + size = 'sparse', + actionType = 'default', + type = 'button', +}: ButtonProps) => { + const priority = useContext(ButtonPriorityContext); + + return ( + theme.color.action[outlineColorByActionType[actionType]]} + $getBorderRadius={theme => + size === 'sparse' ? theme.borderRadius.sm : theme.borderRadius.full + } + > + {IconComponent && } + + {!iconOnly && children} + + ); +}; diff --git a/packages/ui/src/ButtonGroup/index.stories.tsx b/packages/ui/src/ButtonGroup/index.stories.tsx new file mode 100644 index 0000000000..5ea487ae66 --- /dev/null +++ b/packages/ui/src/ButtonGroup/index.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { ButtonGroup } from '.'; +import { Ban, HandCoins, Send } from 'lucide-react'; + +const meta: Meta = { + component: ButtonGroup, + tags: ['autodocs', '!dev'], + argTypes: { + buttons: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + actionType: 'default', + size: 'sparse', + iconOnly: false, + buttons: [ + { + label: 'Delegate', + icon: Send, + }, + { + label: 'Undelegate', + icon: HandCoins, + }, + { + label: 'Cancel', + icon: Ban, + }, + ], + }, +}; diff --git a/packages/ui/src/ButtonGroup/index.test.tsx b/packages/ui/src/ButtonGroup/index.test.tsx new file mode 100644 index 0000000000..588aa4a515 --- /dev/null +++ b/packages/ui/src/ButtonGroup/index.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ButtonGroup, ButtonGroupProps } from '.'; +import { fireEvent, render } from '@testing-library/react'; +import { Ban, HandCoins, Send } from 'lucide-react'; +import { ThemeProvider } from '../ThemeProvider'; + +const onClickDelegate = vi.fn(); +const onClickUndelegate = vi.fn(); +const onClickCancel = vi.fn(); + +const BUTTONS: ButtonGroupProps['buttons'] = [ + { + label: 'Delegate', + icon: Send, + onClick: onClickDelegate, + }, + { + label: 'Undelegate', + icon: HandCoins, + onClick: onClickUndelegate, + }, + { + label: 'Cancel', + icon: Ban, + onClick: onClickCancel, + }, +]; + +describe('', () => { + it('renders a button for each item in the `buttons` prop', () => { + const { queryByText } = render(, { wrapper: ThemeProvider }); + + expect(queryByText('Delegate')).toBeTruthy(); + expect(queryByText('Undelegate')).toBeTruthy(); + expect(queryByText('Cancel')).toBeTruthy(); + }); + + it("calls the given button's click handler when clicked", () => { + const { getByText } = render(, { wrapper: ThemeProvider }); + + fireEvent.click(getByText('Delegate')); + expect(onClickDelegate).toHaveBeenCalled(); + + fireEvent.click(getByText('Undelegate')); + expect(onClickUndelegate).toHaveBeenCalled(); + + fireEvent.click(getByText('Cancel')); + expect(onClickCancel).toHaveBeenCalled(); + }); + + describe('when `iconOnly` is `true`', () => { + it('renders an icon button for each item in the `buttons` prop', () => { + const { queryByText, queryByLabelText } = render(, { + wrapper: ThemeProvider, + }); + + expect(queryByText('Delegate')).toBeNull(); + expect(queryByText('Undelegate')).toBeNull(); + expect(queryByText('Cancel')).toBeNull(); + + expect(queryByLabelText('Delegate')).toBeTruthy(); + expect(queryByLabelText('Undelegate')).toBeTruthy(); + expect(queryByLabelText('Cancel')).toBeTruthy(); + }); + }); +}); diff --git a/packages/ui/src/ButtonGroup/index.tsx b/packages/ui/src/ButtonGroup/index.tsx new file mode 100644 index 0000000000..00dddbf99b --- /dev/null +++ b/packages/ui/src/ButtonGroup/index.tsx @@ -0,0 +1,106 @@ +import { LucideIcon } from 'lucide-react'; +import { MouseEventHandler } from 'react'; +import { ActionType, Size } from '../utils/button'; +import { Button } from '../Button'; +import styled from 'styled-components'; +import { media } from '../utils/media'; +import { ButtonPriorityContext } from '../utils/ButtonPriorityContext'; + +const Root = styled.div<{ $size: Size }>` + display: flex; + flex-direction: ${props => (props.$size === 'sparse' ? 'column' : 'row')}; + gap: ${props => props.theme.spacing(2)}; + + ${props => media.tablet` + flex-direction: row; + gap: ${props.theme.spacing(props.$size === 'sparse' ? 4 : 2)}; + `} +`; + +const ButtonWrapper = styled.div<{ $size: Size; $iconOnly?: boolean }>` + flex-grow: ${props => (props.$size === 'sparse' && !props.$iconOnly ? 1 : 0)}; + flex-shrink: ${props => (props.$size === 'sparse' && !props.$iconOnly ? 1 : 0)}; +`; + +type ButtonDescription = { + label: string; + onClick?: MouseEventHandler; +} & (IconOnly extends true ? { icon: LucideIcon } : { icon?: LucideIcon }); + +export interface ButtonGroupProps { + /** + * An array of objects, each describing a button to render. The first will be + * rendered with the `primary` priority, the rest with the `secondary` + * priority. + */ + buttons: ButtonDescription[]; + /** + * The action type of the button group. Will be used for all buttons in the + * group. + */ + actionType?: ActionType; + /** Will be used for all buttons in the group. */ + size?: Size; + /** + * When `true`, will render just icon buttons. The label for each button will + * be used as the `aria-label`. + * + * Will be used for all buttons in the group. + */ + iconOnly?: IconOnly; +} + +const isIconOnly = (props: ButtonGroupProps): props is ButtonGroupProps => + !!props.iconOnly; + +/** + * Use a `` to render multiple buttons in a group with the same + * `actionType` and `size`. + * + * When rendering multiple Penumbra UI buttons together, always use a + * `` rather than individual ` + + + ))} + + {!isIconOnly(props) && + props.buttons.map((button, index) => ( + + + + + + ))} + +); diff --git a/packages/ui/src/Colors.mdx b/packages/ui/src/Colors.mdx new file mode 100644 index 0000000000..316f688d59 --- /dev/null +++ b/packages/ui/src/Colors.mdx @@ -0,0 +1,10 @@ +import { Meta, Canvas } from '@storybook/blocks'; +import * as ColorsStories from './Colors.stories'; + + + +# Colors + +Below are the colors we use at Penumbra. We deliberately do not include RGB/hex values for our colors, as they should be used via tokens — e.g., `text.primary`, `neutral.light`, `destructive.main`, etc. — to ensure consistency. + + diff --git a/packages/ui/src/Colors.stories.tsx b/packages/ui/src/Colors.stories.tsx new file mode 100644 index 0000000000..9f763ba1b7 --- /dev/null +++ b/packages/ui/src/Colors.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Grid } from './Grid'; +import { Text } from './Text'; +import styled from 'styled-components'; +import type { ColorVariant, Color as TColor, TextColorVariant } from './ThemeProvider/theme'; +import { Fragment } from 'react'; +import { media } from './utils/media'; + +const meta: Meta = {}; +export default meta; + +const Label = styled.div` + display: flex; + height: 100%; + + ${media.tablet` + align-items: center; + `} +`; + +const Variants = styled.div` + display: grid; + gap: ${props => props.theme.spacing(4)}; + grid-template-columns: 1fr; + + ${media.tablet` + grid-template-columns: repeat(4, 1fr); + `} +`; + +type VariantProps = + | { + $color: 'text'; + $colorVariant: TextColorVariant; + } + | { + $color: Exclude; + $colorVariant: ColorVariant; + }; + +const Variant = styled.div` + background-color: ${props => + props.$color === 'text' ? 'transparent' : props.theme.color[props.$color][props.$colorVariant]}; + border-radius: ${props => props.theme.borderRadius.xl}; + color: ${props => + props.$color === 'text' + ? props.theme.color.text[props.$colorVariant] + : props.$colorVariant === 'contrast' || props.$colorVariant === 'light' + ? props.theme.color[props.$color].dark + : props.theme.color.text.primary}; + padding: ${props => props.theme.spacing(2)}; +`; + +const BASE_COLORS: Exclude[] = [ + 'neutral', + 'primary', + 'secondary', + 'unshield', + 'destructive', + 'caution', + 'success', +]; + +const Color = >({ color }: { color: T }) => ( + + + + + + + {color === 'text' + ? (['primary', 'secondary', 'disabled', 'special'] as const).map(variant => ( + + {variant} + + )) + : (['main', 'light', 'dark', 'contrast'] as const).map(variant => ( + + {variant} + + ))} + + + +); + +export const ColorGrid: StoryObj = { + tags: ['!dev'], + render: function Render() { + return ( + <> + + + + {BASE_COLORS.map(color => ( + + ))} + + + ); + }, +}; diff --git a/packages/ui/src/Grid/index.stories.tsx b/packages/ui/src/Grid/index.stories.tsx new file mode 100644 index 0000000000..14f95a435b --- /dev/null +++ b/packages/ui/src/Grid/index.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Grid } from '.'; +import styled from 'styled-components'; +import { Text } from '../Text'; + +const meta: Meta = { + component: Grid, + title: 'Grid', + tags: ['autodocs', '!dev'], + argTypes: { + container: { control: false }, + mobile: { control: false }, + tablet: { control: false }, + desktop: { control: false }, + lg: { control: false }, + xl: { control: false }, + as: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +const Item = styled.div` + background-color: ${props => props.theme.color.neutral.main}; + display: flex; + align-items: center; + justify-content: center; + padding: ${props => props.theme.spacing(2)}; +`; + +export const Demo: Story = { + render: function Render() { + return ( + + + + mobile=12 + + + + {Array(2) + .fill(null) + .map((_, index) => ( + + + mobile=12 tablet=6 + + + ))} + + {Array(4) + .fill(null) + .map((_, index) => ( + + + mobile=6 tablet=6 desktop=3 + + + ))} + + {Array(48) + .fill(null) + .map((_, index) => ( + + + lg=1 + + + ))} + + ); + }, +}; diff --git a/packages/ui/src/Grid/index.tsx b/packages/ui/src/Grid/index.tsx new file mode 100644 index 0000000000..4d8c882456 --- /dev/null +++ b/packages/ui/src/Grid/index.tsx @@ -0,0 +1,135 @@ +import { PropsWithChildren } from 'react'; +import styled from 'styled-components'; +import { AsTransientProps, asTransientProps } from '../utils/asTransientProps'; +import { media } from '../utils/media'; + +type GridElement = 'div' | 'main' | 'section'; + +interface BaseGridProps extends Record { + /** Which element to use. Defaults to `'div'`. */ + as?: GridElement; +} + +interface GridContainerProps extends BaseGridProps { + /** Whether this is a grid container, vs. an item. */ + container: true; + + // For some reason, Storybook needs these properties to be defined on the + // container props interface in order to show their typings properly. + mobile?: undefined; + tablet?: undefined; + desktop?: undefined; + lg?: undefined; + xl?: undefined; +} + +interface GridItemProps extends BaseGridProps { + /** Whether this is a grid container, vs. an item. */ + container?: false; + /** + * The number of columns this grid item should span on mobile. + * + * The mobile grid layout can only be split in half, so you can only set a + * grid item to 6 or 12 columns on mobile. + */ + mobile?: 6 | 12; + /** + * The number of columns this grid item should span on tablet. + * + * The tablet grid layout can only be split into six columns. + */ + tablet?: 2 | 4 | 6 | 8 | 10 | 12; + /** The number of columns this grid item should span on desktop. */ + desktop?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** The number of columns this grid item should span on large screens. */ + lg?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** The number of columns this grid item should span on XL screens. */ + xl?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; +} + +export type GridProps = PropsWithChildren; + +const Container = styled.div` + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: ${props => props.theme.spacing(4)}; +`; + +const Item = styled.div>>` + ${props => media.mobile` + grid-column: span ${props.$mobile ?? 12}; + `} + + ${props => + props.$tablet && + media.tablet` + grid-column: span ${props.$tablet}; + `} + + ${props => + props.$desktop && + media.desktop` + grid-column: span ${props.$desktop}; + `} + + ${props => + props.$lg && + media.lg` + grid-column: span ${props.$lg}; + `} + + ${props => + props.$xl && + media.xl` + grid-column: span ${props.$xl}; + `} +`; + +/** + * A responsive grid component that makes 12-column layouts super easy to build. + * + * Pass the `container` prop to the root `` component; then, any nested + * children ``s will be treated as grid items. You can customize which + * HTML element to use for each grid container or item by passing the element's + * name via the optional `as` prop. + * + * Use the `` component — rather than styling your own HTML elements + * with `display: grid` — to ensure consistent behavior (such as grid gutters) + * throughout your app. + * + * ```tsx + * + * This will span the full width on all screen sizes. + * + * So will this. + * + * + * These will span the full width on mobile... + * + * + * + * ...but half the width on desktop. + * + * + * + * These will... + * + * + * + * ...take up... + * + * + * + * ...one third each. + * + * + * ``` + */ +export const Grid = ({ container, children, as = 'div', ...props }: GridProps) => + container ? ( + {children} + ) : ( + + {children} + + ); diff --git a/packages/ui/src/Icon/index.stories.ts b/packages/ui/src/Icon/index.stories.ts new file mode 100644 index 0000000000..4e15331937 --- /dev/null +++ b/packages/ui/src/Icon/index.stories.ts @@ -0,0 +1,25 @@ +import { ArrowRightLeft, Send, Wallet } from 'lucide-react'; +import { Meta, StoryObj } from '@storybook/react'; + +import { Icon } from '.'; + +const meta: Meta = { + component: Icon, + tags: ['autodocs', '!dev'], + argTypes: { + IconComponent: { + options: ['ArrowRightLeft', 'Send', 'Wallet'], + mapping: { ArrowRightLeft, Send, Wallet }, + }, + }, +}; + +export default meta; + +export const Basic: StoryObj = { + args: { + IconComponent: ArrowRightLeft, + size: 'sm', + color: 'white', + }, +}; diff --git a/packages/ui/src/Icon/index.tsx b/packages/ui/src/Icon/index.tsx new file mode 100644 index 0000000000..154f2d9969 --- /dev/null +++ b/packages/ui/src/Icon/index.tsx @@ -0,0 +1,56 @@ +import { LucideIcon } from 'lucide-react'; +import { ComponentProps } from 'react'; + +export type IconSize = 'sm' | 'md' | 'lg'; + +export interface IconProps { + /** + * The icon import from `lucide-react` to render. + * + * ```tsx + * import { ChevronRight } from 'lucide-react'; + * + * ``` + */ + IconComponent: LucideIcon; + /** + * - `sm`: 16px square + * - `md`: 24px square + * - `lg`: 48px square + */ + size: IconSize; + /** + * The CSS color to render the icon with. If left undefined, will default to + * the parent's text color (`currentColor` in SVG terms). + */ + color?: string; +} + +const PROPS_BY_SIZE: Record> = { + sm: { + size: 16, + strokeWidth: 1, + }, + md: { + size: 24, + strokeWidth: 1.5, + }, + lg: { + size: 48, + strokeWidth: 2, + }, +}; + +/** + * Renders the Lucide icon passed in via the `IconComponent` prop. Use this + * component rather than rendering Lucide icon components directly, since this + * component standardizes the stroke width and sizes throughout the Penumbra + * ecosystem. + * + * ```tsx + * + * ``` + */ +export const Icon = ({ IconComponent, size = 'sm', color }: IconProps) => ( + +); diff --git a/packages/ui/src/Pill/index.stories.tsx b/packages/ui/src/Pill/index.stories.tsx new file mode 100644 index 0000000000..b5cbd08eef --- /dev/null +++ b/packages/ui/src/Pill/index.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Pill } from '.'; + +const meta: Meta = { + component: Pill, + tags: ['autodocs', '!dev'], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + children: 'Pill', + size: 'sparse', + priority: 'primary', + }, +}; diff --git a/packages/ui/src/Pill/index.test.tsx b/packages/ui/src/Pill/index.test.tsx new file mode 100644 index 0000000000..ba4b6540aa --- /dev/null +++ b/packages/ui/src/Pill/index.test.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { Pill } from '.'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '../ThemeProvider'; + +describe('', () => { + it('renders its `children`', () => { + const { queryByText } = render(Contents, { wrapper: ThemeProvider }); + + expect(queryByText('Contents')).toBeTruthy(); + }); +}); diff --git a/packages/ui/src/Pill/index.tsx b/packages/ui/src/Pill/index.tsx new file mode 100644 index 0000000000..797747e83a --- /dev/null +++ b/packages/ui/src/Pill/index.tsx @@ -0,0 +1,43 @@ +import styled from 'styled-components'; +import { asTransientProps } from '../utils/asTransientProps'; +import { ReactNode } from 'react'; +import { button } from '../utils/typography'; + +type Size = 'sparse' | 'dense'; +type Priority = 'primary' | 'secondary'; + +const TEN_PERCENT_OPACITY_IN_HEX = '1a'; + +const Root = styled.span<{ $size: Size; $priority: Priority }>` + ${button} + + box-sizing: border-box; + border: 2px dashed + ${props => + props.$priority === 'secondary' ? props.theme.color.other.tonalStroke : 'transparent'}; + border-radius: ${props => props.theme.borderRadius.full}; + + display: inline-block; + max-width: 100%; + + padding-top: ${props => props.theme.spacing(props.$size === 'sparse' ? 2 : 1)}; + padding-bottom: ${props => props.theme.spacing(props.$size === 'sparse' ? 2 : 1)}; + + padding-left: ${props => props.theme.spacing(props.$size === 'sparse' ? 4 : 2)}; + padding-right: ${props => props.theme.spacing(props.$size === 'sparse' ? 4 : 2)}; + + background-color: ${props => + props.$priority === 'primary' + ? props.theme.color.text.primary + TEN_PERCENT_OPACITY_IN_HEX + : 'transparent'}; +`; + +export interface PillProps { + children: ReactNode; + size?: Size; + priority?: Priority; +} + +export const Pill = ({ children, size = 'sparse', priority = 'primary' }: PillProps) => ( + {children} +); diff --git a/packages/ui/src/Tabs/index.stories.tsx b/packages/ui/src/Tabs/index.stories.tsx new file mode 100644 index 0000000000..055c3d0d4c --- /dev/null +++ b/packages/ui/src/Tabs/index.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useArgs } from '@storybook/preview-api'; + +import { Tabs } from '.'; + +const meta: Meta = { + component: Tabs, + tags: ['autodocs', '!dev'], + argTypes: { + value: { control: false }, + options: { control: false }, + onChange: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + actionType: 'default', + value: 'first', + options: [ + { label: 'First', value: 'first' }, + { label: 'Second', value: 'second' }, + { label: 'Third', value: 'third' }, + { label: 'Fourth (disabled)', value: 'fourth', disabled: true }, + ], + }, + + render: function Render(props) { + const [, updateArgs] = useArgs(); + + const onChange = (value: { toString: () => string }) => updateArgs({ value }); + + return ; + }, +}; diff --git a/packages/ui/src/Tabs/index.test.tsx b/packages/ui/src/Tabs/index.test.tsx new file mode 100644 index 0000000000..dbc82ea583 --- /dev/null +++ b/packages/ui/src/Tabs/index.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Tabs } from '.'; +import { fireEvent, render } from '@testing-library/react'; +import { ThemeProvider } from '../ThemeProvider'; + +describe('', () => { + it('renders a button for each of the `options`', () => { + const { queryByText } = render( + , + { wrapper: ThemeProvider }, + ); + + expect(queryByText('One')).toBeTruthy(); + expect(queryByText('Two')).toBeTruthy(); + }); + + it("calls the `onChange` handler with the clicked option's value when clicked", () => { + const onChange = vi.fn(); + const { getByText } = render( + , + { wrapper: ThemeProvider }, + ); + + fireEvent.click(getByText('Two')); + + expect(onChange).toHaveBeenCalledWith('two'); + }); +}); diff --git a/packages/ui/src/Tabs/index.tsx b/packages/ui/src/Tabs/index.tsx new file mode 100644 index 0000000000..d88e707c33 --- /dev/null +++ b/packages/ui/src/Tabs/index.tsx @@ -0,0 +1,135 @@ +import styled, { DefaultTheme } from 'styled-components'; +import { tab } from '../utils/typography'; +import { motion } from 'framer-motion'; +import { useId } from 'react'; +import { buttonInteractions } from '../utils/button'; +import * as RadixTabs from '@radix-ui/react-tabs'; + +const TEN_PERCENT_OPACITY_IN_HEX = '1a'; + +const Root = styled.div` + border: 1px solid ${props => props.theme.color.other.tonalStroke}; + border-radius: ${props => props.theme.borderRadius.sm}; + height: 52px; + padding: ${props => props.theme.spacing(1)}; + + display: flex; + align-items: stretch; + box-sizing: border-box; +`; + +type ActionType = 'default' | 'accent' | 'unshield'; + +const outlineColorByActionType: Record = { + default: 'neutralFocusOutline', + accent: 'primaryFocusOutline', + unshield: 'unshieldFocusOutline', +}; + +const Tab = styled.button<{ + $actionType: ActionType; + $getFocusOutlineColor: (theme: DefaultTheme) => string; + $getBorderRadius: (theme: DefaultTheme) => string; +}>` + flex-grow: 1; + flex-shrink: 1; + flex-basis: 0; /** Ensure equal widths */ + + appearance: none; + background-color: transparent; + border: none; + border-radius: ${props => props.theme.borderRadius.xs}; + color: ${props => { + switch (props.$actionType) { + case 'accent': + return props.theme.color.primary.light; + case 'unshield': + return props.theme.color.unshield.light; + default: + return props.theme.color.text.primary; + } + }}; + position: relative; + white-space: nowrap; + cursor: pointer; + + ${tab} + ${buttonInteractions} + + &::after { + inset: ${props => props.theme.spacing(0.5)}; + } +`; + +const SelectedIndicator = styled(motion.div)` + background-color: ${props => props.theme.color.text.primary + TEN_PERCENT_OPACITY_IN_HEX}; + border-radius: ${props => props.theme.borderRadius.xs}; + position: absolute; + inset: 0; + z-index: -1; +`; + +export interface TabsTab { + value: string; + label: string; + disabled?: boolean; +} + +export interface TabsProps { + value: string; + onChange: (value: string) => void; + options: TabsTab[]; + actionType?: ActionType; +} + +/** + * Use tabs for switching between related pages or views. + * + * Built atop Radix UI's `` component, so it's fully accessible and + * supports keyboard navigation. + * + * ```TSX + * + * ``` + */ +export const Tabs = ({ value, onChange, options, actionType = 'default' }: TabsProps) => { + const layoutId = useId(); + + return ( + + + + {options.map(option => ( + + onChange(option.value)} + disabled={option.disabled} + $actionType={actionType} + $getFocusOutlineColor={theme => + theme.color.action[outlineColorByActionType[actionType]] + } + $getBorderRadius={theme => theme.borderRadius.xs} + > + {value === option.value && } + {option.label} + + + ))} + + + + ); +}; diff --git a/packages/ui/src/Text/index.stories.tsx b/packages/ui/src/Text/index.stories.tsx new file mode 100644 index 0000000000..2fccaf195f --- /dev/null +++ b/packages/ui/src/Text/index.stories.tsx @@ -0,0 +1,155 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Text } from '.'; +import styled from 'styled-components'; +import { useArgs } from '@storybook/preview-api'; + +const meta: Meta = { + component: Text, + tags: ['autodocs', '!dev'], + argTypes: { + h1: { control: false }, + h2: { control: false }, + h3: { control: false }, + h4: { control: false }, + large: { control: false }, + body: { control: false }, + p: { control: false }, + strong: { control: false }, + detail: { control: false }, + small: { control: false }, + technical: { control: false }, + + as: { + options: ['span', 'div', 'h1', 'h2', 'h3', 'h4', 'p', 'main', 'section'], + }, + }, +}; +export default meta; + +const Wrapper = styled.div<{ $dir: 'column' | 'row' }>` + display: flex; + flex-direction: ${props => props.$dir}; + ${props => (props.$dir === 'row' ? `align-items: center;` : '')} + gap: ${props => props.theme.spacing(2)}; +`; + +const OPTIONS = [ + 'h1', + 'h2', + 'h3', + 'h4', + 'large', + 'body', + 'p', + 'strong', + 'detail', + 'small', + 'technical', +] as const; + +const Option = ({ + value, + checked, + onSelect, +}: { + value: (typeof OPTIONS)[number]; + checked: boolean; + onSelect: (value: (typeof OPTIONS)[number]) => void; +}) => ( + +); +export const KitchenSink: StoryObj = { + args: { + children: 'The quick brown fox jumps over the lazy dog.', + h1: true, + as: 'span', + }, + + render: function Render(props) { + const [, updateArgs] = useArgs(); + + const onSelect = (option: (typeof OPTIONS)[number]) => + updateArgs( + OPTIONS.reduce( + (prev, curr) => ({ + ...prev, + [curr]: curr === option ? true : undefined, + }), + {}, + ), + ); + + return ( + + + Text style: + {OPTIONS.map(option => ( + + + + + ); + }, +}; + +export const UsageExample: StoryObj = { + render: function Render() { + return ( + <> + h1. Typography + h2. This is a section + + Here is some filler text: Giggster kickstarter painting with light + academy award charlie kaufman shotdeck breakdown services indie white balance. Student + emmys sound design ots character arc low angle coming-of-age composition. Storyboard beat + sheet greenlight cowboy shot margarita shot blocking foley stage seed&spark. + + + + Shot list low angle mit out sound telephoto rec.709 high angle eyeline assembly cut 8 1/2 + dga. Post-viz circle of confusion location scout unpaid internship reality of doing genre + film. Jean-luc godard ilm symbolism alexa mini white balance margarita shot. Jordan peele + log line ryan coogler actors access. + + + h2. Section two + + Silent film conflict sound design blocking script treatment. Teal and orange composition + fotokem third act blackmagic ingmar bergman jordan peele rembrandt lighting critical + darling silent film. Wes anderson arthouse diegetic sound after effects. + + + This is some large text. + + + White balance crafty debut film pan up 180-degree rule academy award exposure triangle + director's vision. Lavs led wall the actor prepares wrylies character arc stinger + sanford meisner. Given circumstances under-exposed jordan peele color grade nomadland team + deakins crafty dogme 95. French new wave pan up save the cat contrast ratio blue filter + cinema studies super 16 jump cut cannes unreal engine. + + + + Establishing shot stella adler ludwig göransson first-time director shotdeck fotokem + over-exposed flashback reality of doing color grade. Fetch coffee student emmys indie key + light rembrandt lighting. Undercranking beat beat scriptnotes podcast. Sound design + academy award day-for-night christopher nolan undercranking. Unreal engine visionary match + cut grain vs. noise 35mm anti-hero production design. + + + ); + }, +}; diff --git a/packages/ui/src/Text/index.tsx b/packages/ui/src/Text/index.tsx new file mode 100644 index 0000000000..3b3ca6d3cc --- /dev/null +++ b/packages/ui/src/Text/index.tsx @@ -0,0 +1,242 @@ +import styled, { WebTarget } from 'styled-components'; +import { body, detail, h1, h2, h3, h4, large, small, strong, technical } from '../utils/typography'; +import { ReactNode } from 'react'; + +const H1 = styled.h1` + ${h1} +`; + +const H2 = styled.h2` + ${h2} +`; + +const H3 = styled.h3` + ${h3} +`; + +const H4 = styled.h4` + ${h4} +`; + +const Large = styled.span` + ${large} +`; + +const Body = styled.span` + ${body} +`; + +const Strong = styled.span` + ${strong} +`; + +const Detail = styled.span` + ${detail} +`; + +const Small = styled.span` + ${small} +`; + +const Technical = styled.span` + ${technical} +`; + +const P = styled.p` + ${body} + + margin-bottom: ${props => props.theme.lineHeight.textBase}; + + &:last-child { + margin-bottom: 0; + } +`; + +/** + * Utility interface to be used below to ensure that only one text type is used + * at a time. + */ +interface NeverTextTypes { + h1?: never; + h2?: never; + h3?: never; + h4?: never; + large?: never; + p?: never; + strong?: never; + detail?: never; + small?: never; + technical?: never; + body?: never; +} + +type TextType = + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h1: true; + }) + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h2: true; + }) + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h3: true; + }) + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h4: true; + }) + | (Omit & { + /** + * Renders bigger text used for section titles. Renders a `` by + * default; pass the `as` prop to use a different HTML element with the + * same styling. + */ + large: true; + }) + | (Omit & { + /** + * Renders a styled `

` tag with a bottom-margin (unless it's the last + * child). Aside from the margin, `

` is identical to ``. + * + * Note that this is the only component in the entire Penumbra UI library + * that renders an external margin. It's a convenience for developers who + * don't want to wrap each `` in a `

` with the + * appropriate margin, or a flex columnn with a gap. + */ + p: true; + }) + | (Omit & { + /** + * Emphasized body text. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + strong: true; + }) + | (Omit & { + /** + * Detail text used for small bits of tertiary information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + detail: true; + }) + | (Omit & { + /** + * Small text used for secondary information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + small: true; + }) + | (Omit & { + /** + * Monospaced text used for code, values, and other technical information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + technical: true; + }) + | (Omit & { + /** + * Body text used throughout most of our UIs. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + body?: true; + }); + +export type TextProps = TextType & { + children?: ReactNode; + /** + * Which component or HTML element to render this text as. + * + * @example + * ```tsx + * This is a span with H1 styling + * ``` + */ + as?: WebTarget; +}; + +/** + * All-purpose text wrapper for quickly styling text per the Penumbra UI + * guidelines. + * + * Use with a _single_ text style name: + * + * ```tsx + * This will be rendered with the `h1` style. + * This will be rendered with the `body` style. + * + * INCORRECT: This will result in a TypeScript error. Only use one text style + * at a time. + * + * ``` + * + * When no text style is passed, it will render using the `body` style. + * + * The heading text styles are rendered as their corresponding heading tags + * (`

`, `

`, etc.), and the `p` style is rendered as a `

` tag. + * All other styles are rendered as ``s. To customize which tag is + * rendered without affecting its appearance, use the `as` prop: + * + * ```tsx + * + * This will render with the h1 style, but inside an inline span tag. + * + * ``` + */ +export const Text = (props: TextProps) => { + if (props.h1) { + return

; + } + if (props.h2) { + return

; + } + if (props.h3) { + return

; + } + if (props.h4) { + return

; + } + if (props.large) { + return ; + } + if (props.strong) { + return ; + } + if (props.detail) { + return ; + } + if (props.small) { + return ; + } + if (props.technical) { + return ; + } + if (props.p) { + return

; + } + + return ; +}; diff --git a/packages/ui/src/ThemeProvider/FontFaces.tsx b/packages/ui/src/ThemeProvider/FontFaces.tsx new file mode 100644 index 0000000000..b00158185b --- /dev/null +++ b/packages/ui/src/ThemeProvider/FontFaces.tsx @@ -0,0 +1,122 @@ +import { createGlobalStyle } from 'styled-components'; + +import iosevkaTerm from './fonts/IosevkaTerm-Regular.woff2?url'; +import poppinsItalic from './fonts/Poppins-Italic.woff2?url'; +import poppinsItalicLatinExt from './fonts/Poppins-Italic-LatinExt.woff2?url'; +import poppinsMedium from './fonts/Poppins-Medium.woff2?url'; +import poppinsMediumItalic from './fonts/Poppins-MediumItalic.woff2?url'; +import poppinsMediumItalicLatinExt from './fonts/Poppins-MediumItalic-LatinExt.woff2?url'; +import poppinsMediumLatinExt from './fonts/Poppins-Medium-LatinExt.woff2?url'; +import poppinsRegular from './fonts/Poppins-Regular.woff2?url'; +import poppinsRegularLatinExt from './fonts/Poppins-Regular-LatinExt.woff2?url'; +import workSansMedium from './fonts/WorkSans-Medium.woff2?url'; +import workSansMediumLatinExt from './fonts/WorkSans-Medium-LatinExt.woff2?url'; +import workSansMediumVietnamese from './fonts/WorkSans-Medium-Vietnamese.woff2?url'; + +/** @see https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;1,400;1,500&family=Work+Sans:wght@500&display=swap */ +export const FontFaces = createGlobalStyle` +@font-face { + font-family: 'Iosevka Term'; + font-weight: 400; + src: url('${iosevkaTerm}') format('woff2'); +} +/* latin-ext */ +@font-face { + font-family: 'Poppins'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('${poppinsItalicLatinExt}') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Poppins'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('${poppinsItalic}') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Poppins'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: url('${poppinsMediumItalicLatinExt}') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Poppins'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: url('${poppinsMediumItalic}') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('${poppinsRegularLatinExt}') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('${poppinsRegular}') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('${poppinsMediumLatinExt}') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('${poppinsMedium}') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* vietnamese */ +@font-face { + font-family: 'Work Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('${workSansMediumVietnamese}') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Work Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('${workSansMediumLatinExt}') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Work Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('${workSansMedium}') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +`; diff --git a/packages/ui/src/ThemeProvider/fonts.d.ts b/packages/ui/src/ThemeProvider/fonts.d.ts new file mode 100644 index 0000000000..302db6981b --- /dev/null +++ b/packages/ui/src/ThemeProvider/fonts.d.ts @@ -0,0 +1,4 @@ +declare module '*?url' { + const content: string; + export default content; +} diff --git a/packages/ui/src/ThemeProvider/fonts/IosevkaTerm-Regular.woff2 b/packages/ui/src/ThemeProvider/fonts/IosevkaTerm-Regular.woff2 new file mode 100644 index 0000000000..4c0d1e4810 Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/IosevkaTerm-Regular.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/Poppins-Italic-LatinExt.woff2 b/packages/ui/src/ThemeProvider/fonts/Poppins-Italic-LatinExt.woff2 new file mode 100644 index 0000000000..98bf7e0a4a Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/Poppins-Italic-LatinExt.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/Poppins-Italic.woff2 b/packages/ui/src/ThemeProvider/fonts/Poppins-Italic.woff2 new file mode 100644 index 0000000000..7c769e4d9b Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/Poppins-Italic.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/Poppins-Medium-LatinExt.woff2 b/packages/ui/src/ThemeProvider/fonts/Poppins-Medium-LatinExt.woff2 new file mode 100644 index 0000000000..e567af4a1b Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/Poppins-Medium-LatinExt.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/Poppins-Medium.woff2 b/packages/ui/src/ThemeProvider/fonts/Poppins-Medium.woff2 new file mode 100644 index 0000000000..ebe2c494e9 Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/Poppins-Medium.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/Poppins-MediumItalic-LatinExt.woff2 b/packages/ui/src/ThemeProvider/fonts/Poppins-MediumItalic-LatinExt.woff2 new file mode 100644 index 0000000000..4e56f206f4 Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/Poppins-MediumItalic-LatinExt.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/Poppins-MediumItalic.woff2 b/packages/ui/src/ThemeProvider/fonts/Poppins-MediumItalic.woff2 new file mode 100644 index 0000000000..3fede4b83b Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/Poppins-MediumItalic.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/Poppins-Regular-LatinExt.woff2 b/packages/ui/src/ThemeProvider/fonts/Poppins-Regular-LatinExt.woff2 new file mode 100644 index 0000000000..23b9ce118e Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/Poppins-Regular-LatinExt.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/Poppins-Regular.woff2 b/packages/ui/src/ThemeProvider/fonts/Poppins-Regular.woff2 new file mode 100644 index 0000000000..ed45aa4a70 Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/Poppins-Regular.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/WorkSans-Medium-LatinExt.woff2 b/packages/ui/src/ThemeProvider/fonts/WorkSans-Medium-LatinExt.woff2 new file mode 100644 index 0000000000..e11f4626b6 Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/WorkSans-Medium-LatinExt.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/WorkSans-Medium-Vietnamese.woff2 b/packages/ui/src/ThemeProvider/fonts/WorkSans-Medium-Vietnamese.woff2 new file mode 100644 index 0000000000..aeb59df91e Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/WorkSans-Medium-Vietnamese.woff2 differ diff --git a/packages/ui/src/ThemeProvider/fonts/WorkSans-Medium.woff2 b/packages/ui/src/ThemeProvider/fonts/WorkSans-Medium.woff2 new file mode 100644 index 0000000000..12b62a4dd6 Binary files /dev/null and b/packages/ui/src/ThemeProvider/fonts/WorkSans-Medium.woff2 differ diff --git a/packages/ui/src/ThemeProvider/index.tsx b/packages/ui/src/ThemeProvider/index.tsx new file mode 100644 index 0000000000..cc1a025cb8 --- /dev/null +++ b/packages/ui/src/ThemeProvider/index.tsx @@ -0,0 +1,19 @@ +import { ThemeProvider as ThemeProviderPrimitive } from 'styled-components'; +import { theme } from './theme'; +import { PropsWithChildren } from 'react'; +import { FontFaces } from './FontFaces'; +import { MotionConfig } from 'framer-motion'; + +/** + * Place at the root of your app, above all Penumbra UI components, to provide + * the theme values that they use. + */ +export const ThemeProvider = ({ children }: PropsWithChildren) => ( + + + + + {children} + + +); diff --git a/packages/ui/src/ThemeProvider/theme.ts b/packages/ui/src/ThemeProvider/theme.ts new file mode 100644 index 0000000000..779948e11a --- /dev/null +++ b/packages/ui/src/ThemeProvider/theme.ts @@ -0,0 +1,227 @@ +import { DefaultTheme } from 'styled-components'; + +/** + * Used for reference in the `theme` object below. Not intended to be used + * directly by consumers, but rather as a semantic reference for building the + * theme. + */ +const PALETTE = { + green: { + 50: '#f0fdf4', + 100: '#DEFAE8', + 200: '#BFF3D1', + 300: '#8DE8AE', + 400: '#55D383', + 500: '#2DBA61', + 600: '#1F9A4C', + 700: '#1C793F', + 800: '#1C5F36', + 900: '#194E2E', + 950: '#03160B', + }, + neutral: { + 50: '#fafafa', + 100: '#f5f5f5', + 200: '#e5e5e5', + 300: '#d4d4d4', + 400: '#a3a3a3', + 500: '#737373', + 600: '#525252', + 700: '#404040', + 800: '#262626', + 900: '#171717', + 950: '#0a0a0a', + }, + orange: { + 50: '#FFF8ED', + 100: '#FDEED6', + 200: '#FBDBAD', + 300: '#F8C079', + 400: '#F49C43', + 500: '#F07E1C', + 600: '#E16615', + 700: '#BA4D14', + 800: '#933E19', + 900: '#773517', + 950: '#200B04', + }, + purple: { + 50: '#FAF7FC', + 100: '#F5F0F7', + 200: '#E9E0EE', + 300: '#D8C7E0', + 400: '#C1A6CC', + 500: '#A582B3', + 600: '#886693', + 700: '#705279', + 800: '#5F4766', + 900: '#4F3C53', + 950: '#180E1B', + }, + red: { + 50: '#fef2f2', + 100: '#FCE4E4', + 200: '#FBCDCD', + 300: '#F8A9A9', + 400: '#F17878', + 500: '#E54E4E', + 600: '#CF3333', + 700: '#AF2626', + 800: '#902424', + 900: '#772525', + 950: '#1E0606', + }, + teal: { + 50: '#f1fcfa', + 100: '#D4F3EE', + 200: '#92DFD5', + 300: '#77D1C8', + 400: '#53AEA8', + 500: '#319B96', + 600: '#257C79', + 700: '#226362', + 800: '#204F4F', + 900: '#1F4242', + 950: '#031516', + }, + yellow: { + 50: '#FDFCE9', + 100: '#FBF7C6', + 200: '#F8EB90', + 300: '#F4DA50', + 400: '#E8C127', + 500: '#DDAD15', + 600: '#C0860E', + 700: '#99610F', + 800: '#7E4D15', + 900: '#6B3F18', + 950: '#201004', + }, +}; + +const FIFTEEN_PERCENT_OPACITY_IN_HEX = '26'; +const EIGHTY_PERCENT_OPACITY_IN_HEX = 'cc'; + +export const theme: DefaultTheme = { + borderRadius: { + none: '0px', + xs: '4px', + sm: '8px', + md: '12px', + lg: '16px', + xl: '20px', + '2xl': '24px', + full: '9999px', + }, + breakpoint: { + mobile: 0, + tablet: 600, + desktop: 900, + lg: 1200, + xl: 1600, + }, + color: { + neutral: { + main: PALETTE.neutral['700'], + light: PALETTE.neutral['400'], + dark: PALETTE.neutral['900'], + contrast: PALETTE.neutral['50'], + }, + primary: { + main: PALETTE.orange['700'], + light: PALETTE.orange['400'], + dark: PALETTE.orange['950'], + contrast: PALETTE.orange['50'], + }, + secondary: { + main: PALETTE.teal['700'], + light: PALETTE.teal['400'], + dark: PALETTE.teal['950'], + contrast: PALETTE.teal['50'], + }, + unshield: { + main: PALETTE.purple['700'], + light: PALETTE.purple['400'], + dark: PALETTE.purple['950'], + contrast: PALETTE.purple['50'], + }, + destructive: { + main: PALETTE.red['700'], + light: PALETTE.red['400'], + dark: PALETTE.red['950'], + contrast: PALETTE.red['50'], + }, + caution: { + main: PALETTE.yellow['700'], + light: PALETTE.yellow['400'], + dark: PALETTE.yellow['950'], + contrast: PALETTE.yellow['50'], + }, + success: { + main: PALETTE.green['700'], + light: PALETTE.green['400'], + dark: PALETTE.green['950'], + contrast: PALETTE.green['50'], + }, + text: { + primary: PALETTE.neutral['50'], + secondary: PALETTE.neutral['300'], + disabled: PALETTE.neutral['500'], + special: PALETTE.orange['400'], + }, + action: { + hoverOverlay: PALETTE.neutral['50'] + FIFTEEN_PERCENT_OPACITY_IN_HEX, + activeOverlay: PALETTE.neutral['950'] + FIFTEEN_PERCENT_OPACITY_IN_HEX, + disabledOverlay: PALETTE.neutral['950'] + EIGHTY_PERCENT_OPACITY_IN_HEX, + primaryFocusOutline: PALETTE.orange['400'], + secondaryFocusOutline: PALETTE.teal['400'], + unshieldFocusOutline: PALETTE.purple['400'], + neutralFocusOutline: PALETTE.neutral['400'], + destructiveFocusOutline: PALETTE.red['400'], + }, + other: { + tonalStroke: PALETTE.neutral['50'] + FIFTEEN_PERCENT_OPACITY_IN_HEX, + solidStroke: PALETTE.neutral['700'], + }, + }, + font: { + default: 'Poppins', + mono: 'Iosevka Term, monospace', + heading: 'Work Sans', + }, + fontSize: { + text9xl: '8rem', + text8xl: '6rem', + text7xl: '4.5rem', + text6xl: '3.75rem', + text5xl: '3rem', + text4xl: '2.25rem', + text3xl: '1.875rem', + text2xl: '1.5rem', + textXl: '1.25rem', + textLg: '1.125rem', + textBase: '1rem', + textSm: '0.875rem', + textXs: '0.75rem', + }, + lineHeight: { + text9xl: '8.25rem', + text8xl: '6.25rem', + text7xl: '5rem', + text6xl: '4.25rem', + text5xl: '3.5rem', + text4xl: '2.75rem', + text3xl: '2.5rem', + text2xl: '2.25rem', + textXl: '2rem', + textLg: '1.75rem', + textBase: '1.5rem', + textSm: '1.25rem', + textXs: '1rem', + }, + spacing: spacingUnits => `${spacingUnits * 4}px`, +}; + +export type Color = keyof DefaultTheme['color']; +export type ColorVariant = keyof DefaultTheme['color']['neutral']; +export type TextColorVariant = keyof DefaultTheme['color']['text']; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/DelegationTokenIcon.tsx b/packages/ui/src/ValueViewComponent/AssetIcon/DelegationTokenIcon.tsx new file mode 100644 index 0000000000..db7a46a802 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/DelegationTokenIcon.tsx @@ -0,0 +1,111 @@ +import { assetPatterns } from '@penumbra-zone/types/assets'; +import styled from 'styled-components'; + +const Svg = styled.svg.attrs({ + id: 'delegation', + xmlns: 'http://www.w3.org/2000/svg', + xmlnsXlink: 'http://www.w3.org/1999/xlink', + viewBox: '0 0 32 32', +})` + display: block; + border-radius: ${props => props.theme.borderRadius.full}; + width: 24px; + height: 24px; +`; + +const getFirstEightCharactersOfValidatorId = (displayDenom = ''): [string, string] => { + const id = (assetPatterns.delegationToken.capture(displayDenom)?.id ?? '').substring(0, 8); + + const firstFour = id.substring(0, 4); + const lastFour = id.substring(4); + + return [firstFour, lastFour]; +}; + +export interface DelegationTokenIconProps { + displayDenom?: string; +} + +export const DelegationTokenIcon = ({ displayDenom }: DelegationTokenIconProps) => { + const [firstFour, lastFour] = getFirstEightCharactersOfValidatorId(displayDenom); + + return ( + + + + + + + + + + + + + + {firstFour} + + + {lastFour} + + + + + + + + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts b/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts new file mode 100644 index 0000000000..500c56120c --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts @@ -0,0 +1,42 @@ +// Inspired by: https://github.com/vercel/avatar + +import color from 'tinycolor2'; +import Murmur from 'murmurhash3js'; + +// Deterministically getting a gradient from a string for use as an identicon +export const generateGradient = (str: string) => { + // Get first color + const hash = Murmur.x86.hash32(str); + const c = color({ h: hash % 360, s: 0.95, l: 0.5 }); + + const tetrad = c.tetrad(); // 4 colors spaced around the color wheel, the first being the input + const secondColorOptions = tetrad.slice(1); + const index = hash % 3; + const toColor = secondColorOptions[index]!.toHexString(); + + return { + fromColor: c.toHexString(), + toColor, + }; +}; + +export const generateSolidColor = (str: string) => { + // Get color + const hash = Murmur.x86.hash32(str); + const c = color({ h: hash % 360, s: 0.95, l: 0.5 }) + .saturate(0) + .darken(20); + return { + bg: c.toHexString(), + // get readable text color + text: color + .mostReadable(c, ['white', 'black'], { + includeFallbackColors: true, + level: 'AAA', + size: 'small', + }) + .saturate() + .darken(20) + .toHexString(), + }; +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx b/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx new file mode 100644 index 0000000000..7b30f26d11 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import { generateGradient, generateSolidColor } from './generate'; +import styled from 'styled-components'; + +const Svg = styled.svg.attrs<{ $size: number }>(props => ({ + width: props.$size, + height: props.$size, + viewBox: `0 0 ${props.$size} ${props.$size}`, + version: '1.1', + xmlns: 'http://www.w3.org/2000/svg', +}))` + display: block; + border-radius: ${props => props.theme.borderRadius.full}; +`; + +export interface IdenticonProps { + uniqueIdentifier: string; + size?: number; + className?: string; + type: 'gradient' | 'solid'; +} + +export const Identicon = (props: IdenticonProps) => { + if (props.type === 'gradient') { + return ; + } + return ; +}; + +const IdenticonGradient = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => { + const gradient = useMemo(() => generateGradient(uniqueIdentifier), [uniqueIdentifier]); + const gradientId = useMemo(() => `gradient-${uniqueIdentifier}`, [uniqueIdentifier]); + + return ( + + + + + + + + + + + + ); +}; + +const IdenticonSolid = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => { + const color = useMemo(() => generateSolidColor(uniqueIdentifier), [uniqueIdentifier]); + + return ( + + + + {uniqueIdentifier[0]} + + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/UnbondingTokenIcon.tsx b/packages/ui/src/ValueViewComponent/AssetIcon/UnbondingTokenIcon.tsx new file mode 100644 index 0000000000..e2a848e77c --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/UnbondingTokenIcon.tsx @@ -0,0 +1,111 @@ +import { assetPatterns } from '@penumbra-zone/types/assets'; +import styled from 'styled-components'; + +const Svg = styled.svg.attrs({ + id: 'unbonding', + xmlns: 'http://www.w3.org/2000/svg', + xmlnsXlink: 'http://www.w3.org/1999/xlink', + viewBox: '0 0 32 32', +})` + display: block; + border-radius: ${props => props.theme.borderRadius.full}; + width: 24px; + height: 24px; +`; + +const getFirstEightCharactersOfValidatorId = (displayDenom = ''): [string, string] => { + const id = (assetPatterns.unbondingToken.capture(displayDenom)?.id ?? '').substring(0, 8); + + const firstFour = id.substring(0, 4); + const lastFour = id.substring(4); + + return [firstFour, lastFour]; +}; + +export interface UnbondingTokenIconProps { + displayDenom?: string; +} + +export const UnbondingTokenIcon = ({ displayDenom }: UnbondingTokenIconProps) => { + const [firstFour, lastFour] = getFirstEightCharactersOfValidatorId(displayDenom); + + return ( + + + + + + + + + + + + + + {firstFour} + + + {lastFour} + + + + + + + + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/index.tsx b/packages/ui/src/ValueViewComponent/AssetIcon/index.tsx new file mode 100644 index 0000000000..83bff060f3 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/index.tsx @@ -0,0 +1,47 @@ +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; +import { Identicon } from './Identicon'; +import { DelegationTokenIcon } from './DelegationTokenIcon'; +import { getDisplay } from '@penumbra-zone/getters/metadata'; +import { assetPatterns } from '@penumbra-zone/types/assets'; +import { UnbondingTokenIcon } from './UnbondingTokenIcon'; +import styled from 'styled-components'; + +const IconImg = styled.img` + display: block; + border-radius: ${props => props.theme.borderRadius.full}; + width: 24px; + height: 24px; +`; + +export interface AssetIcon { + metadata?: Metadata; + size?: 'sparse' | 'dense'; +} + +export const AssetIcon = ({ metadata }: AssetIcon) => { + // Image default is "" and thus cannot do nullish-coalescing + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const icon = metadata?.images[0]?.png || metadata?.images[0]?.svg; + const display = getDisplay.optional()(metadata); + const isDelegationToken = display ? assetPatterns.delegationToken.matches(display) : false; + const isUnbondingToken = display ? assetPatterns.unbondingToken.matches(display) : false; + + return ( + <> + {icon ? ( + + ) : isDelegationToken ? ( + + ) : isUnbondingToken ? ( + /** + * @todo: Render a custom unbonding token for validators that have a + * logo -- e.g., with the validator ID superimposed over the validator + * logo. + */ + + ) : ( + + )} + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/index.stories.tsx b/packages/ui/src/ValueViewComponent/index.stories.tsx new file mode 100644 index 0000000000..aacd4c813b --- /dev/null +++ b/packages/ui/src/ValueViewComponent/index.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ValueViewComponent } from '.'; +import { + DELEGATION_VALUE_VIEW, + PENUMBRA_VALUE_VIEW, + UNBONDING_VALUE_VIEW, + UNKNOWN_ASSET_ID_VALUE_VIEW, + UNKNOWN_ASSET_VALUE_VIEW, +} from './sampleValueViews'; + +const meta: Meta = { + component: ValueViewComponent, + tags: ['autodocs', '!dev'], + argTypes: { + valueView: { + options: [ + 'Penumbra', + 'Delegation token', + 'Unbonding token', + 'Unknown asset', + 'Unknown asset ID', + ], + mapping: { + Penumbra: PENUMBRA_VALUE_VIEW, + 'Delegation token': DELEGATION_VALUE_VIEW, + 'Unbonding token': UNBONDING_VALUE_VIEW, + 'Unknown asset': UNKNOWN_ASSET_VALUE_VIEW, + 'Unknown asset ID': UNKNOWN_ASSET_ID_VALUE_VIEW, + }, + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + valueView: PENUMBRA_VALUE_VIEW, + context: 'default', + size: 'sparse', + priority: 'primary', + }, +}; diff --git a/packages/ui/src/ValueViewComponent/index.test.tsx b/packages/ui/src/ValueViewComponent/index.test.tsx new file mode 100644 index 0000000000..2f064e2cb6 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/index.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { ValueViewComponent } from '.'; +import { render } from '@testing-library/react'; +import { + PENUMBRA_VALUE_VIEW, + UNKNOWN_ASSET_ID_VALUE_VIEW, + UNKNOWN_ASSET_VALUE_VIEW, +} from './sampleValueViews'; +import { ThemeProvider } from '../ThemeProvider'; + +describe('', () => { + it('renders the formatted amount and symbol', () => { + const { container } = render(, { + wrapper: ThemeProvider, + }); + + expect(container).toHaveTextContent('123 UM'); + }); + + it("renders 'Unknown' for metadata without a symbol", () => { + const { container } = render(, { + wrapper: ThemeProvider, + }); + + expect(container).toHaveTextContent('123,000,000 Unknown'); + }); + + it("renders 'Unknown' for a value view with a `case` of `unknownAssetId`", () => { + const { container } = render(, { + wrapper: ThemeProvider, + }); + + expect(container).toHaveTextContent('123,000,000 Unknown'); + }); +}); diff --git a/packages/ui/src/ValueViewComponent/index.tsx b/packages/ui/src/ValueViewComponent/index.tsx new file mode 100644 index 0000000000..4685aa1263 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/index.tsx @@ -0,0 +1,118 @@ +import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { ConditionalWrap } from '../utils/ConditionalWrap'; +import { Pill } from '../Pill'; +import { Text } from '../Text'; +import styled from 'styled-components'; +import { AssetIcon } from './AssetIcon'; +import { getMetadata } from '@penumbra-zone/getters/value-view'; +import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; + +type Context = 'default' | 'table'; + +const Row = styled.span<{ $context: Context; $priority: 'primary' | 'secondary' }>` + display: flex; + gap: ${props => props.theme.spacing(2)}; + align-items: center; + width: min-content; + max-width: 100%; + text-overflow: ellipsis; + + ${props => + props.$context === 'table' && props.$priority === 'secondary' + ? ` + border-bottom: 2px dashed ${props.theme.color.other.tonalStroke}; + padding-bottom: ${props.theme.spacing(2)}; + ` + : ''}; +`; + +const AssetIconWrapper = styled.div` + flex-shrink: 0; +`; + +const PillMarginOffsets = styled.div<{ $size: 'dense' | 'sparse' }>` + margin-left: ${props => props.theme.spacing(props.$size === 'sparse' ? -2 : -1)}; + margin-right: ${props => props.theme.spacing(props.$size === 'sparse' ? -1 : 0)}; +`; + +const Content = styled.div` + flex-grow: 1; + flex-shrink: 1; + + display: flex; + gap: ${props => props.theme.spacing(2)}; + align-items: center; + + overflow: hidden; +`; + +const SymbolWrapper = styled.div` + flex-grow: 1; + flex-shrink: 1; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export interface ValueViewComponentProps { + valueView: ValueView; + /** + * A `ValueViewComponent` will be rendered differently depending on which + * context it's rendered in. By default, it'll be rendered in a pill. But in a + * table context, it'll be rendered as just an icon and text. + */ + context?: SelectedContext; + /** + * Can only be set when the `context` is `default`. For the `table` context, + * there is only one size (`sparse`). + */ + size?: SelectedContext extends 'table' ? 'sparse' : 'dense' | 'sparse'; + /** + * Use `primary` in most cases, or `secondary` when this value view + * represents a secondary value, such as when it's an equivalent value of a + * numeraire. + */ + priority?: 'primary' | 'secondary'; +} + +/** + * `ValueViewComponent` renders a `ValueView` — its amount, icon, and symbol. + * Use this anywhere you would like to render a `ValueView`. + */ +export const ValueViewComponent = ({ + valueView, + context, + size = 'sparse', + priority = 'primary', +}: ValueViewComponentProps) => { + const formattedAmount = getFormattedAmtFromValueView(valueView, true); + const metadata = getMetadata.optional()(valueView); + // Symbol default is "" and thus cannot do nullish coalescing + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const symbol = metadata?.symbol || 'Unknown'; + + return ( + ( + + {children} + + )} + > + + + + + + + {formattedAmount} + + {symbol} + + + + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/sampleValueViews.ts b/packages/ui/src/ValueViewComponent/sampleValueViews.ts new file mode 100644 index 0000000000..48e7a88f9e --- /dev/null +++ b/packages/ui/src/ValueViewComponent/sampleValueViews.ts @@ -0,0 +1,111 @@ +import { + Metadata, + ValueView, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { bech32mIdentityKey } from '@penumbra-zone/bech32m/penumbravalid'; + +const u8 = (length: number) => Uint8Array.from({ length }, () => Math.floor(Math.random() * 256)); +const validatorIk = { ik: u8(32) }; +const validatorIkString = bech32mIdentityKey(validatorIk); +const delString = 'delegation_' + validatorIkString; +const udelString = 'udelegation_' + validatorIkString; +const delAsset = { inner: u8(32) }; +const unbondString = 'unbonding_start_at_123_' + validatorIkString; +const uunbondString = 'uunbonding_start_at_123_' + validatorIkString; +const unbondAsset = { inner: u8(32) }; + +const DELEGATION_TOKEN_METADATA = new Metadata({ + display: delString, + base: udelString, + denomUnits: [{ denom: udelString }, { denom: delString, exponent: 6 }], + name: 'Delegation token', + penumbraAssetId: delAsset, + symbol: `delUM(${validatorIkString})`, +}); + +const UNBONDING_TOKEN_METADATA = new Metadata({ + display: unbondString, + base: uunbondString, + denomUnits: [{ denom: uunbondString }, { denom: unbondString, exponent: 6 }], + name: 'Unbonding token', + penumbraAssetId: unbondAsset, + symbol: `unbondUMat123(${validatorIkString})`, +}); + +const PENUMBRA_METADATA = new Metadata({ + denomUnits: [ + { + denom: 'penumbra', + exponent: 6, + }, + { + denom: 'mpenumbra', + exponent: 3, + }, + { + denom: 'upenumbra', + }, + ], + base: 'upenumbra', + display: 'penumbra', + symbol: 'UM', + penumbraAssetId: { + altBaseDenom: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=', + }, + images: [ + { + svg: 'https://raw.githubusercontent.com/prax-wallet/registry/main/images/um.svg', + }, + ], +}); + +export const PENUMBRA_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: PENUMBRA_METADATA, + }, + }, +}); + +export const DELEGATION_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: DELEGATION_TOKEN_METADATA, + }, + }, +}); + +export const UNBONDING_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: UNBONDING_TOKEN_METADATA, + }, + }, +}); + +export const UNKNOWN_ASSET_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: { + penumbraAssetId: { inner: new Uint8Array([]) }, + }, + }, + }, +}); + +export const UNKNOWN_ASSET_ID_VALUE_VIEW = new ValueView({ + valueView: { + case: 'unknownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + }, + }, +}); diff --git a/packages/ui/src/styled-components.d.ts b/packages/ui/src/styled-components.d.ts new file mode 100644 index 0000000000..7fbbe21be4 --- /dev/null +++ b/packages/ui/src/styled-components.d.ts @@ -0,0 +1,104 @@ +import 'styled-components'; + +interface ColorVariants { + main: string; + light: string; + dark: string; + contrast: string; +} + +declare module 'styled-components' { + export interface DefaultTheme { + borderRadius: { + none: string; + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + '2xl': string; + full: string; + }; + color: { + neutral: ColorVariants; + primary: ColorVariants; + secondary: ColorVariants; + unshield: ColorVariants; + destructive: ColorVariants; + caution: ColorVariants; + success: ColorVariants; + + // Special cases + + text: { + primary: string; + secondary: string; + disabled: string; + special: string; + }; + + action: { + hoverOverlay: string; + activeOverlay: string; + disabledOverlay: string; + primaryFocusOutline: string; + secondaryFocusOutline: string; + unshieldFocusOutline: string; + neutralFocusOutline: string; + destructiveFocusOutline: string; + }; + + other: { + tonalStroke: string; + solidStroke: string; + }; + }; + breakpoint: { + mobile: number; + tablet: number; + desktop: number; + lg: number; + xl: number; + }; + font: { + default: string; + mono: string; + heading: string; + }; + fontSize: { + text9xl: string; + text8xl: string; + text7xl: string; + text6xl: string; + text5xl: string; + text4xl: string; + text3xl: string; + text2xl: string; + textXl: string; + textLg: string; + textBase: string; + textSm: string; + textXs: string; + }; + lineHeight: { + text9xl: string; + text8xl: string; + text7xl: string; + text6xl: string; + text5xl: string; + text4xl: string; + text3xl: string; + text2xl: string; + textXl: string; + textLg: string; + textBase: string; + textSm: string; + textXs: string; + }; + /** + * A function that takes a number of spacing units, and returns a string to + * use for a CSS property. + */ + spacing: (spacingUnits: number) => string; + } +} diff --git a/packages/ui/src/tailwindConfig.ts b/packages/ui/src/tailwindConfig.ts new file mode 100644 index 0000000000..ee1255022e --- /dev/null +++ b/packages/ui/src/tailwindConfig.ts @@ -0,0 +1,30 @@ +import { Config } from 'tailwindcss'; +import { theme } from './ThemeProvider/theme'; +import { RecursiveKeyValuePair, ResolvableTo } from 'tailwindcss/types/config'; + +/** + * For consumers using Tailwind, this file exports a Tailwind config based on + * the Penumbra UI theme values. + */ +export const tailwindConfig: Config = { + content: [], + theme: { + extend: { + borderRadius: theme.borderRadius, + colors: theme.color as unknown as ResolvableTo, + fontFamily: theme.font, + fontSize: theme.fontSize, + lineHeight: theme.lineHeight, + screens: Object.keys(theme.breakpoint).reduce( + (prev, curr) => ({ + ...prev, + [curr]: theme.breakpoint[curr as keyof (typeof theme)['breakpoint']].toString() + 'px', + }), + {}, + ), + + // No need to customize spacing, since Tailwind's default is the same as + // Penumbra UI's. + }, + }, +}; diff --git a/packages/ui/src/utils/ButtonPriorityContext.ts b/packages/ui/src/utils/ButtonPriorityContext.ts new file mode 100644 index 0000000000..d66fd30a10 --- /dev/null +++ b/packages/ui/src/utils/ButtonPriorityContext.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react'; +import { Priority } from './button'; + +/** + * For internal Penumbra UI library use only. `