diff --git a/.changeset/clean-cougars-jump.md b/.changeset/clean-cougars-jump.md new file mode 100644 index 0000000000..2185c73d17 --- /dev/null +++ b/.changeset/clean-cougars-jump.md @@ -0,0 +1,6 @@ +--- +"@near-js/keystores": minor +"@near-js/keystores-browser": minor +--- + +Add multi_contract_keystore diff --git a/packages/iframe-rpc/src/iframe-rpc.ts b/packages/iframe-rpc/src/iframe-rpc.ts index e97bfcfcd3..fc5ab17513 100644 --- a/packages/iframe-rpc/src/iframe-rpc.ts +++ b/packages/iframe-rpc/src/iframe-rpc.ts @@ -231,4 +231,4 @@ export class IFrameRPC extends EventEmitter { // Ignore } } -} +} \ No newline at end of file diff --git a/packages/keystores-browser/src/index.ts b/packages/keystores-browser/src/index.ts index 2efe7c05b7..dff1cc4029 100644 --- a/packages/keystores-browser/src/index.ts +++ b/packages/keystores-browser/src/index.ts @@ -1 +1,2 @@ export { BrowserLocalStorageKeyStore } from './browser_local_storage_key_store'; +export { MultiContractBrowserLocalStorageKeyStore } from './multi_contract_browser_local_storage_key_store'; diff --git a/packages/keystores-browser/src/multi_contract_browser_local_storage_key_store.ts b/packages/keystores-browser/src/multi_contract_browser_local_storage_key_store.ts new file mode 100644 index 0000000000..33cdcb029d --- /dev/null +++ b/packages/keystores-browser/src/multi_contract_browser_local_storage_key_store.ts @@ -0,0 +1,159 @@ +import { KeyPair, KeyPairString } from '@near-js/crypto'; +import { MultiContractKeyStore } from '@near-js/keystores'; + +const LOCAL_STORAGE_KEY_PREFIX = 'near-api-js:keystore:'; + +/** + * This class is used to store keys in the browsers local storage. + * + * @see [https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store](https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store) + * @example + * ```js + * import { connect, keyStores } from 'near-api-js'; + * + * const keyStore = new keyStores.MultiContractBrowserLocalStorageKeyStore(); + * const config = { + * keyStore, // instance of MultiContractBrowserLocalStorageKeyStore + * networkId: 'testnet', + * nodeUrl: 'https://rpc.testnet.near.org', + * walletUrl: 'https://wallet.testnet.near.org', + * helperUrl: 'https://helper.testnet.near.org', + * explorerUrl: 'https://explorer.testnet.near.org' + * }; + * + * // inside an async function + * const near = await connect(config) + * ``` + */ +export class MultiContractBrowserLocalStorageKeyStore extends MultiContractKeyStore { + /** @hidden */ + private localStorage: Storage; + /** @hidden */ + private prefix: string; + + /** + * @param localStorage defaults to window.localStorage + * @param prefix defaults to `near-api-js:keystore:` + */ + constructor(localStorage: any = window.localStorage, prefix = LOCAL_STORAGE_KEY_PREFIX) { + super(); + this.localStorage = localStorage; + this.prefix = prefix || LOCAL_STORAGE_KEY_PREFIX; + } + + /** + * Stores a {@link utils/key_pair!KeyPair} in local storage. + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @param keyPair The key pair to store in local storage + * @param contractId The contract to store in local storage + */ + async setKey(networkId: string, accountId: string, keyPair: KeyPair, contractId: string): Promise { + this.localStorage.setItem(this.storageKeyForSecretKey(networkId, accountId, contractId), keyPair.toString()); + } + + /** + * Gets a {@link utils/key_pair!KeyPair} from local storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @param contractId The NEAR contract tied to the key pair + * @returns {Promise} + */ + async getKey(networkId: string, accountId: string, contractId: string): Promise { + const value = this.localStorage.getItem(this.storageKeyForSecretKey(networkId, accountId, contractId)); + if (!value) { + return null; + } + return KeyPair.fromString(value as KeyPairString); + } + + /** + * Removes a {@link utils/key_pair!KeyPair} from local storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @param contractId The NEAR contract tied to the key pair + */ + async removeKey(networkId: string, accountId: string, contractId: string): Promise { + this.localStorage.removeItem(this.storageKeyForSecretKey(networkId, accountId, contractId)); + } + + /** + * Removes all items that start with `prefix` from local storage + */ + async clear(): Promise { + for (const key of this.storageKeys()) { + if (key.startsWith(this.prefix)) { + this.localStorage.removeItem(key); + } + } + } + + /** + * Get the network(s) from local storage + * @returns {Promise} + */ + async getNetworks(): Promise { + const result = new Set(); + for (const key of this.storageKeys()) { + if (key.startsWith(this.prefix)) { + const parts = key.substring(this.prefix.length).split(':'); + result.add(parts[1]); + } + } + return Array.from(result.values()); + } + + /** + * Gets the account(s) from local storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + */ + async getAccounts(networkId: string): Promise { + const result: string[] = []; + for (const key of this.storageKeys()) { + if (key.startsWith(this.prefix)) { + const parts = key.substring(this.prefix.length).split(':'); + if (parts[1] === networkId) { + result.push(parts[0]); + } + } + } + return result; + } + + /** + * Gets the contract(s) from local storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The targeted account. + */ + async getContracts(networkId: string, accountId: string): Promise { + const result: string[] = []; + for (const key of this.storageKeys()) { + if (key.startsWith(this.prefix)) { + const parts = key.substring(this.prefix.length).split(':'); + if (parts[1] === networkId && parts[0] === accountId) { + result.push(parts[2]); + } + } + } + return result; + } + + /** + * @hidden + * Helper function to retrieve a local storage key + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the storage keythat's sought + * @param contractId The NEAR contract tied to the storage keythat's sought + * @returns {string} An example might be: `near-api-js:keystore:near-friend:default` + */ + private storageKeyForSecretKey(networkId: string, accountId: string, contractId: string): string { + return `${this.prefix}${accountId}:${networkId}:${contractId}`; + } + + /** @hidden */ + private *storageKeys(): IterableIterator { + for (let i = 0; i < this.localStorage.length; i++) { + yield this.localStorage.key(i) as string; + } + } +} diff --git a/packages/keystores-browser/test/browser_keystore.test.js b/packages/keystores-browser/test/browser_keystore.test.js index 7bdf34206c..117c8a3ef4 100644 --- a/packages/keystores-browser/test/browser_keystore.test.js +++ b/packages/keystores-browser/test/browser_keystore.test.js @@ -1,4 +1,4 @@ -const { BrowserLocalStorageKeyStore } = require('../lib'); +const { BrowserLocalStorageKeyStore, MultiContractBrowserLocalStorageKeyStore } = require('../lib'); describe('Browser keystore', () => { let ctx = {}; @@ -9,3 +9,14 @@ describe('Browser keystore', () => { require('./keystore_common').shouldStoreAndRetrieveKeys(ctx); }); + + +describe('Browser multi keystore', () => { + let ctx = {}; + + beforeAll(async () => { + ctx.keyStore = new MultiContractBrowserLocalStorageKeyStore(require('localstorage-memory')); + }); + + require('./multi_contract_browser_keystore_common').shouldStoreAndRetrieveKeys(ctx); +}); \ No newline at end of file diff --git a/packages/keystores-browser/test/multi_contract_browser_keystore_common.js b/packages/keystores-browser/test/multi_contract_browser_keystore_common.js new file mode 100644 index 0000000000..4329544cd5 --- /dev/null +++ b/packages/keystores-browser/test/multi_contract_browser_keystore_common.js @@ -0,0 +1,64 @@ +const { KeyPairEd25519 } = require('@near-js/crypto'); + +const NETWORK_ID = 'networkid'; +const ACCOUNT_ID = 'accountid'; +const CONTRACT_ID = 'contractid'; +const KEYPAIR = new KeyPairEd25519('2wyRcSwSuHtRVmkMCGjPwnzZmQLeXLzLLyED1NDMt4BjnKgQL6tF85yBx6Jr26D2dUNeC716RBoTxntVHsegogYw'); + +module.exports.shouldStoreAndRetrieveKeys = ctx => { + beforeEach(async () => { + await ctx.keyStore.clear(); + await ctx.keyStore.setKey(NETWORK_ID, ACCOUNT_ID, KEYPAIR, CONTRACT_ID); + }); + + test('Get all keys with empty network returns empty list', async () => { + const emptyList = await ctx.keyStore.getAccounts('emptynetwork'); + expect(emptyList).toEqual([]); + }); + + test('Get all keys with single key in keystore', async () => { + const accountIds = await ctx.keyStore.getAccounts(NETWORK_ID); + expect(accountIds).toEqual([ACCOUNT_ID]); + }); + + test('Get not-existing account', async () => { + expect(await ctx.keyStore.getKey('somenetwork', 'someaccount', 'somecontract')).toBeNull(); + }); + + test('Get account id from a network with single key', async () => { + const key = await ctx.keyStore.getKey(NETWORK_ID, ACCOUNT_ID, CONTRACT_ID); + expect(key).toEqual(KEYPAIR); + }); + + test('Get networks', async () => { + const networks = await ctx.keyStore.getNetworks(); + expect(networks).toEqual([NETWORK_ID]); + }); + + test('Get accounts', async () => { + const accounts = await ctx.keyStore.getAccounts(NETWORK_ID); + expect(accounts).toEqual([ACCOUNT_ID]); + }); + + test('Get contracts', async () => { + const contracts = await ctx.keyStore.getContracts(NETWORK_ID, ACCOUNT_ID); + expect(contracts).toEqual([CONTRACT_ID]); + }); + + test('Add two contracts to account and retrieve them', async () => { + const networkId = 'network'; + const accountId = 'account'; + const contract1 = 'contract1'; + const contract2 = 'contract2'; + const key1Expected = KeyPairEd25519.fromRandom(); + const key2Expected = KeyPairEd25519.fromRandom(); + await ctx.keyStore.setKey(networkId, accountId, key1Expected, contract1); + await ctx.keyStore.setKey(networkId, accountId, key2Expected, contract2); + const key1 = await ctx.keyStore.getKey(networkId, accountId, contract1); + const key2 = await ctx.keyStore.getKey(networkId, accountId, contract2); + expect(key1).toEqual(key1Expected); + expect(key2).toEqual(key2Expected); + const contractIds = await ctx.keyStore.getContracts(networkId, accountId); + expect(contractIds).toEqual([contract1, contract2]); + }); +}; diff --git a/packages/keystores/src/index.ts b/packages/keystores/src/index.ts index f095dee7f8..36c3f96339 100644 --- a/packages/keystores/src/index.ts +++ b/packages/keystores/src/index.ts @@ -1,3 +1,4 @@ export { InMemoryKeyStore } from './in_memory_key_store'; export { KeyStore } from './keystore'; export { MergeKeyStore } from './merge_key_store'; +export { MultiContractKeyStore } from './multi_contract_keystore'; diff --git a/packages/keystores/src/multi_contract_keystore.ts b/packages/keystores/src/multi_contract_keystore.ts new file mode 100644 index 0000000000..c6eb98d99a --- /dev/null +++ b/packages/keystores/src/multi_contract_keystore.ts @@ -0,0 +1,17 @@ +import { KeyPair } from '@near-js/crypto'; + +/** + * KeyStores are passed to {@link near!Near} via {@link near!NearConfig} + * and are used by the {@link signer!InMemorySigner} to sign transactions. + * + * @see {@link connect} + */ +export abstract class MultiContractKeyStore { + abstract setKey(networkId: string, accountId: string, keyPair: KeyPair, contractId: string): Promise; + abstract getKey(networkId: string, accountId: string, contractId: string): Promise; + abstract removeKey(networkId: string, accountId: string, contractId: string): Promise; + abstract clear(): Promise; + abstract getNetworks(): Promise; + abstract getAccounts(networkId: string): Promise; + abstract getContracts(networkId: string, accountId: string): Promise; +}