@penumbra-zone/react
v1.2.0
Published
React package for connecting to any Penumbra extension, including Prax.
Downloads
1
Readme
@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:
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, <PenumbraContextProvider>
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
const prax: PenumbraProvider | undefined =
window[Symbol.for('penumbra')]?.['chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe'];
or with helpers available from @penumbra-zone/client
, like
import { assertProvider } from '@penumbra-zone/client';
const prax = assertProvider('chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe');
Use of <PenumbraContextProvider>
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
with a failure before connection.
Hooks usePenumbraTransportSync
or usePenumbraServiceSync
will
unconditionally provide a transport or client to the Penumbra extension that
queues requests while connection is pending, and begins returning responses when
appropriate. If the provider fails to connect, requests via the transport or
client may time out.
<PenumbraContextProvider>
This wrapping component will provide a context available to all child components
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
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:
import { Outlet } from 'react-router-dom';
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';
const praxOrigin = 'chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe';
const queryClient = new QueryClient();
export const PenumbraDappPage = () => (
<PenumbraContextProvider penumbra={assertProvider(praxOrigin)} makeApprovalRequest>
<TransportProvider transport={usePenumbraTransportSync()}>
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
</TransportProvider>
</PenumbraContextProvider>
);
A querying component:
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 }) => {
// note this is not tanstack's useQuery
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.
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
Each Penumbra provider exposes a simple .isConnected()
method and a more
complex .state()
method, which also tracks pending transitions. It is
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 <PenumbraContextProvider>
component handles this 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
usePenumbra().state
. All possible values are represented by the enum
PenumbraState
available from @penumbra-zone/client
.
Hooks usePenumbraTransportSync
and usePenumbraServiceSync
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 PenumbraState
. 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".
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>>
[*] --> global_exists: p = window[Symbol.for('penumbra')][validOrigin]
global_exists --> [*]: undefined
Failed:::BadNode --> [*]: p.failure
Disconnected --> [*]
Connected:::GoodNode --> [*]
manifest_present --> Failed
RequestPending --> Failed
ConnectPending --> Failed
global_exists --> manifest_present: fetch(p.manifest)
manifest_present --> Present: json
Present:::PossibleNode --> make_request: makeApprovalRequest
make_request --> RequestPending: p.request()
RequestPending:::PossibleNode --> Requested
Requested:::PossibleNode --> ConnectPending: p.connect()
make_request --> ConnectPending: p.connect()
ConnectPending:::PossibleNode --> Connected:::PossibleNode
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