RFC: Wallet RPC spec

I am posting the typescript interface of the wallet RPC that is currently being used by wallet.shieldswap.org and Obsidion wallet. I invite other wallet teams to implement the same spec to maintain wallets ↔ apps compatibility. If you see how the spec can be improved, write a comment. The spec in this comment will be continuously updated as the improvements are accepted.

type AztecRpc = {
  /**
   * Requests the user to connect 1 or more accounts to the app.
   * @returns the list of `CompleteAddress`es of the connected accounts. The first one must be the currently selected account.
   */
  aztec_requestAccounts: () => string[];
  /**
   * @returns the list of `CompleteAddress`es of the previously connected accounts. The first one must be the currently selected account.
   */
  aztec_accounts: () => string[];
  /**
   * Sends a transaction to the blockchain from `request.from` account.
   * @returns the transaction hash
   */
  aztec_sendTransaction: (request: {
    /** `AztecAddress` of the account that will send the transaction */
    from: string;
    /** List of `FunctionCall`s to be executed in the transaction */
    calls: SerializedFunctionCall[];
    /** `Fr[]` - auth witnesses required for the transaction */
    authWitnesses: string[];
  }) => string;
};

type SerializedFunctionCall = {
  selector: string;
  name: string;
  type: string;
  isStatic: boolean;
  to: string;
  args: string[];
  returnTypes: AbiType[];
};
2 Likes

I’ve got a slightly different version of this: aztec/packages/rpc/src/types.ts at main · WalletMesh/aztec · GitHub

It has some minor differences in fetching account addresses and aztec_sendTransaction.

Can you explain the difference & reasoning behind between aztec_accounts and aztec_requestAccounts in your proposal? The comments say “connected” vs “previously connected”, but I don’t really understand what that would be used for.

In my version of aztec_sendTransaction, I’m having the relevant ABI snippet (FunctionABI) sent over, which carries a bit more information than the FunctionCall, but now I’m wondering if the FunctionCall is sufficient because the full ABI can be pulled from the ContractArtifact in the PXE (I am not sure if that assumption is 100% true, though). Do you have any thoughts on this?

1 Like

aztec_requestAccounts shows a popup where user has to select and confirm the list of accounts they want to connect to the app.

aztec_accounts does NOT trigger any popups but should simply return the list of accounts that were previously connected via aztec_requestAccounts. Note that this method can return an empty array if no accounts were connected yet.

This approach mimic eth_requestAccounts and eth_accounts.

1 Like

At the Azguard wallet we have implemented

the following interface:
interface IWallet {
    // Requests dapp connection.
    // Returns an array of accounts the user has allowed to interact with.
    connect(dapp: Dapp, permissions: DappPermissions): Promise<string[]>;

    // Requests execution of the batch of actions, using the specified account
    // (one of the accounts returned from the `connect` method).
    // Returns a tx hash or undefined, if the actions didn't mean to send a tx.
    execute(account: string, actions: IAction[]): Promise<string | undefined>;
    
    // Requests simulation of the batch of actions, using the specified account
    // (one of the accounts returned from the `connect` method).
    // Returns an array of results.
    simulate(account: string, actions: IAction[]): Promise<unknown[]>;
}

class Dapp {
    name: string; // Dapp name
    description?: string; // Dapp description
    icon?: string; // Dapp logo
    url?: string; // Dapp url
}

class DappPermissions {
    chains: number[]; // chain ids
    methods: string[]; // wallet methods
    events: string[]; // wallet events
}

interface IAction {
    type: string; // action type
}

class AddCapsuleAction implements IAction {
    type = "add_capsule";
    capsule: string[]; // array of Fr, converted to strings
}

class AddNoteAction implements IAction {
    type = "add_note";
    note: string; // note, converted to string
}

class AddContactAction implements IAction {
    type = "add_contact";
    address: string; // contact address
}

class AddContractAction implements IAction {
    type = "add_contract";
    address: string; // contract address
    instance?: unknown; // optional contract instance
    artifact?: unknown; // optional contract artifact
}

class AuthorizeCallAction implements IAction {
    type = "authorize_call";
    isPublicAuthwit: boolean; // whether to add public or private authwit
    caller: string; // who may call on behalf of a user
    contract: string; // allowed contract to call
    method: string; // allowed method to call
    args: any[]; // allowed arguments of the call
}

class AuthorizeIntentAction implements IAction {
    type = "authorize_intent";
    isPublicAuthwit: boolean; // whether to add public or private authwit
    consumer: string; // who may consume the authwit
    intent: string[]; // plain intent message (for `computeInnerAuthWitHash`)
}

class CallAction implements IAction {
    type = "call";
    contract: string; // contract address
    method: string; // function name
    args: any[]; // plain arguments
}

// Alternative way to call contract, for some specific use cases.
class FunctionCallAction implements IAction {
    type = "function_call";
    to: string; // contract address
    name: string; // function name
    selector: string; // function selector
    type: string; // function type
    isStatic: boolean; // whether or not the function is static
    args: any[]; // encoded arguments
    returnTypes: unknown[]; // return types ABI
}

The connection workflow is:

  1. Dapp invokes connect, passing information about itself and requested permissions.
  2. Wallet asks the user to confirm it and select one or more accounts to share with the dapp.
  3. Wallet responds with the list of shared accounts, and the dapp saves it.
Example
const accounts = await wallet.connect(
    {
        name: "My Dapp",
    },
    {
        chains: [31337],
        methods: ["execute"],
        events: [],
    },
);

The execution/simulation workflow is:

  1. Dapp builds a batch of actions and invokes either execute or simulate, also specifying the account to use (one of the accounts saved at the connection phase).
  2. Wallet asks the user to confirm the list of actions and executes/simulates them.
  3. Wallet responds with the execution/simulation results.
Example
const txHash = await wallet.execute(
    accounts[0],
    [
        {
            type: "add_capsule",
            capsule: ["0xab..cd"],
        },
        {
            type: "call",
            contract: "0x00..03",
            method: "register",
            args: ["0x11..11", "0x22..22", "0x33..33"],
        },
        {
            type: "authorize_call",
            isPublicAuthwit: true,
            caller: "0x12..34",
            contract: "0x56..78",
            method: "transfer_in_public",
            args: ["0x90..12", "0x12..34", "5000"],
        },
        {
            type: "call",
            contract: "0x12..34",
            method: "swap",
            args: ["5000"],
        }
    ]
);

Why don’t we have a getAccount(s) method?

The accounts are returned at the connection phase, from the connect method, so it doesn’t make much sense to have another method doing basically the same. In general, we propose to make the wallet interface as minimalistic and simple as possible, for better DevX.

Why do we batch actions instead of having separate methods for each action?

There are several reasons:

  • First of all, because almost all actions, like adding authwits, capsules, contracts, notes, are scoped to a particular execution context, so having separate methods would lead to multiple confirmation requests for a single action in a dapp. It would be weird UX.
  • Also, to make the confirmation process more comfortable for wallet users, because it’s much easier to understand and verify each action, if you see the context.
  • Last but not least, batches enable wallets to “free” resources after execution/simulation. For example, if you receive a batch [add capsule, call contract], you can be sure that the capsule is needed just for that call, so in the end you can safely remove the capsule from PXE, in case it wasn’t consumed for some reason (ofc, when PXE will have such the functionality). Same for notes and authwits.

Why don’t we use aztec.js types?

If we used original types, like capsule: Fr[], or note: ExtendedNote, we would force people to use aztec.js with all its deps, like bb.js, which is quite unreasonable.

Why is the “call” action that simple?

Because it’s actually simple! A wallet can retrieve function ABI having only contract address and fn name (it’s cheap, because it’s stored locally in PXE), and then encode args/decode return values using this ABI. Nothing else is needed, so there is no need to complicate dapp devs life. Nevertheless, for some hypothetical use cases there is the “function_call” action, fully compatible with aztec.js’ FunctionCall.

Why do we ask for plain intents in the “authorize_*” actions?

The reason is the same as why we ask for plain args in the calls - to make the confirmation process more comfortable for wallet users. No one will be happy signing a random hash, so being able to see what exactly you are signing is really important for good UX.


So, we have tried to design a minimalistic, but at the same time flexible interface, that would cover the majority of use cases, carrying about ease of use for dapp devs.

We would be happy to hear your thoughts on this :slight_smile:

2 Likes

Nice. Do you have an RPC spec as a typescript interface(similar to above)?

We can then combine the best ideas from each spec to create a single spec we all agree with

What do you mean by “RPC spec as a typescript interface”? Doesn’t the wallet interface I’ve shown describe the RPC spec?

interface IWallet {
    connect(dapp: Dapp, permissions: DappPermissions): Promise<string[]>;
    execute(account: string, actions: IAction[]): Promise<string | undefined>;
    simulate(account: string, actions: IAction[]): Promise<unknown[]>;
}
1 Like

I like @Groxan 's spec, especially because of the following reasons:

  1. connect() serves as a single method that handles connection between dapp and wallet.
    Apparently, eth_accounts exists partly to keep backward compatibility and there are many cases that developers only use eth_requestAccounts for both building a new connection and returning already-connected accounts.

  2. connect()'s permissions param.
    Depending on applications that request connections, users should be able to decide what he/she wants to allow the dapp to do.

  • If they don’t fully trust the website but want to see what it’s like by connecting, they might want to exclude execute() but only allow simulate().
  • Some apps don’t have to have the ability to ask users to execute transactions, e.g. portfolio and analytics website.
  • Some users might like optional, more granular permissions, i.e. what actions is allowed or not. So, one feedback is to consider extending DappPermissions class to have actions, because some apps only need users’ authwit but tx execution. Also, actions can also include viewing actions as well as adding, such as viewing the existence of contracts/notes in user’s PXE and/or the content of them. Adding params could make UI in connection popup more complex and add more friction to UX, so it’s a trade-off between UX and flexibility in security/privacy configuration. But it can be optional so not all wallets necessarily have to implement it in their connection popup. It’s up to wallet.
  1. batching offers good UX.
    As you mentioned, it’d be awful UX if user has to see multiple popups for a single execution, like having to approve actions such as add_contract, add_note before finally approving call in the popup. Though, I’m curious how you envision an ideal confirmation flow within the single popup. Does user still click multiple confirmation buttons for each/some of the items or it’s just one-click maybe with radio buttons for each item, etc…?

btw, I’m from Obsidion Wallet team and currently we follow @oleh 's spec in our MVP app.

Thanks everyone for the thoughtful discussion! I’d like to share some additional thoughts on the wallet RPC spec, particularly in response to @Groxan’s detailed proposal.

  1. Session-based approach: I agree that having a single connect() method makes sense, but I think we could improve it further: Instead of returning account addresses, what if connect() used a session identifier? This would allow dapps to work without requiring account addresses by default, and providing account addresses can be opt-in instead of mandatory. This aligns better with privacy-first principles.
  2. Rich connection response: The connect response could include more metadata about the connection:
type ChainAccount = {
  address: string;
  chainId: number;
};

type ConnectResponse = {
  sessionId: string;
  approvedPermissions: DappPermissions;
  accounts?: ChainAccount[]; // Optional chain-specific accounts if requested & approved
};

Note that accounts are tightly bound to specific chains in the response, rather than having separate lists of accounts and supported chains. This reflects the reality that accounts are chain-specific and helps prevent confusion about which accounts can be used where.

  1. Action batching: While I understand the UX benefits of batching actions, I see some potential issues:
  • How do we handle partial failures in a batch?
  • What does it mean to simulate non-Call actions?
  • Different types of actions may need different handling.
  • Combining unrelated actions might actually worsen the UX (from an end-user confusion standpoint) rather than improve it.

I propose splitting this into two concepts:

  • Transaction batching (for related blockchain operations like approve+swap)
  • Individual operations for other distinct wallet interactions (contract registration, etc.)
  1. Transaction Parameter Templates: Another privacy consideration is that dapps may not know (and shouldn’t need to know) specific values needed for transactions, like which account or note to use. I propose adding parameter templates that let dapps describe what kind of value they need without knowing the actual value:
type TransactionParamTemplate = {
  type: 'account' | 'token' | 'note' | 'amount' | 'custom';
  description: string;
  constraints?: {
    minValue?: bigint;
    maxValue?: bigint;
    tokenType?: string;
    noteType?: string;
  };
  defaultValue?: unknown;
};

type TransactionArg = {
  value?: unknown;           // Direct value if known
  template?: TransactionParamTemplate;  // Template for wallet/user to fill
};

This allows dapps to create transaction requests like:

{
  contractAddress: TOKEN_CONTRACT,
  functionName: 'transfer',
  args: [
    {
      template: {
        type: 'account',
        description: 'Recipient address'
      }
    },
    {
      template: {
        type: 'amount',
        description: 'Amount to transfer',
        constraints: { minValue: 0n }
      }
    }
  ]
}

The wallet can then present appropriate UI for users to select/enter values while maintaining privacy.

  1. Permissions and Meta-operations: @porco raises good points about granular permissions. However, I lean towards broader permission categories at the RPC level, with more granular controls happening closer to the user interface. We also need to consider:
  • How to handle permission changes mid-session
  • Whether to add a separate interface for wallet “meta” operations
  • How to handle capability discovery

Here’s an example of how this might look:

type WalletCapabilities = {
  supportsAccountAddressSharing: boolean;
  supportedFeatures: string[];
};

interface IAztecWallet {
  // Get wallet capabilities
  getCapabilities(): Promise<WalletCapabilities>;
  
  // Connection with optional account sharing
  connect(dapp: Dapp, permissions: DappPermissions): Promise<ConnectResponse>;
  
  // Meta operations
  requestPermissions(permissions: DappPermissions): Promise<boolean>;
  getSession(): Promise<SessionInfo>;

  // Transaction operations
  sendTransaction(txs: TransactionBatch): Promise<string>;
  simulateTransaction(tx: TransactionCall): Promise<unknown>;
  
  // Wallet operations (separate from batching)
  registerContract(contract: ContractRegistration): Promise<boolean>;
  registerContact(contact: ContactRegistration): Promise<boolean>;

  // ...
}

Implementation Considerations

One other point I’d like to bring up is about the interface definition level. While high-level TypeScript interfaces are useful for developers, I believe we should focus first on defining the lower-level JSON-RPC 2.0 interface. Here’s why:

  1. Context Separation: Wallets and dapps will typically live in separate contexts (different windows, processes, or devices), making remote procedure calls necessary.
  2. Protocol First: A well-defined JSON-RPC interface ensures interoperability regardless of the implementation language or framework.

You can see my current implementation (pre-discussion) here: aztec/packages/rpc/src/types.ts at 07996c6369fb925ddce5aae43004b7258de80e02 · WalletMesh/aztec · GitHub
Considering this discussion, I think it could be updated as follows:

type TransactionBatch = {
  chainId: number;
  transactions: TransactionCall[];
};

type ContractRegistration = {
  chainId: number;
  address: string;
  instance: ContractInstance;
  artifact?: ContractArtifact;
};

type ContactRegistration = {
  chainId: number;
  address: string;
};

type AztecWalletRPCMethodMap = {
  // Connection & session management
  'aztec_connect': {
    params: {
      dapp: Dapp;
      permissions: DappPermissions;
    };
    result: ConnectResponse;
  };
  
  'aztec_getCapabilities': {
    params: null;
    result: WalletCapabilities;
  };

  // Transaction operations
  'aztec_sendTransaction': {
    params: TransactionBatch;
    result: string; // tx hash
  };
  
  'aztec_simulateTransaction': {
    // I don't think a TransactionBatch makes sense here, but maybe it does?
    params: TransactionCall;
    result: unknown;
  };

  // Wallet operations
  'aztec_registerContract': {
    params: ContractRegistration;
    result: boolean;
  };

  'aztec_registerContact': {
    params: ContactRegistration;
    result: boolean;
   };
    
  // ... other methods
};

Higher-level TypeScript interfaces can then be built on top of this RPC layer, providing more ergonomic APIs for different contexts while maintaining the same underlying protocol.

What are your thoughts on structuring the spec this way? Are folks OK with starting on JSON-RPC layer definitions and then moving on to higher-level abstractions?

Whatever we go with, we’ll need more discussion to drill down into the details and come to a consensus on what works best. To start with, how about we focus on just the initial connection setup with the session & capabilities methods? Some key questions:

  1. Is optional / opt-in sharing of account address info acceptable to you?
  2. What should the capability / permissions set contain besides chain IDs and permitted RPC methods?
  3. How should we handle permission changes during an active session - should we require a new session or add a separate “upgrade permissions” flow?
  4. What information should dapps provide during connection to help users make informed decisions about permissions?
  5. Should session management include the ability to manage multiple concurrent sessions from the same dapp (e.g., for different accounts or permission sets)?

Some users might like optional, more granular permissions, i.e. what actions is allowed or not.

Yeah, that’s a good point. If we have methods taking batch of actions, then it makes sense to explicitly grant permissions for action types as well, for more secure permissions settings :+1:

Though, I’m curious how you envision an ideal confirmation flow within the single popup. Does user still click multiple confirmation buttons for each/some of the items or it’s just one-click maybe with radio buttons for each item, etc…?

By radio buttons do you mean making users explicitly confirm each action separately, or an ability to keep some actions unconfirmed?

Asking users to explicitly confirm each action separately would be definitely more secure, but at the same time annoying :slight_smile: So, we need to find some balance.

As for allowing some actions to be unconfirmed, not sure if it makes sense, because a dapp actually shouldn’t request actions, which are unnecessary. So, if all actions are necessary, then having at least one rejected action will mean a rejected batch.

Right now we have a single confirmation window, where we display the whole list of actions a dapp requested to execute and a single confirmation button, so a user checks the whole batch and confirms all or nothing by a single click.

  1. Action batching: While I understand the UX benefits of batching actions, I see some potential issues:
  • How do we handle partial failures in a batch?

If we speak about how it should work, then if a batch fails at some action, then all the previous actions must be reverted and all the following actions must be skipped. But right now it’s not possible, because PXE doesn’t provide remove_* methods, so you cannot revert adding a contract, for example.

Anyway, even without reverting, it will work well. If you have a batch [add_contract, call] failed on the call due to wrong parameters, for example, and the dapp sends it again after adjusting the parameters, you will just add the contract again, that won’t break anything (btw, you can check if you’ve already added the contract, and if so, simply ignore the action).

  • What does it mean to simulate non-Call actions?

Most non-Call actions can be needed for a Call action to succeed, so they can be a necessary part of the simulation. For instance, if you have a contract method that consumes a capsule or a note, then you will have to add it before simulation, otherwise the call will fail. Moreover, capsules are removed when consumed, so you will have to add it each time.

If I don’t miss anything, only add_contact action is never needed for execution/simulation directly, so its simulation indeed doesn’t make any sense. Also it doesn’t make sense to simulate a batch consisting of non-Call actions only. So, there are two options what we could do:

  • restrict meaningless actions or combination of actions in the simulate method, making it more complicated for dapp devs;
  • or allow passing any actions to provide the same UX as with execute, but in case of meaningless actions simply do nothing on the wallet side and just respond with OK.
  • Different types of actions may need different handling.

Why do you think that is an issue? With method-per-action approach the same different handling will be needed.

  • Combining unrelated actions might actually worsen the UX (from an end-user confusion standpoint) rather than improve it.

That should be dapp’s responsibility to follow best practices and avoid batching unrelated actions, to not confuse their users when they are asked to approve it.

I propose splitting this into two concepts:

  • Transaction batching (for related blockchain operations like approve+swap)
  • Individual operations for other distinct wallet interactions (contract registration, etc.)

That approach totally makes sense. However, besides forcing dapp users to do multiple confirmations, there is another issue.

One of the main ideas of batching is to have atomic-ish execution, which is necessary, for example, when working with capsules. PXE stores capsules in global stack, so if you call a contract that is going to consume a capsule, you must ensure that between adding the capsule and calling the contract no other capsules were pushed and no other contracts popped it.

It’s easy to achieve with batch execution, and problematic with independent workflows for adding and consuming capsules.

Transaction Parameter Templates

That’s a very interesting idea :+1:

This would allow dapps to work without requiring account addresses by default, and providing account addresses can be opt-in instead of mandatory

Yeah, that would make sense if we had the parameter templates feature standardized.

The connect response could include more metadata about the connection

Yeah, I actually skipped such details to focus more on the wallet interface instead. So, the connect method indeed should return a session, containing:

  • a session id (which is used further to authorize dapp requests);
  • granted permissions (because the connect method actually takes two parameters: requiredPermissions and optionalPermissions, so the dapp should know which optional permissions were approved);
  • shared accounts (we use CAIP-10, that is "{chain_id}:{address}" strings).

Btw, short question: do we want to care about session hijacking, or it’s not worth it? If yes, we would also need to introduce something like signature-based authorization for dapp requests.

Permissions and Meta-operations

  • How to handle permission changes mid-session

That’s a good question. There are actually two different cases:

  1. Dapp wants to adjust permissions. In this case either the dapp can simply reconnect with the new permissions, or we can have another method in the wallet interface, like upgradeSession(newPermissions). No big difference, IMO :slight_smile:
  2. User wants to adjust permissions. For instance, he wants to share one more account, or revoke some of the previously shared accounts. In this case the wallet should notify the dapp about changes somehow. This can be done, for instance, by producing a session_changed event. However, still not sure what to do if the dapp is “offline” at that moment. Perhaps dapps should be able to get info about the active session.
  • How to handle capability discovery

I’d propose to handle it by the permissions functionality. As I mentioned above, there is the optionalPermissions parameter, that could be used to request/discover additional features supported by a dapp.

Simple example,

const requiredPermissions: [
    "execute 'call' action on mainnet"
];
const optionalPermissions: [
    "allow template parameters"
];
await connect(dapp, requiredPermissions, optionalPermissions);

Roughly speaking, a dapp requests permissions for particular functionalities, and the wallet responds with a subset, that is supported by the wallet and approved by the user.

In this case, the getCapabilities() method doesn’t make much sense, because a dapp doesn’t really care about all wallet’s capabilities, but only about specific ones (supported by the dapp), so the dapp can simply request them via optional permissions.


Are folks OK with starting on JSON-RPC layer definitions and then moving on to higher-level abstractions?

Actually, JSON-RPC spec is the “higher-level abstraction”, because it describes a particular implementation of a base interface, determining wallet’s functionality and behavior :slight_smile: So, IMO, it’s more correct to have a base interface first, and then talk about particular implementations, like JSON-RPC spec, gRPC spec, etc…

But I don’t insist :slight_smile: It’s not something that I want to argue about.

1 Like