Skip to content

Commit 3b7853c

Browse files
authored
Merge pull request #2050 from aeternity/snap-versioning
Implicitly connect to Aeternity snap, detect MetaMask over EIP-6963
2 parents 6663b58 + 9e74b53 commit 3b7853c

File tree

5 files changed

+157
-65
lines changed

5 files changed

+157
-65
lines changed

docs/guides/metamask-snap.md

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,6 @@ import { AccountMetamaskFactory } from '@aeternity/aepp-sdk';
1919
const accountFactory = new AccountMetamaskFactory();
2020
```
2121

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-
3022
Using the factory, you can create instances of specific accounts by providing an index
3123

3224
```js
@@ -69,4 +61,6 @@ console.log(accounts[0].address); // 'ak_2dA...'
6961

7062
## Error handling
7163

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.".
64+
If the user rejects an action (snap installation or connection, address retrieving or transaction/message signing) you will get an exception as a plain object with property `code` equals 4001, and `message` equals "User rejected the request.".
65+
66+
If the snap downgrade is requested, the code will be -32602.

examples/browser/aepp/src/components/ConnectMetamask.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<button v-if="!accountFactory" @click="connect">Connect</button>
44
<template v-else>
55
<button @click="disconnect">Disconnect</button>
6-
<button @click="installSnap">Install Aeternity Snap</button>
6+
<button @click="requestSnap">Request Aeternity Snap</button>
77
<button @click="addAccount">Add Account</button>
88
<button v-if="accounts.length > 1" @click="switchAccount">Switch Account</button>
99
<button @click="discoverAccounts">Discover Accounts</button>
@@ -54,10 +54,10 @@ export default {
5454
this.$store.commit('setAddress', undefined);
5555
if (Object.keys(this.aeSdk.accounts).length) this.aeSdk.removeAccount(this.aeSdk.address);
5656
},
57-
async installSnap() {
57+
async requestSnap() {
5858
try {
5959
this.status = 'Waiting for MetaMask response';
60-
this.status = await this.accountFactory.installSnap();
60+
this.status = await this.accountFactory.requestSnap();
6161
} catch (error) {
6262
if (error instanceof UnsupportedPlatformError) {
6363
this.status = error.message;

src/account/Metamask.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export async function invokeSnap<R>(
2727
* https://www.npmjs.com/package/\@aeternity-snap/plugin
2828
*/
2929
export default class AccountMetamask extends AccountBase {
30+
/**
31+
* @deprecated this class is not intended to provide raw access to the provider
32+
*/
3033
readonly provider: BaseProvider;
3134

3235
readonly index: number;

src/account/MetamaskFactory.ts

Lines changed: 93 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import AccountMetamask, { invokeSnap, snapId } from './Metamask.js';
1111

1212
const snapMinVersion = '0.0.9';
1313
const snapMaxVersion = '0.1.0';
14+
const metamaskVersionPrefix = 'MetaMask/v';
1415

1516
interface SnapDetails {
1617
blocked: boolean;
@@ -22,90 +23,138 @@ interface SnapDetails {
2223

2324
/**
2425
* A factory class that generates instances of AccountMetamask.
26+
* @see {@link https://www.npmjs.com/package/@aeternity-snap/plugin | Aeternity snap}
2527
*/
2628
export default class AccountMetamaskFactory extends AccountBaseFactory {
27-
readonly provider: BaseProvider;
29+
// TODO: remove after removing `provider`
30+
#provider: BaseProvider | undefined;
31+
32+
/**
33+
* @deprecated this class is not intended to provide raw access to the provider
34+
*/
35+
get provider(): BaseProvider {
36+
if (this.#provider == null) throw new UnsupportedPlatformError('Metamask is not detected yet');
37+
return this.#provider;
38+
}
39+
40+
async #getMetamaskAsInjected(): Promise<BaseProvider | undefined> {
41+
if (!('ethereum' in window) || window.ethereum == null) return;
42+
const provider = window.ethereum as BaseProvider;
43+
const version = await provider.request<string>({ method: 'web3_clientVersion' });
44+
if (version == null) throw new InternalError("Can't get Ethereum Provider version");
45+
if (!version.startsWith(metamaskVersionPrefix)) return;
46+
return provider;
47+
}
48+
49+
async #getMetamaskOverEip6963(): Promise<BaseProvider | undefined> {
50+
setTimeout(() => window.dispatchEvent(new Event('eip6963:requestProvider')));
51+
return new Promise<BaseProvider | undefined>((resolve) => {
52+
const handler = (
53+
event: CustomEvent<{ info: { rdns: string }; provider: BaseProvider }>,
54+
): void => {
55+
if (event.detail.info.rdns !== 'io.metamask') return;
56+
window.removeEventListener('eip6963:announceProvider', handler);
57+
resolve(event.detail.provider);
58+
};
59+
window.addEventListener('eip6963:announceProvider', handler);
60+
setTimeout(() => {
61+
window.removeEventListener('eip6963:announceProvider', handler);
62+
resolve(undefined);
63+
}, 500);
64+
});
65+
}
66+
67+
#providerPromise: Promise<BaseProvider> | undefined;
68+
69+
async #getProvider(): Promise<BaseProvider> {
70+
this.#providerPromise ??= (async () => {
71+
this.#provider ??=
72+
(await this.#getMetamaskAsInjected()) ?? (await this.#getMetamaskOverEip6963());
73+
if (this.#provider == null) {
74+
throw new UnsupportedPlatformError(
75+
"Can't find a Metamask extension as an injected provider and over EIP-6963. Ensure that Metamask is installed or setup a provider.",
76+
);
77+
}
78+
const version = await this.#provider.request<string>({ method: 'web3_clientVersion' });
79+
if (version == null) throw new InternalError("Can't get Ethereum Provider version");
80+
const args = [version.slice(metamaskVersionPrefix.length), '12.2.4'] as const;
81+
if (!semverSatisfies(...args)) throw new UnsupportedVersionError('Metamask', ...args);
82+
return this.#provider;
83+
})();
84+
return this.#providerPromise;
85+
}
2886

2987
/**
3088
* @param provider - Connection to MetaMask to use
3189
*/
3290
constructor(provider?: BaseProvider) {
3391
super();
34-
if (provider != null) {
35-
this.provider = provider;
36-
return;
37-
}
38-
if (window == null) {
92+
this.#provider = provider;
93+
if (this.#provider == null && window == null) {
3994
throw new UnsupportedPlatformError(
4095
'Window object not found, you can run AccountMetamaskFactory only in browser or setup a provider',
4196
);
4297
}
43-
if (!('ethereum' in window) || window.ethereum == null) {
44-
throw new UnsupportedPlatformError(
45-
'`ethereum` object not found, you can run AccountMetamaskFactory only with Metamask enabled or setup a provider',
46-
);
47-
}
48-
this.provider = window.ethereum as BaseProvider;
4998
}
5099

51100
/**
52-
* It throws an exception if MetaMask has an incompatible version.
101+
* Request MetaMask to install Aeternity snap.
102+
* @deprecated use `requestSnap` instead
53103
*/
54-
async #ensureMetamaskSupported(): Promise<void> {
55-
const version = await this.provider.request<string>({ method: 'web3_clientVersion' });
56-
if (version == null) throw new InternalError("Can't get Ethereum Provider version");
57-
const metamaskPrefix = 'MetaMask/v';
58-
if (!version.startsWith(metamaskPrefix)) {
59-
throw new UnsupportedPlatformError(`Expected Metamask, got ${version} instead`);
60-
}
61-
const args = [version.slice(metamaskPrefix.length), '12.2.4'] as const;
62-
if (!semverSatisfies(...args)) throw new UnsupportedVersionError('Metamask', ...args);
104+
async installSnap(): Promise<SnapDetails> {
105+
const provider = await this.#getProvider();
106+
const details = (await provider.request({
107+
method: 'wallet_requestSnaps',
108+
params: { [snapId]: { version: snapMinVersion } },
109+
})) as { [key in typeof snapId]: SnapDetails };
110+
return details[snapId];
63111
}
64112

65-
#ensureReadyPromise?: Promise<void>;
66-
67113
/**
68-
* Request MetaMask to install Aeternity snap.
114+
* Request MetaMask to install Aeternity snap or connect it to the current aepp.
115+
* MetaMask can have only one Aeternity snap version installed at a time.
116+
* This method is intended to upgrade the snap to a specified version if needed by the aepp.
117+
* If Aeternity snap is installed but wasn't used by the aepp, then the user still needs to approve the connection.
118+
* If the currently installed version corresponds to the version range, then the snap won't be upgraded.
119+
* To downgrade the snap, the user must manually uninstall the current version.
120+
* @param version - Snap version range (e.g. `1`, `0.1.*`, `^0.0.9`, `~0.0.9`; `>=0.0.9 <0.1.0`)
121+
* (default: a version range supported by sdk)
69122
*/
70-
async installSnap(): Promise<SnapDetails> {
71-
await this.#ensureMetamaskSupported();
72-
const details = (await this.provider.request({
123+
async requestSnap(version = `>=${snapMinVersion} <${snapMaxVersion}`): Promise<SnapDetails> {
124+
const provider = await this.#getProvider();
125+
const details = (await provider.request({
73126
method: 'wallet_requestSnaps',
74-
params: { [snapId]: { version: snapMinVersion } },
127+
params: { [snapId]: { version } },
75128
})) as { [key in typeof snapId]: SnapDetails };
76-
this.#ensureReadyPromise = Promise.resolve();
77129
return details[snapId];
78130
}
79131

80132
/**
81133
* It throws an exception if MetaMask or Aeternity snap has an incompatible version or is not
82-
* installed.
134+
* installed or is not connected to the aepp.
135+
* @deprecated use `requestSnap` instead
83136
*/
84137
async ensureReady(): Promise<void> {
85138
const snapVersion = await this.getSnapVersion();
86139
const args = [snapVersion, snapMinVersion, snapMaxVersion] as const;
87140
if (!semverSatisfies(...args))
88141
throw new UnsupportedVersionError('Aeternity snap in MetaMask', ...args);
89-
this.#ensureReadyPromise = Promise.resolve();
90-
}
91-
92-
async #ensureReady(): Promise<void> {
93-
this.#ensureReadyPromise ??= this.ensureReady();
94-
return this.#ensureReadyPromise;
95142
}
96143

97144
/**
98145
* @returns the version of snap installed in MetaMask
99146
*/
100147
async getSnapVersion(): Promise<string> {
101-
await this.#ensureMetamaskSupported();
102-
const snaps = (await this.provider.request({ method: 'wallet_getSnaps' })) as Record<
148+
const provider = await this.#getProvider();
149+
const snaps = (await provider.request({ method: 'wallet_getSnaps' })) as Record<
103150
string,
104151
{ version: string }
105152
>;
106153
const version = snaps[snapId]?.version;
107154
if (version == null)
108-
throw new UnsupportedPlatformError('Aeternity snap is not installed to MetaMask');
155+
throw new UnsupportedPlatformError(
156+
'Aeternity snap is not installed to MetaMask or not connected to this aepp',
157+
);
109158
return version;
110159
}
111160

@@ -114,13 +163,14 @@ export default class AccountMetamaskFactory extends AccountBaseFactory {
114163
* @param accountIndex - Index of account
115164
*/
116165
async initialize(accountIndex: number): Promise<AccountMetamask> {
117-
await this.#ensureReady();
166+
await this.requestSnap();
167+
const provider = await this.#getProvider();
118168
const address = await invokeSnap<Encoded.AccountAddress>(
119-
this.provider,
169+
provider,
120170
'getPublicKey',
121171
{ derivationPath: [`${accountIndex}'`, "0'", "0'"] },
122172
'publicKey',
123173
);
124-
return new AccountMetamask(this.provider, accountIndex, address);
174+
return new AccountMetamask(provider, accountIndex, address);
125175
}
126176
}

test/unit/metamask.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,14 @@ describe('Aeternity Snap for MetaMask', function () {
107107
version: '0.0.9',
108108
};
109109

110-
const versionChecks = [
110+
const metamaskVersionCheck = [
111111
{ request: { method: 'web3_clientVersion' } },
112112
{ resolve: 'MetaMask/v12.3.1' },
113-
{ request: { method: 'wallet_getSnaps' } },
114-
{ resolve: { 'npm:@aeternity-snap/plugin': snapDetails } },
115113
];
116114

117115
it('installs snap', async () => {
118116
const provider = await initProvider([
119-
...versionChecks.slice(0, 2),
117+
...metamaskVersionCheck,
120118
{
121119
request: {
122120
method: 'wallet_requestSnaps',
@@ -133,23 +131,68 @@ describe('Aeternity Snap for MetaMask', function () {
133131
expect(await factory.installSnap()).to.eql(snapDetails);
134132
});
135133

134+
it('requests snap', async () => {
135+
const provider = await initProvider([
136+
...metamaskVersionCheck,
137+
{
138+
request: {
139+
method: 'wallet_requestSnaps',
140+
params: { 'npm:@aeternity-snap/plugin': { version: '>=0.0.9 <0.1.0' } },
141+
},
142+
},
143+
{
144+
resolve: {
145+
'npm:@aeternity-snap/plugin': snapDetails,
146+
},
147+
},
148+
]);
149+
const factory = new AccountMetamaskFactory(provider);
150+
expect(await factory.requestSnap()).to.eql(snapDetails);
151+
});
152+
153+
const snapVersionCheck = [
154+
{ request: { method: 'wallet_getSnaps' } },
155+
{ resolve: { 'npm:@aeternity-snap/plugin': snapDetails } },
156+
];
157+
136158
it('gets snap version', async () => {
137-
const provider = await initProvider([...versionChecks, ...versionChecks.slice(2, 2)]);
159+
const provider = await initProvider([...metamaskVersionCheck, ...snapVersionCheck]);
138160
const factory = new AccountMetamaskFactory(provider);
139161
expect(await factory.getSnapVersion()).to.equal('0.0.9');
140162
});
141163

164+
const requestSnaps = [
165+
{
166+
request: {
167+
method: 'wallet_requestSnaps',
168+
params: {
169+
'npm:@aeternity-snap/plugin': {
170+
version: '>=0.0.9 <0.1.0',
171+
},
172+
},
173+
},
174+
},
175+
{ resolve: { 'npm:@aeternity-snap/plugin': snapDetails } },
176+
];
177+
142178
it('ensures that snap version is compatible', async () => {
143179
const provider = await initProvider(
144180
[
145-
...versionChecks.slice(0, 3),
146-
{ resolve: { 'npm:@aeternity-snap/plugin': { ...snapDetails, version: '1.0.0' } } },
181+
...metamaskVersionCheck,
182+
requestSnaps[0],
183+
{
184+
reject: {
185+
code: -32602,
186+
message:
187+
'Snap "npm:@aeternity-snap/plugin@<>" is already installed. Couldn\'t update to a version inside requested "<>" range.',
188+
},
189+
},
147190
],
148191
true,
149192
);
150193
const factory = new AccountMetamaskFactory(provider);
151194
await expect(factory.initialize(42)).to.be.rejectedWith(
152-
'Unsupported Aeternity snap in MetaMask version 1.0.0. Supported: >= 0.0.9 < 0.1.0',
195+
'Snap "npm:@aeternity-snap/plugin@<>" is already installed. Couldn\'t update to a version inside requested "<>" range.',
153196
);
154197
});
155198

@@ -165,7 +208,8 @@ describe('Aeternity Snap for MetaMask', function () {
165208

166209
it('initializes an account', async () => {
167210
const provider = await initProvider([
168-
...versionChecks,
211+
...metamaskVersionCheck,
212+
...requestSnaps,
169213
getPublicKeyRequest,
170214
{ resolve: { publicKey: 'ak_2HteeujaJzutKeFZiAmYTzcagSoRErSXpBFV179xYgqT4teakv' } },
171215
]);
@@ -179,7 +223,8 @@ describe('Aeternity Snap for MetaMask', function () {
179223

180224
it('initializes an account rejected', async () => {
181225
const provider = await initProvider([
182-
...versionChecks,
226+
...metamaskVersionCheck,
227+
...requestSnaps,
183228
getPublicKeyRequest,
184229
{ reject: { code: 4001, message: 'User rejected the request.' } },
185230
]);

0 commit comments

Comments
 (0)