Injected Wallets
Summary
Standard interface for injected wallets.
Motivation
dApps are finding it increasingly difficult to support the ever expanding choice of wallets due to their wildly different implementations. While projects such as Wallet Selector attempt to mask this problem, it's clear the ecosystem requires a standard that will not only benefit dApps but make it easier for established wallets to support NEAR.
Rationale and alternatives
At its most basic, a wallet contains key pairs required to sign messages. This standard aims to define an API (based on our learning from Wallet Selector) that achieves this requirement through a number of methods exposed on the window
object.
The introduction of this standard makes it possible for near-api-js
to become wallet-agnostic and eventually move away from the high amount of coupling with NEAR Wallet. It simplifies projects such as Wallet Selector that must implement various abstractions to normalise the different APIs before it can display a modal for selecting a wallet.
This standard takes a different approach to a wallet API than other blockchains such as Ethereum's JSON-RPC Methods. Mainly, it rejects the request
abstraction that feels unnecessary and only adds to the complexity both in terms of implementation and types. Instead, it exposes various methods directly on the top-level object that also improves discoverability.
There have been many iterations of this standard to help inform what we consider the "best" approach right now for NEAR. Below is a summary of the key design choices:
Single account vs. multiple account
Almost every wallet implementation in NEAR used a single account model until we began integrating with WalletConnect. In WalletConnect, sessions can contain any number of accounts that can be modified by the dApp or wallet. The decision to use a multiple account model was influenced by the following reasons:
- It future-proofs the API even if wallets (such as MetaMask) only support a single "active" account.
- Other blockchains such as Ethereum implement this model.
- Access to multiple accounts allow dApps more freedom to improve UX as users can seamlessly switch between accounts.
- Aligns with WalletConnect via the Bridge Wallet Standard.
Storage of key pairs for FunctionCall access keys in dApp context vs. wallet context
- NEAR's unique concept of
FunctionCall
access keys allow for the concept of 'signing in' to a dApp using your wallet. 'Signing In' to a dApp is accomplished by addingFunctionCall
type access key that the dApp owns to the account that the user is logging in as. - Once a user has 'signed in' to a dApp, the dApp can then use the keypair that it owns to execute transactions without having to prompt the user to route and approve those transactions through their wallet.
FunctionCall
access keys have a limited quota that can only be used to pay for gas fees (typically 0.25 NEAR) and can further be restricted to only be allowed to call specific methods on one specific smart contract.- This allows for an ideal user experience for dApps that require small gas-only transactions regularly while in use. Those transactions can be done without interrupting the user experience by requiring them to be approved through their wallet. A great example of this is evident in gaming use-cases -- take a gaming dApp where some interactions the user makes must write to the blockchain as they do common actions in the game world. Without the 'sign in' concept that provides the dApp with its own limited usage key, the user might be constantly interrupted by needing to approve transactions on their wallet as they perform common actions. If a player has their account secured with a ledger, the gameplay experience would be constantly interrupted by prompts to approve transactions on their ledger device! With the 'sign in' concept, the user will only intermittently need to approve transactions to re-sign-in, when the quota that they approved for gas usage during their last login has been used up.
- Generally, it is recommended to only keep
FullAccess
keys in wallet scope and hidden from the dApp consumer.FunctionCall
type keys should be generated and owned by the dApp, and requested to be added using thesignIn
method. They should not be 'hidden' inside the wallet in the way thatFullAccess
type keys are.
Specification
Injected wallets are typically browser extensions that implement the Wallet
API (see below). References to the currently available wallets are tracked on the window
object. To avoid namespace collisions and easily detect when they're available, wallets must mount under their own key of the object window.near
(e.g. window.near.sender
).
NOTE: Do not replace the entire window.near
object with your wallet implementation, or add any objects as properties of the window.near
object that do not conform to the Injected Wallet Standard
At the core of a wallet are signTransaction
and signTransactions
. These methods, when given a TransactionOptions
instance, will prompt the user to sign with a key pair previously imported (with the assumption it has FullAccess
permission).
In most cases, a dApp will need a reference to an account and associated public key to construct a Transaction
. The connect
method helps solve this issue by prompting the user to select one or more accounts they would like to make visible to the dApp. When at least one account is visible, the wallet considers the dApp connected
and they can access a list of accounts
containing an accountId
and publicKey
.
For dApps that often sign gas-only transactions, FunctionCall
access keys can be added/deleted for one or more accounts using the signIn
and signOut
methods. While this functionality could be achieved with signTransactions
, it suggests a direct intention that a user wishes to sign in/out of a dApp's smart contract.
Wallet API
Below is the entire API for injected wallets. It makes use of near-api-js
to enable interoperability with dApps that will already use it for constructing transactions and communicating with RPC endpoints.
import { transactions, utils } from "near-api-js";
interface Account {
accountId: string;
publicKey: utils.PublicKey;
}
interface Network {
networkId: string;
nodeUrl: string;
}
interface SignInParams {
permission: transactions.FunctionCallPermission;
account: Account;
}
interface SignInMultiParams {
permissions: Array<transactions.FunctionCallPermission>;
account: Account;
}
interface SignOutParams {
accounts: Array<Account>;
}
interface TransactionOptions {
receiverId: string;
actions: Array<transactions.Action>;
signerId?: string;
}
interface SignTransactionParams {
transaction: TransactionOptions;
}
interface SignTransactionsParams {
transactions: Array<TransactionOptions>;
}
interface Events {
accountsChanged: { accounts: Array<Account> };
}
interface ConnectParams {
networkId: string;
}
type Unsubscribe = () => void;
interface Wallet {
id: string;
connected: boolean;
network: Network;
accounts: Array<Account>;
supportsNetwork(networkId: string): Promise<boolean>;
connect(params: ConnectParams): Promise<Array<Account>>;
signIn(params: SignInParams): Promise<void>;
signInMulti(params: SignInMultiParams): Promise<void>;
signOut(params: SignOutParams): Promise<void>;
signTransaction(
params: SignTransactionParams
): Promise<transactions.SignedTransaction>;
signTransactions(
params: SignTransactionsParams
): Promise<Array<transactions.SignedTransaction>>;
disconnect(): Promise<void>;
on<EventName extends keyof Events>(
event: EventName,
callback: (params: Events[EventName]) => void
): Unsubscribe;
off<EventName extends keyof Events>(
event: EventName,
callback?: () => void
): void;
}
Properties
id
Retrieve the wallet's unique identifier.
const { id } = window.near.wallet;
console.log(id); // "wallet"
connected
Determine whether we're already connected to the wallet and have visibility of at least one account.
const { connected } = window.near.wallet;
console.log(connected); // true
network
Retrieve the currently selected network.
const { network } = window.near.wallet;
console.log(network); // { networkId: "testnet", nodeUrl: "https://rpc.testnet.near.org" }
accounts
Retrieve all accounts visible to the dApp.
const { accounts } = window.near.wallet;
console.log(accounts); // [{ accountId: "test.testnet", publicKey: PublicKey }]
Methods
connect
Request visibility for one or more accounts from the wallet. This should explicitly prompt the user to select from their list of imported accounts. dApps can use the accounts
property once connected to retrieve the list of visible accounts.
Note: Calling this method when already connected will allow users to modify their selection, triggering the 'accountsChanged' event.
const accounts = await window.near.wallet.connect();
signTransaction
Sign a transaction. This request should require explicit approval from the user.
import { transactions, providers, utils } from "near-api-js";
// Retrieve accounts (assuming already connected) and current network.
const { network, accounts } = window.near.wallet;
// Setup RPC to retrieve transaction-related prerequisites.
const provider = new providers.JsonRpcProvider({ url: network.nodeUrl });
const signedTx = await window.near.wallet.signTransaction({
transaction: {
signerId: accounts[0].accountId,
receiverId: "guest-book.testnet",
actions: [
transactions.functionCall(
"addMessage",
{ text: "Hello World!" },
utils.format.parseNearAmount("0.00000000003"),
utils.format.parseNearAmount("0.01")
),
],
},
});
// Send the transaction to the blockchain.
await provider.sendTransaction(signedTx);
signTransactions
Sign a list of transactions. This request should require explicit approval from the user.
import { transactions, providers, utils } from "near-api-js";
// Retrieve accounts (assuming already connected) and current network.
const { network, accounts } = window.near.wallet;
// Setup RPC to retrieve transaction-related prerequisites.
const provider = new providers.JsonRpcProvider({ url: network.nodeUrl });
const signedTxs = await window.near.wallet.signTransactions({
transactions: [
{
signerId: accounts[0].accountId,
receiverId: "guest-book.testnet",
actions: [
transactions.functionCall(
"addMessage",
{ text: "Hello World! (1/2)" },
utils.format.parseNearAmount("0.00000000003"),
utils.format.parseNearAmount("0.01")
),
],
},
{
signerId: accounts[0].accountId,
receiverId: "guest-book.testnet",
actions: [
transactions.functionCall(
"addMessage",
{ text: "Hello World! (2/2)" },
utils.format.parseNearAmount("0.00000000003"),
utils.format.parseNearAmount("0.01")
),
],
},
],
});
for (let i = 0; i < signedTxs.length; i += 1) {
const signedTx = signedTxs[i];
// Send the transaction to the blockchain.
await provider.sendTransaction(signedTx);
}
disconnect
Remove visibility of all accounts from the wallet.
await window.near.wallet.disconnect();
signIn
Add one FunctionCall
access key for one or more accounts. This request should require explicit approval from the user.
import { utils } from "near-api-js";
// Retrieve the list of accounts we have visibility of.
const { accounts } = window.near.wallet;
// Request FunctionCall access to the 'guest-book.testnet' smart contract for each account.
await window.near.wallet.signIn({
permission: {
receiverId: "guest-book.testnet",
methodNames: [],
},
account: {
accountId: accounts[0].accountId,
publicKey: utils.KeyPair.fromRandom("ed25519").getPublicKey(),
},
});
signInMulti
Add multiple FunctionCall
access keys for one or more accounts. This request should require explicit approval from the user.
import { utils } from "near-api-js";
// Retrieve the list of accounts we have visibility of.
const { accounts } = window.near.wallet;
// Request FunctionCall access to the 'guest-book.testnet' and 'guest-book2.testnet' smart contract for each account.
await window.near.wallet.signInMulti({
permissions: [
{
receiverId: "guest-book.testnet",
methodNames: [],
},
{
receiverId: "guest-book2.testnet",
methodNames: [],
},
],
account: {
accountId: accounts[0].accountId,
publicKey: utils.KeyPair.fromRandom("ed25519").getPublicKey(),
},
});
Benefits
This NEP will optimize UX for multi contract DApps and avoid multiple redirects. These are more and more common in the ecosystem and this NEP will benefit the UX for those DApps.
Concerns
- The currently available keystores will have to catch up in order to support multiple keys per account
- We should add the new method to the Wallet interface for clarity in the NEP doc
signOut
Delete FunctionCall
access key(s) for one or more accounts. This request should require explicit approval from the user.
import { utils, keyStores } from "near-api-js";
// Setup keystore to retrieve locally stored FunctionCall access keys.
const keystore = new keyStores.BrowserLocalStorageKeyStore();
// Retrieve accounts (assuming already connected) and current network.
const { network, accounts } = window.near.wallet;
// Remove FunctionCall access (previously granted via signIn) for each account.
await window.near.wallet.signOut({
accounts: await Promise.all(
accounts.map(async ({ accountId }) => {
const keyPair = await keystore.getKey(network.networkId, accountId);
return {
accountId,
publicKey: keyPair.getPublicKey(),
};
})
),
});
Events
accountsChanged
Triggered whenever accounts are updated (e.g. calling connect
or disconnect
).
window.near.wallet.on("accountsChanged", ({ accounts }) => {
console.log("Accounts Changed", accounts);
});