Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ADR-006 for web APIs and general description #1471

Merged
merged 5 commits into from
Aug 9, 2024

Conversation

VanishMax
Copy link
Contributor

Specs for the client package API.

Slogan of this PR is: better developer experience, greater penumbra adoption

@VanishMax VanishMax requested review from grod220, turbocrime and a team July 12, 2024 14:33
@VanishMax VanishMax self-assigned this Jul 12, 2024
Copy link

changeset-bot bot commented Jul 12, 2024

⚠️ No Changeset found

Latest commit: a6b6b76

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@turbocrime
Copy link
Contributor

looked over this, will post some detailed comments today

@turbocrime
Copy link
Contributor

turbocrime commented Jul 16, 2024

okay, this reply is a little excessive and kind of disorganized, but i'm trying to be
comprehensive and deliver all the necessary context. i think some of what's in
your draft is already achieved or partly achieved, there are some good ideas,
and there are some things that aren't possible or shouldn't be done.

this begins with historical reasons and choices just to cover the existing
situation

at the end i'll have a list of proposed actions, later i might suggest some
explicit changes to your doc or add inline comments linking to issues, PRs, or
code i think relevant.

we definitely need clear documentation and direction so thank you for starting
the writing process.

please read all of this as concept+notes+commentary+discussion in the interest
of collaboration. if it seems like i'm saying obvious things, i think that's a
good sign we are on the same page, if i say something that doesn't make sense or
feels conflicted, let's figure it out

the existing design

implementations announce by injection

due to ui behavior specifications, manifest v3 and chrome limitations, and
development priorities, implementations inject content scripts to provide
connection.

these scripts are injected not just to pages that the user approves, but to
nearly every browser tab the user opens. so the injected script must be
minimal.

proto spec first

using protofbuf to specify communication between dapp and extension was a
deliberate choice to minimize effort spent (re)designing and (re)implementing
the existing api in parallel.

use of the same protocol for both pd rpc and browser rpc ensures that browser
clients have access to all the same features as any direct pd client, and vice
versa.

support use of generated tools

so a dapp independenlty imports packages and service types to construct useful
clients. @penumbra-zone/client defines the simplest tools possible to enable
the dapp to create a connectrpc client. connectrpc provides a few different
kinds of generated clients and the page api alone supports any of them.

the client package exports very simple utilities that can validate providers as
much as possible, create clients, and fetch provider metadata. the exported
createPenumbraClient will completely handle connection
validation/request/approval/transport init and simply return a client to the
specified service.

react

react is eager to destroy and recreate state. sync rendering requirements make
it awkward to manage async behaviors of a PromiseClient. and unfortunately
connectrpc's react solution @connectrpc/connect-query is quite limited (no
streaming requests).

so an additional react-focused wrapper was requested. the react wrapper focuses
on supporting transport lifecycle management and making lifecycle inspectable in
a detailed way without manual state management by an app importing the package.

internally it uses @penumbra-zone/client tools and sometimes uses the page api
directly for more detailed control. it provides hooks that support use of either
PromiseClient without constant destruction/recreation of the client, or
connect-query, if the developer chooses.

in the future it could export query functions for common activities that
currently require multiple requests.

too many words about 'wallet'

the term 'wallet' may be familiar to developers with experience with other
chains and cryptowallets software, but i think it's a confusing term in most
cases, and in the way it's used in this ADR.

The term 'wallet' is overloaded with several meanings. There are more than this,
but here are four:

  • wallet-keys: the cryptographic keys capable of mutating a specific part of the
    chain state
  • wallet-custody: the software (and sometimes hardware) stack for gating secure
    access to keys
  • wallet-app: a application user interface to examine associated state and
    assets, usually with tools for authoring state activity.
  • wallet-api: closest to the subject of this ADR, the API surface or some
    concept/metaphor of the API to a wallet-app.

penumbra wallets

Penumbra includes the concepts of many accounts per wallet-keys, and many
addresses per account. This satisfies some of the same solutions achieved by a
wallet-app hosting multiple wallet-keys, but it is different enough that
penumbra wallet-apps must use different metaphors.

Outside the proto spec, penumbra wallet-apps may also contain multiple
wallet-keys. But this should not be considered. Switching wallet-keys is not
described by the proto spec - stub references to walletId in the protos are
deprecated - so this probably shouldn't be exposed to dapps.

So penumbra wallet-apps will probably just expose one wallet at a time. It's
also worth considering that multiple penumbra wallet-apps, simultaneosuly
present, may hold the same wallet-keys.

So the term wallet may be 'familiar' but the familiarity might be more
misleading and confusing than useful.

'Prax wallet' does use the term, but critically, Prax only provides custody and
an api. it implements zero of the typical wallet-app features. independently, it
offers no UI for taking action. it doesn't ever actually display existing chain
state, it only includes UI for viewing proposed state changes. dapps are
responsible for all the rest.

the existing penumbra api is more of a chain api

Clients to penumbra services are more literally clients to the chain, than
clients to a wallet.

the api is the same specified api used to query pd, the penumbra daemon
operating a remote chain node providing public rpc, just extended to also
include private chain state. clients using this api are literally querying a
local penumbra node, providing a private local rpc identical to the remote
public rpc, in some cases a straight proxy, plus the extra private services.

many of these services are entirely public, and don't actually involve
wallet-keys at all. This somewhat achieves the separation of public
state/private state apis as you suggested - much of that separation is
represented by the separation of services.

loosely, the 'view service' and 'custody service' together may be considered the
equivalent of a 'wallet api'.

penumbra api terms

Dapp developers won't be able to escape penumbra-specific concepts. The concepts
are introduced because they are necessary.

In terms of api i think it's important to clearly speak in terms of

  • penumbra 'services', the software implementing protobuf-specified rpc
    interfaces
    • fullnode 'public services'. public view, opaque data, fullnode queries.
      any service requiring no wallet keys.
    • local extension 'wallet services'. private view, decrypted data, local
      queries.
      • notably the 'custody service' for keys and authorization
      • notably the 'view service' for inspecting private state
  • penumbra 'provider', a wallet-app handle within the page interface discussed
    in this ADR, by which protobuf-specified rpc services are exposed to and
    accessed by dapps
  • provider 'origin', uniquely identifies software providing services
  • penumbra 'client', the lowest-level tool used by dapps to query those provided
    services

Reserve the term 'wallet' to refer to

  • the wallet-keys
  • at the user level, loosely qualified: the wallet-app in its capacity as a
    metaphorical place, where accounts and addresses are located
  • at the developer level, always qualified: terms like 'wallet services' or
    'wallet id' or 'wallet custody'

avoid applying 'wallet' to any concept not directly involving wallet-keys.

  • an injected connection handle is providing penumbra service
  • 'balances' or 'assets' belong to an 'account'
  • tx assets are sourced from an 'account'
  • tx destinations are named by an 'address'
  • a destination 'address' selects a destination 'account'

action items

what the page api should not add

these are not action items: for privacy and security reasons, most
extension/provider settings, configuration, and control should not be available
to the dapp.

  1. rpc endpoint should never be available to dapp
  2. 'default frontend' is a concept outside of spec, and is not likely to be
    shared by other implementations
  3. all other settings actions are very sensitive (clearing cache, recovery keys, etc)

in general: if it's not in the protobuf spec, there shouldn't be a page api for it.

what the page api could add

these suggestions need discussion and review before action.

  1. a simple method to get a 'send' destination address pointing to the user.
    this would function without requiring connection, but would trigger an approval
    dialog. benefit: an app may 'send' to the user, without gaining full viewing
    access.
  2. a simple method to suggest a 'send' destination address and a specific asset
    and quantity to the user. this would function without requiring connection, and
    would trigger an approval dialog. benefit: an app may request a 'send' from the
    user, without gaining full viewing access.
  3. more detailed/restricted connection approval. a page might request approval
    per-service, instead of general approval for all services.

This is probably it. These suggestions are candidates specifically because they
operate outside of the connection lifecycle.

Pretty much any other feature, involving an active connection to the provider,
should go into the proto spec.

what the proto spec could add

  • indefinite stream of transactions by height. this would allow dapps and dapp
    components to be ambiently aware of arriving transactions, without being
    specifically responsible for issuing the transactions.

errors

  • errors emitted from services are all using a special error class
    ConnectError, which supports elaboration with detail.
  • errors in @penumbra-zone/client or @penumbra-zone/react do have room for
    improvement and documentation

connectrpc tooling can be improved

with some effort applied to @connectrpc/connect-query, it could support
streaming endpoints. connect-query provides a similar pattern to the queriers
you've suggested, and generated queriers for every endpoint would make a lot of
existing code redundant.

a simple wrapper to PromiseClient or an alternative implementation could
provide the result-style errors described in your ADR.

maybe move interface definitions to a dedicated package

Interface definitions could be moved to a dedicated package, leaving
@penumbra-zone/client more directly concerned with client init.

focus on integrations

The primary objective right now is not dapp development but integration,
assisting other teams with integrations, and identifying what API or spec
changes other teams may benefit from in integration.

To this end, good demos, and a well-featured react package with helpful hooks
and queriers is a priority.

The react package could begin adding modules that export queriers. Some of the
current minifront hooks and queriers could be moved into the react package, and
thus be made available to consumers.

@VanishMax
Copy link
Contributor Author

@turbocrime I highly appreciate the context and the ideas you shared. Special thanks for explaining different wallet-related terms – I never thought about Prax as just wallet-api implementation.

Interestingly, not so many things actually contradict with the ADR. Some needs to be rewritten, some apis updated and added. Strongly agree on integrations focus: making examples, writing docs and collecting feedback is extremely important too. It could open our eyes even wider to see the needs of the community.

Agree that wallet-app settings should not be exposed to the API, at least not in the injected connection and not in this ADR. If wallet developers want it, they could create their own logic exposing this feature. Going to remove this section from the proposal.

While I'm rewriting the ADR, one more question: can we actually use 'public services' publicly?

I mean, right now we require users to have an injected connection, and it's not public to me. It might not require the wallet keys but the service users are querying is kinda auth-protected by the connection. So maybe we shouldn't expose the 'public services' term to users?

Copy link
Contributor

Visit the preview URL for this PR (updated for commit d414238):

https://penumbra-ui-preview--pr1471-feat-adr-006-client-llv69aur.web.app

(expires Wed, 24 Jul 2024 15:47:16 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: 709d729610ef7a6369b23f1cb2b820a60cc685b1

@VanishMax
Copy link
Contributor Author

@turbocrime I updated the ADR considering your feedback.

Would appreciate if you elaborate on the "what the page api could add" section of your comment with examples and maybe addition to this ADR

docs/adrs/006-web-apis.md Outdated Show resolved Hide resolved
Comment on lines 79 to 84
/** Should synchronously return the present connection state.
* - `true` indicates active connection.
* - `false` indicates connection is closed or rejected.
* - `undefined` no attempt has resolved. Connection may be attempted
*/
readonly isConnected: () => boolean | undefined;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A boolean | undefined is not a good API design. It should either be boolean or ConnectedState representing many possible states.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to boolean. The ConnectedState can be accessed via state function as a more descriptive one

Copy link
Contributor

@turbocrime turbocrime Jul 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i want to keep this aspect of the api.

it is intuitive that 'truthy value means yes, falsy value means no'.

the additional information of 'maybe' and 'never' is available by inspection of the falsy value.

  • 'undefined' is used to represent a connection that is unknown - it should become 'true' or 'false' later
  • 'false' is used to represent a connection that is rejected - it will definitely remain 'false'

i think this is simple to explain and simple to understand. i find it very useful to have a simple truthy/falsy check.

detailed state is available from the state method.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, this is not a good API design. We should not assign meaning to undefined and cause our consumers to try to guess what it means and hope they infer like we have. We should replace this with an enum if there are more possible states than two.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'false' is used to represent a connection that is rejected - it will definitely remain 'false'

@turbocrime why can't users re-establish the connection when isConnected = false?

docs/adrs/006-web-apis.md Outdated Show resolved Hide resolved
Comment on lines 68 to 82
/** Call to gain approval. Returns `MessagePort` to this provider.
* Might throw descriptive errors if user denies the connection */
readonly connect: () => Promise<MessagePort>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This combines connect() with request() right? Think that is a good thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, it combines both functions together

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previously these were combined, then separated because it was asked that they become separate. we've had recent discussions where it was suggested that helper functions exported from the client package should not helpfully perform this combination. so it seems like opinions are contradictory.

these are currently separate and i think should remain separate.

if they are combined, then a long 'connection init is happening' wait is indistinguishable from a long 'user approval is happening' wait. it is useful for these to be distinct time periods.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what cases would a consumer want to initiate a connection, but not trigger an approval? This behavior does not feel standard amongst wallet APIs in the industry.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't think of the cases when the request shouldn't be followed by connect. Don't know about the previous decisions but reverting them doesn't seem to be really breaking

Comment on lines 89 to 102
/**
* A connection listener function. Fires a callback with
* the `origin`, `state`, and `connected` fields each time the state changes
*/
readonly onConnectionChange: (cb: (connection: { origin: string, connected: boolean, state: PenumbraState }) => void) => void;
}
```
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to me how this compares to the recently added pattern of add/remove event listeners. Is this a better pattern that should replace that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's basically a simplified version of the listeners with added connected boolean to the callback. I feel like the event in addEventListener is redundant since users will only access the detail property of event. But if we don't expose CustomEvent, then probably it shouldn't be called addEventListener.

We can vote on this design or come up with another version

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the event interface is standard, and extensible. flexibility may help avoid breaking changes to this api. it may eventually be useful for the provider to emit additional detail with the events, or emit other event types we haven't thought of yet.

for example we could add a specific 'penumbraconnected' event without changing any interface signature in a way that breaks consumers.

use the standard functionality also prevents any implementation errors on our part - all of the callback logic is handled very well by browser developers. we only write the message types.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I agree to compromise. Put back the addEventListener and removeEventListener but simplified the types definition. In the current implementation (probably why I was so hesitant to accept this API before), CustomEvents aren't really typed, so in Nextjs example I wrote the following:

const typedEvent = event as CustomEvent<{ origin: string, state: PenumbraState }>;

If we fix the types, it'll already be better. Even though I was really tempted to change the addEventListener to simply addListener: (type: 'penumbrastate', cb: (value: { origin: string; connected: boolean; state: PenumbraState }) => void)

Comment on lines 133 to 134
/** Synchronously creates a connectrpc `PromiseClient` instance to a given Penumbra service */
readonly getService: <SERVICE extends PenumbraService>(service: SERVICE) => PromiseClient<SERVICE>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that the client is something with methods on it. I've suggested this previously before. Think it's more clear versus having random functions that get imported.

Copy link
Contributor

@turbocrime turbocrime Jul 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarifying: this client interface should not be part of the injected script

i avoided adding a client construction method to the page interface intentionally.

  1. a content-script injection must meet the page interface
  2. all packages and types required to meet this interface will be injected
  3. due to requested functionality, the content script is injected to every page the user visits
  4. due to requested functionality, the content script is injected at document_start which blocks page execution
  5. a developer writing a dapp for this interface is going to import a package to interact with it anyway, so heavy things can go in there, and avoid injection.

without any effort at size reduction, the current injection imports no dependencies and compiles to 4775 bytes. this is acceptable for a script that we are causing the user to execute on every single page they visit.

if we add client creation to the page interface, the injection increases to 217022 bytes, including dependencies such as all service type definitions and a complete library for instantiating a promiseclient, and connectrpc's entire dependency tree. this is injected to every page the user visits, and will block page execution.

https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#run_time

so, this must remain part of a different module, like the existing client helper functions, and should not be returned from any page interface function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@turbocrime thanks for measuring the bundle! You point is absolutely valid – we don't want users to load too much JS on EVERY page. For this reason, I updated the ADR and moved the function from the client instance to @penumbra-zone/client/service. Though, this function can and should modify the client instance by inserting initiated services, so the service clients won't be duplicated

Comment on lines 148 to 158
```ts
export type PenumbraManifest = Partial<chrome.runtime.ManifestV3> &
Required<Pick<chrome.runtime.ManifestV3, 'name' | 'version' | 'description' | 'icons'>>;

export type getInjectedProvider = (penumbraOrigin: string) => Promise<PenumbraProvider>;

export type getAllInjectedProviders = () => string[];

export type getPenumbraManifest = (penumbraOrigin: string, signal?: AbortSignal) => Promise<PenumbraManifest>;

export type getAllPenumbraManifests = () => Record<
keyof (typeof window)[typeof PenumbraSymbol],
Promise<PenumbraManifest>
>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if this was encapsulated so users don't have to guess imports.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you imagine the encapsulation in this case? Adding the independent public actions inside the client feels weird

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I previously suggested something like:

interface PenumbraClient {
  requestPraxConnection(): Promise<void>;
  isPraxConnected(): boolean;
  isPraxInstalled(): boolean;
  throwIfPraxNotInstalled(): void;
  throwIfPraxNotConnected(): void;
  createPraxClient<T extends ServiceType>(serviceType: T): PromiseClient<T>;
}

But open if folks don't find that useful

docs/adrs/006-web-apis.md Outdated Show resolved Hide resolved
docs/adrs/006-web-apis.md Outdated Show resolved Hide resolved
Comment on lines 231 to 163
```ts
const res = await client.getAddressByIndex({ account: 0 });
if (res) {
console.log(res.data);
} else if (res.error instanceof NotConnectedError) {
client.reconnect();
} else {
console.error('Unknown error:', res.error);
}
```
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example exhibits some patterns that don't feel so great. The caller of service functions shouldn't have to keep track of and troubleshoot the client connection state. It feels like the wrong place to do so. Think erroring is fine because before they attempt to make this call, their view should first determine whether the client is connected or not---and if not, show a connect button (and not make this fetch at all).

While pairing with Finch on the governance site, this was a common pattern:

export default function useAppParameters() {
  const { viewClient, connected } = useStore((state) => state.prax);

  return useQuery({
    queryKey: ["appParameters"],
    queryFn: async () => {
      return (await viewClient())?.appParameters({});
    },
    enabled: connected,
  });
}

@VanishMax
Copy link
Contributor Author

@turbocrime Let's finish this up. Wanted to mention why stick to this design if we have almost the same api right now.

This ADR, as you mentioned before, renames a bunch of methods with the main idea to unify the notion of 'client'. It was mostly referred to the service client but is changing in this ADR to the Penumbra client as a set of encapsulated methods to work with the injected connection. It's a common pattern for libraries that users are used to: no need to guess the imports, create the 'client' once and share everywhere, while it stores the useful state and simplifies the work.

The main point disagreement for now is this Gabe's comment about the connection listener. We can vote on this, I don't mind different versions.

At tell me if there's more to add to this document, I might have forgotten something.

@turbocrime
Copy link
Contributor

still going through this will add some more review later. please push the latest version it doesn't seem up-to-date wrt the comments

@turbocrime
Copy link
Contributor

have some work done for implementing these issues and some ADR, will push tonight or in the morning

@VanishMax VanishMax mentioned this pull request Jul 29, 2024
@VanishMax VanishMax merged commit 8fc6c85 into main Aug 9, 2024
6 checks passed
@VanishMax VanishMax deleted the feat/adr-006-client-apis branch August 9, 2024 07:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants