mirror of
https://github.com/pezkuwichain/pezkuwi-extension.git
synced 2026-07-17 21:15:44 +00:00
Update domain references to pezkuwichain.app and rebrand from polkadot
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeyringPair } from '@pezkuwi/keyring/types';
|
||||
import type { TypeRegistry } from '@pezkuwi/types';
|
||||
import type { SignerPayloadRaw } from '@pezkuwi/types/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { RequestSign } from './types.js';
|
||||
|
||||
import { u8aToHex, u8aWrapBytes } from '@pezkuwi/util';
|
||||
|
||||
export default class RequestBytesSign implements RequestSign {
|
||||
public readonly payload: SignerPayloadRaw;
|
||||
|
||||
constructor (payload: SignerPayloadRaw) {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
sign (_registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
|
||||
return {
|
||||
signature: u8aToHex(
|
||||
pair.sign(
|
||||
u8aWrapBytes(this.payload.data)
|
||||
)
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeyringPair } from '@pezkuwi/keyring/types';
|
||||
import type { TypeRegistry } from '@pezkuwi/types';
|
||||
import type { SignerPayloadJSON } from '@pezkuwi/types/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { RequestSign } from './types.js';
|
||||
|
||||
export default class RequestExtrinsicSign implements RequestSign {
|
||||
public readonly payload: SignerPayloadJSON;
|
||||
|
||||
constructor (payload: SignerPayloadJSON) {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
sign (registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
|
||||
return registry
|
||||
.createType('ExtrinsicPayload', this.payload, { version: this.payload.version })
|
||||
.sign(pair);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
import type { ResponseSigning } from '@pezkuwi/extension-base/background/types';
|
||||
import type { MetadataDef } from '@pezkuwi/extension-inject/types';
|
||||
import type { KeyringPair } from '@pezkuwi/keyring/types';
|
||||
import type { ExtDef } from '@pezkuwi/types/extrinsic/signedExtensions/types';
|
||||
import type { SignerPayloadJSON } from '@pezkuwi/types/types';
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
|
||||
import { TypeRegistry } from '@pezkuwi/types';
|
||||
import keyring from '@pezkuwi/ui-keyring';
|
||||
import { cryptoWaitReady } from '@pezkuwi/util-crypto';
|
||||
|
||||
import { AccountsStore } from '../../stores/index.js';
|
||||
import Extension from './Extension.js';
|
||||
import State from './State.js';
|
||||
import Tabs from './Tabs.js';
|
||||
|
||||
describe('Extension', () => {
|
||||
let extension: Extension;
|
||||
let state: State;
|
||||
let tabs: Tabs;
|
||||
const suri = 'seed sock milk update focus rotate barely fade car face mechanic mercy';
|
||||
const password = 'passw0rd';
|
||||
|
||||
async function createExtension (): Promise<Extension> {
|
||||
try {
|
||||
await cryptoWaitReady();
|
||||
|
||||
keyring.loadAll({ store: new AccountsStore() });
|
||||
|
||||
state = new State({}, 0);
|
||||
await state.init();
|
||||
tabs = new Tabs(state);
|
||||
|
||||
return new Extension(state);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const createAccount = async (type?: KeypairType): Promise<string> => {
|
||||
await extension.handle('id', 'pri(accounts.create.suri)', type && type === 'ethereum'
|
||||
? {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri,
|
||||
type
|
||||
}
|
||||
: {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri
|
||||
}, {} as chrome.runtime.Port);
|
||||
const { address } = await extension.handle('id', 'pri(seed.validate)', type && type === 'ethereum'
|
||||
? {
|
||||
suri,
|
||||
type
|
||||
}
|
||||
: {
|
||||
suri
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
return address;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
extension = await createExtension();
|
||||
});
|
||||
|
||||
it('exports account from keyring', async () => {
|
||||
const { pair: { address } } = keyring.addUri(suri, password);
|
||||
const result = await extension.handle('id', 'pri(accounts.export)', {
|
||||
address,
|
||||
password
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(result.exportedJson.address).toBe(address);
|
||||
expect(result.exportedJson.encoded).toBeDefined();
|
||||
});
|
||||
|
||||
describe('account derivation', () => {
|
||||
let address: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
});
|
||||
|
||||
it('pri(derivation.validate) passes for valid suri', async () => {
|
||||
const result = await extension.handle('id', 'pri(derivation.validate)', {
|
||||
parentAddress: address,
|
||||
parentPassword: password,
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(result).toEqual({
|
||||
address: '5FP3TT3EruYBNh8YM8yoxsreMx7uZv1J1zNX7fFhoC5enwmN',
|
||||
suri: '//path'
|
||||
});
|
||||
});
|
||||
|
||||
it('pri(derivation.validate) throws for invalid suri', async () => {
|
||||
await expect(extension.handle('id', 'pri(derivation.validate)', {
|
||||
parentAddress: address,
|
||||
parentPassword: password,
|
||||
suri: 'invalid-path'
|
||||
}, {} as chrome.runtime.Port)).rejects.toThrow(/is not a valid derivation path/);
|
||||
});
|
||||
|
||||
it('pri(derivation.validate) throws for invalid password', async () => {
|
||||
await expect(extension.handle('id', 'pri(derivation.validate)', {
|
||||
parentAddress: address,
|
||||
parentPassword: 'invalid-password',
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port)).rejects.toThrow(/invalid password/);
|
||||
});
|
||||
|
||||
it('pri(derivation.create) adds a derived account', async () => {
|
||||
await extension.handle('id', 'pri(derivation.create)', {
|
||||
name: 'child',
|
||||
parentAddress: address,
|
||||
parentPassword: password,
|
||||
password,
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port);
|
||||
expect(keyring.getAccounts()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('pri(derivation.create) saves parent address in meta', async () => {
|
||||
await extension.handle('id', 'pri(derivation.create)', {
|
||||
name: 'child',
|
||||
parentAddress: address,
|
||||
parentPassword: password,
|
||||
password,
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port);
|
||||
expect(keyring.getAccount('5FP3TT3EruYBNh8YM8yoxsreMx7uZv1J1zNX7fFhoC5enwmN')?.meta.parentAddress).toEqual(address);
|
||||
});
|
||||
});
|
||||
|
||||
describe('account management', () => {
|
||||
let address: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
});
|
||||
|
||||
it('pri(accounts.changePassword) changes account password', async () => {
|
||||
const newPass = 'pa55word';
|
||||
const wrongPass = 'ZZzzZZzz';
|
||||
|
||||
await expect(extension.handle('id', 'pri(accounts.changePassword)', {
|
||||
address,
|
||||
newPass,
|
||||
oldPass: wrongPass
|
||||
}, {} as chrome.runtime.Port)).rejects.toThrow(/oldPass is invalid/);
|
||||
|
||||
const res = await extension.handle('id', 'pri(accounts.changePassword)', {
|
||||
address,
|
||||
newPass,
|
||||
oldPass: password
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
|
||||
const pair = keyring.getPair(address);
|
||||
|
||||
expect(pair.decodePkcs8(newPass)).toEqual(undefined);
|
||||
|
||||
expect(() => {
|
||||
pair.decodePkcs8(password);
|
||||
}).toThrow(/Unable to decode using the supplied passphrase/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom user extension', () => {
|
||||
let address: string, payload: SignerPayloadJSON, pair: KeyringPair;
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
pair = keyring.getPair(address);
|
||||
pair.decodePkcs8(password);
|
||||
payload = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
};
|
||||
});
|
||||
|
||||
it('signs with default signed extensions', async () => {
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with default signed extensions - ethereum', async () => {
|
||||
const ethAddress = await createAccount('ethereum');
|
||||
const ethPair = keyring.getPair(ethAddress);
|
||||
|
||||
ethPair.decodePkcs8(password);
|
||||
const ethPayload: SignerPayloadJSON = {
|
||||
address: ethAddress,
|
||||
blockHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
|
||||
method: '0x03003cd0a705a2dc65e5b1e1205896baa2be8a07c6e0070010a5d4e8',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: [
|
||||
'CheckSpecVersion',
|
||||
'CheckTxVersion',
|
||||
'CheckGenesis',
|
||||
'CheckMortality',
|
||||
'CheckNonce',
|
||||
'CheckWeight',
|
||||
'ChargeTransactionPayment'
|
||||
],
|
||||
specVersion: '0x000003e9',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000002',
|
||||
version: 4
|
||||
};
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', ethPayload, { version: ethPayload.version }).sign(ethPair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', ethPayload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with user extensions, known types', async () => {
|
||||
const types = {} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
MyUserExtension: {
|
||||
extrinsic: {
|
||||
assetId: 'AssetId'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const payload: SignerPayloadJSON = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['MyUserExtension'],
|
||||
specVersion: '0x00000026',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
};
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('override default signed extension', async () => {
|
||||
const types = {
|
||||
FeeExchangeV1: {
|
||||
assetId: 'Compact<AssetId>',
|
||||
maxPayment: 'Compact<Balance>'
|
||||
},
|
||||
PaymentOptions: {
|
||||
feeExchange: 'FeeExchangeV1',
|
||||
tip: 'Compact<Balance>'
|
||||
}
|
||||
} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
ChargeTransactionPayment: {
|
||||
extrinsic: {
|
||||
transactionPayment: 'PaymentOptions'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with user extensions, additional types', async () => {
|
||||
const types = {
|
||||
myCustomType: {
|
||||
feeExchange: 'Compact<AssetId>',
|
||||
tip: 'Compact<Balance>'
|
||||
}
|
||||
} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
MyUserExtension: {
|
||||
extrinsic: {
|
||||
myCustomType: 'myCustomType'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const payload = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['MyUserExtension', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
tip: null,
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
} as unknown as SignerPayloadJSON;
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,690 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import type { MetadataDef } from '@pezkuwi/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@pezkuwi/keyring/types';
|
||||
import type { Registry, SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types';
|
||||
import type { SubjectInfo } from '@pezkuwi/ui-keyring/observable/types';
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types.js';
|
||||
import type { AuthorizedAccountsDiff } from './State.js';
|
||||
import type State from './State.js';
|
||||
|
||||
import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@pezkuwi/extension-base/defaults';
|
||||
import { metadataExpand } from '@pezkuwi/extension-chains';
|
||||
import { TypeRegistry } from '@pezkuwi/types';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { accounts as accountsObservable } from '@pezkuwi/ui-keyring/observable/accounts';
|
||||
import { assert, isHex } from '@pezkuwi/util';
|
||||
import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@pezkuwi/util-crypto';
|
||||
|
||||
import { withErrorLog } from './helpers.js';
|
||||
import { createSubscription, unsubscribe } from './subscriptions.js';
|
||||
|
||||
type CachedUnlocks = Record<string, number>;
|
||||
|
||||
const SEED_DEFAULT_LENGTH = 12;
|
||||
const SEED_LENGTHS = [12, 15, 18, 21, 24];
|
||||
const ETH_DERIVE_DEFAULT = "/m/44'/60'/0'/0/0";
|
||||
|
||||
function getSuri (seed: string, type?: KeypairType): string {
|
||||
return type === 'ethereum'
|
||||
? `${seed}${ETH_DERIVE_DEFAULT}`
|
||||
: seed;
|
||||
}
|
||||
|
||||
function isJsonPayload (value: SignerPayloadJSON | SignerPayloadRaw): value is SignerPayloadJSON {
|
||||
return (value as SignerPayloadJSON).genesisHash !== undefined;
|
||||
}
|
||||
|
||||
export default class Extension {
|
||||
readonly #cachedUnlocks: CachedUnlocks;
|
||||
|
||||
readonly #state: State;
|
||||
|
||||
constructor (state: State) {
|
||||
this.#cachedUnlocks = {};
|
||||
this.#state = state;
|
||||
}
|
||||
|
||||
private transformAccounts (accounts: SubjectInfo): AccountJson[] {
|
||||
return Object.values(accounts).map(({ json: { address, meta }, type }): AccountJson => ({
|
||||
address,
|
||||
isDefaultAuthSelected: this.#state.defaultAuthAccountSelection.includes(address),
|
||||
...meta,
|
||||
type
|
||||
}));
|
||||
}
|
||||
|
||||
private accountsCreateExternal ({ address, genesisHash, name }: RequestAccountCreateExternal): boolean {
|
||||
keyring.addExternal(address, { genesisHash, name });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private accountsCreateHardware ({ accountIndex, address, addressOffset, genesisHash, hardwareType, name, type }: RequestAccountCreateHardware): boolean {
|
||||
keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name, type });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private accountsCreateSuri ({ genesisHash, name, password, suri, type }: RequestAccountCreateSuri): boolean {
|
||||
keyring.addUri(getSuri(suri, type), password, { genesisHash, name }, type);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private accountsChangePassword ({ address, newPass, oldPass }: RequestAccountChangePassword): boolean {
|
||||
const pair = keyring.getPair(address);
|
||||
|
||||
assert(pair, 'Unable to find pair');
|
||||
|
||||
try {
|
||||
if (!pair.isLocked) {
|
||||
pair.lock();
|
||||
}
|
||||
|
||||
pair.decodePkcs8(oldPass);
|
||||
} catch {
|
||||
throw new Error('oldPass is invalid');
|
||||
}
|
||||
|
||||
keyring.encryptAccount(pair, newPass);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private accountsEdit ({ address, name }: RequestAccountEdit): boolean {
|
||||
const pair = keyring.getPair(address);
|
||||
|
||||
assert(pair, 'Unable to find pair');
|
||||
|
||||
keyring.saveAccountMeta(pair, { ...pair.meta, name });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private accountsExport ({ address, password }: RequestAccountExport): ResponseAccountExport {
|
||||
return { exportedJson: keyring.backupAccount(keyring.getPair(address), password) };
|
||||
}
|
||||
|
||||
private async accountsBatchExport ({ addresses, password }: RequestAccountBatchExport): Promise<ResponseAccountsExport> {
|
||||
return {
|
||||
exportedJson: await keyring.backupAccounts(addresses, password)
|
||||
};
|
||||
}
|
||||
|
||||
private async accountsForget ({ address }: RequestAccountForget): Promise<boolean> {
|
||||
const authorizedAccountsDiff: AuthorizedAccountsDiff = [];
|
||||
|
||||
// cycle through authUrls and prepare the array of diff
|
||||
Object.entries(this.#state.authUrls).forEach(([url, urlInfo]) => {
|
||||
// Note that urlInfo.authorizedAccounts may be undefined if this website entry
|
||||
// was created before the "account authorization per website" functionality was introduced
|
||||
if (urlInfo.authorizedAccounts?.includes(address)) {
|
||||
authorizedAccountsDiff.push([url, urlInfo.authorizedAccounts.filter((previousAddress) => previousAddress !== address)]);
|
||||
}
|
||||
});
|
||||
|
||||
await this.#state.updateAuthorizedAccounts(authorizedAccountsDiff);
|
||||
|
||||
// cycle through default account selection for auth and remove any occurrence of the account
|
||||
const newDefaultAuthAccounts = this.#state.defaultAuthAccountSelection.filter((defaultSelectionAddress) => defaultSelectionAddress !== address);
|
||||
|
||||
await this.#state.updateDefaultAuthAccounts(newDefaultAuthAccounts);
|
||||
|
||||
keyring.forgetAccount(address);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private refreshAccountPasswordCache (pair: KeyringPair): number {
|
||||
const { address } = pair;
|
||||
|
||||
const savedExpiry = this.#cachedUnlocks[address] || 0;
|
||||
const remainingTime = savedExpiry - Date.now();
|
||||
|
||||
if (remainingTime < 0) {
|
||||
this.#cachedUnlocks[address] = 0;
|
||||
pair.lock();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return remainingTime;
|
||||
}
|
||||
|
||||
private accountsShow ({ address, isShowing }: RequestAccountShow): boolean {
|
||||
const pair = keyring.getPair(address);
|
||||
|
||||
assert(pair, 'Unable to find pair');
|
||||
|
||||
keyring.saveAccountMeta(pair, { ...pair.meta, isHidden: !isShowing });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private accountsTie ({ address, genesisHash }: RequestAccountTie): boolean {
|
||||
const pair = keyring.getPair(address);
|
||||
|
||||
assert(pair, 'Unable to find pair');
|
||||
|
||||
keyring.saveAccountMeta(pair, { ...pair.meta, genesisHash });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private accountsValidate ({ address, password }: RequestAccountValidate): boolean {
|
||||
try {
|
||||
keyring.backupAccount(keyring.getPair(address), password);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private accountsSubscribe (id: string, port: chrome.runtime.Port): boolean {
|
||||
const cb = createSubscription<'pri(accounts.subscribe)'>(id, port);
|
||||
const subscription = accountsObservable.subject.subscribe((accounts: SubjectInfo): void =>
|
||||
cb(this.transformAccounts(accounts))
|
||||
);
|
||||
|
||||
port.onDisconnect.addListener((): void => {
|
||||
unsubscribe(id);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private authorizeApprove ({ authorizedAccounts, id }: RequestAuthorizeApprove): boolean {
|
||||
const queued = this.#state.getAuthRequest(id);
|
||||
|
||||
assert(queued, 'Unable to find request');
|
||||
|
||||
const { resolve } = queued;
|
||||
|
||||
resolve({ authorizedAccounts, result: true });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async authorizeUpdate ({ authorizedAccounts, url }: RequestUpdateAuthorizedAccounts): Promise<void> {
|
||||
return await this.#state.updateAuthorizedAccounts([[url, authorizedAccounts]]);
|
||||
}
|
||||
|
||||
private getAuthList (): ResponseAuthorizeList {
|
||||
return { list: this.#state.authUrls };
|
||||
}
|
||||
|
||||
// FIXME This looks very much like what we have in accounts
|
||||
private authorizeSubscribe (id: string, port: chrome.runtime.Port): boolean {
|
||||
const cb = createSubscription<'pri(authorize.requests)'>(id, port);
|
||||
const subscription = this.#state.authSubject.subscribe((requests: AuthorizeRequest[]): void =>
|
||||
cb(requests)
|
||||
);
|
||||
|
||||
port.onDisconnect.addListener((): void => {
|
||||
unsubscribe(id);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async metadataApprove ({ id }: RequestMetadataApprove): Promise<boolean> {
|
||||
const queued = this.#state.getMetaRequest(id);
|
||||
|
||||
assert(queued, 'Unable to find request');
|
||||
|
||||
const { request, resolve } = queued;
|
||||
|
||||
await this.#state.saveMetadata(request);
|
||||
|
||||
resolve(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private metadataGet (genesisHash: string | null): MetadataDef | null {
|
||||
return this.#state.knownMetadata.find((result) => result.genesisHash === genesisHash) || null;
|
||||
}
|
||||
|
||||
private metadataList (): MetadataDef[] {
|
||||
return this.#state.knownMetadata;
|
||||
}
|
||||
|
||||
private metadataReject ({ id }: RequestMetadataReject): boolean {
|
||||
const queued = this.#state.getMetaRequest(id);
|
||||
|
||||
assert(queued, 'Unable to find request');
|
||||
|
||||
const { reject } = queued;
|
||||
|
||||
reject(new Error('Rejected'));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private metadataSubscribe (id: string, port: chrome.runtime.Port): boolean {
|
||||
const cb = createSubscription<'pri(metadata.requests)'>(id, port);
|
||||
const subscription = this.#state.metaSubject.subscribe((requests: MetadataRequest[]): void =>
|
||||
cb(requests)
|
||||
);
|
||||
|
||||
port.onDisconnect.addListener((): void => {
|
||||
unsubscribe(id);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private jsonRestore ({ file, password }: RequestJsonRestore): void {
|
||||
try {
|
||||
keyring.restoreAccount(file, password);
|
||||
} catch (error) {
|
||||
throw new Error((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private batchRestore ({ file, password }: RequestBatchRestore): void {
|
||||
try {
|
||||
keyring.restoreAccounts(file, password);
|
||||
} catch (error) {
|
||||
throw new Error((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private jsonGetAccountInfo (json: KeyringPair$Json): ResponseJsonGetAccountInfo {
|
||||
try {
|
||||
const { address, meta: { genesisHash, name }, type } = keyring.createFromJson(json);
|
||||
|
||||
return {
|
||||
address,
|
||||
genesisHash,
|
||||
name,
|
||||
type
|
||||
} as ResponseJsonGetAccountInfo;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private seedCreate ({ length = SEED_DEFAULT_LENGTH, seed: _seed, type }: RequestSeedCreate): ResponseSeedCreate {
|
||||
const seed = _seed || mnemonicGenerate(length);
|
||||
|
||||
return {
|
||||
address: keyring.createFromUri(getSuri(seed, type), {}, type).address,
|
||||
seed
|
||||
};
|
||||
}
|
||||
|
||||
private seedValidate ({ suri, type }: RequestSeedValidate): ResponseSeedValidate {
|
||||
const { phrase } = keyExtractSuri(suri);
|
||||
|
||||
if (isHex(phrase)) {
|
||||
assert(isHex(phrase, 256), 'Hex seed needs to be 256-bits');
|
||||
} else {
|
||||
// sadly isHex detects as string, so we need a cast here
|
||||
assert(SEED_LENGTHS.includes((phrase).split(' ').length), `Mnemonic needs to contain ${SEED_LENGTHS.join(', ')} words`);
|
||||
assert(mnemonicValidate(phrase), 'Not a valid mnemonic seed');
|
||||
}
|
||||
|
||||
return {
|
||||
address: keyring.createFromUri(getSuri(suri, type), {}, type).address,
|
||||
suri
|
||||
};
|
||||
}
|
||||
|
||||
private signingApprovePassword ({ id, password, savePass }: RequestSigningApprovePassword): boolean {
|
||||
const queued = this.#state.getSignRequest(id);
|
||||
|
||||
assert(queued, 'Unable to find request');
|
||||
|
||||
const { reject, request, resolve } = queued;
|
||||
const pair = keyring.getPair(queued.account.address);
|
||||
|
||||
if (!pair) {
|
||||
reject(new Error('Unable to find pair'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.refreshAccountPasswordCache(pair);
|
||||
|
||||
// if the keyring pair is locked, the password is needed
|
||||
if (pair.isLocked && !password) {
|
||||
reject(new Error('Password needed to unlock the account'));
|
||||
}
|
||||
|
||||
if (pair.isLocked) {
|
||||
pair.decodePkcs8(password);
|
||||
}
|
||||
|
||||
// construct a new registry (avoiding pollution), between requests
|
||||
let registry: Registry;
|
||||
const { payload } = request;
|
||||
|
||||
if (isJsonPayload(payload)) {
|
||||
// Get the metadata for the genesisHash
|
||||
const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === payload.genesisHash);
|
||||
|
||||
if (metadata) {
|
||||
// we have metadata, expand it and extract the info/registry
|
||||
const expanded = metadataExpand(metadata, false);
|
||||
|
||||
registry = expanded.registry;
|
||||
registry.setSignedExtensions(payload.signedExtensions, expanded.definition.userExtensions);
|
||||
} else {
|
||||
// we have no metadata, create a new registry
|
||||
registry = new TypeRegistry();
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
}
|
||||
} else {
|
||||
// for non-payload, just create a registry to use
|
||||
registry = new TypeRegistry();
|
||||
}
|
||||
|
||||
const result = request.sign(registry, pair);
|
||||
|
||||
if (savePass) {
|
||||
// unlike queued.account.address the following
|
||||
// address is encoded with the default prefix
|
||||
// which what is used for password caching mapping
|
||||
this.#cachedUnlocks[pair.address] = Date.now() + PASSWORD_EXPIRY_MS;
|
||||
} else {
|
||||
pair.lock();
|
||||
}
|
||||
|
||||
resolve({
|
||||
id,
|
||||
...result
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private signingApproveSignature ({ id, signature, signedTransaction }: RequestSigningApproveSignature): boolean {
|
||||
const queued = this.#state.getSignRequest(id);
|
||||
|
||||
assert(queued, 'Unable to find request');
|
||||
|
||||
const { resolve } = queued;
|
||||
|
||||
resolve({ id, signature, signedTransaction });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private signingCancel ({ id }: RequestSigningCancel): boolean {
|
||||
const queued = this.#state.getSignRequest(id);
|
||||
|
||||
assert(queued, 'Unable to find request');
|
||||
|
||||
const { reject } = queued;
|
||||
|
||||
reject(new Error('Cancelled'));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private signingIsLocked ({ id }: RequestSigningIsLocked): ResponseSigningIsLocked {
|
||||
const queued = this.#state.getSignRequest(id);
|
||||
|
||||
assert(queued, 'Unable to find request');
|
||||
|
||||
const address = queued.request.payload.address;
|
||||
const pair = keyring.getPair(address);
|
||||
|
||||
assert(pair, 'Unable to find pair');
|
||||
|
||||
const remainingTime = this.refreshAccountPasswordCache(pair);
|
||||
|
||||
return {
|
||||
isLocked: pair.isLocked,
|
||||
remainingTime
|
||||
};
|
||||
}
|
||||
|
||||
// FIXME This looks very much like what we have in authorization
|
||||
private signingSubscribe (id: string, port: chrome.runtime.Port): boolean {
|
||||
const cb = createSubscription<'pri(signing.requests)'>(id, port);
|
||||
const subscription = this.#state.signSubject.subscribe((requests: SigningRequest[]): void =>
|
||||
cb(requests)
|
||||
);
|
||||
|
||||
port.onDisconnect.addListener((): void => {
|
||||
unsubscribe(id);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private windowOpen (path: AllowedPath): boolean {
|
||||
const url = `${chrome.runtime.getURL('index.html')}#${path}`;
|
||||
|
||||
if (!ALLOWED_PATH.includes(path)) {
|
||||
console.error('Not allowed to open the url:', url);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
withErrorLog(() => chrome.tabs.create({ url }));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private derive (parentAddress: string, suri: string, password: string, metadata: KeyringPair$Meta): KeyringPair {
|
||||
const parentPair = keyring.getPair(parentAddress);
|
||||
|
||||
try {
|
||||
parentPair.decodePkcs8(password);
|
||||
} catch {
|
||||
throw new Error('invalid password');
|
||||
}
|
||||
|
||||
try {
|
||||
return parentPair.derive(suri, metadata);
|
||||
} catch {
|
||||
throw new Error(`"${suri}" is not a valid derivation path`);
|
||||
}
|
||||
}
|
||||
|
||||
private derivationValidate ({ parentAddress, parentPassword, suri }: RequestDeriveValidate): ResponseDeriveValidate {
|
||||
const childPair = this.derive(parentAddress, suri, parentPassword, {});
|
||||
|
||||
return {
|
||||
address: childPair.address,
|
||||
suri
|
||||
};
|
||||
}
|
||||
|
||||
private derivationCreate ({ genesisHash, name, parentAddress, parentPassword, password, suri }: RequestDeriveCreate): boolean {
|
||||
const childPair = this.derive(parentAddress, suri, parentPassword, {
|
||||
genesisHash,
|
||||
name,
|
||||
parentAddress,
|
||||
suri
|
||||
});
|
||||
|
||||
keyring.addPair(childPair, password);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async removeAuthorization (url: string): Promise<ResponseAuthorizeList> {
|
||||
const remAuth = await this.#state.removeAuthorization(url);
|
||||
|
||||
return { list: remAuth };
|
||||
}
|
||||
|
||||
// Reject the authorization request and add the URL to the authorized list with no keys.
|
||||
// The site will not prompt for re-authorization on future visits.
|
||||
private rejectAuthRequest (id: string): void {
|
||||
const queued = this.#state.getAuthRequest(id);
|
||||
|
||||
assert(queued, 'Unable to find request');
|
||||
|
||||
const { reject } = queued;
|
||||
|
||||
reject(new Error('Rejected'));
|
||||
}
|
||||
|
||||
// Cancel the authorization request and do not add the URL to the authorized list.
|
||||
// The site will prompt for authorization on future visits.
|
||||
private cancelAuthRequest (id: string): void {
|
||||
const queued = this.#state.getAuthRequest(id);
|
||||
|
||||
assert(queued, 'Unable to find request');
|
||||
|
||||
const { reject } = queued;
|
||||
|
||||
reject(new Error('Cancelled'));
|
||||
}
|
||||
|
||||
private updateCurrentTabs ({ urls }: RequestActiveTabsUrlUpdate) {
|
||||
this.#state.updateCurrentTabsUrl(urls);
|
||||
}
|
||||
|
||||
private getConnectedTabsUrl () {
|
||||
return this.#state.getConnectedTabsUrl();
|
||||
}
|
||||
|
||||
// Weird thought, the eslint override is not needed in Tabs
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
public async handle<TMessageType extends MessageTypes> (id: string, type: TMessageType, request: RequestTypes[TMessageType], port?: chrome.runtime.Port): Promise<ResponseType<TMessageType>> {
|
||||
switch (type) {
|
||||
case 'pri(authorize.approve)':
|
||||
return this.authorizeApprove(request as RequestAuthorizeApprove);
|
||||
|
||||
case 'pri(authorize.list)':
|
||||
return this.getAuthList();
|
||||
|
||||
case 'pri(authorize.remove)':
|
||||
return this.removeAuthorization(request as string);
|
||||
|
||||
case 'pri(authorize.reject)':
|
||||
return this.rejectAuthRequest(request as string);
|
||||
|
||||
case 'pri(authorize.cancel)':
|
||||
return this.cancelAuthRequest(request as string);
|
||||
|
||||
case 'pri(authorize.requests)':
|
||||
return port && this.authorizeSubscribe(id, port);
|
||||
|
||||
case 'pri(authorize.update)':
|
||||
return this.authorizeUpdate(request as RequestUpdateAuthorizedAccounts);
|
||||
|
||||
case 'pri(accounts.create.external)':
|
||||
return this.accountsCreateExternal(request as RequestAccountCreateExternal);
|
||||
|
||||
case 'pri(accounts.create.hardware)':
|
||||
return this.accountsCreateHardware(request as RequestAccountCreateHardware);
|
||||
|
||||
case 'pri(accounts.create.suri)':
|
||||
return this.accountsCreateSuri(request as RequestAccountCreateSuri);
|
||||
|
||||
case 'pri(accounts.changePassword)':
|
||||
return this.accountsChangePassword(request as RequestAccountChangePassword);
|
||||
|
||||
case 'pri(accounts.edit)':
|
||||
return this.accountsEdit(request as RequestAccountEdit);
|
||||
|
||||
case 'pri(accounts.export)':
|
||||
return this.accountsExport(request as RequestAccountExport);
|
||||
|
||||
case 'pri(accounts.batchExport)':
|
||||
return this.accountsBatchExport(request as RequestAccountBatchExport);
|
||||
|
||||
case 'pri(accounts.forget)':
|
||||
return this.accountsForget(request as RequestAccountForget);
|
||||
|
||||
case 'pri(accounts.show)':
|
||||
return this.accountsShow(request as RequestAccountShow);
|
||||
|
||||
case 'pri(accounts.subscribe)':
|
||||
return port && this.accountsSubscribe(id, port);
|
||||
|
||||
case 'pri(accounts.tie)':
|
||||
return this.accountsTie(request as RequestAccountTie);
|
||||
|
||||
case 'pri(accounts.validate)':
|
||||
return this.accountsValidate(request as RequestAccountValidate);
|
||||
|
||||
case 'pri(metadata.approve)':
|
||||
return await this.metadataApprove(request as RequestMetadataApprove);
|
||||
|
||||
case 'pri(metadata.get)':
|
||||
return this.metadataGet(request as string);
|
||||
|
||||
case 'pri(metadata.list)':
|
||||
return this.metadataList();
|
||||
|
||||
case 'pri(metadata.reject)':
|
||||
return this.metadataReject(request as RequestMetadataReject);
|
||||
|
||||
case 'pri(metadata.requests)':
|
||||
return port && this.metadataSubscribe(id, port);
|
||||
|
||||
case 'pri(activeTabsUrl.update)':
|
||||
return this.updateCurrentTabs(request as RequestActiveTabsUrlUpdate);
|
||||
|
||||
case 'pri(connectedTabsUrl.get)':
|
||||
return this.getConnectedTabsUrl();
|
||||
|
||||
case 'pri(derivation.create)':
|
||||
return this.derivationCreate(request as RequestDeriveCreate);
|
||||
|
||||
case 'pri(derivation.validate)':
|
||||
return this.derivationValidate(request as RequestDeriveValidate);
|
||||
|
||||
case 'pri(json.restore)':
|
||||
return this.jsonRestore(request as RequestJsonRestore);
|
||||
|
||||
case 'pri(json.batchRestore)':
|
||||
return this.batchRestore(request as RequestBatchRestore);
|
||||
|
||||
case 'pri(json.account.info)':
|
||||
return this.jsonGetAccountInfo(request as KeyringPair$Json);
|
||||
|
||||
case 'pri(ping)':
|
||||
return Promise.resolve(true);
|
||||
|
||||
case 'pri(seed.create)':
|
||||
return this.seedCreate(request as RequestSeedCreate);
|
||||
|
||||
case 'pri(seed.validate)':
|
||||
return this.seedValidate(request as RequestSeedValidate);
|
||||
|
||||
case 'pri(settings.notification)':
|
||||
return this.#state.setNotification(request as string);
|
||||
|
||||
case 'pri(signing.approve.password)':
|
||||
return this.signingApprovePassword(request as RequestSigningApprovePassword);
|
||||
|
||||
case 'pri(signing.approve.signature)':
|
||||
return this.signingApproveSignature(request as RequestSigningApproveSignature);
|
||||
|
||||
case 'pri(signing.cancel)':
|
||||
return this.signingCancel(request as RequestSigningCancel);
|
||||
|
||||
case 'pri(signing.isLocked)':
|
||||
return this.signingIsLocked(request as RequestSigningIsLocked);
|
||||
|
||||
case 'pri(signing.requests)':
|
||||
return port && this.signingSubscribe(id, port);
|
||||
|
||||
case 'pri(window.open)':
|
||||
return this.windowOpen(request as AllowedPath);
|
||||
|
||||
default:
|
||||
throw new Error(`Unable to handle message of type ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-bg authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import type { MetadataDef, ProviderMeta } from '@pezkuwi/extension-inject/types';
|
||||
import type { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback } from '@pezkuwi/rpc-provider/types';
|
||||
import type { AccountJson, AuthorizeRequest, AuthUrlInfo, AuthUrls, MetadataRequest, RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestSign, ResponseRpcListProviders, ResponseSigning, SigningRequest } from '../types.js';
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { addMetadata, knownMetadata } from '@pezkuwi/extension-chains';
|
||||
import { knownGenesis } from '@pezkuwi/networks/defaults';
|
||||
import { settings } from '@pezkuwi/ui-settings';
|
||||
import { assert } from '@pezkuwi/util';
|
||||
|
||||
import { MetadataStore } from '../../stores/index.js';
|
||||
import { getId } from '../../utils/getId.js';
|
||||
import { withErrorLog } from './helpers.js';
|
||||
|
||||
interface Resolver<T> {
|
||||
reject: (error: Error) => void;
|
||||
resolve: (result: T) => void;
|
||||
}
|
||||
|
||||
interface AuthRequest extends Resolver<AuthResponse> {
|
||||
id: string;
|
||||
idStr: string;
|
||||
request: RequestAuthorizeTab;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type AuthorizedAccountsDiff = [url: string, authorizedAccounts: AuthUrlInfo['authorizedAccounts']][]
|
||||
|
||||
interface MetaRequest extends Resolver<boolean> {
|
||||
id: string;
|
||||
request: MetadataDef;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
result: boolean;
|
||||
authorizedAccounts: string[];
|
||||
}
|
||||
|
||||
// List of providers passed into constructor. This is the list of providers
|
||||
// exposed by the extension.
|
||||
type Providers = Record<string, {
|
||||
meta: ProviderMeta;
|
||||
// The provider is not running at init, calling this will instantiate the
|
||||
// provider.
|
||||
start: () => ProviderInterface;
|
||||
}>
|
||||
|
||||
interface SignRequest extends Resolver<ResponseSigning> {
|
||||
account: AccountJson;
|
||||
id: string;
|
||||
request: RequestSign;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const NOTIFICATION_URL = chrome.runtime.getURL('notification.html');
|
||||
|
||||
const POPUP_WINDOW_OPTS: chrome.windows.CreateData = {
|
||||
focused: true,
|
||||
height: 621,
|
||||
left: 150,
|
||||
top: 150,
|
||||
type: 'popup',
|
||||
url: NOTIFICATION_URL,
|
||||
width: 560
|
||||
};
|
||||
|
||||
const NORMAL_WINDOW_OPTS: chrome.windows.CreateData = {
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
url: NOTIFICATION_URL
|
||||
};
|
||||
|
||||
export enum NotificationOptions {
|
||||
None,
|
||||
Normal,
|
||||
PopUp,
|
||||
}
|
||||
|
||||
const AUTH_URLS_KEY = 'authUrls';
|
||||
const DEFAULT_AUTH_ACCOUNTS = 'defaultAuthAccounts';
|
||||
|
||||
async function extractMetadata (store: MetadataStore): Promise<void> {
|
||||
await store.allMap(async (map): Promise<void> => {
|
||||
const knownEntries = Object.entries(knownGenesis);
|
||||
const defs: Record<string, { def: MetadataDef, index: number, key: string }> = {};
|
||||
const removals: string[] = [];
|
||||
|
||||
Object
|
||||
.entries(map)
|
||||
.forEach(([key, def]): void => {
|
||||
const entry = knownEntries.find(([, hashes]) => hashes.includes(def.genesisHash));
|
||||
|
||||
if (entry) {
|
||||
const [name, hashes] = entry;
|
||||
const index = hashes.indexOf(def.genesisHash);
|
||||
|
||||
// flatten the known metadata based on the genesis index
|
||||
// (lower is better/newer)
|
||||
if (!defs[name] || (defs[name].index > index)) {
|
||||
if (defs[name]) {
|
||||
// remove the old version of the metadata
|
||||
removals.push(defs[name].key);
|
||||
}
|
||||
|
||||
defs[name] = { def, index, key };
|
||||
}
|
||||
} else {
|
||||
// this is not a known entry, so we will just apply it
|
||||
defs[key] = { def, index: 0, key };
|
||||
}
|
||||
});
|
||||
|
||||
for (const key of removals) {
|
||||
await store.remove(key);
|
||||
}
|
||||
|
||||
Object.values(defs).forEach(({ def }) => addMetadata(def));
|
||||
});
|
||||
}
|
||||
|
||||
export default class State {
|
||||
#authUrls = new Map<string, AuthUrlInfo>();
|
||||
|
||||
#lastRequestTimestamps = new Map<string, number>();
|
||||
#maxEntries = 10;
|
||||
#rateLimitInterval = 3000; // 3 seconds
|
||||
|
||||
readonly #authRequests: Record<string, AuthRequest> = {};
|
||||
|
||||
readonly #metaStore = new MetadataStore();
|
||||
|
||||
// Map of providers currently injected in tabs
|
||||
readonly #injectedProviders = new Map<chrome.runtime.Port, ProviderInterface>();
|
||||
|
||||
readonly #metaRequests: Record<string, MetaRequest> = {};
|
||||
|
||||
#notification = settings.notification;
|
||||
|
||||
// Map of all providers exposed by the extension, they are retrievable by key
|
||||
readonly #providers: Providers;
|
||||
|
||||
readonly #signRequests: Record<string, SignRequest> = {};
|
||||
|
||||
#windows: number[] = [];
|
||||
|
||||
#connectedTabsUrl: string[] = [];
|
||||
|
||||
public readonly authSubject: BehaviorSubject<AuthorizeRequest[]> = new BehaviorSubject<AuthorizeRequest[]>([]);
|
||||
|
||||
public readonly metaSubject: BehaviorSubject<MetadataRequest[]> = new BehaviorSubject<MetadataRequest[]>([]);
|
||||
|
||||
public readonly signSubject: BehaviorSubject<SigningRequest[]> = new BehaviorSubject<SigningRequest[]>([]);
|
||||
|
||||
public readonly authUrlSubjects: Record<string, BehaviorSubject<AuthUrlInfo>> = {};
|
||||
|
||||
public defaultAuthAccountSelection: string[] = [];
|
||||
|
||||
constructor (providers: Providers = {}, rateLimitInterval = 3000) {
|
||||
assert(rateLimitInterval >= 0, 'Expects non-negative number for rateLimitInterval');
|
||||
this.#providers = providers;
|
||||
this.#rateLimitInterval = rateLimitInterval;
|
||||
}
|
||||
|
||||
public async init () {
|
||||
await extractMetadata(this.#metaStore);
|
||||
// retrieve previously set authorizations
|
||||
const storageAuthUrls: Record<string, string> = await chrome.storage.local.get(AUTH_URLS_KEY);
|
||||
const authString = storageAuthUrls?.[AUTH_URLS_KEY] || '{}';
|
||||
const previousAuth = JSON.parse(authString) as AuthUrls;
|
||||
|
||||
this.#authUrls = new Map(Object.entries(previousAuth));
|
||||
|
||||
// Initialize authUrlSubjects for each URL
|
||||
this.#authUrls.forEach((authInfo, url) => {
|
||||
this.authUrlSubjects[url] = new BehaviorSubject<AuthUrlInfo>(authInfo);
|
||||
});
|
||||
|
||||
// retrieve previously set default auth accounts
|
||||
const storageDefaultAuthAccounts: Record<string, string> = await chrome.storage.local.get(DEFAULT_AUTH_ACCOUNTS);
|
||||
const defaultAuthString: string = storageDefaultAuthAccounts?.[DEFAULT_AUTH_ACCOUNTS] || '[]';
|
||||
const previousDefaultAuth = JSON.parse(defaultAuthString) as string[];
|
||||
|
||||
this.defaultAuthAccountSelection = previousDefaultAuth;
|
||||
}
|
||||
|
||||
public get knownMetadata (): MetadataDef[] {
|
||||
return knownMetadata();
|
||||
}
|
||||
|
||||
public get numAuthRequests (): number {
|
||||
return Object.keys(this.#authRequests).length;
|
||||
}
|
||||
|
||||
public get numMetaRequests (): number {
|
||||
return Object.keys(this.#metaRequests).length;
|
||||
}
|
||||
|
||||
public get numSignRequests (): number {
|
||||
return Object.keys(this.#signRequests).length;
|
||||
}
|
||||
|
||||
public get allAuthRequests (): AuthorizeRequest[] {
|
||||
return Object
|
||||
.values(this.#authRequests)
|
||||
.map(({ id, request, url }): AuthorizeRequest => ({ id, request, url }));
|
||||
}
|
||||
|
||||
public get allMetaRequests (): MetadataRequest[] {
|
||||
return Object
|
||||
.values(this.#metaRequests)
|
||||
.map(({ id, request, url }): MetadataRequest => ({ id, request, url }));
|
||||
}
|
||||
|
||||
public get allSignRequests (): SigningRequest[] {
|
||||
return Object
|
||||
.values(this.#signRequests)
|
||||
.map(({ account, id, request, url }): SigningRequest => ({ account, id, request, url }));
|
||||
}
|
||||
|
||||
public get authUrls (): AuthUrls {
|
||||
return Object.fromEntries(this.#authUrls);
|
||||
}
|
||||
|
||||
private popupClose (): void {
|
||||
this.#windows.forEach((id: number) =>
|
||||
withErrorLog(() => chrome.windows.remove(id))
|
||||
);
|
||||
this.#windows = [];
|
||||
}
|
||||
|
||||
private popupOpen (): void {
|
||||
this.#notification !== 'extension' &&
|
||||
chrome.windows.create(
|
||||
this.#notification === 'window'
|
||||
? NORMAL_WINDOW_OPTS
|
||||
: POPUP_WINDOW_OPTS,
|
||||
(window): void => {
|
||||
if (window) {
|
||||
this.#windows.push(window.id || 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private authComplete = (id: string, resolve: (resValue: AuthResponse) => void, reject: (error: Error) => void): Resolver<AuthResponse> => {
|
||||
const complete = async (authorizedAccounts: string[] = []) => {
|
||||
const { idStr, request: { origin }, url } = this.#authRequests[id];
|
||||
|
||||
const strippedUrl = this.stripUrl(url);
|
||||
|
||||
const authInfo: AuthUrlInfo = {
|
||||
authorizedAccounts,
|
||||
count: 0,
|
||||
id: idStr,
|
||||
origin,
|
||||
url
|
||||
};
|
||||
|
||||
this.#authUrls.set(strippedUrl, authInfo);
|
||||
|
||||
if (!this.authUrlSubjects[strippedUrl]) {
|
||||
this.authUrlSubjects[strippedUrl] = new BehaviorSubject<AuthUrlInfo>(authInfo);
|
||||
} else {
|
||||
this.authUrlSubjects[strippedUrl].next(authInfo);
|
||||
}
|
||||
|
||||
await this.saveCurrentAuthList();
|
||||
await this.updateDefaultAuthAccounts(authorizedAccounts);
|
||||
delete this.#authRequests[id];
|
||||
this.updateIconAuth(true);
|
||||
};
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
reject: async (error: Error): Promise<void> => {
|
||||
if (error.message === 'Cancelled') {
|
||||
delete this.#authRequests[id];
|
||||
this.updateIconAuth(true);
|
||||
reject(new Error('Connection request was cancelled by the user.'));
|
||||
} else {
|
||||
await complete();
|
||||
reject(new Error('Connection request was rejected by the user.'));
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
resolve: async ({ authorizedAccounts, result }: AuthResponse): Promise<void> => {
|
||||
await complete(authorizedAccounts);
|
||||
resolve({ authorizedAccounts, result });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated This method is deprecated in favor of {@link updateCurrentTabs} and will be removed in a future release.
|
||||
*/
|
||||
public udateCurrentTabsUrl (urls: string[]) {
|
||||
this.updateCurrentTabsUrl(urls);
|
||||
}
|
||||
|
||||
public updateCurrentTabsUrl (urls: string[]) {
|
||||
const connectedTabs = urls.map((url) => {
|
||||
let strippedUrl = '';
|
||||
|
||||
// the assert in stripUrl may throw for new tabs with "chrome://newtab/"
|
||||
try {
|
||||
strippedUrl = this.stripUrl(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// return the stripped url only if this website is known
|
||||
return !!strippedUrl && this.authUrls[strippedUrl]
|
||||
? strippedUrl
|
||||
: undefined;
|
||||
})
|
||||
.filter((value) => !!value) as string[];
|
||||
|
||||
this.#connectedTabsUrl = connectedTabs;
|
||||
}
|
||||
|
||||
public getConnectedTabsUrl () {
|
||||
return this.#connectedTabsUrl;
|
||||
}
|
||||
|
||||
public deleteAuthRequest (requestId: string) {
|
||||
delete this.#authRequests[requestId];
|
||||
this.updateIconAuth(true);
|
||||
}
|
||||
|
||||
private async saveCurrentAuthList () {
|
||||
await chrome.storage.local.set({ [AUTH_URLS_KEY]: JSON.stringify(Object.fromEntries(this.#authUrls)) });
|
||||
}
|
||||
|
||||
private async saveDefaultAuthAccounts () {
|
||||
await chrome.storage.local.set({ [DEFAULT_AUTH_ACCOUNTS]: JSON.stringify(this.defaultAuthAccountSelection) });
|
||||
}
|
||||
|
||||
public async updateDefaultAuthAccounts (newList: string[]) {
|
||||
this.defaultAuthAccountSelection = newList;
|
||||
await this.saveDefaultAuthAccounts();
|
||||
}
|
||||
|
||||
private metaComplete = (id: string, resolve: (result: boolean) => void, reject: (error: Error) => void): Resolver<boolean> => {
|
||||
const complete = (): void => {
|
||||
delete this.#metaRequests[id];
|
||||
this.updateIconMeta(true);
|
||||
};
|
||||
|
||||
return {
|
||||
reject: (error: Error): void => {
|
||||
complete();
|
||||
reject(error);
|
||||
},
|
||||
resolve: (result: boolean): void => {
|
||||
complete();
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
private signComplete = (id: string, resolve: (result: ResponseSigning) => void, reject: (error: Error) => void): Resolver<ResponseSigning> => {
|
||||
const complete = (): void => {
|
||||
delete this.#signRequests[id];
|
||||
this.updateIconSign(true);
|
||||
};
|
||||
|
||||
return {
|
||||
reject: (error: Error): void => {
|
||||
complete();
|
||||
reject(error);
|
||||
},
|
||||
resolve: (result: ResponseSigning): void => {
|
||||
complete();
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
public stripUrl (url: string): string {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (!['http:', 'https:', 'ipfs:', 'ipns:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error(`Invalid protocol ${parsedUrl.protocol}`);
|
||||
}
|
||||
|
||||
// For ipfs/ipns which don't have a standard origin, we handle it differently.
|
||||
if (parsedUrl.protocol === 'ipfs:' || parsedUrl.protocol === 'ipns:') {
|
||||
// ipfs://<hash> | ipns://<hash>
|
||||
return `${parsedUrl.protocol}//${parsedUrl.hostname}`;
|
||||
}
|
||||
|
||||
return parsedUrl.origin;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
}
|
||||
|
||||
private updateIcon (shouldClose?: boolean): void {
|
||||
const authCount = this.numAuthRequests;
|
||||
const metaCount = this.numMetaRequests;
|
||||
const signCount = this.numSignRequests;
|
||||
const text = (
|
||||
authCount
|
||||
? 'Auth'
|
||||
: metaCount
|
||||
? 'Meta'
|
||||
: (signCount ? `${signCount}` : '')
|
||||
);
|
||||
|
||||
withErrorLog(() => chrome.action.setBadgeText({ text }));
|
||||
|
||||
if (shouldClose && text === '') {
|
||||
this.popupClose();
|
||||
}
|
||||
}
|
||||
|
||||
public async removeAuthorization (url: string): Promise<AuthUrls> {
|
||||
const entry = this.#authUrls.get(url);
|
||||
|
||||
assert(entry, `The source ${url} is not known`);
|
||||
|
||||
this.#authUrls.delete(url);
|
||||
await this.saveCurrentAuthList();
|
||||
|
||||
if (this.authUrlSubjects[url]) {
|
||||
entry.authorizedAccounts = [];
|
||||
this.authUrlSubjects[url].next(entry);
|
||||
}
|
||||
|
||||
return this.authUrls;
|
||||
}
|
||||
|
||||
private updateIconAuth (shouldClose?: boolean): void {
|
||||
this.authSubject.next(this.allAuthRequests);
|
||||
this.updateIcon(shouldClose);
|
||||
}
|
||||
|
||||
private updateIconMeta (shouldClose?: boolean): void {
|
||||
this.metaSubject.next(this.allMetaRequests);
|
||||
this.updateIcon(shouldClose);
|
||||
}
|
||||
|
||||
private updateIconSign (shouldClose?: boolean): void {
|
||||
this.signSubject.next(this.allSignRequests);
|
||||
this.updateIcon(shouldClose);
|
||||
}
|
||||
|
||||
public async updateAuthorizedAccounts (authorizedAccountsDiff: AuthorizedAccountsDiff): Promise<void> {
|
||||
authorizedAccountsDiff.forEach(([url, authorizedAccountDiff]) => {
|
||||
const authInfo = this.#authUrls.get(url);
|
||||
|
||||
if (authInfo) {
|
||||
authInfo.authorizedAccounts = authorizedAccountDiff;
|
||||
this.#authUrls.set(url, authInfo);
|
||||
this.authUrlSubjects[url].next(authInfo);
|
||||
}
|
||||
});
|
||||
|
||||
await this.saveCurrentAuthList();
|
||||
}
|
||||
|
||||
public async authorizeUrl (url: string, request: RequestAuthorizeTab): Promise<AuthResponse> {
|
||||
const idStr = this.stripUrl(url);
|
||||
|
||||
// Do not enqueue duplicate authorization requests.
|
||||
const isDuplicate = Object
|
||||
.values(this.#authRequests)
|
||||
.some((request) => request.idStr === idStr);
|
||||
|
||||
assert(!isDuplicate, `The source ${url} has a pending authorization request`);
|
||||
|
||||
if (this.#authUrls.has(idStr)) {
|
||||
// this url was seen in the past
|
||||
const authInfo = this.#authUrls.get(idStr);
|
||||
|
||||
assert(authInfo?.authorizedAccounts || authInfo?.isAllowed, `The source ${url} is not allowed to interact with this extension`);
|
||||
|
||||
return {
|
||||
authorizedAccounts: [],
|
||||
result: false
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject): void => {
|
||||
const id = getId();
|
||||
|
||||
this.#authRequests[id] = {
|
||||
...this.authComplete(id, resolve, reject),
|
||||
id,
|
||||
idStr,
|
||||
request,
|
||||
url
|
||||
};
|
||||
|
||||
this.updateIconAuth();
|
||||
this.popupOpen();
|
||||
});
|
||||
}
|
||||
|
||||
public ensureUrlAuthorized (url: string): boolean {
|
||||
const entry = this.#authUrls.get(this.stripUrl(url));
|
||||
|
||||
assert(entry, `The source ${url} has not been enabled yet`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public injectMetadata (url: string, request: MetadataDef): Promise<boolean> {
|
||||
return new Promise((resolve, reject): void => {
|
||||
const id = getId();
|
||||
|
||||
this.#metaRequests[id] = {
|
||||
...this.metaComplete(id, resolve, reject),
|
||||
id,
|
||||
request,
|
||||
url
|
||||
};
|
||||
|
||||
this.updateIconMeta();
|
||||
this.popupOpen();
|
||||
});
|
||||
}
|
||||
|
||||
public getAuthRequest (id: string): AuthRequest {
|
||||
return this.#authRequests[id];
|
||||
}
|
||||
|
||||
public getMetaRequest (id: string): MetaRequest {
|
||||
return this.#metaRequests[id];
|
||||
}
|
||||
|
||||
public getSignRequest (id: string): SignRequest {
|
||||
return this.#signRequests[id];
|
||||
}
|
||||
|
||||
// List all providers the extension is exposing
|
||||
public rpcListProviders (): Promise<ResponseRpcListProviders> {
|
||||
return Promise.resolve(Object.keys(this.#providers).reduce((acc, key) => {
|
||||
acc[key] = this.#providers[key].meta;
|
||||
|
||||
return acc;
|
||||
}, {} as ResponseRpcListProviders));
|
||||
}
|
||||
|
||||
public rpcSend (request: RequestRpcSend, port: chrome.runtime.Port): Promise<JsonRpcResponse<unknown>> {
|
||||
const provider = this.#injectedProviders.get(port);
|
||||
|
||||
assert(provider, 'Cannot call pub(rpc.subscribe) before provider is set');
|
||||
|
||||
return provider.send(request.method, request.params);
|
||||
}
|
||||
|
||||
// Start a provider, return its meta
|
||||
public rpcStartProvider (key: string, port: chrome.runtime.Port): Promise<ProviderMeta> {
|
||||
assert(Object.keys(this.#providers).includes(key), `Provider ${key} is not exposed by extension`);
|
||||
|
||||
if (this.#injectedProviders.get(port)) {
|
||||
return Promise.resolve(this.#providers[key].meta);
|
||||
}
|
||||
|
||||
// Instantiate the provider
|
||||
this.#injectedProviders.set(port, this.#providers[key].start());
|
||||
|
||||
// Close provider connection when page is closed
|
||||
port.onDisconnect.addListener((): void => {
|
||||
const provider = this.#injectedProviders.get(port);
|
||||
|
||||
if (provider) {
|
||||
withErrorLog(() => provider.disconnect());
|
||||
}
|
||||
|
||||
this.#injectedProviders.delete(port);
|
||||
});
|
||||
|
||||
return Promise.resolve(this.#providers[key].meta);
|
||||
}
|
||||
|
||||
public rpcSubscribe ({ method, params, type }: RequestRpcSubscribe, cb: ProviderInterfaceCallback, port: chrome.runtime.Port): Promise<number | string> {
|
||||
const provider = this.#injectedProviders.get(port);
|
||||
|
||||
assert(provider, 'Cannot call pub(rpc.subscribe) before provider is set');
|
||||
|
||||
return provider.subscribe(type, method, params, cb);
|
||||
}
|
||||
|
||||
public rpcSubscribeConnected (_request: null, cb: ProviderInterfaceCallback, port: chrome.runtime.Port): void {
|
||||
const provider = this.#injectedProviders.get(port);
|
||||
|
||||
assert(provider, 'Cannot call pub(rpc.subscribeConnected) before provider is set');
|
||||
|
||||
cb(null, provider.isConnected); // Immediately send back current isConnected
|
||||
provider.on('connected', () => cb(null, true));
|
||||
provider.on('disconnected', () => cb(null, false));
|
||||
}
|
||||
|
||||
public rpcUnsubscribe (request: RequestRpcUnsubscribe, port: chrome.runtime.Port): Promise<boolean> {
|
||||
const provider = this.#injectedProviders.get(port);
|
||||
|
||||
assert(provider, 'Cannot call pub(rpc.unsubscribe) before provider is set');
|
||||
|
||||
return provider.unsubscribe(request.type, request.method, request.subscriptionId);
|
||||
}
|
||||
|
||||
public async saveMetadata (meta: MetadataDef): Promise<void> {
|
||||
await this.#metaStore.set(meta.genesisHash, meta);
|
||||
|
||||
addMetadata(meta);
|
||||
}
|
||||
|
||||
public setNotification (notification: string): boolean {
|
||||
this.#notification = notification;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleSignRequest (origin: string) {
|
||||
const now = Date.now();
|
||||
const lastTime = this.#lastRequestTimestamps.get(origin) || 0;
|
||||
|
||||
if (now - lastTime < this.#rateLimitInterval) {
|
||||
throw new Error('Rate limit exceeded. Try again later.');
|
||||
}
|
||||
|
||||
// If we're about to exceed max entries, evict the oldest
|
||||
if (!this.#lastRequestTimestamps.has(origin) && this.#lastRequestTimestamps.size >= this.#maxEntries) {
|
||||
const oldestKey = this.#lastRequestTimestamps.keys().next().value;
|
||||
|
||||
oldestKey && this.#lastRequestTimestamps.delete(oldestKey);
|
||||
}
|
||||
|
||||
this.#lastRequestTimestamps.set(origin, now);
|
||||
}
|
||||
|
||||
public sign (url: string, request: RequestSign, account: AccountJson): Promise<ResponseSigning> {
|
||||
const id = getId();
|
||||
|
||||
try {
|
||||
this.handleSignRequest(url);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject): void => {
|
||||
this.#signRequests[id] = {
|
||||
...this.signComplete(id, resolve, reject),
|
||||
account,
|
||||
id,
|
||||
request,
|
||||
url
|
||||
};
|
||||
|
||||
this.updateIconSign();
|
||||
this.popupOpen();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderMeta } from '@pezkuwi/extension-inject/types';
|
||||
import type { KeyringPair } from '@pezkuwi/keyring/types';
|
||||
import type { JsonRpcResponse } from '@pezkuwi/rpc-provider/types';
|
||||
import type { SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types';
|
||||
import type { SubjectInfo } from '@pezkuwi/ui-keyring/observable/types';
|
||||
import type { AuthUrlInfo, MessageTypes, RequestAccountList, RequestAccountUnsubscribe, RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestTypes, ResponseRpcListProviders, ResponseSigning, ResponseTypes, SubscriptionMessageTypes } from '../types.js';
|
||||
import type { AuthResponse } from './State.js';
|
||||
import type State from './State.js';
|
||||
|
||||
import { combineLatest, type Subscription } from 'rxjs';
|
||||
|
||||
import { checkIfDenied } from '@pezkuwi/phishing';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { accounts as accountsObservable } from '@pezkuwi/ui-keyring/observable/accounts';
|
||||
import { assert, isNumber } from '@pezkuwi/util';
|
||||
|
||||
import { PHISHING_PAGE_REDIRECT } from '../../defaults.js';
|
||||
import { canDerive } from '../../utils/index.js';
|
||||
import RequestBytesSign from '../RequestBytesSign.js';
|
||||
import RequestExtrinsicSign from '../RequestExtrinsicSign.js';
|
||||
import { withErrorLog } from './helpers.js';
|
||||
import { createSubscription, unsubscribe } from './subscriptions.js';
|
||||
|
||||
interface AccountSub {
|
||||
subscription: Subscription;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function transformAccounts (accounts: SubjectInfo, anyType = false): InjectedAccount[] {
|
||||
return Object
|
||||
.values(accounts)
|
||||
.filter(({ json: { meta: { isHidden } } }) => !isHidden)
|
||||
.filter(({ type }) => anyType ? true : canDerive(type))
|
||||
.sort((a, b) => (a.json.meta.whenCreated || 0) - (b.json.meta.whenCreated || 0))
|
||||
.map(({ json: { address, meta: { genesisHash, name } }, type }): InjectedAccount => ({
|
||||
address,
|
||||
genesisHash,
|
||||
name,
|
||||
type
|
||||
}));
|
||||
}
|
||||
|
||||
export default class Tabs {
|
||||
readonly #accountSubs: Record<string, AccountSub> = {};
|
||||
|
||||
readonly #state: State;
|
||||
|
||||
constructor (state: State) {
|
||||
this.#state = state;
|
||||
}
|
||||
|
||||
private filterForAuthorizedAccounts (accounts: InjectedAccount[], url: string): InjectedAccount[] {
|
||||
const auth = this.#state.authUrls[this.#state.stripUrl(url)];
|
||||
|
||||
if (!auth) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return accounts.filter(
|
||||
(allAcc) =>
|
||||
auth.authorizedAccounts
|
||||
// we have a list, use it
|
||||
? auth.authorizedAccounts.includes(allAcc.address)
|
||||
// if no authorizedAccounts and isAllowed return all - these are old converted urls
|
||||
: auth.isAllowed
|
||||
);
|
||||
}
|
||||
|
||||
private authorize (url: string, request: RequestAuthorizeTab): Promise<AuthResponse> {
|
||||
return this.#state.authorizeUrl(url, request);
|
||||
}
|
||||
|
||||
private accountsListAuthorized (url: string, { anyType }: RequestAccountList): InjectedAccount[] {
|
||||
const transformedAccounts = transformAccounts(accountsObservable.subject.getValue(), anyType);
|
||||
|
||||
return this.filterForAuthorizedAccounts(transformedAccounts, url);
|
||||
}
|
||||
|
||||
private accountsSubscribeAuthorized (url: string, id: string, port: chrome.runtime.Port): string {
|
||||
const cb = createSubscription<'pub(accounts.subscribe)'>(id, port);
|
||||
|
||||
const strippedUrl = this.#state.stripUrl(url);
|
||||
|
||||
const authUrlObservable = this.#state.authUrlSubjects[strippedUrl]?.asObservable();
|
||||
|
||||
if (!authUrlObservable) {
|
||||
console.error(`No authUrlSubject found for ${strippedUrl}`);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
this.#accountSubs[id] = {
|
||||
subscription: combineLatest([accountsObservable.subject, authUrlObservable]).subscribe(([accounts, _authUrlInfo]: [SubjectInfo, AuthUrlInfo]): void => {
|
||||
const transformedAccounts = transformAccounts(accounts);
|
||||
|
||||
cb(this.filterForAuthorizedAccounts(transformedAccounts, url));
|
||||
}),
|
||||
url
|
||||
};
|
||||
|
||||
port.onDisconnect.addListener((): void => {
|
||||
this.accountsUnsubscribe(url, { id });
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
private accountsUnsubscribe (url: string, { id }: RequestAccountUnsubscribe): boolean {
|
||||
const sub = this.#accountSubs[id];
|
||||
|
||||
if (!sub || sub.url !== url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete this.#accountSubs[id];
|
||||
|
||||
unsubscribe(id);
|
||||
sub.subscription.unsubscribe();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getSigningPair (address: string): KeyringPair {
|
||||
const pair = keyring.getPair(address);
|
||||
|
||||
assert(pair, 'Unable to find keypair');
|
||||
|
||||
return pair;
|
||||
}
|
||||
|
||||
private bytesSign (url: string, request: SignerPayloadRaw): Promise<ResponseSigning> {
|
||||
const address = request.address;
|
||||
const pair = this.getSigningPair(address);
|
||||
|
||||
return this.#state.sign(url, new RequestBytesSign(request), { address, ...pair.meta });
|
||||
}
|
||||
|
||||
private extrinsicSign (url: string, request: SignerPayloadJSON): Promise<ResponseSigning> {
|
||||
const address = request.address;
|
||||
const pair = this.getSigningPair(address);
|
||||
|
||||
return this.#state.sign(url, new RequestExtrinsicSign(request), { address, ...pair.meta });
|
||||
}
|
||||
|
||||
private metadataProvide (url: string, request: MetadataDef): Promise<boolean> {
|
||||
return this.#state.injectMetadata(url, request);
|
||||
}
|
||||
|
||||
private metadataList (_url: string): InjectedMetadataKnown[] {
|
||||
return this.#state.knownMetadata.map(({ genesisHash, specVersion }) => ({
|
||||
genesisHash,
|
||||
specVersion
|
||||
}));
|
||||
}
|
||||
|
||||
private rpcListProviders (): Promise<ResponseRpcListProviders> {
|
||||
return this.#state.rpcListProviders();
|
||||
}
|
||||
|
||||
private rpcSend (request: RequestRpcSend, port: chrome.runtime.Port): Promise<JsonRpcResponse<unknown>> {
|
||||
return this.#state.rpcSend(request, port);
|
||||
}
|
||||
|
||||
private rpcStartProvider (key: string, port: chrome.runtime.Port): Promise<ProviderMeta> {
|
||||
return this.#state.rpcStartProvider(key, port);
|
||||
}
|
||||
|
||||
private async rpcSubscribe (request: RequestRpcSubscribe, id: string, port: chrome.runtime.Port): Promise<boolean> {
|
||||
const innerCb = createSubscription<'pub(rpc.subscribe)'>(id, port);
|
||||
const cb = (_error: Error | null, data: SubscriptionMessageTypes['pub(rpc.subscribe)']): void => innerCb(data);
|
||||
const subscriptionId = await this.#state.rpcSubscribe(request, cb, port);
|
||||
|
||||
port.onDisconnect.addListener((): void => {
|
||||
unsubscribe(id);
|
||||
withErrorLog(() => this.rpcUnsubscribe({ ...request, subscriptionId }, port));
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private rpcSubscribeConnected (request: null, id: string, port: chrome.runtime.Port): Promise<boolean> {
|
||||
const innerCb = createSubscription<'pub(rpc.subscribeConnected)'>(id, port);
|
||||
const cb = (_error: Error | null, data: SubscriptionMessageTypes['pub(rpc.subscribeConnected)']): void => innerCb(data);
|
||||
|
||||
this.#state.rpcSubscribeConnected(request, cb, port);
|
||||
|
||||
port.onDisconnect.addListener((): void => {
|
||||
unsubscribe(id);
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
private async rpcUnsubscribe (request: RequestRpcUnsubscribe, port: chrome.runtime.Port): Promise<boolean> {
|
||||
return this.#state.rpcUnsubscribe(request, port);
|
||||
}
|
||||
|
||||
private redirectPhishingLanding (phishingWebsite: string): void {
|
||||
const nonFragment = phishingWebsite.split('#')[0];
|
||||
const encodedWebsite = encodeURIComponent(nonFragment);
|
||||
const url = `${chrome.runtime.getURL('index.html')}#${PHISHING_PAGE_REDIRECT}/${encodedWebsite}`;
|
||||
|
||||
chrome.tabs.query({ url: nonFragment }, (tabs) => {
|
||||
tabs
|
||||
.map(({ id }) => id)
|
||||
.filter((id): id is number => isNumber(id))
|
||||
.forEach((id) =>
|
||||
withErrorLog(() => chrome.tabs.update(id, { url }))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async redirectIfPhishing (url: string): Promise<boolean> {
|
||||
const isInDenyList = await checkIfDenied(url);
|
||||
|
||||
if (isInDenyList) {
|
||||
this.redirectPhishingLanding(url);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async handle<TMessageType extends MessageTypes> (id: string, type: TMessageType, request: RequestTypes[TMessageType], url: string, port?: chrome.runtime.Port): Promise<ResponseTypes[keyof ResponseTypes]> {
|
||||
if (type === 'pub(phishing.redirectIfDenied)') {
|
||||
return this.redirectIfPhishing(url);
|
||||
}
|
||||
|
||||
if (type !== 'pub(authorize.tab)') {
|
||||
this.#state.ensureUrlAuthorized(url);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'pub(authorize.tab)':
|
||||
return this.authorize(url, request as RequestAuthorizeTab);
|
||||
|
||||
case 'pub(accounts.list)':
|
||||
return this.accountsListAuthorized(url, request as RequestAccountList);
|
||||
|
||||
case 'pub(accounts.subscribe)':
|
||||
return port && this.accountsSubscribeAuthorized(url, id, port);
|
||||
|
||||
case 'pub(accounts.unsubscribe)':
|
||||
return this.accountsUnsubscribe(url, request as RequestAccountUnsubscribe);
|
||||
|
||||
case 'pub(bytes.sign)':
|
||||
return this.bytesSign(url, request as SignerPayloadRaw);
|
||||
|
||||
case 'pub(extrinsic.sign)':
|
||||
return this.extrinsicSign(url, request as SignerPayloadJSON);
|
||||
|
||||
case 'pub(metadata.list)':
|
||||
return this.metadataList(url);
|
||||
|
||||
case 'pub(metadata.provide)':
|
||||
return this.metadataProvide(url, request as MetadataDef);
|
||||
|
||||
case 'pub(ping)':
|
||||
return Promise.resolve(true);
|
||||
|
||||
case 'pub(rpc.listProviders)':
|
||||
return this.rpcListProviders();
|
||||
|
||||
case 'pub(rpc.send)':
|
||||
return port && this.rpcSend(request as RequestRpcSend, port);
|
||||
|
||||
case 'pub(rpc.startProvider)':
|
||||
return port && this.rpcStartProvider(request as string, port);
|
||||
|
||||
case 'pub(rpc.subscribe)':
|
||||
return port && this.rpcSubscribe(request as RequestRpcSubscribe, id, port);
|
||||
|
||||
case 'pub(rpc.subscribeConnected)':
|
||||
return port && this.rpcSubscribeConnected(request as null, id, port);
|
||||
|
||||
case 'pub(rpc.unsubscribe)':
|
||||
return port && this.rpcUnsubscribe(request as RequestRpcUnsubscribe, port);
|
||||
|
||||
default:
|
||||
throw new Error(`Unable to handle message of type ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export function withErrorLog (fn: () => unknown): void {
|
||||
try {
|
||||
const p = fn();
|
||||
|
||||
if (p && typeof p === 'object' && typeof (p as Promise<unknown>).catch === 'function') {
|
||||
(p as Promise<unknown>).catch(console.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import type { MessageTypes, TransportRequestMessage } from '../types.js';
|
||||
|
||||
import { assert } from '@pezkuwi/util';
|
||||
|
||||
import { PORT_EXTENSION } from '../../defaults.js';
|
||||
import Extension from './Extension.js';
|
||||
import State from './State.js';
|
||||
import Tabs from './Tabs.js';
|
||||
|
||||
export { withErrorLog } from './helpers.js';
|
||||
|
||||
const state = new State();
|
||||
|
||||
await state.init();
|
||||
const extension = new Extension(state);
|
||||
const tabs = new Tabs(state);
|
||||
|
||||
export default function handler<TMessageType extends MessageTypes> ({ id, message, request }: TransportRequestMessage<TMessageType>, port?: chrome.runtime.Port, extensionPortName = PORT_EXTENSION): void {
|
||||
const isExtension = !port || port?.name === extensionPortName;
|
||||
const sender = port?.sender;
|
||||
|
||||
if (!isExtension && !sender) {
|
||||
throw new Error('Unable to extract message sender');
|
||||
}
|
||||
|
||||
const from = isExtension
|
||||
? 'extension'
|
||||
: sender?.url || sender?.tab?.url || '<unknown>';
|
||||
const source = `${from}: ${id}: ${message}`;
|
||||
|
||||
console.log(` [in] ${source}`); // :: ${JSON.stringify(request)}`);
|
||||
|
||||
const promise = isExtension
|
||||
? extension.handle(id, message, request, port)
|
||||
: tabs.handle(id, message, request, from, port);
|
||||
|
||||
promise
|
||||
.then((response: unknown): void => {
|
||||
console.log(`[out] ${source}`); // :: ${JSON.stringify(response)}`);
|
||||
|
||||
// between the start and the end of the promise, the user may have closed
|
||||
// the tab, in which case port will be undefined
|
||||
assert(port, 'Port has been disconnected');
|
||||
|
||||
port.postMessage({ id, response });
|
||||
})
|
||||
.catch((error: Error): void => {
|
||||
console.log(`[err] ${source}:: ${error.message}`);
|
||||
|
||||
// only send message back to port if it's still connected
|
||||
if (port) {
|
||||
port.postMessage({ error: error.message, id });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import type { MessageTypesWithSubscriptions, SubscriptionMessageTypes } from '../types.js';
|
||||
|
||||
type Subscriptions = Record<string, chrome.runtime.Port>;
|
||||
|
||||
const subscriptions: Subscriptions = {};
|
||||
|
||||
// return a subscription callback, that will send the data to the caller via the port
|
||||
export function createSubscription<TMessageType extends MessageTypesWithSubscriptions> (id: string, port: chrome.runtime.Port): (data: SubscriptionMessageTypes[TMessageType]) => void {
|
||||
subscriptions[id] = port;
|
||||
|
||||
return (subscription: unknown): void => {
|
||||
if (subscriptions[id]) {
|
||||
port.postMessage({ id, subscription });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// clear a previous subscriber
|
||||
export function unsubscribe (id: string): void {
|
||||
if (subscriptions[id]) {
|
||||
console.log(`Unsubscribing from ${id}`);
|
||||
|
||||
delete subscriptions[id];
|
||||
} else {
|
||||
console.error(`Unable to unsubscribe from ${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export { default as handlers, withErrorLog } from './handlers/index.js';
|
||||
@@ -0,0 +1,432 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
|
||||
import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderList, ProviderMeta } from '@pezkuwi/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@pezkuwi/keyring/types';
|
||||
import type { JsonRpcResponse } from '@pezkuwi/rpc-provider/types';
|
||||
import type { Registry, SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types';
|
||||
import type { KeyringPairs$Json } from '@pezkuwi/ui-keyring/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
import type { ALLOWED_PATH } from '../defaults.js';
|
||||
import type { AuthResponse } from './handlers/State.js';
|
||||
|
||||
type KeysWithDefinedValues<T> = {
|
||||
[K in keyof T]: T[K] extends undefined ? never : K
|
||||
}[keyof T];
|
||||
|
||||
type NoUndefinedValues<T> = {
|
||||
[K in KeysWithDefinedValues<T>]: T[K]
|
||||
};
|
||||
|
||||
type IsNull<T, K extends keyof T> = { [K1 in Exclude<keyof T, K>]: T[K1] } & T[K] extends null ? K : never;
|
||||
|
||||
type NullKeys<T> = { [K in keyof T]: IsNull<T, K> }[keyof T];
|
||||
|
||||
export type AuthUrls = Record<string, AuthUrlInfo>;
|
||||
|
||||
export interface AuthUrlInfo {
|
||||
count: number;
|
||||
id: string;
|
||||
// this is from pre-0.44.1
|
||||
isAllowed?: boolean;
|
||||
origin: string;
|
||||
url: string;
|
||||
authorizedAccounts: string[];
|
||||
}
|
||||
|
||||
export type SeedLengths = 12 | 24;
|
||||
|
||||
export interface AccountJson extends KeyringPair$Meta {
|
||||
address: string;
|
||||
}
|
||||
|
||||
export type AccountWithChildren = AccountJson & {
|
||||
children?: AccountWithChildren[];
|
||||
}
|
||||
|
||||
export interface AccountsContext {
|
||||
accounts: AccountJson[];
|
||||
hierarchy: AccountWithChildren[];
|
||||
master?: AccountJson;
|
||||
selectedAccounts?: AccountJson['address'][];
|
||||
setSelectedAccounts?: (address: AccountJson['address'][]) => void;
|
||||
}
|
||||
|
||||
export interface AuthorizeRequest {
|
||||
id: string;
|
||||
request: RequestAuthorizeTab;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MetadataRequest {
|
||||
id: string;
|
||||
request: MetadataDef;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SigningRequest {
|
||||
account: AccountJson;
|
||||
id: string;
|
||||
request: RequestSign;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type ConnectedTabsUrlResponse = string[]
|
||||
|
||||
// [MessageType]: [RequestType, ResponseType, SubscriptionMessageType?]
|
||||
export interface RequestSignatures {
|
||||
// private/internal requests, i.e. from a popup
|
||||
'pri(accounts.create.external)': [RequestAccountCreateExternal, boolean];
|
||||
'pri(accounts.create.hardware)': [RequestAccountCreateHardware, boolean];
|
||||
'pri(accounts.create.suri)': [RequestAccountCreateSuri, boolean];
|
||||
'pri(accounts.edit)': [RequestAccountEdit, boolean];
|
||||
'pri(accounts.export)': [RequestAccountExport, ResponseAccountExport];
|
||||
'pri(accounts.batchExport)': [RequestAccountBatchExport, ResponseAccountsExport]
|
||||
'pri(accounts.forget)': [RequestAccountForget, boolean];
|
||||
'pri(accounts.list)': [RequestAccountList, InjectedAccount[]];
|
||||
'pri(accounts.show)': [RequestAccountShow, boolean];
|
||||
'pri(accounts.tie)': [RequestAccountTie, boolean];
|
||||
'pri(accounts.subscribe)': [RequestAccountSubscribe, boolean, AccountJson[]];
|
||||
'pri(accounts.validate)': [RequestAccountValidate, boolean];
|
||||
'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean];
|
||||
'pri(authorize.approve)': [RequestAuthorizeApprove, boolean];
|
||||
'pri(authorize.list)': [null, ResponseAuthorizeList];
|
||||
'pri(authorize.requests)': [RequestAuthorizeSubscribe, boolean, AuthorizeRequest[]];
|
||||
'pri(authorize.remove)': [string, ResponseAuthorizeList];
|
||||
'pri(authorize.reject)': [string, void];
|
||||
'pri(authorize.cancel)': [string, void];
|
||||
'pri(authorize.update)': [RequestUpdateAuthorizedAccounts, void];
|
||||
'pri(activeTabsUrl.update)': [RequestActiveTabsUrlUpdate, void];
|
||||
'pri(connectedTabsUrl.get)': [null, ConnectedTabsUrlResponse];
|
||||
'pri(derivation.create)': [RequestDeriveCreate, boolean];
|
||||
'pri(derivation.validate)': [RequestDeriveValidate, ResponseDeriveValidate];
|
||||
'pri(json.restore)': [RequestJsonRestore, void];
|
||||
'pri(json.batchRestore)': [RequestBatchRestore, void];
|
||||
'pri(json.account.info)': [KeyringPair$Json, ResponseJsonGetAccountInfo];
|
||||
'pri(metadata.approve)': [RequestMetadataApprove, boolean];
|
||||
'pri(metadata.get)': [string | null, MetadataDef | null];
|
||||
'pri(metadata.reject)': [RequestMetadataReject, boolean];
|
||||
'pri(metadata.requests)': [RequestMetadataSubscribe, boolean, MetadataRequest[]];
|
||||
'pri(metadata.list)': [null, MetadataDef[]];
|
||||
'pri(ping)': [null, boolean];
|
||||
'pri(seed.create)': [RequestSeedCreate, ResponseSeedCreate];
|
||||
'pri(seed.validate)': [RequestSeedValidate, ResponseSeedValidate];
|
||||
'pri(settings.notification)': [string, boolean];
|
||||
'pri(signing.approve.password)': [RequestSigningApprovePassword, boolean];
|
||||
'pri(signing.approve.signature)': [RequestSigningApproveSignature, boolean];
|
||||
'pri(signing.cancel)': [RequestSigningCancel, boolean];
|
||||
'pri(signing.isLocked)': [RequestSigningIsLocked, ResponseSigningIsLocked];
|
||||
'pri(signing.requests)': [RequestSigningSubscribe, boolean, SigningRequest[]];
|
||||
'pri(window.open)': [AllowedPath, boolean];
|
||||
// public/external requests, i.e. from a page
|
||||
'pub(accounts.list)': [RequestAccountList, InjectedAccount[]];
|
||||
'pub(accounts.subscribe)': [RequestAccountSubscribe, string, InjectedAccount[]];
|
||||
'pub(accounts.unsubscribe)': [RequestAccountUnsubscribe, boolean];
|
||||
'pub(authorize.tab)': [RequestAuthorizeTab, Promise<AuthResponse>];
|
||||
'pub(bytes.sign)': [SignerPayloadRaw, ResponseSigning];
|
||||
'pub(extrinsic.sign)': [SignerPayloadJSON, ResponseSigning];
|
||||
'pub(metadata.list)': [null, InjectedMetadataKnown[]];
|
||||
'pub(metadata.provide)': [MetadataDef, boolean];
|
||||
'pub(phishing.redirectIfDenied)': [null, boolean];
|
||||
'pub(ping)': [null, boolean];
|
||||
'pub(rpc.listProviders)': [void, ResponseRpcListProviders];
|
||||
'pub(rpc.send)': [RequestRpcSend, JsonRpcResponse<unknown>];
|
||||
'pub(rpc.startProvider)': [string, ProviderMeta];
|
||||
'pub(rpc.subscribe)': [RequestRpcSubscribe, number, JsonRpcResponse<unknown>];
|
||||
'pub(rpc.subscribeConnected)': [null, boolean, boolean];
|
||||
'pub(rpc.unsubscribe)': [RequestRpcUnsubscribe, boolean];
|
||||
}
|
||||
|
||||
export type MessageTypes = keyof RequestSignatures;
|
||||
|
||||
// Requests
|
||||
|
||||
export type RequestTypes = {
|
||||
[MessageType in keyof RequestSignatures]: RequestSignatures[MessageType][0]
|
||||
};
|
||||
|
||||
export type MessageTypesWithNullRequest = NullKeys<RequestTypes>
|
||||
|
||||
export interface TransportRequestMessage<TMessageType extends MessageTypes> {
|
||||
id: string;
|
||||
message: TMessageType;
|
||||
origin: string;
|
||||
request: RequestTypes[TMessageType];
|
||||
}
|
||||
|
||||
export interface RequestAuthorizeTab {
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export interface RequestAuthorizeApprove {
|
||||
id: string;
|
||||
authorizedAccounts: string[]
|
||||
}
|
||||
|
||||
export interface RequestUpdateAuthorizedAccounts {
|
||||
url: string;
|
||||
authorizedAccounts: string[]
|
||||
}
|
||||
|
||||
export type RequestAuthorizeSubscribe = null;
|
||||
|
||||
export interface RequestMetadataApprove {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RequestMetadataReject {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type RequestMetadataSubscribe = null;
|
||||
|
||||
export interface RequestAccountCreateExternal {
|
||||
address: string;
|
||||
genesisHash?: HexString | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RequestAccountCreateSuri {
|
||||
name: string;
|
||||
genesisHash?: HexString | null;
|
||||
password: string;
|
||||
suri: string;
|
||||
type?: KeypairType;
|
||||
}
|
||||
|
||||
export interface RequestAccountCreateHardware {
|
||||
accountIndex: number;
|
||||
address: string;
|
||||
addressOffset: number;
|
||||
genesisHash: HexString;
|
||||
hardwareType: string;
|
||||
name: string;
|
||||
type: KeypairType;
|
||||
}
|
||||
|
||||
export interface RequestAccountChangePassword {
|
||||
address: string;
|
||||
oldPass: string;
|
||||
newPass: string;
|
||||
}
|
||||
|
||||
export interface RequestAccountEdit {
|
||||
address: string;
|
||||
genesisHash?: HexString | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RequestAccountForget {
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface RequestAccountShow {
|
||||
address: string;
|
||||
isShowing: boolean;
|
||||
}
|
||||
|
||||
export interface RequestAccountTie {
|
||||
address: string;
|
||||
genesisHash: HexString | null;
|
||||
}
|
||||
|
||||
export interface RequestAccountValidate {
|
||||
address: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestDeriveCreate {
|
||||
name: string;
|
||||
genesisHash?: HexString | null;
|
||||
suri: string;
|
||||
parentAddress: string;
|
||||
parentPassword: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestDeriveValidate {
|
||||
suri: string;
|
||||
parentAddress: string;
|
||||
parentPassword: string;
|
||||
}
|
||||
|
||||
export interface RequestAccountExport {
|
||||
address: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestAccountBatchExport {
|
||||
addresses: string[];
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestAccountList {
|
||||
anyType?: boolean;
|
||||
}
|
||||
|
||||
export type RequestAccountSubscribe = null;
|
||||
|
||||
export interface RequestActiveTabsUrlUpdate {
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export interface RequestAccountUnsubscribe {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RequestRpcSend {
|
||||
method: string;
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
export interface RequestRpcSubscribe extends RequestRpcSend {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface RequestRpcUnsubscribe {
|
||||
method: string;
|
||||
subscriptionId: number | string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface RequestSigningApprovePassword {
|
||||
id: string;
|
||||
password?: string;
|
||||
savePass: boolean;
|
||||
}
|
||||
|
||||
export interface RequestSigningApproveSignature {
|
||||
id: string;
|
||||
signature: HexString;
|
||||
signedTransaction?: HexString;
|
||||
}
|
||||
|
||||
export interface RequestSigningCancel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RequestSigningIsLocked {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ResponseSigningIsLocked {
|
||||
isLocked: boolean;
|
||||
remainingTime: number;
|
||||
}
|
||||
|
||||
export type RequestSigningSubscribe = null;
|
||||
|
||||
export interface RequestSeedCreate {
|
||||
length?: SeedLengths;
|
||||
seed?: string;
|
||||
type?: KeypairType;
|
||||
}
|
||||
|
||||
export interface RequestSeedValidate {
|
||||
suri: string;
|
||||
type?: KeypairType;
|
||||
}
|
||||
|
||||
// Responses
|
||||
|
||||
export type ResponseTypes = {
|
||||
[MessageType in keyof RequestSignatures]: RequestSignatures[MessageType][1]
|
||||
};
|
||||
|
||||
export type ResponseType<TMessageType extends keyof RequestSignatures> = RequestSignatures[TMessageType][1];
|
||||
|
||||
interface TransportResponseMessageSub<TMessageType extends MessageTypesWithSubscriptions> {
|
||||
error?: string;
|
||||
id: string;
|
||||
response?: ResponseTypes[TMessageType];
|
||||
subscription?: SubscriptionMessageTypes[TMessageType];
|
||||
}
|
||||
|
||||
interface TransportResponseMessageNoSub<TMessageType extends MessageTypesWithNoSubscriptions> {
|
||||
error?: string;
|
||||
id: string;
|
||||
response?: ResponseTypes[TMessageType];
|
||||
}
|
||||
|
||||
export type TransportResponseMessage<TMessageType extends MessageTypes> =
|
||||
TMessageType extends MessageTypesWithNoSubscriptions
|
||||
? TransportResponseMessageNoSub<TMessageType>
|
||||
: TMessageType extends MessageTypesWithSubscriptions
|
||||
? TransportResponseMessageSub<TMessageType>
|
||||
: never;
|
||||
|
||||
export interface ResponseSigning {
|
||||
id: string;
|
||||
signature: HexString;
|
||||
signedTransaction?: HexString;
|
||||
}
|
||||
|
||||
export interface ResponseDeriveValidate {
|
||||
address: string;
|
||||
suri: string;
|
||||
}
|
||||
|
||||
export interface ResponseSeedCreate {
|
||||
address: string;
|
||||
seed: string;
|
||||
}
|
||||
|
||||
export interface ResponseSeedValidate {
|
||||
address: string;
|
||||
suri: string;
|
||||
}
|
||||
|
||||
export interface ResponseAccountExport {
|
||||
exportedJson: KeyringPair$Json;
|
||||
}
|
||||
|
||||
export interface ResponseAccountsExport {
|
||||
exportedJson: KeyringPairs$Json;
|
||||
}
|
||||
|
||||
export type ResponseRpcListProviders = ProviderList;
|
||||
|
||||
// Subscriptions
|
||||
|
||||
export type SubscriptionMessageTypes = NoUndefinedValues<{
|
||||
[MessageType in keyof RequestSignatures]: RequestSignatures[MessageType][2]
|
||||
}>;
|
||||
|
||||
export type MessageTypesWithSubscriptions = keyof SubscriptionMessageTypes;
|
||||
export type MessageTypesWithNoSubscriptions = Exclude<MessageTypes, keyof SubscriptionMessageTypes>
|
||||
|
||||
export interface RequestSign {
|
||||
readonly payload: SignerPayloadJSON | SignerPayloadRaw;
|
||||
|
||||
sign (registry: Registry, pair: KeyringPair): { signature: HexString };
|
||||
}
|
||||
|
||||
export interface RequestJsonRestore {
|
||||
file: KeyringPair$Json;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestBatchRestore {
|
||||
file: KeyringPairs$Json;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ResponseJsonRestore {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type AllowedPath = typeof ALLOWED_PATH[number];
|
||||
|
||||
export interface ResponseJsonGetAccountInfo {
|
||||
address: string;
|
||||
name: string;
|
||||
genesisHash: HexString;
|
||||
type: KeypairType;
|
||||
}
|
||||
|
||||
export interface ResponseAuthorizeList {
|
||||
list: AuthUrls;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export { packageInfo } from './packageInfo.js';
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// this _must_ be changed for each extension
|
||||
export const EXTENSION_PREFIX = process.env['EXTENSION_PREFIX'] || '';
|
||||
|
||||
if (!EXTENSION_PREFIX && !process.env['PORT_PREFIX']) {
|
||||
throw new Error('CRITICAL: The extension does not define an own EXTENSION_PREFIX environment variable as part of the build, this is required to ensure that messages are not shared between extensions. Failure to do so will yield messages sent to multiple extensions.');
|
||||
}
|
||||
|
||||
const PORT_PREFIX = `${EXTENSION_PREFIX || 'unknown'}-${process.env['PORT_PREFIX'] || 'unknown'}`;
|
||||
|
||||
export const PORT_CONTENT = `${PORT_PREFIX}-content`;
|
||||
export const PORT_EXTENSION = `${PORT_PREFIX}-extension`;
|
||||
|
||||
export const MESSAGE_ORIGIN_PAGE = `${PORT_PREFIX}-page`;
|
||||
export const MESSAGE_ORIGIN_CONTENT = `${PORT_PREFIX}-content`;
|
||||
|
||||
export const ALLOWED_PATH = ['/', '/account/import-ledger', '/account/restore-json'] as const;
|
||||
|
||||
export const PASSWORD_EXPIRY_MIN = 15;
|
||||
export const PASSWORD_EXPIRY_MS = PASSWORD_EXPIRY_MIN * 60 * 1000;
|
||||
|
||||
export const PHISHING_PAGE_REDIRECT = '/phishing-page-detected';
|
||||
|
||||
// console.log(`Extension is sending and receiving messages on ${PORT_PREFIX}-*`);
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Since we inject into pages, we skip this
|
||||
// import './detectPackage';
|
||||
|
||||
export * from './bundle.js';
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2017-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Do not edit, auto-generated by @pezkuwi/dev
|
||||
// (packageInfo imports will be kept as-is, user-editable)
|
||||
|
||||
import { packageInfo as chainsInfo } from '@pezkuwi/extension-chains/packageInfo';
|
||||
import { packageInfo as dappInfo } from '@pezkuwi/extension-dapp/packageInfo';
|
||||
import { packageInfo as injectInfo } from '@pezkuwi/extension-inject/packageInfo';
|
||||
import { detectPackage } from '@pezkuwi/util';
|
||||
|
||||
import { packageInfo } from './packageInfo.js';
|
||||
|
||||
detectPackage(packageInfo, null, [chainsInfo, dappInfo, injectInfo]);
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright 2017-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Do not edit, auto-generated by @pezkuwi/dev
|
||||
|
||||
export const packageInfo = { name: '@pezkuwi/extension-base', path: 'auto', type: 'auto', version: '0.62.6' };
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { InjectedAccount, InjectedAccounts, Unsubcall } from '@pezkuwi/extension-inject/types';
|
||||
import type { SendRequest } from './types.js';
|
||||
|
||||
// External to class, this.# is not private enough (yet)
|
||||
let sendRequest: SendRequest;
|
||||
|
||||
export default class Accounts implements InjectedAccounts {
|
||||
constructor (_sendRequest: SendRequest) {
|
||||
sendRequest = _sendRequest;
|
||||
}
|
||||
|
||||
public get (anyType?: boolean): Promise<InjectedAccount[]> {
|
||||
return sendRequest('pub(accounts.list)', { anyType });
|
||||
}
|
||||
|
||||
public subscribe (cb: (accounts: InjectedAccount[]) => unknown): Unsubcall {
|
||||
let id: string | null = null;
|
||||
|
||||
sendRequest('pub(accounts.subscribe)', null, cb)
|
||||
.then((subId): void => {
|
||||
id = subId;
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return (): void => {
|
||||
id && sendRequest('pub(accounts.unsubscribe)', { id })
|
||||
.catch(console.error);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Injected } from '@pezkuwi/extension-inject/types';
|
||||
import type { SendRequest } from './types.js';
|
||||
|
||||
import Accounts from './Accounts.js';
|
||||
import Metadata from './Metadata.js';
|
||||
import PostMessageProvider from './PostMessageProvider.js';
|
||||
import Signer from './Signer.js';
|
||||
|
||||
export default class implements Injected {
|
||||
public readonly accounts: Accounts;
|
||||
|
||||
public readonly metadata: Metadata;
|
||||
|
||||
public readonly provider: PostMessageProvider;
|
||||
|
||||
public readonly signer: Signer;
|
||||
|
||||
constructor (sendRequest: SendRequest) {
|
||||
this.accounts = new Accounts(sendRequest);
|
||||
this.metadata = new Metadata(sendRequest);
|
||||
this.provider = new PostMessageProvider(sendRequest);
|
||||
this.signer = new Signer(sendRequest);
|
||||
|
||||
setInterval((): void => {
|
||||
sendRequest('pub(ping)', null).catch((): void => {
|
||||
console.error('Extension unavailable, ping failed');
|
||||
});
|
||||
}, 5_000 + Math.floor(Math.random() * 5_000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { InjectedMetadata, InjectedMetadataKnown, MetadataDef } from '@pezkuwi/extension-inject/types';
|
||||
import type { SendRequest } from './types.js';
|
||||
|
||||
// External to class, this.# is not private enough (yet)
|
||||
let sendRequest: SendRequest;
|
||||
|
||||
export default class Metadata implements InjectedMetadata {
|
||||
constructor (_sendRequest: SendRequest) {
|
||||
sendRequest = _sendRequest;
|
||||
}
|
||||
|
||||
public get (): Promise<InjectedMetadataKnown[]> {
|
||||
return sendRequest('pub(metadata.list)');
|
||||
}
|
||||
|
||||
public provide (definition: MetadataDef): Promise<boolean> {
|
||||
return sendRequest('pub(metadata.provide)', definition);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { InjectedProvider, ProviderList, ProviderMeta } from '@pezkuwi/extension-inject/types';
|
||||
import type { ProviderInterfaceEmitCb, ProviderInterfaceEmitted } from '@pezkuwi/rpc-provider/types';
|
||||
import type { AnyFunction } from '@pezkuwi/types/types';
|
||||
import type { SendRequest } from './types.js';
|
||||
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
import { isUndefined, logger } from '@pezkuwi/util';
|
||||
|
||||
const l = logger('PostMessageProvider');
|
||||
|
||||
type CallbackHandler = (error?: null | Error, value?: unknown) => void;
|
||||
|
||||
// Same as https://github.com/polkadot-js/api/blob/57ca9a9c3204339e1e1f693fcacc33039868dc27/packages/rpc-provider/src/ws/Provider.ts#L17
|
||||
interface SubscriptionHandler {
|
||||
callback: CallbackHandler;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// External to class, this.# is not private enough (yet)
|
||||
let sendRequest: SendRequest;
|
||||
|
||||
/**
|
||||
* @name PostMessageProvider
|
||||
*
|
||||
* @description Extension provider to be used by dapps
|
||||
*/
|
||||
export default class PostMessageProvider implements InjectedProvider {
|
||||
readonly #eventemitter: EventEmitter;
|
||||
|
||||
// Whether or not the actual extension background provider is connected
|
||||
#isConnected = false;
|
||||
|
||||
// Subscription IDs are (historically) not guaranteed to be globally unique;
|
||||
// only unique for a given subscription method; which is why we identify
|
||||
// the subscriptions based on subscription id + type
|
||||
readonly #subscriptions: Record<string, AnyFunction> = {}; // {[(type,subscriptionId)]: callback}
|
||||
|
||||
/**
|
||||
* @param {function} sendRequest The function to be called to send requests to the node
|
||||
* @param {function} subscriptionNotificationHandler Channel for receiving subscription messages
|
||||
*/
|
||||
public constructor (_sendRequest: SendRequest) {
|
||||
this.#eventemitter = new EventEmitter();
|
||||
|
||||
sendRequest = _sendRequest;
|
||||
}
|
||||
|
||||
public get isClonable (): boolean {
|
||||
return !!true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Returns a clone of the object
|
||||
*/
|
||||
public clone (): PostMessageProvider {
|
||||
return new PostMessageProvider(sendRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Manually disconnect from the connection, clearing autoconnect logic
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
public async connect (): Promise<void> {
|
||||
// FIXME This should see if the extension's state's provider can disconnect
|
||||
console.error('PostMessageProvider.disconnect() is not implemented.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Manually disconnect from the connection, clearing autoconnect logic
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
public async disconnect (): Promise<void> {
|
||||
// FIXME This should see if the extension's state's provider can disconnect
|
||||
console.error('PostMessageProvider.disconnect() is not implemented.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary `true` when this provider supports subscriptions
|
||||
*/
|
||||
public get hasSubscriptions (): boolean {
|
||||
// FIXME This should see if the extension's state's provider has subscriptions
|
||||
return !!true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Whether the node is connected or not.
|
||||
* @return {boolean} true if connected
|
||||
*/
|
||||
public get isConnected (): boolean {
|
||||
return this.#isConnected;
|
||||
}
|
||||
|
||||
public listProviders (): Promise<ProviderList> {
|
||||
return sendRequest('pub(rpc.listProviders)', undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Listens on events after having subscribed using the [[subscribe]] function.
|
||||
* @param {ProviderInterfaceEmitted} type Event
|
||||
* @param {ProviderInterfaceEmitCb} sub Callback
|
||||
* @return unsubscribe function
|
||||
*/
|
||||
public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void {
|
||||
this.#eventemitter.on(type, sub);
|
||||
|
||||
return (): void => {
|
||||
this.#eventemitter.removeListener(type, sub);
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public async send (method: string, params: unknown[], _?: boolean, subscription?: SubscriptionHandler): Promise<any> {
|
||||
if (subscription) {
|
||||
const { callback, type } = subscription;
|
||||
|
||||
const id = await sendRequest('pub(rpc.subscribe)', { method, params, type }, (res): void => {
|
||||
subscription.callback(null, res);
|
||||
});
|
||||
|
||||
this.#subscriptions[`${type}::${id}`] = callback;
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
return sendRequest('pub(rpc.send)', { method, params });
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Spawn a provider on the extension background.
|
||||
*/
|
||||
public async startProvider (key: string): Promise<ProviderMeta> {
|
||||
// Disconnect from the previous provider
|
||||
this.#isConnected = false;
|
||||
this.#eventemitter.emit('disconnected');
|
||||
|
||||
const meta = await sendRequest('pub(rpc.startProvider)', key);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
sendRequest('pub(rpc.subscribeConnected)', null, (connected) => {
|
||||
this.#isConnected = connected;
|
||||
|
||||
if (connected) {
|
||||
this.#eventemitter.emit('connected');
|
||||
} else {
|
||||
this.#eventemitter.emit('disconnected');
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
public subscribe (type: string, method: string, params: unknown[], callback: AnyFunction): Promise<number> {
|
||||
return this.send(method, params, false, { callback, type }) as Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Allows unsubscribing to subscriptions made with [[subscribe]].
|
||||
*/
|
||||
public async unsubscribe (type: string, method: string, id: number): Promise<boolean> {
|
||||
const subscription = `${type}::${id}`;
|
||||
|
||||
// FIXME This now could happen with re-subscriptions. The issue is that with a re-sub
|
||||
// the assigned id now does not match what the API user originally received. It has
|
||||
// a slight complication in solving - since we cannot rely on the send id, but rather
|
||||
// need to find the actual subscription id to map it
|
||||
if (isUndefined(this.#subscriptions[subscription])) {
|
||||
l.debug((): string => `Unable to find active subscription=${subscription}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
delete this.#subscriptions[subscription];
|
||||
|
||||
return this.send(method, [id]) as Promise<boolean>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Signer as SignerInterface, SignerResult } from '@pezkuwi/api/types';
|
||||
import type { SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types';
|
||||
import type { SendRequest } from './types.js';
|
||||
|
||||
// External to class, this.# is not private enough (yet)
|
||||
let sendRequest: SendRequest;
|
||||
let nextId = 0;
|
||||
|
||||
export default class Signer implements SignerInterface {
|
||||
constructor (_sendRequest: SendRequest) {
|
||||
sendRequest = _sendRequest;
|
||||
}
|
||||
|
||||
public async signPayload (payload: SignerPayloadJSON): Promise<SignerResult> {
|
||||
const id = ++nextId;
|
||||
const result = await sendRequest('pub(extrinsic.sign)', payload);
|
||||
|
||||
// we add an internal id (number) - should have a mapping from the
|
||||
// extension id (string) -> internal id (number) if we wish to provide
|
||||
// updated via the update functionality (noop at this point)
|
||||
return {
|
||||
...result,
|
||||
id
|
||||
};
|
||||
}
|
||||
|
||||
public async signRaw (payload: SignerPayloadRaw): Promise<SignerResult> {
|
||||
const id = ++nextId;
|
||||
const result = await sendRequest('pub(bytes.sign)', payload);
|
||||
|
||||
return {
|
||||
...result,
|
||||
id
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE We don't listen to updates at all, if we do we can interpret the
|
||||
// result as provided by the API here
|
||||
// public update (id: number, status: Hash | SubmittableResult): void {
|
||||
// // ignore
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* eslint-disable no-redeclare */
|
||||
|
||||
import type { MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, RequestTypes, ResponseTypes, SubscriptionMessageTypes, TransportRequestMessage, TransportResponseMessage } from '../background/types.js';
|
||||
|
||||
import { MESSAGE_ORIGIN_PAGE } from '../defaults.js';
|
||||
import { getId } from '../utils/getId.js';
|
||||
import Injected from './Injected.js';
|
||||
|
||||
// when sending a message from the injector to the extension, we
|
||||
// - create an event - this we send to the loader
|
||||
// - the loader takes this event and uses port.postMessage to background
|
||||
// - on response, the loader creates a response event
|
||||
// - this injector, listens on the events, maps it to the original
|
||||
// - resolves/rejects the promise with the result (or sub data)
|
||||
|
||||
export interface Handler {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolve: (data?: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
subscriber?: (data: any) => void;
|
||||
}
|
||||
|
||||
export type Handlers = Record<string, Handler>;
|
||||
|
||||
const handlers: Handlers = {};
|
||||
|
||||
// a generic message sender that creates an event, returning a promise that will
|
||||
// resolve once the event is resolved (by the response listener just below this)
|
||||
export function sendMessage<TMessageType extends MessageTypesWithNullRequest>(message: TMessageType): Promise<ResponseTypes[TMessageType]>;
|
||||
export function sendMessage<TMessageType extends MessageTypesWithNoSubscriptions>(message: TMessageType, request: RequestTypes[TMessageType]): Promise<ResponseTypes[TMessageType]>;
|
||||
export function sendMessage<TMessageType extends MessageTypesWithSubscriptions>(message: TMessageType, request: RequestTypes[TMessageType], subscriber: (data: SubscriptionMessageTypes[TMessageType]) => void): Promise<ResponseTypes[TMessageType]>;
|
||||
|
||||
export function sendMessage<TMessageType extends MessageTypes> (message: TMessageType, request?: RequestTypes[TMessageType], subscriber?: (data: unknown) => void): Promise<ResponseTypes[TMessageType]> {
|
||||
return new Promise((resolve, reject): void => {
|
||||
const id = getId();
|
||||
|
||||
handlers[id] = { reject, resolve, subscriber };
|
||||
|
||||
const transportRequestMessage: TransportRequestMessage<TMessageType> = {
|
||||
id,
|
||||
message,
|
||||
origin: MESSAGE_ORIGIN_PAGE,
|
||||
request: request || null as RequestTypes[TMessageType]
|
||||
};
|
||||
|
||||
window.postMessage(transportRequestMessage, '*');
|
||||
});
|
||||
}
|
||||
|
||||
// the enable function, called by the dapp to allow access
|
||||
export async function enable (origin: string): Promise<Injected> {
|
||||
await sendMessage('pub(authorize.tab)', { origin });
|
||||
|
||||
return new Injected(sendMessage);
|
||||
}
|
||||
|
||||
// redirect users if this page is considered as phishing, otherwise return false
|
||||
export async function redirectIfPhishing (): Promise<boolean> {
|
||||
const res = await sendMessage('pub(phishing.redirectIfDenied)');
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export function handleResponse<TMessageType extends MessageTypes> (data: TransportResponseMessage<TMessageType> & { subscription?: string }): void {
|
||||
const handler = handlers[data.id];
|
||||
|
||||
if (!handler) {
|
||||
console.error(`Unknown response: ${JSON.stringify(data)}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!handler.subscriber) {
|
||||
delete handlers[data.id];
|
||||
}
|
||||
|
||||
if (data.subscription) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
(handler.subscriber as Function)(data.subscription);
|
||||
} else if (data.error) {
|
||||
handler.reject(new Error(data.error));
|
||||
} else {
|
||||
handler.resolve(data.response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, RequestTypes, ResponseTypes, SubscriptionMessageTypes } from '../background/types.js';
|
||||
|
||||
export interface SendRequest {
|
||||
<TMessageType extends MessageTypesWithNullRequest>(message: TMessageType): Promise<ResponseTypes[TMessageType]>;
|
||||
<TMessageType extends MessageTypesWithNoSubscriptions>(message: TMessageType, request: RequestTypes[TMessageType]): Promise<ResponseTypes[TMessageType]>;
|
||||
<TMessageType extends MessageTypesWithSubscriptions>(message: TMessageType, request: RequestTypes[TMessageType], subscriber: (data: SubscriptionMessageTypes[TMessageType]) => void): Promise<ResponseTypes[TMessageType]>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeyringJson, KeyringStore } from '@pezkuwi/ui-keyring/types';
|
||||
|
||||
import { EXTENSION_PREFIX } from '../defaults.js';
|
||||
import BaseStore from './Base.js';
|
||||
|
||||
export default class AccountsStore extends BaseStore<KeyringJson> implements KeyringStore {
|
||||
constructor () {
|
||||
super(
|
||||
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
|
||||
? `${EXTENSION_PREFIX}accounts`
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
public override async set (key: string, value: KeyringJson, update?: () => void): Promise<void> {
|
||||
// shortcut, don't save testing accounts in extension storage
|
||||
if (key.startsWith('account:') && value.meta && value.meta.isTesting) {
|
||||
update && update();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await super.set(key, value, update);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
|
||||
type StoreValue = Record<string, unknown>;
|
||||
|
||||
const lastError = (type: string): void => {
|
||||
const error = chrome.runtime.lastError;
|
||||
|
||||
if (error) {
|
||||
console.error(`BaseStore.${type}:: runtime.lastError:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export default abstract class BaseStore <T> {
|
||||
#prefix: string;
|
||||
|
||||
constructor (prefix: string | null) {
|
||||
this.#prefix = prefix ? `${prefix}:` : '';
|
||||
}
|
||||
|
||||
public async all (update: (key: string, value: T) => void): Promise<void> {
|
||||
await this.allMap(async (map): Promise<void> => {
|
||||
const entries = Object.entries(map);
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await update(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async allMap (update: (value: Record<string, T>) => Promise<void>): Promise<void> {
|
||||
await chrome.storage.local.get(null).then(async (result: StoreValue) => {
|
||||
lastError('all');
|
||||
|
||||
const entries = Object.entries(result);
|
||||
const map: Record<string, T> = {};
|
||||
|
||||
for (let i = 0, count = entries.length; i < count; i++) {
|
||||
const [key, value] = entries[i];
|
||||
|
||||
if (key.startsWith(this.#prefix)) {
|
||||
map[key.replace(this.#prefix, '')] = value as T;
|
||||
}
|
||||
}
|
||||
|
||||
await update(map);
|
||||
}).catch(({ message }: Error) => {
|
||||
console.error(`BaseStore error within allMap: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async get (key: string, update: (value: T) => void): Promise<void> {
|
||||
const prefixedKey = `${this.#prefix}${key}`;
|
||||
|
||||
await chrome.storage.local.get([prefixedKey]).then(async (result: StoreValue) => {
|
||||
lastError('get');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await update(result[prefixedKey] as T);
|
||||
}).catch(({ message }: Error) => {
|
||||
console.error(`BaseStore error within get: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async remove (key: string, update?: () => void): Promise<void> {
|
||||
const prefixedKey = `${this.#prefix}${key}`;
|
||||
|
||||
await chrome.storage.local.remove(prefixedKey).then(async () => {
|
||||
lastError('remove');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
update && await update();
|
||||
}).catch(({ message }: Error) => {
|
||||
console.error(`BaseStore error within remove: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async set (key: string, value: T, update?: () => void): Promise<void> {
|
||||
const prefixedKey = `${this.#prefix}${key}`;
|
||||
|
||||
await chrome.storage.local.set({ [prefixedKey]: value }).then(async () => {
|
||||
lastError('set');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
update && await update();
|
||||
}).catch(({ message }: Error) => {
|
||||
console.error(`BaseStore error within set: ${message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { MetadataDef } from '@pezkuwi/extension-inject/types';
|
||||
|
||||
import { EXTENSION_PREFIX } from '../defaults.js';
|
||||
import BaseStore from './Base.js';
|
||||
|
||||
export default class MetadataStore extends BaseStore<MetadataDef> {
|
||||
constructor () {
|
||||
super(
|
||||
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
|
||||
? `${EXTENSION_PREFIX}metadata`
|
||||
: 'metadata'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export { default as AccountsStore } from './Accounts.js';
|
||||
export { default as MetadataStore } from './Metadata.js';
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export interface Message extends MessageEvent {
|
||||
data: {
|
||||
error?: string;
|
||||
id: string;
|
||||
origin: string;
|
||||
response?: string;
|
||||
subscription?: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
|
||||
export function canDerive (type?: KeypairType): boolean {
|
||||
return !!type && ['ed25519', 'sr25519', 'ecdsa', 'ethereum'].includes(type);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { EXTENSION_PREFIX } from '../defaults.js';
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export function getId (): string {
|
||||
return `${EXTENSION_PREFIX}.${Date.now()}.${++counter}`;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export { canDerive } from './canDerive.js';
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Message } from '@pezkuwi/extension-base/types';
|
||||
|
||||
import { chrome } from '@pezkuwi/extension-inject/chrome';
|
||||
|
||||
export function setupPort (portName: string, onMessageHandler: (data: Message['data']) => void, onDisconnectHandler: () => void): chrome.runtime.Port {
|
||||
const port = chrome.runtime.connect({ name: portName });
|
||||
|
||||
port.onMessage.addListener(onMessageHandler);
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
console.log(`Disconnected from ${portName}`);
|
||||
onDisconnectHandler();
|
||||
});
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
export async function wakeUpServiceWorker (): Promise<{ status: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ type: 'wakeup' }, (response: { status: string }) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// This object is required to allow jest.spyOn to be used to create a mock Implementation for testing
|
||||
export const wakeUpServiceWorkerWrapper = { wakeUpServiceWorker };
|
||||
|
||||
export async function ensurePortConnection (
|
||||
portRef: chrome.runtime.Port | undefined,
|
||||
portConfig: {
|
||||
portName: string,
|
||||
onPortMessageHandler: (data: Message['data']) => void,
|
||||
onPortDisconnectHandler: () => void
|
||||
}
|
||||
): Promise<chrome.runtime.Port> {
|
||||
const maxAttempts = 5;
|
||||
const delayMs = 1000;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const response = await wakeUpServiceWorkerWrapper.wakeUpServiceWorker();
|
||||
|
||||
if (response?.status === 'awake') {
|
||||
if (!portRef) {
|
||||
return setupPort(portConfig.portName, portConfig.onPortMessageHandler, portConfig.onPortDisconnectHandler);
|
||||
}
|
||||
|
||||
return portRef;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Attempt ${attempt + 1} failed: ${(error as Error).message}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to wake up the service worker and setup the port after multiple attempts');
|
||||
}
|
||||
Reference in New Issue
Block a user