Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AccountMetamask #2023

Merged
merged 10 commits into from
Oct 5, 2024
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 aeternity@0.4.4 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 aeternity@0.4.4 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