>;
- }
-}
-```
-
## 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}
+
+ )}