Skip to content

Commit 7dfb62f

Browse files
authored
Merge pull request #2023 from aeternity/snap
Add AccountMetamask
2 parents 6c2dbfe + 0831f08 commit 7dfb62f

File tree

20 files changed

+1987
-325
lines changed

20 files changed

+1987
-325
lines changed

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ on:
55
pull_request:
66
jobs:
77
main:
8-
runs-on: ubuntu-22.04
8+
runs-on: ubuntu-latest
99
steps:
10-
- run: sudo apt install erlang
10+
- run: sudo apt update && sudo apt install --no-install-recommends erlang
1111
- uses: actions/checkout@v4
1212
with:
1313
fetch-depth: 100

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@ site
3434
/tooling/autorest/compiler-swagger.yaml
3535
/tooling/autorest/middleware-openapi.yaml
3636
/test/environment/ledger/browser
37+
/test/assets
3738
/bin

docs/guides/ledger-wallet.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ This guide explains basic interactions on getting access to aeternity accounts o
66

77
Run the code from below you need:
88

9-
- a Ledger Hardware Wallet like Ledger Nano X, Ledger Nano S
10-
- to install [Ledger Live](https://www.ledger.com/ledger-live)
11-
- to install aeternity@0.4.4 or above app from Ledger Live to HW
12-
- to have Ledger HW connected to computer, unlocked, with aeternity app opened
9+
- a Ledger Hardware Wallet like Ledger Nano X, Ledger Nano S;
10+
- to install [Ledger Live](https://www.ledger.com/ledger-live);
11+
- to install aeternity@0.4.4 or above app from Ledger Live to HW;
12+
- to have Ledger HW connected to computer, unlocked, with aeternity app opened.
1313

1414
## Usage
1515

@@ -31,7 +31,7 @@ console.log(account.address); // 'ak_2dA...'
3131
console.log(await account.signTransaction('tx_...')); // 'tx_...' (with signature added)
3232
```
3333

34-
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.
34+
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.
3535

3636
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).
3737

@@ -69,3 +69,7 @@ const node = new Node('https://testnet.aeternity.io');
6969
const accounts = await accountFactory.discover(node);
7070
console.log(accounts[0].address); // 'ak_2dA...'
7171
```
72+
73+
## Error handling
74+
75+
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`.

docs/guides/metamask-snap.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Aeternity snap for MetaMask
2+
3+
This guide explains basic interactions on getting access to accounts on Aeternity snap for MetaMask using JS SDK.
4+
5+
## Prerequisite
6+
7+
Run the code from below you need:
8+
9+
- a MetaMask extension 12.2.4 or above installed in Chrome or Firefox browser;
10+
- to setup an account in MetaMask (create a new one or restore by mnemonic phrase).
11+
12+
## Usage
13+
14+
Firstly, you need to create a factory of MetaMask accounts
15+
16+
```js
17+
import { AccountMetamaskFactory } from '@aeternity/aepp-sdk';
18+
19+
const accountFactory = new AccountMetamaskFactory();
20+
```
21+
22+
The next step is to install Aeternity snap to MetaMask. You can request installation by calling
23+
24+
```js
25+
await accountFactory.installSnap();
26+
```
27+
28+
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.
29+
30+
Using the factory, you can create instances of specific accounts by providing an index
31+
32+
```js
33+
const account = await accountFactory.initialize(0);
34+
console.log(account.address); // 'ak_2dA...'
35+
console.log(await account.signTransaction('tx_...')); // 'tx_...' (with signature added)
36+
```
37+
38+
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.
39+
40+
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).
41+
42+
## Account persistence
43+
44+
Account can be persisted and restored by saving values of `index`, `address` properties
45+
46+
```js
47+
import { AccountMetamask } from '@aeternity/aepp-sdk';
48+
49+
const accountIndex = accountToPersist.index;
50+
const accountAddress = accountToPersist.address;
51+
52+
const accountFactory = new AccountMetamaskFactory();
53+
const restoredAccount = new AccountMetamask(accountFactory.provider, accountIndex, accountAddress);
54+
```
55+
56+
It can be used to remember accounts between app restarts.
57+
58+
## Account discovery
59+
60+
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.
61+
62+
```js
63+
import { Node } from '@aeternity/aepp-sdk';
64+
65+
const node = new Node('https://testnet.aeternity.io');
66+
const accounts = await accountFactory.discover(node);
67+
console.log(accounts[0].address); // 'ak_2dA...'
68+
```
69+
70+
## Error handling
71+
72+
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.".
Lines changed: 10 additions & 237 deletions
Original file line numberDiff line numberDiff line change
@@ -1,247 +1,20 @@
11
<template>
2-
<div class="group">
3-
<div>
4-
<label>
5-
<input v-model="connectMethod" type="radio" value="default" />
6-
Iframe or WebExtension
7-
</label>
8-
</div>
9-
<div>
10-
<label>
11-
<input v-model="connectMethod" type="radio" value="reverse-iframe" />
12-
Reverse iframe
13-
</label>
14-
<div><input v-model="reverseIframeWalletUrl" /></div>
15-
</div>
16-
17-
<button v-if="walletConnected" @click="disconnect">Disconnect</button>
18-
<button v-else-if="connectMethod" :disabled="walletConnecting" @click="connect">Connect</button>
19-
20-
<button v-if="cancelWalletDetection" @click="cancelWalletDetection">Cancel detection</button>
21-
22-
<template v-if="walletConnected">
23-
<br />
24-
<button @click="getAccounts">Get accounts</button>
25-
<button @click="subscribeAccounts('subscribe', 'current')">Subscribe current</button>
26-
<button @click="subscribeAccounts('unsubscribe', 'current')">Unsubscribe current</button>
27-
<button @click="subscribeAccounts('subscribe', 'connected')">Subscribe connected</button>
28-
<button @click="subscribeAccounts('unsubscribe', 'connected')">Unsubscribe connected</button>
29-
30-
<div>
31-
<div>RPC Accounts</div>
32-
<div>{{ rpcAccounts.map((account) => account.address.slice(0, 8)).join(', ') }}</div>
33-
</div>
34-
</template>
35-
</div>
36-
37-
<SelectNetwork :select="(network) => this.walletConnector.askToSelectNetwork(network)" />
38-
39-
<h2>Ledger Hardware Wallet</h2>
40-
<div class="group">
41-
<template v-if="ledgerStatus">
42-
<div>
43-
<div>Connection status</div>
44-
<div>{{ ledgerStatus }}</div>
45-
</div>
46-
</template>
47-
<button v-else-if="!ledgerAccountFactory" @click="connectLedger">Connect</button>
48-
<template v-else>
49-
<button @click="disconnectLedger">Disconnect</button>
50-
<button @click="addLedgerAccount">Add Account</button>
51-
<button v-if="ledgerAccounts.length > 1" @click="switchLedgerAccount">Switch Account</button>
52-
<button @click="switchNode">Switch Node</button>
53-
<div v-if="ledgerAccounts.length">
54-
<div>Ledger Accounts</div>
55-
<div>{{ ledgerAccounts.map((account) => account.address.slice(0, 8)).join(', ') }}</div>
56-
</div>
57-
</template>
2+
<div class="nav">
3+
<a href="#" :class="{ active: view === 'Frame' }" @click="view = 'Frame'">Frame</a>
4+
<a href="#" :class="{ active: view === 'Ledger' }" @click="view = 'Ledger'">Ledger HW</a>
5+
<a href="#" :class="{ active: view === 'Metamask' }" @click="view = 'Metamask'">MetaMask</a>
586
</div>
597

60-
<div class="group">
61-
<div>
62-
<div>SDK status</div>
63-
<div>
64-
{{
65-
(walletConnected && 'Wallet connected') ||
66-
(cancelWalletDetection && 'Wallet detection') ||
67-
(walletConnecting && 'Wallet connecting') ||
68-
'Ready to connect to wallet'
69-
}}
70-
</div>
71-
</div>
72-
<div>
73-
<div>Wallet name</div>
74-
<div>{{ walletName }}</div>
75-
</div>
76-
</div>
8+
<Component v-if="view" :is="view" />
779
</template>
7810

7911
<script>
80-
import {
81-
walletDetector,
82-
BrowserWindowMessageConnection,
83-
RpcConnectionDenyError,
84-
RpcRejectedByUserError,
85-
WalletConnectorFrame,
86-
AccountLedgerFactory,
87-
} from '@aeternity/aepp-sdk';
88-
import { mapState } from 'vuex';
89-
import TransportWebUSB from '@ledgerhq/hw-transport-webusb';
90-
import SelectNetwork from './components/SelectNetwork.vue';
12+
import Frame from './components/ConnectFrame.vue';
13+
import Ledger from './components/ConnectLedger.vue';
14+
import Metamask from './components/ConnectMetamask.vue';
9115
9216
export default {
93-
components: { SelectNetwork },
94-
data: () => ({
95-
connectMethod: 'default',
96-
walletConnected: false,
97-
walletConnecting: null,
98-
reverseIframe: null,
99-
reverseIframeWalletUrl: process.env.VUE_APP_WALLET_URL ?? `http://${location.hostname}:9000`,
100-
walletInfo: null,
101-
cancelWalletDetection: null,
102-
rpcAccounts: [],
103-
ledgerStatus: '',
104-
ledgerAccountFactory: null,
105-
ledgerAccounts: [],
106-
}),
107-
computed: {
108-
...mapState(['aeSdk']),
109-
walletName() {
110-
if (!this.walletConnected) return 'Wallet is not connected';
111-
return this.walletInfo.name;
112-
},
113-
},
114-
methods: {
115-
async connectLedger() {
116-
try {
117-
this.ledgerStatus = 'Waiting for Ledger response';
118-
const transport = await TransportWebUSB.create();
119-
this.ledgerAccountFactory = new AccountLedgerFactory(transport);
120-
} catch (error) {
121-
if (error.name === 'TransportOpenUserCancelled') return;
122-
throw error;
123-
} finally {
124-
this.ledgerStatus = '';
125-
}
126-
},
127-
async disconnectLedger() {
128-
this.ledgerAccountFactory = null;
129-
this.ledgerAccounts = [];
130-
this.$store.commit('setAddress', undefined);
131-
if (Object.keys(this.aeSdk.accounts).length) this.aeSdk.removeAccount(this.aeSdk.address);
132-
},
133-
async addLedgerAccount() {
134-
try {
135-
this.ledgerStatus = 'Waiting for Ledger response';
136-
const idx = this.ledgerAccounts.length;
137-
const account = await this.ledgerAccountFactory.initialize(idx);
138-
this.ledgerStatus = `Ensure that ${account.address} is displayed on Ledger HW screen`;
139-
await this.ledgerAccountFactory.getAddress(idx, true);
140-
this.ledgerAccounts.push(account);
141-
this.setAccount(this.ledgerAccounts[0]);
142-
} catch (error) {
143-
if (error.statusCode === 0x6985) return;
144-
throw error;
145-
} finally {
146-
this.ledgerStatus = '';
147-
}
148-
},
149-
switchLedgerAccount() {
150-
this.ledgerAccounts.push(this.ledgerAccounts.shift());
151-
this.setAccount(this.ledgerAccounts[0]);
152-
},
153-
async switchNode() {
154-
await this.setNode(this.$store.state.networkId === 'ae_mainnet' ? 'ae_uat' : 'ae_mainnet');
155-
},
156-
async getAccounts() {
157-
this.rpcAccounts = await this.walletConnector.getAccounts();
158-
if (this.rpcAccounts.length) this.setAccount(this.rpcAccounts[0]);
159-
},
160-
async subscribeAccounts(type, value) {
161-
await this.walletConnector.subscribeAccounts(type, value);
162-
},
163-
async detectWallets() {
164-
if (this.connectMethod === 'reverse-iframe') {
165-
this.reverseIframe = document.createElement('iframe');
166-
this.reverseIframe.src = this.reverseIframeWalletUrl;
167-
this.reverseIframe.style.display = 'none';
168-
document.body.appendChild(this.reverseIframe);
169-
}
170-
const connection = new BrowserWindowMessageConnection();
171-
return new Promise((resolve, reject) => {
172-
const stopDetection = walletDetector(connection, async ({ newWallet }) => {
173-
if (
174-
confirm(
175-
`Do you want to connect to wallet ${newWallet.info.name} with id ${newWallet.info.id}`,
176-
)
177-
) {
178-
stopDetection();
179-
resolve(newWallet.getConnection());
180-
this.cancelWalletDetection = null;
181-
this.walletInfo = newWallet.info;
182-
}
183-
});
184-
this.cancelWalletDetection = () => {
185-
reject(new Error('Wallet detection cancelled'));
186-
stopDetection();
187-
this.cancelWalletDetection = null;
188-
if (this.reverseIframe) this.reverseIframe.remove();
189-
};
190-
});
191-
},
192-
async setNode(networkId) {
193-
const [{ name }] = (await this.aeSdk.getNodesInPool()).filter(
194-
(node) => node.nodeNetworkId === networkId,
195-
);
196-
this.aeSdk.selectNode(name);
197-
this.$store.commit('setNetworkId', networkId);
198-
},
199-
setAccount(account) {
200-
if (Object.keys(this.aeSdk.accounts).length) this.aeSdk.removeAccount(this.aeSdk.address);
201-
this.aeSdk.addAccount(account, { select: true });
202-
this.$store.commit('setAddress', account.address);
203-
},
204-
async connect() {
205-
this.walletConnecting = true;
206-
try {
207-
const connection = await this.detectWallets();
208-
try {
209-
this.walletConnector = await WalletConnectorFrame.connect('Simple æpp', connection);
210-
} catch (error) {
211-
if (error instanceof RpcConnectionDenyError) connection.disconnect();
212-
throw error;
213-
}
214-
this.walletConnector.on('disconnect', () => {
215-
this.walletConnected = false;
216-
this.walletInfo = null;
217-
this.rpcAccounts = [];
218-
this.$store.commit('setAddress', undefined);
219-
if (this.reverseIframe) this.reverseIframe.remove();
220-
});
221-
this.walletConnected = true;
222-
223-
this.setNode(this.walletConnector.networkId);
224-
this.walletConnector.on('networkIdChange', (networkId) => this.setNode(networkId));
225-
226-
this.walletConnector.on('accountsChange', (accounts) => {
227-
this.rpcAccounts = accounts;
228-
if (accounts.length) this.setAccount(accounts[0]);
229-
});
230-
} catch (error) {
231-
if (
232-
error.message === 'Wallet detection cancelled' ||
233-
error instanceof RpcConnectionDenyError ||
234-
error instanceof RpcRejectedByUserError
235-
)
236-
return;
237-
throw error;
238-
} finally {
239-
this.walletConnecting = false;
240-
}
241-
},
242-
disconnect() {
243-
this.walletConnector.disconnect();
244-
},
245-
},
17+
components: { Frame, Ledger, Metamask },
18+
data: () => ({ view: 'Frame' }),
24619
};
24720
</script>

0 commit comments

Comments
 (0)