-
Notifications
You must be signed in to change notification settings - Fork 59
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2023 from aeternity/snap
Add AccountMetamask
- Loading branch information
Showing
20 changed files
with
1,987 additions
and
325 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,10 +6,10 @@ This guide explains basic interactions on getting access to aeternity accounts o | |
|
||
Run the code from below you need: | ||
|
||
- a Ledger Hardware Wallet like Ledger Nano X, Ledger Nano S | ||
- to install [Ledger Live](https://www.ledger.com/ledger-live) | ||
- to install [email protected] or above app from Ledger Live to HW | ||
- to have Ledger HW connected to computer, unlocked, with aeternity app opened | ||
- a Ledger Hardware Wallet like Ledger Nano X, Ledger Nano S; | ||
- to install [Ledger Live](https://www.ledger.com/ledger-live); | ||
- to install [email protected] or above app from Ledger Live to HW; | ||
- to have Ledger HW connected to computer, unlocked, with aeternity app opened. | ||
|
||
## Usage | ||
|
||
|
@@ -31,7 +31,7 @@ console.log(account.address); // 'ak_2dA...' | |
console.log(await account.signTransaction('tx_...')); // 'tx_...' (with signature added) | ||
``` | ||
|
||
The private key for the account would be derived on the Ledger device using the provided index and the mnemonic phrase it was initialized with. | ||
The private key for the account would be derived on the Ledger device using the provided index and the mnemonic phrase it was initialized with. The private key won't leave the device. | ||
|
||
The complete examples of how to use it in nodejs and browser can be found [here](https://github.com/aeternity/aepp-sdk-js/tree/71da12b5df56b41f7317d1fb064e44e8ea118d6c/test/environment/ledger). | ||
|
||
|
@@ -69,3 +69,7 @@ const node = new Node('https://testnet.aeternity.io'); | |
const accounts = await accountFactory.discover(node); | ||
console.log(accounts[0].address); // 'ak_2dA...' | ||
``` | ||
|
||
## Error handling | ||
|
||
If the user rejects a transaction/message signing or address confirmation you will get an exception inherited from TransportStatusError (exposed in '@ledgerhq/hw-transport' package). With the message "Ledger device: Condition of use not satisfied (denied by the user?) (0x6985)". Also, `statusCode` equals 0x6985, and `statusText` equals `CONDITIONS_OF_USE_NOT_SATISFIED`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
# Aeternity snap for MetaMask | ||
|
||
This guide explains basic interactions on getting access to accounts on Aeternity snap for MetaMask using JS SDK. | ||
|
||
## Prerequisite | ||
|
||
Run the code from below you need: | ||
|
||
- a MetaMask extension 12.2.4 or above installed in Chrome or Firefox browser; | ||
- to setup an account in MetaMask (create a new one or restore by mnemonic phrase). | ||
|
||
## Usage | ||
|
||
Firstly, you need to create a factory of MetaMask accounts | ||
|
||
```js | ||
import { AccountMetamaskFactory } from '@aeternity/aepp-sdk'; | ||
|
||
const accountFactory = new AccountMetamaskFactory(); | ||
``` | ||
|
||
The next step is to install Aeternity snap to MetaMask. You can request installation by calling | ||
|
||
```js | ||
await accountFactory.installSnap(); | ||
``` | ||
|
||
If succeed it means that MetaMask is ready to provide access to accounts. Alternatively, you can call `ensureReady` instead of `installSnap`. The latter won't trigger a snap installation, it would just fall with the exception if not installed. | ||
|
||
Using the factory, you can create instances of specific accounts by providing an index | ||
|
||
```js | ||
const account = await accountFactory.initialize(0); | ||
console.log(account.address); // 'ak_2dA...' | ||
console.log(await account.signTransaction('tx_...')); // 'tx_...' (with signature added) | ||
``` | ||
|
||
The private key for the account would be derived in the MetaMask browser extension using the provided index and the mnemonic phrase it was initialized with. The private key won't leave the extension. | ||
|
||
The complete examples of how to use it in browser can be found [here](https://github.com/aeternity/aepp-sdk-js/tree/develop/examples/browser/aepp/src/components/ConnectMetamask.vue). | ||
|
||
## Account persistence | ||
|
||
Account can be persisted and restored by saving values of `index`, `address` properties | ||
|
||
```js | ||
import { AccountMetamask } from '@aeternity/aepp-sdk'; | ||
|
||
const accountIndex = accountToPersist.index; | ||
const accountAddress = accountToPersist.address; | ||
|
||
const accountFactory = new AccountMetamaskFactory(); | ||
const restoredAccount = new AccountMetamask(accountFactory.provider, accountIndex, accountAddress); | ||
``` | ||
|
||
It can be used to remember accounts between app restarts. | ||
|
||
## Account discovery | ||
|
||
In addition to the above, it is possible to get access to a sequence of accounts that already have been used on chain. It is needed, for example, to restore the previously used accounts in case the user connects MetaMask to an app that doesn't aware of them. | ||
|
||
```js | ||
import { Node } from '@aeternity/aepp-sdk'; | ||
|
||
const node = new Node('https://testnet.aeternity.io'); | ||
const accounts = await accountFactory.discover(node); | ||
console.log(accounts[0].address); // 'ak_2dA...' | ||
``` | ||
|
||
## Error handling | ||
|
||
If the user rejects a transaction/message signing or address retrieving you will get an exception as a plain object with property `code` equals 4001, and `message` equals "User rejected the request.". |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,247 +1,20 @@ | ||
<template> | ||
<div class="group"> | ||
<div> | ||
<label> | ||
<input v-model="connectMethod" type="radio" value="default" /> | ||
Iframe or WebExtension | ||
</label> | ||
</div> | ||
<div> | ||
<label> | ||
<input v-model="connectMethod" type="radio" value="reverse-iframe" /> | ||
Reverse iframe | ||
</label> | ||
<div><input v-model="reverseIframeWalletUrl" /></div> | ||
</div> | ||
|
||
<button v-if="walletConnected" @click="disconnect">Disconnect</button> | ||
<button v-else-if="connectMethod" :disabled="walletConnecting" @click="connect">Connect</button> | ||
|
||
<button v-if="cancelWalletDetection" @click="cancelWalletDetection">Cancel detection</button> | ||
|
||
<template v-if="walletConnected"> | ||
<br /> | ||
<button @click="getAccounts">Get accounts</button> | ||
<button @click="subscribeAccounts('subscribe', 'current')">Subscribe current</button> | ||
<button @click="subscribeAccounts('unsubscribe', 'current')">Unsubscribe current</button> | ||
<button @click="subscribeAccounts('subscribe', 'connected')">Subscribe connected</button> | ||
<button @click="subscribeAccounts('unsubscribe', 'connected')">Unsubscribe connected</button> | ||
|
||
<div> | ||
<div>RPC Accounts</div> | ||
<div>{{ rpcAccounts.map((account) => account.address.slice(0, 8)).join(', ') }}</div> | ||
</div> | ||
</template> | ||
</div> | ||
|
||
<SelectNetwork :select="(network) => this.walletConnector.askToSelectNetwork(network)" /> | ||
|
||
<h2>Ledger Hardware Wallet</h2> | ||
<div class="group"> | ||
<template v-if="ledgerStatus"> | ||
<div> | ||
<div>Connection status</div> | ||
<div>{{ ledgerStatus }}</div> | ||
</div> | ||
</template> | ||
<button v-else-if="!ledgerAccountFactory" @click="connectLedger">Connect</button> | ||
<template v-else> | ||
<button @click="disconnectLedger">Disconnect</button> | ||
<button @click="addLedgerAccount">Add Account</button> | ||
<button v-if="ledgerAccounts.length > 1" @click="switchLedgerAccount">Switch Account</button> | ||
<button @click="switchNode">Switch Node</button> | ||
<div v-if="ledgerAccounts.length"> | ||
<div>Ledger Accounts</div> | ||
<div>{{ ledgerAccounts.map((account) => account.address.slice(0, 8)).join(', ') }}</div> | ||
</div> | ||
</template> | ||
<div class="nav"> | ||
<a href="#" :class="{ active: view === 'Frame' }" @click="view = 'Frame'">Frame</a> | ||
<a href="#" :class="{ active: view === 'Ledger' }" @click="view = 'Ledger'">Ledger HW</a> | ||
<a href="#" :class="{ active: view === 'Metamask' }" @click="view = 'Metamask'">MetaMask</a> | ||
</div> | ||
|
||
<div class="group"> | ||
<div> | ||
<div>SDK status</div> | ||
<div> | ||
{{ | ||
(walletConnected && 'Wallet connected') || | ||
(cancelWalletDetection && 'Wallet detection') || | ||
(walletConnecting && 'Wallet connecting') || | ||
'Ready to connect to wallet' | ||
}} | ||
</div> | ||
</div> | ||
<div> | ||
<div>Wallet name</div> | ||
<div>{{ walletName }}</div> | ||
</div> | ||
</div> | ||
<Component v-if="view" :is="view" /> | ||
</template> | ||
|
||
<script> | ||
import { | ||
walletDetector, | ||
BrowserWindowMessageConnection, | ||
RpcConnectionDenyError, | ||
RpcRejectedByUserError, | ||
WalletConnectorFrame, | ||
AccountLedgerFactory, | ||
} from '@aeternity/aepp-sdk'; | ||
import { mapState } from 'vuex'; | ||
import TransportWebUSB from '@ledgerhq/hw-transport-webusb'; | ||
import SelectNetwork from './components/SelectNetwork.vue'; | ||
import Frame from './components/ConnectFrame.vue'; | ||
import Ledger from './components/ConnectLedger.vue'; | ||
import Metamask from './components/ConnectMetamask.vue'; | ||
export default { | ||
components: { SelectNetwork }, | ||
data: () => ({ | ||
connectMethod: 'default', | ||
walletConnected: false, | ||
walletConnecting: null, | ||
reverseIframe: null, | ||
reverseIframeWalletUrl: process.env.VUE_APP_WALLET_URL ?? `http://${location.hostname}:9000`, | ||
walletInfo: null, | ||
cancelWalletDetection: null, | ||
rpcAccounts: [], | ||
ledgerStatus: '', | ||
ledgerAccountFactory: null, | ||
ledgerAccounts: [], | ||
}), | ||
computed: { | ||
...mapState(['aeSdk']), | ||
walletName() { | ||
if (!this.walletConnected) return 'Wallet is not connected'; | ||
return this.walletInfo.name; | ||
}, | ||
}, | ||
methods: { | ||
async connectLedger() { | ||
try { | ||
this.ledgerStatus = 'Waiting for Ledger response'; | ||
const transport = await TransportWebUSB.create(); | ||
this.ledgerAccountFactory = new AccountLedgerFactory(transport); | ||
} catch (error) { | ||
if (error.name === 'TransportOpenUserCancelled') return; | ||
throw error; | ||
} finally { | ||
this.ledgerStatus = ''; | ||
} | ||
}, | ||
async disconnectLedger() { | ||
this.ledgerAccountFactory = null; | ||
this.ledgerAccounts = []; | ||
this.$store.commit('setAddress', undefined); | ||
if (Object.keys(this.aeSdk.accounts).length) this.aeSdk.removeAccount(this.aeSdk.address); | ||
}, | ||
async addLedgerAccount() { | ||
try { | ||
this.ledgerStatus = 'Waiting for Ledger response'; | ||
const idx = this.ledgerAccounts.length; | ||
const account = await this.ledgerAccountFactory.initialize(idx); | ||
this.ledgerStatus = `Ensure that ${account.address} is displayed on Ledger HW screen`; | ||
await this.ledgerAccountFactory.getAddress(idx, true); | ||
this.ledgerAccounts.push(account); | ||
this.setAccount(this.ledgerAccounts[0]); | ||
} catch (error) { | ||
if (error.statusCode === 0x6985) return; | ||
throw error; | ||
} finally { | ||
this.ledgerStatus = ''; | ||
} | ||
}, | ||
switchLedgerAccount() { | ||
this.ledgerAccounts.push(this.ledgerAccounts.shift()); | ||
this.setAccount(this.ledgerAccounts[0]); | ||
}, | ||
async switchNode() { | ||
await this.setNode(this.$store.state.networkId === 'ae_mainnet' ? 'ae_uat' : 'ae_mainnet'); | ||
}, | ||
async getAccounts() { | ||
this.rpcAccounts = await this.walletConnector.getAccounts(); | ||
if (this.rpcAccounts.length) this.setAccount(this.rpcAccounts[0]); | ||
}, | ||
async subscribeAccounts(type, value) { | ||
await this.walletConnector.subscribeAccounts(type, value); | ||
}, | ||
async detectWallets() { | ||
if (this.connectMethod === 'reverse-iframe') { | ||
this.reverseIframe = document.createElement('iframe'); | ||
this.reverseIframe.src = this.reverseIframeWalletUrl; | ||
this.reverseIframe.style.display = 'none'; | ||
document.body.appendChild(this.reverseIframe); | ||
} | ||
const connection = new BrowserWindowMessageConnection(); | ||
return new Promise((resolve, reject) => { | ||
const stopDetection = walletDetector(connection, async ({ newWallet }) => { | ||
if ( | ||
confirm( | ||
`Do you want to connect to wallet ${newWallet.info.name} with id ${newWallet.info.id}`, | ||
) | ||
) { | ||
stopDetection(); | ||
resolve(newWallet.getConnection()); | ||
this.cancelWalletDetection = null; | ||
this.walletInfo = newWallet.info; | ||
} | ||
}); | ||
this.cancelWalletDetection = () => { | ||
reject(new Error('Wallet detection cancelled')); | ||
stopDetection(); | ||
this.cancelWalletDetection = null; | ||
if (this.reverseIframe) this.reverseIframe.remove(); | ||
}; | ||
}); | ||
}, | ||
async setNode(networkId) { | ||
const [{ name }] = (await this.aeSdk.getNodesInPool()).filter( | ||
(node) => node.nodeNetworkId === networkId, | ||
); | ||
this.aeSdk.selectNode(name); | ||
this.$store.commit('setNetworkId', networkId); | ||
}, | ||
setAccount(account) { | ||
if (Object.keys(this.aeSdk.accounts).length) this.aeSdk.removeAccount(this.aeSdk.address); | ||
this.aeSdk.addAccount(account, { select: true }); | ||
this.$store.commit('setAddress', account.address); | ||
}, | ||
async connect() { | ||
this.walletConnecting = true; | ||
try { | ||
const connection = await this.detectWallets(); | ||
try { | ||
this.walletConnector = await WalletConnectorFrame.connect('Simple æpp', connection); | ||
} catch (error) { | ||
if (error instanceof RpcConnectionDenyError) connection.disconnect(); | ||
throw error; | ||
} | ||
this.walletConnector.on('disconnect', () => { | ||
this.walletConnected = false; | ||
this.walletInfo = null; | ||
this.rpcAccounts = []; | ||
this.$store.commit('setAddress', undefined); | ||
if (this.reverseIframe) this.reverseIframe.remove(); | ||
}); | ||
this.walletConnected = true; | ||
this.setNode(this.walletConnector.networkId); | ||
this.walletConnector.on('networkIdChange', (networkId) => this.setNode(networkId)); | ||
this.walletConnector.on('accountsChange', (accounts) => { | ||
this.rpcAccounts = accounts; | ||
if (accounts.length) this.setAccount(accounts[0]); | ||
}); | ||
} catch (error) { | ||
if ( | ||
error.message === 'Wallet detection cancelled' || | ||
error instanceof RpcConnectionDenyError || | ||
error instanceof RpcRejectedByUserError | ||
) | ||
return; | ||
throw error; | ||
} finally { | ||
this.walletConnecting = false; | ||
} | ||
}, | ||
disconnect() { | ||
this.walletConnector.disconnect(); | ||
}, | ||
}, | ||
components: { Frame, Ledger, Metamask }, | ||
data: () => ({ view: 'Frame' }), | ||
}; | ||
</script> |
Oops, something went wrong.