diff --git a/README.md b/README.md index 69d9fe66..fed50635 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,15 @@ This is the list of currently supported Account types. For each of them you can: On top of that some accounts allow you to **encrypt** messages, retrieve an account from a **browser based** wallet (ex: Metamask), or from a **Ledger** wallet. -| Chain | Encryption | Wallet | Ledger | -| --------- | ------------------ | ------------------ | ------ | -| Avalanche | :heavy_check_mark: | :x: | :x: | -| Cosmos | :x: | :x: | :x: | -| Ethereum | :heavy_check_mark: | :heavy_check_mark: | :x: | -| NULS2 | :heavy_check_mark: | :x: | :x: | -| Solana | :x: | :heavy_check_mark: | :x: | -| Substrate | :heavy_check_mark: | :x: | :x: | -| Tezos | :x: | :heavy_check_mark: | :x: | +| Chain | Encryption | Wallet | Ledger | +| --------- | ------------------ |---------------------| ------ | +| Avalanche | :heavy_check_mark: | :heavy_check_mark: | :x: | +| Cosmos | :x: | :x: | :x: | +| Ethereum | :heavy_check_mark: | :heavy_check_mark: | :x: | +| NULS2 | :heavy_check_mark: | :x: | :x: | +| Solana | :x: | :heavy_check_mark: | :x: | +| Substrate | :heavy_check_mark: | :x: | :x: | +| Tezos | :x: | :heavy_check_mark: | :x: | ## Running from source diff --git a/examples/toolshed/src/components/SelectProvider.tsx b/examples/toolshed/src/components/SelectProvider.tsx index 049aeb40..9f858120 100644 --- a/examples/toolshed/src/components/SelectProvider.tsx +++ b/examples/toolshed/src/components/SelectProvider.tsx @@ -21,6 +21,7 @@ export const availableKeypairs: Option[] = [ ] export const availableWallets: Option[] = [ + { label: 'Avalanche (via Metamask)', value: WalletChains.Avalanche }, { label: 'Ethereum (via Metamask)', value: WalletChains.Ethereum }, { label: 'Solana (via Phantom)', value: WalletChains.Solana }, ] diff --git a/examples/toolshed/src/components/WalletConfig.tsx b/examples/toolshed/src/components/WalletConfig.tsx index 7dee15f5..aa3a1e1e 100644 --- a/examples/toolshed/src/components/WalletConfig.tsx +++ b/examples/toolshed/src/components/WalletConfig.tsx @@ -1,4 +1,4 @@ -import { solana, ethereum } from '../../../../src/accounts' +import { solana, ethereum, avalanche } from '../../../../src/accounts' import { WalletChains } from '../model/chains' import { dispatchAndConsume } from '../model/componentProps' import { Actions } from '../reducer' @@ -6,7 +6,8 @@ import { Actions } from '../reducer' function WalletConfig({ dispatch, state } : dispatchAndConsume) { const getAccountClass = () => ( - state.selectedChain === WalletChains.Ethereum ? [ethereum, window.ethereum] + state.selectedChain === WalletChains.Avalanche ? [avalanche, window.ethereum] + : state.selectedChain === WalletChains.Ethereum ? [ethereum, window.ethereum] : state.selectedChain === WalletChains.Solana ? [solana, window.phantom?.solana] : [null, null] ) diff --git a/examples/toolshed/src/model/chains.ts b/examples/toolshed/src/model/chains.ts index df255c17..4dae62c9 100644 --- a/examples/toolshed/src/model/chains.ts +++ b/examples/toolshed/src/model/chains.ts @@ -9,6 +9,7 @@ export enum KeypairChains { } export enum WalletChains { + Avalanche = "AVAX", Ethereum = "ETH", Solana = "SOL", } diff --git a/src/accounts/avalanche.ts b/src/accounts/avalanche.ts index f9193986..ee9c47fe 100644 --- a/src/accounts/avalanche.ts +++ b/src/accounts/avalanche.ts @@ -5,21 +5,30 @@ import { BaseMessage, Chain } from "../messages/message"; import { decrypt as secp256k1_decrypt, encrypt as secp256k1_encrypt } from "eciesjs"; import { KeyPair } from "avalanche/dist/apis/avm"; import { Avalanche, BinTools, Buffer as AvaBuff } from "avalanche"; +import { JsonRPCWallet, RpcChainType } from "../providers/JsonRPCWallet"; +import { BaseProviderWallet } from "../providers/BaseProviderWallet"; +import { providers } from "ethers"; /** * AvalancheAccount implements the Account class for the Avalanche protocol. * It is used to represent an Avalanche account when publishing a message on the Aleph network. */ export class AvalancheAccount extends Account { - private signer; + private signer?: KeyPair; + private provider?: BaseProviderWallet; - constructor(signer: KeyPair) { - super(signer.getAddressString()); - this.signer = signer; + constructor(signerOrProvider: KeyPair | BaseProviderWallet, address: string) { + super(address); + + if (signerOrProvider instanceof KeyPair) this.signer = signerOrProvider; + if (signerOrProvider instanceof BaseProviderWallet) this.provider = signerOrProvider; } override GetChain(): Chain { - return Chain.AVAX; + if (this.signer) return Chain.AVAX; + if (this.provider) return Chain.ETH; + + throw new Error("Cannot determine chain"); } /** @@ -27,9 +36,11 @@ export class AvalancheAccount extends Account { * * @param content The content to encrypt. */ - encrypt(content: Buffer): Buffer { - const publicKey = this.signer.getPublicKey().toString("hex"); - return secp256k1_encrypt(publicKey, content); + async encrypt(content: Buffer): Promise { + const publicKey = this.signer?.getPublicKey().toString("hex") || (await this.provider?.getPublicKey()); + if (publicKey) return secp256k1_encrypt(publicKey, content); + + throw new Error("Cannot encrypt content"); } /** @@ -37,9 +48,16 @@ export class AvalancheAccount extends Account { * * @param encryptedContent The encrypted content to decrypt. */ - decrypt(encryptedContent: Buffer): Buffer { - const secret = this.signer.getPrivateKey().toString("hex"); - return secp256k1_decrypt(secret, encryptedContent); + async decrypt(encryptedContent: Buffer): Promise { + if (this.signer) { + const secret = this.signer.getPrivateKey().toString("hex"); + return secp256k1_decrypt(secret, encryptedContent); + } + if (this.provider) { + const decrypted = await this.provider.decrypt(encryptedContent); + return Buffer.from(decrypted); + } + throw new Error("Cannot encrypt content"); } private async digestMessage(message: Buffer) { @@ -63,13 +81,18 @@ export class AvalancheAccount extends Account { const buffer = GetVerificationBuffer(message); const digest = await this.digestMessage(buffer); - const digestHex = digest.toString("hex"); - const digestBuff = AvaBuff.from(digestHex, "hex"); + if (this.signer) { + const digestHex = digest.toString("hex"); + const digestBuff = AvaBuff.from(digestHex, "hex"); + const signatureBuffer = this.signer?.sign(digestBuff); - const signatureBuffer = this.signer.sign(digestBuff); - const bintools = BinTools.getInstance(); + const bintools = BinTools.getInstance(); + return bintools.cb58Encode(signatureBuffer); + } else if (this.provider) { + return await this.provider.signMessage(buffer); + } - return bintools.cb58Encode(signatureBuffer); + throw new Error("Cannot sign message"); } } @@ -107,7 +130,24 @@ export async function getKeyPair(privateKey?: string): Promise { */ export async function ImportAccountFromPrivateKey(privateKey: string): Promise { const keyPair = await getKeyPair(privateKey); - return new AvalancheAccount(keyPair); + return new AvalancheAccount(keyPair, keyPair.getAddressString()); +} + +/** + * Get an account from a Web3 provider (ex: Metamask) + * + * @param {providers.ExternalProvider} provider from metamask + */ +export async function GetAccountFromProvider(provider: providers.ExternalProvider): Promise { + const avaxProvider = new providers.Web3Provider(provider); + const jrw = new JsonRPCWallet(avaxProvider); + await jrw.changeNetwork(RpcChainType.AVAX); + + await jrw.connect(); + if (jrw.address) { + return new AvalancheAccount(jrw, jrw.address); + } + throw new Error("Insufficient permissions"); } /** @@ -118,5 +158,5 @@ export async function NewAccount(): Promise<{ account: AvalancheAccount; private const keypair = await getKeyPair(); const privateKey = keypair.getPrivateKey().toString("hex"); - return { account: new AvalancheAccount(keypair), privateKey }; + return { account: new AvalancheAccount(keypair, keypair.getAddressString()), privateKey }; } diff --git a/src/providers/JsonRPCWallet.ts b/src/providers/JsonRPCWallet.ts index 56ec4c01..79aa8bbf 100644 --- a/src/providers/JsonRPCWallet.ts +++ b/src/providers/JsonRPCWallet.ts @@ -4,6 +4,36 @@ import { BaseProviderWallet } from "./BaseProviderWallet"; const RPC_WARNING = `DEPRECATION WARNING: Encryption/Decryption features may become obsolete, for more information: https://github.com/aleph-im/aleph-sdk-ts/issues/37`; +export enum RpcChainType { + ETH, + AVAX, +} + +const ChainData = { + [RpcChainType.AVAX]: { + chainId: "0xA86A", + rpcUrls: ["https://api.avax.network/ext/bc/C/rpc"], + chainName: "Avalanche Mainnet", + nativeCurrency: { + name: "AVAX", + symbol: "AVAX", + decimals: 18, + }, + blockExplorerUrls: ["https://snowtrace.io"], + }, + [RpcChainType.ETH]: { + chainId: "0x1", + rpcUrls: ["https://mainnet.infura.io/v3/"], + chainName: "Ethereum Mainnet", + nativeCurrency: { + name: "ETH", + symbol: "ETH", + decimals: 18, + }, + blockExplorerUrls: ["https://etherscan.io"], + }, +}; + /** * Wrapper for JSON RPC Providers (ex: Metamask) */ @@ -51,4 +81,10 @@ export class JsonRPCWallet extends BaseProviderWallet { if (!this.signer) throw new Error("Wallet not connected"); return this.signer.signMessage(data); } + + public async changeNetwork(chain: RpcChainType = RpcChainType.ETH): Promise { + if (chain === RpcChainType.ETH) { + await this.provider.send("wallet_switchEthereumChain", [{ chainId: "0x1" }]); + } else await this.provider.send("wallet_addEthereumChain", [ChainData[chain]]); + } } diff --git a/tests/accounts/avalanche.test.ts b/tests/accounts/avalanche.test.ts index 8546a00f..99672c84 100644 --- a/tests/accounts/avalanche.test.ts +++ b/tests/accounts/avalanche.test.ts @@ -1,8 +1,12 @@ import { avalanche, post } from "../index"; import { DEFAULT_API_V2 } from "../../src/global"; import { ItemType } from "../../src/messages/message"; +import { EthereumProvider } from "../providers/ethereumProvider"; describe("Avalanche accounts", () => { + const providerAddress = "0xB98bD7C7f656290071E52D1aA617D9cB4467Fd6D"; + const providerPrivateKey = "de926db3012af759b4f24b5a51ef6afa397f04670f634aa4f48d4480417007f3"; + it("should retrieved an avalanche keypair from an hexadecimal private key", async () => { const { account, privateKey } = await avalanche.NewAccount(); @@ -32,17 +36,81 @@ describe("Avalanche accounts", () => { expect(fromHex.address).toBe(fromCb58.address); }); + it("should import an ethereum accounts using a provider", async () => { + const provider = new EthereumProvider({ + address: providerAddress, + privateKey: providerPrivateKey, + networkVersion: 31, + }); + + const accountFromProvider = await avalanche.GetAccountFromProvider(provider); + expect(accountFromProvider.address).toStrictEqual(providerAddress); + }); + it("Should encrypt and decrypt some data with an Avalanche keypair", async () => { const { account } = await avalanche.NewAccount(); const msg = Buffer.from("Laŭ Ludoviko Zamenhof bongustas freŝa ĉeĥa manĝaĵo kun spicoj"); - const c = account.encrypt(msg); - const d = account.decrypt(c); + const c = await account.encrypt(msg); + const d = await account.decrypt(c); + + expect(c).not.toBe(msg); + expect(d).toStrictEqual(msg); + }); + + it("Should encrypt and decrypt some data with an Avalanche account from provider", async () => { + const provider = new EthereumProvider({ + address: providerAddress, + privateKey: providerPrivateKey, + networkVersion: 31, + }); + const accountFromProvider = await avalanche.GetAccountFromProvider(provider); + const msg = Buffer.from("Laŭ Ludoviko Zamenhof bongustas freŝa ĉeĥa manĝaĵo kun spicoj"); + + const c = await accountFromProvider.encrypt(msg); + const d = await accountFromProvider.decrypt(c); expect(c).not.toBe(msg); expect(d).toStrictEqual(msg); }); + it("should publish a post message correctly with an account from a provider", async () => { + const provider = new EthereumProvider({ + address: providerAddress, + privateKey: providerPrivateKey, + networkVersion: 31, + }); + const accountFromProvider = await avalanche.GetAccountFromProvider(provider); + const content: { body: string } = { + body: "This message was posted from the typescript-SDK test suite", + }; + + const msg = await post.Publish({ + APIServer: DEFAULT_API_V2, + channel: "TEST", + inlineRequested: true, + storageEngine: ItemType.ipfs, + account: accountFromProvider, + postType: "avalanche", + content: content, + }); + + expect(msg.item_hash).not.toBeUndefined(); + setTimeout(async () => { + const amends = await post.Get({ + types: "avalanche", + APIServer: DEFAULT_API_V2, + pagination: 200, + page: 1, + refs: [], + addresses: [], + tags: [], + hashes: [msg.item_hash], + }); + expect(amends.posts[0].content).toStrictEqual(content); + }); + }); + it("should publish a post message correctly", async () => { const { account } = await avalanche.NewAccount(); const content: { body: string } = { diff --git a/tests/providers/ethereumProvider.ts b/tests/providers/ethereumProvider.ts index ab0b1fc9..bad12aca 100644 --- a/tests/providers/ethereumProvider.ts +++ b/tests/providers/ethereumProvider.ts @@ -92,6 +92,14 @@ export class EthereumProvider implements IMockProvider { return Promise.resolve(decrypted); } + case "wallet_addEthereumChain": { + return Promise.resolve(); + } + + case "wallet_switchEthereumChain": { + return Promise.resolve(); + } + default: this.log(`resquesting missing method ${method}`); // eslint-disable-next-line prefer-promise-reject-errors