Skip to content

Commit

Permalink
Merge pull request #2023 from aeternity/snap
Browse files Browse the repository at this point in the history
Add AccountMetamask
  • Loading branch information
davidyuk authored Oct 5, 2024
2 parents 6c2dbfe + 0831f08 commit 7dfb62f
Show file tree
Hide file tree
Showing 20 changed files with 1,987 additions and 325 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ on:
pull_request:
jobs:
main:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- run: sudo apt install erlang
- run: sudo apt update && sudo apt install --no-install-recommends erlang
- uses: actions/checkout@v4
with:
fetch-depth: 100
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ site
/tooling/autorest/compiler-swagger.yaml
/tooling/autorest/middleware-openapi.yaml
/test/environment/ledger/browser
/test/assets
/bin
14 changes: 9 additions & 5 deletions docs/guides/ledger-wallet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).

Expand Down Expand Up @@ -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`.
72 changes: 72 additions & 0 deletions docs/guides/metamask-snap.md
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.".
247 changes: 10 additions & 237 deletions examples/browser/aepp/src/Connect.vue
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>
Loading

0 comments on commit 7dfb62f

Please sign in to comment.