Skip to content

Commit

Permalink
react wallet
Browse files Browse the repository at this point in the history
  • Loading branch information
turbocrime committed Jul 8, 2024
1 parent 1011b3b commit b99c577
Show file tree
Hide file tree
Showing 18 changed files with 1,415 additions and 717 deletions.
5 changes: 5 additions & 0 deletions .changeset/dirty-mice-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/react': major
---

initial react wallet
5 changes: 5 additions & 0 deletions .changeset/three-oranges-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/transport-dom': patch
---

use Transport type for createChannelTransport return
207 changes: 207 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# `@penumbra-zone/react`

This package contains a React context provider and some simple hooks for using
the page API described in `@penumbra-zone/client`. You might want to use this if
you're writing a Penumbra dapp in React.

**To use this package, you need to [enable the Buf Schema Registry](https://buf.build/docs/bsr/generated-sdks/npm):**

```sh
npm config set @buf:registry https://buf.build/gen/npm/v1
```

## Overview

You must independently identify a Penumbra extension to which your app wishes to
connect.

Then, use of `<PenumbraProvider>` 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.

Hooks `usePenumbraTransport` and `usePenumbraService` will unconditionally
provide a transport or client to the Penumbra extension that queues requests
while connection is pending, and begins returning responses when appropriate.

## `<PenumbraProvider>`

This wrapping component will provide a context available to all child components
that is directly accessible by `usePenumbra`, or additionally by
`usePenumbraTransport` or `usePenumbraService`.

### Unary requests may use `@connectrpc/connect-query`

If you'd like to use `@connectrpc/connect-query`, you may call
`usePenumbraTransport` to satisfy `<TransportProvider>`.

Be aware that connect query only supports unary requests at the moment (no
streaming).

A wrapping component:

```tsx
import { Outlet } from 'react-router-dom';
import { PenumbraProvider } 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';

const praxOrigin = 'chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe';
const queryClient = new QueryClient();

export const PenumbraDappPage = () => (
<PenumbraProvider origin={praxOrigin} makeApprovalRequest>
<TransportProvider transport={usePenumbraTransportSync()}>
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
</TransportProvider>
</PenumbraProvider>
);
```

A querying component:

```tsx
import { addressByIndex } from '@buf/penumbra-zone_penumbra.connectrpc_query-es/penumbra/view/v1/view-ViewService_connectquery';
import { useQuery } from '@connectrpc/connect-query';
import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra';

export const PraxAddress = ({ account }: { account?: number }) => {
const { data } = useQuery(addressByIndex, { addressIndex: { account } });
return data?.address && bech32mAddress(data.address);
};
```

### Streaming requests must directly use a `PromiseClient`

If you'd like to make streaming queries, or you just want to manage queries
yourself, you can call `usePenumbraService` with the `ServiceType` you're
interested in to acquire a `PromiseClient` of that service. A simplistic example
is below.

Some streaming queries may return large amounts of data, or stream updates
continuosuly until aborted. For a good user experience with those queries, you
may need more complex query and state management.

```tsx
import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js';
import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js';
import { usePenumbraServiceSync } from '@penumbra-zone/react/hooks/use-penumbra-service';
import { ViewService } from '@penumbra-zone/protobuf';
import { useQuery } from '@tanstack/react-query';
import { AccountBalancesTable } from './imaginary-components';

export default function AssetBalancesByAccount({ assetIdFilter }: { assetIdFilter: AssetId }) {
const viewClient = usePenumbraServiceSync(ViewService);

const { isPending, data: groupedBalances } = useQuery({
queryKey: ['balances', assetIdFilter.inner],

queryFn: ({ signal }): Promise<BalancesResponse[]> =>
// wait for stream to collect
Array.fromAsync(viewClient.balances({ assetIdFilter }, { signal })),

select: (data: BalancesResponse[]) =>
Map.groupBy(
// filter undefined
data.filter(({ balanceView, accountAddress }) => accountAddress?.addressView?.value),
// group by account
({ accountAddress }) => accountAddress.addressView.value.index,
),
});

if (isPending) return <LoadingSpinner />;
if (groupedBalances)
return Array.from(groupedBalances.entries()).map(([accountIndex, balanceResponses]) => (
<AccountBalancesTable key={accountIndex} asset={assetIdFilter} balances={balanceResponses} />
));
}
```

## Possible provider states

On the bare Penumbra injection, there is only a boolean/undefined
`isConnected()` state and a few simple actions available. It is generally robust
and should asynchronously progress towards an active connection if possible,
even if steps are performed 'out-of-order'.

This package's exported `<PenumbraProvider>` component handles this state and
all of these transitions for you. Use of `<PenumbraProvider>` with an `origin`
or `injection` prop will result in automatic progress towards a `Connected`
state.

During this progress, the context exposes an explicit status, so you may easily
condition your layout and display. You can access this status via
`usePenumbra().state`. All possible values are represented by the exported enum
`PenumbraProviderState`.

Hooks `usePenumbraTransport` and `usePenumbraService` conceal this state, and
unconditionally provide a transport or client.

`Connected` is the only state in which a `MessagePort`, working `Transport`, or
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
are conditions described by the surrounding path labels.

There are more possible transitions than diagrammed here - for instance once
methods are exposed, a `disconnect()` call will always transition directly into
a `Disconnected` state. A developer not using this wrapper, calling methods
directly, may enjoy failures at any moment. This diagram only represents a
typical state flow.

The far right side path is the "happy path".

```mermaid
stateDiagram-v2
classDef GoodNode fill:chartreuse
classDef BadNode fill:salmon
classDef PossibleNode fill:thistle
state global_exists <<choice>>
state manifest_present <<choice>>
state make_request <<choice>>
Absent:::BadNode --> [*]
Failed:::BadNode --> [*]: p.failure
Disconnected --> [*]
Connected:::GoodNode --> [*]
manifest_present --> Failed
Requested --> Failed
Pending --> Failed
[*] --> global_exists: window[Symbol.for('penumbra')][validOrigin]
global_exists --> Absent: undefined
global_exists --> Injected: defined
Injected --> manifest_present: fetch(p.manifest)
manifest_present --> Present: json
Present:::PossibleNode --> make_request: makeApprovalRequest
make_request --> Requested: p.request()
Requested:::PossibleNode --> Pending: p.connect()
make_request --> Pending: p.connect()
Pending:::PossibleNode --> Connected
Connected --> Disconnected: p.disconnect()
note left of Present
Methods on the injection may
be called after this point.
end note
note left of Connected
Port is acquired and
transports become active.
end note
```
13 changes: 13 additions & 0 deletions packages/react/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { penumbraEslintConfig } from '@repo/eslint-config';
import { config, parser } from 'typescript-eslint';

export default config({
...penumbraEslintConfig,
languageOptions: {
parser,
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
});
57 changes: 57 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@penumbra-zone/react",
"version": "0.0.1",
"license": "(MIT OR Apache-2.0)",
"description": "Reactive package for connecting to any Penumbra extension, including Prax.",
"type": "module",
"scripts": {
"build": "tsc --build && tsc-alias",
"clean": "rm -rfv dist package penumbra-zone-react-*.tgz",
"lint": "eslint src",
"prebuild": "$npm_execpath run clean",
"prepack": "$npm_execpath run build",
"test": "vitest run"
},
"files": [
"dist"
],
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts"
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./components/*": {
"types": "./dist/components/*.d.ts",
"default": "./dist/components/*.js"
},
"./hooks/*": {
"types": "./dist/hooks/*.d.ts",
"default": "./dist/hooks/*.js"
}
}
},
"dependencies": {
"@penumbra-zone/client": "workspace:*",
"@penumbra-zone/protobuf": "workspace:*",
"@penumbra-zone/transport-dom": "workspace:*"
},
"devDependencies": {
"@connectrpc/connect": "^1.4.0",
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^15.0.7",
"@types/react": "^18.3.2",
"react": "^18.3.1",
"vitest": "^1.6.0"
},
"peerDependencies": {
"@bufbuild/protobuf": "^1.10.0",
"@connectrpc/connect": "^1.4.0",
"react": "^18.3.1"
}
}
Loading

0 comments on commit b99c577

Please sign in to comment.