diff --git a/examples/sandbox/index.html b/examples/sandbox/index.html index ef998ec9f..8f0c07917 100644 --- a/examples/sandbox/index.html +++ b/examples/sandbox/index.html @@ -116,7 +116,8 @@

Select

- + + diff --git a/examples/sandbox/index.ts b/examples/sandbox/index.ts index c29b46a01..9d12acdaa 100644 --- a/examples/sandbox/index.ts +++ b/examples/sandbox/index.ts @@ -9,6 +9,7 @@ import * as ledgerWebUSB from "@shapeshiftoss/hdwallet-ledger-webusb"; import * as metaMask from "@shapeshiftoss/hdwallet-metamask"; import * as native from "@shapeshiftoss/hdwallet-native"; import * as portis from "@shapeshiftoss/hdwallet-portis"; +import * as tallyHo from "@shapeshiftoss/hdwallet-tallyho"; import * as trezorConnect from "@shapeshiftoss/hdwallet-trezor-connect"; import * as xdefi from "@shapeshiftoss/hdwallet-xdefi"; import $ from "jquery"; @@ -64,6 +65,7 @@ const kkbridgeAdapter = keepkeyTcp.TCPKeepKeyAdapter.useKeyring(keyring); const kkemuAdapter = keepkeyTcp.TCPKeepKeyAdapter.useKeyring(keyring); const portisAdapter = portis.PortisAdapter.useKeyring(keyring, { portisAppId }); const metaMaskAdapter = metaMask.MetaMaskAdapter.useKeyring(keyring); +const tallyHoAdapter = tallyHo.TallyHoAdapter.useKeyring(keyring); const xdefiAdapter = xdefi.XDEFIAdapter.useKeyring(keyring); const nativeAdapter = native.NativeAdapter.useKeyring(keyring, { mnemonic, @@ -95,6 +97,7 @@ const $ledgerwebhid = $("#ledgerwebhid"); const $portis = $("#portis"); const $native = $("#native"); const $metaMask = $("#metaMask"); +const $tallyHo = $("#tallyHo"); const $xdefi = $("#xdefi"); const $keyring = $("#keyring"); @@ -181,6 +184,20 @@ $metaMask.on("click", async (e) => { console.error(error); } }); + +$tallyHo.on("click", async (e) => { + e.preventDefault(); + wallet = await tallyHoAdapter.pairDevice(); + window["wallet"] = wallet; + let deviceID = "nothing"; + try { + deviceID = await wallet.getDeviceID(); + $("#keyring select").val(deviceID); + } catch (error) { + console.error(error); + } +}); + $xdefi.on("click", async (e) => { e.preventDefault(); wallet = await xdefiAdapter.pairDevice("testid"); @@ -275,6 +292,12 @@ async function deviceConnected(deviceId) { console.error("Could not initialize MetaMaskAdapter", e); } + try { + await tallyHoAdapter.initialize(); + } catch (e) { + console.error("Could not initialize TallyHoAdapter", e); + } + for (const deviceID of Object.keys(keyring.wallets)) { await deviceConnected(deviceID); } diff --git a/integration/src/integration.ts b/integration/src/integration.ts index 96edfb964..d0d48f0cd 100644 --- a/integration/src/integration.ts +++ b/integration/src/integration.ts @@ -4,6 +4,7 @@ import * as ledger from "@shapeshiftoss/hdwallet-ledger"; import * as metamask from "@shapeshiftoss/hdwallet-metamask"; import * as native from "@shapeshiftoss/hdwallet-native"; import * as portis from "@shapeshiftoss/hdwallet-portis"; +import * as tallyHo from "@shapeshiftoss/hdwallet-tallyho"; import * as trezor from "@shapeshiftoss/hdwallet-trezor"; import * as xdefi from "@shapeshiftoss/hdwallet-xdefi"; @@ -54,6 +55,7 @@ export function integration(suite: WalletSuite): void { (portis.isPortis(wallet) ? 1 : 0) + (native.isNative(wallet) ? 1 : 0) + (metamask.isMetaMask(wallet) ? 1 : 0) + + (tallyHo.isTallyHo(wallet) ? 1 : 0) + (xdefi.isXDEFI(wallet) ? 1 : 0) ).toEqual(1); }); diff --git a/integration/src/wallets/tallyho.ts b/integration/src/wallets/tallyho.ts new file mode 100644 index 000000000..cb8f6530f --- /dev/null +++ b/integration/src/wallets/tallyho.ts @@ -0,0 +1,166 @@ +import * as core from "@shapeshiftoss/hdwallet-core"; +import * as tallyHo from "@shapeshiftoss/hdwallet-tallyho"; + +export function name(): string { + return "Tally Ho"; +} + +export function createInfo(): core.HDWalletInfo { + return new tallyHo.TallyHoHDWalletInfo(); +} + +export async function createWallet(): Promise { + const provider = { + request: jest.fn(({ method, params }: any) => { + switch (method) { + case "eth_accounts": + return ["0x3f2329C9ADFbcCd9A84f52c906E936A42dA18CB8"]; + case "personal_sign": { + const [message] = params; + + if (message === "48656c6c6f20576f726c64") + return "0x29f7212ecc1c76cea81174af267b67506f754ea8c73f144afa900a0d85b24b21319621aeb062903e856352f38305710190869c3ce5a1425d65ef4fa558d0fc251b"; + + throw new Error("unknown message"); + } + case "eth_sendTransaction": { + const [{ to }] = params; + + return `txHash-${to}`; + } + default: + throw new Error(`ethereum: Unknown method ${method}`); + } + }), + }; + const wallet = new tallyHo.TallyHoHDWallet(provider); + await wallet.initialize(); + return wallet; +} + +export function selfTest(get: () => core.HDWallet): void { + let wallet: tallyHo.TallyHoHDWallet; + + beforeAll(async () => { + const w = get() as tallyHo.TallyHoHDWallet; + + if (tallyHo.isTallyHo(w) && !core.supportsBTC(w) && core.supportsETH(w)) { + wallet = w; + } else { + throw "Wallet is not a Tally"; + } + }); + + it("supports Ethereum mainnet", async () => { + if (!wallet) return; + expect(await wallet.ethSupportsNetwork()).toEqual(true); + }); + + it("does not support BTC", async () => { + if (!wallet) return; + expect(core.supportsBTC(wallet)).toBe(false); + }); + + it("does not support Native ShapeShift", async () => { + if (!wallet) return; + expect(wallet.ethSupportsNativeShapeShift()).toEqual(false); + }); + + it("does support EIP1559", async () => { + if (!wallet) return; + expect(await wallet.ethSupportsEIP1559()).toEqual(true); + }); + + it("does not support Secure Transfer", async () => { + if (!wallet) return; + expect(await wallet.ethSupportsSecureTransfer()).toEqual(false); + }); + + it("uses correct eth bip44 paths", () => { + if (!wallet) return; + [0, 1, 3, 27].forEach((account) => { + const paths = wallet.ethGetAccountPaths({ + coin: "Ethereum", + accountIdx: account, + }); + expect(paths).toEqual([ + { + addressNList: core.bip32ToAddressNList(`m/44'/60'/${account}'/0/0`), + hardenedPath: core.bip32ToAddressNList(`m/44'/60'/${account}'`), + relPath: [0, 0], + description: "TallyHo", + }, + ]); + paths.forEach((path) => { + expect( + wallet.describePath({ + coin: "Ethereum", + path: path.addressNList, + }).isKnown + ).toBeTruthy(); + }); + }); + }); + + it("can describe ETH paths", () => { + if (!wallet) return; + expect( + wallet.describePath({ + path: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + coin: "Ethereum", + }) + ).toEqual({ + verbose: "Ethereum Account #0", + coin: "Ethereum", + isKnown: true, + accountIdx: 0, + wholeAccount: true, + }); + + expect( + wallet.describePath({ + path: core.bip32ToAddressNList("m/44'/60'/3'/0/0"), + coin: "Ethereum", + }) + ).toEqual({ + verbose: "Ethereum Account #3", + coin: "Ethereum", + isKnown: true, + accountIdx: 3, + wholeAccount: true, + }); + + expect( + wallet.describePath({ + path: core.bip32ToAddressNList("m/44'/60'/0'/0/3"), + coin: "Ethereum", + }) + ).toEqual({ + verbose: "m/44'/60'/0'/0/3", + coin: "Ethereum", + isKnown: false, + }); + }); + + it("should return a valid ETH address", async () => { + if (!wallet) return; + expect( + await wallet.ethGetAddress({ + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + showDisplay: false, + }) + ).toEqual("0x3f2329C9ADFbcCd9A84f52c906E936A42dA18CB8"); + }); + + it("should sign a message", async () => { + if (!wallet) return; + const res = await wallet.ethSignMessage({ + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + message: "Hello World", + }); + expect(res?.address).toEqual("0x3f2329C9ADFbcCd9A84f52c906E936A42dA18CB8"); + expect(res?.signature).toEqual( + "0x29f7212ecc1c76cea81174af267b67506f754ea8c73f144afa900a0d85b24b21319621aeb062903e856352f38305710190869c3ce5a1425d65ef4fa558d0fc251b" + ); + }); +} diff --git a/packages/hdwallet-tallyho/package.json b/packages/hdwallet-tallyho/package.json new file mode 100644 index 000000000..5bd21d6a0 --- /dev/null +++ b/packages/hdwallet-tallyho/package.json @@ -0,0 +1,25 @@ +{ + "name": "@shapeshiftoss/hdwallet-tallyho", + "version": "1.19.0", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "main": "dist/index.js", + "source": "src/index.ts", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "prepublishOnly": "yarn clean && yarn build" + }, + "dependencies": { + "@shapeshiftoss/hdwallet-core": "1.19.0", + "lodash": "^4.17.21", + "tallyho-onboarding": "^1.0.2" + }, + "devDependencies": { + "@types/lodash": "^4.14.168", + "typescript": "^4.3.2" + } +} diff --git a/packages/hdwallet-tallyho/src/adapter.test.ts b/packages/hdwallet-tallyho/src/adapter.test.ts new file mode 100644 index 000000000..29c1c0f85 --- /dev/null +++ b/packages/hdwallet-tallyho/src/adapter.test.ts @@ -0,0 +1,11 @@ +import * as core from "@shapeshiftoss/hdwallet-core"; + +import { TallyHoAdapter } from "./adapter"; + +describe("TallyHoAdapter", () => { + it("throws error if provider is not preset", async () => { + const keyring = new core.Keyring(); + const adapter = TallyHoAdapter.useKeyring(keyring); + await expect(async () => await adapter.pairDevice()).rejects.toThrowError("Could not get Tally Ho accounts."); + }); +}); diff --git a/packages/hdwallet-tallyho/src/adapter.ts b/packages/hdwallet-tallyho/src/adapter.ts new file mode 100644 index 000000000..a60b15c90 --- /dev/null +++ b/packages/hdwallet-tallyho/src/adapter.ts @@ -0,0 +1,99 @@ +import * as core from "@shapeshiftoss/hdwallet-core"; +import TallyHoOnboarding from "tallyho-onboarding"; + +import { TallyHoHDWallet } from "./tallyho"; + +interface TallyHoEthereumProvider { + isTally?: boolean; +} + +interface Window { + ethereum?: TallyHoEthereumProvider; +} + +export class TallyHoAdapter { + keyring: core.Keyring; + + private constructor(keyring: core.Keyring) { + this.keyring = keyring; + } + + public static useKeyring(keyring: core.Keyring) { + return new TallyHoAdapter(keyring); + } + + public async pairDevice(): Promise { + let provider: any; + // eslint-disable-next-line no-useless-catch + try { + provider = await this.detectTallyProvider(); + } catch (error) { + throw error; + } + if (!provider) { + const onboarding = new TallyHoOnboarding(); + onboarding.startOnboarding(); + console.error("Please install Tally Ho!"); + } + if (provider === null) { + throw new Error("Could not get Tally Ho accounts."); + } + + // eslint-disable-next-line no-useless-catch + try { + await provider.request({ method: "eth_requestAccounts" }); + } catch (error) { + throw error; + } + const wallet = new TallyHoHDWallet(provider); + const deviceID = await wallet.getDeviceID(); + this.keyring.add(wallet, deviceID); + this.keyring.emit(["Tally Ho", deviceID, core.Events.CONNECT], deviceID); + + return wallet; + } + + /* + * Tally works the same way as metamask. + * This code is copied from the @metamask/detect-provider package + * @see https://www.npmjs.com/package/@metamask/detect-provider + */ + private async detectTallyProvider(): Promise { + let handled = false; + + return new Promise((resolve) => { + if ((window as Window).ethereum) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + handleEthereum(); + } else { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + window.addEventListener("ethereum#initialized", handleEthereum, { once: true }); + + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + handleEthereum(); + }, 3000); + } + + function handleEthereum() { + if (handled) { + return; + } + handled = true; + + window.removeEventListener("ethereum#initialized", handleEthereum); + + const { ethereum } = window as Window; + + if (ethereum && ethereum.isTally) { + resolve(ethereum as unknown as TallyHoEthereumProvider); + } else { + const message = ethereum ? "Non-TallyHo window.ethereum detected." : "Unable to detect window.ethereum."; + + console.error("hdwallet-tallyho: ", message); + resolve(null); + } + } + }); + } +} diff --git a/packages/hdwallet-tallyho/src/ethereum.test.ts b/packages/hdwallet-tallyho/src/ethereum.test.ts new file mode 100644 index 000000000..6a0c3ca62 --- /dev/null +++ b/packages/hdwallet-tallyho/src/ethereum.test.ts @@ -0,0 +1,206 @@ +import * as core from "@shapeshiftoss/hdwallet-core"; + +import * as ethereum from "./ethereum"; + +describe("Tally Ho - Ethereum Adapter", () => { + it("ethVerifyMessage returns null as its not implemented", async () => { + const ethereumProvider = { + request: jest.fn().mockReturnValue("0x3f2329C9ADFbcCd9A84f52c906E936A42dA18CB8"), + }; + expect( + await ethereum.ethVerifyMessage( + { + address: "0x3f2329C9ADFbcCd9A84f52c906E936A42dA18CB8", + message: "hello world", + signature: + "0x29f7212ecc1c76cea81174af267b67506f754ea8c73f144afa900a0d85b24b21319621aeb062903e856352f38305710190869c3ce5a1425d65ef4fa558d0fc251b", + }, + ethereumProvider + ) + ).toBe(null); + }); + it("ethGetAccountPaths should return correct paths", () => { + const paths = ethereum.ethGetAccountPaths({ coin: "Ethereum", accountIdx: 0 }); + expect(paths).toMatchObject([ + { + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + hardenedPath: core.bip32ToAddressNList("m/44'/60'/0'"), + relPath: [0, 0], + description: "Tally Ho", + }, + ]); + }); + it("ethGetAccountPaths should return empty path", () => { + const paths = ethereum.ethGetAccountPaths({ coin: "RandomCoin", accountIdx: 0 }); + expect(paths).toMatchObject([]); + }); + it("ethSignTx returns null as its not implemented", async () => { + const ethereumProvider = { + request: jest.fn().mockReturnValue({ + r: "0x63db3dd3bf3e1fe7dde1969c0fc8850e34116d0b501c0483a0e08c0f77b8ce0a", + s: "0x28297d012cccf389f6332415e96ee3fc0bbf8474d05f646e029cd281a031464b", + v: 38, + serialized: + "0xf86b018501dcd650008256229412ec06288edd7ae2cc41a843fe089237fc7354f0872c68af0bb140008026a063db3dd3bf3e1fe7dde1969c0fc8850e34116d0b501c0483a0e08c0f77b8ce0aa028297d012cccf389f6332415e96ee3fc0bbf8474d05f646e029cd281a031464b", + }), + }; + expect( + await ethereum.ethSignTx( + { + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + nonce: "0xDEADBEEF", + gasPrice: "0xDEADBEEF", + gasLimit: "0xDEADBEEF", + to: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + value: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + data: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + chainId: 1, + }, + ethereumProvider, + "0x123" + ) + ).toEqual(null); + }); + it("ethSendTx returns a valid hash", async () => { + const ethereumProvider = { + request: jest.fn().mockReturnValue("0x123"), + }; + + const hash = await ethereum.ethSendTx( + { + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + nonce: "0xDEADBEEF", + gasPrice: "0xDEADBEEF", + gasLimit: "0xDEADBEEF", + to: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + value: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + data: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + chainId: 1, + }, + ethereumProvider, + "0x123" + ); + expect(ethereumProvider.request).toHaveBeenCalled(); + expect(hash).toMatchObject({ hash: "0x123" }); + }); + it("ethSendTx returns a valid hash if maxFeePerGas is present in msg", async () => { + const ethereumProvider = { + request: jest.fn().mockReturnValue("0x123"), + }; + + const hash = await ethereum.ethSendTx( + { + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + nonce: "0xDEADBEEF", + gasPrice: "0xDEADBEEF", + gasLimit: "0xDEADBEEF", + maxFeePerGas: "0xDEADBEEF", + to: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + value: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + data: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + chainId: 1, + }, + ethereumProvider, + "0x123" + ); + expect(ethereumProvider.request).toHaveBeenCalled(); + expect(hash).toMatchObject({ hash: "0x123" }); + }); + it("ethSendTx returns null on error", async () => { + const ethereumProvider = { + request: jest.fn().mockRejectedValue(new Error("An Error has occurred")), + }; + + const hash = await ethereum.ethSendTx( + { + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + nonce: "0xDEADBEEF", + gasPrice: "0xDEADBEEF", + gasLimit: "0xDEADBEEF", + to: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + value: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + data: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + chainId: 1, + }, + ethereumProvider, + "0x123" + ); + expect(ethereumProvider.request).toHaveBeenCalled(); + expect(hash).toBe(null); + }); + + it("ethSignMessage returns a valid signature object", async () => { + const ethereumProvider = { + request: jest.fn().mockReturnValue( + `Object { + "address": "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + "signature": "0x05f51140905ffa33ffdc57f46b0b8d8fbb1d2a99f8cd843ca27893c01c31351c08b76d83dce412731c846e3b50649724415deb522d00950fbf4f2c1459c2b70b1b", + }` + ), + }; + + const msg = "super secret message"; + const sig = await ethereum.ethSignMessage( + { + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + message: msg, + }, + ethereumProvider, + "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8" + ); + + expect(sig).toMatchInlineSnapshot(` + Object { + "address": "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + "signature": "Object { + \\"address\\": \\"0x73d0385F4d8E00C5e6504C6030F47BF6212736A8\\", + \\"signature\\": \\"0x05f51140905ffa33ffdc57f46b0b8d8fbb1d2a99f8cd843ca27893c01c31351c08b76d83dce412731c846e3b50649724415deb522d00950fbf4f2c1459c2b70b1b\\", + }", + } + `); + }); + + it("ethSignMessage returns null on error", async () => { + const ethereumProvider = { + request: jest.fn().mockRejectedValue(new Error("An Error has occurred")), + }; + + const msg = "super secret message"; + const sig = await ethereum.ethSignMessage( + { + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + message: msg, + }, + ethereumProvider, + "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8" + ); + + expect(sig).toBe(null); + }); + + it("ethGetAddress returns a valid address", async () => { + const ethereumProvider = { + request: jest.fn().mockReturnValue(["0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"]), + }; + + const address = await ethereum.ethGetAddress(ethereumProvider); + + expect(address).toBe("0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"); + }); + it("ethGetAddress returns null on error", async () => { + const ethereumProvider = { + request: jest.fn().mockRejectedValue(new Error("An error has occurred")), + }; + + const address = await ethereum.ethGetAddress(ethereumProvider); + + expect(address).toBe(null); + }); + it("ethGetAddress returns null if no provider", async () => { + const ethereumProvider = {}; + + const address = await ethereum.ethGetAddress(ethereumProvider); + + expect(address).toBe(null); + }); +}); diff --git a/packages/hdwallet-tallyho/src/ethereum.ts b/packages/hdwallet-tallyho/src/ethereum.ts new file mode 100644 index 000000000..c579e5c96 --- /dev/null +++ b/packages/hdwallet-tallyho/src/ethereum.ts @@ -0,0 +1,130 @@ +import * as core from "@shapeshiftoss/hdwallet-core"; + +export function describeETHPath(path: core.BIP32Path): core.PathDescription { + const pathStr = core.addressNListToBIP32(path); + const unknown: core.PathDescription = { + verbose: pathStr, + coin: "Ethereum", + isKnown: false, + }; + + if (path.length !== 5) return unknown; + + if (path[0] !== 0x80000000 + 44) return unknown; + + if (path[1] !== 0x80000000 + core.slip44ByCoin("Ethereum")) return unknown; + + if ((path[2] & 0x80000000) >>> 0 !== 0x80000000) return unknown; + + if (path[3] !== 0) return unknown; + + if (path[4] !== 0) return unknown; + + const index = path[2] & 0x7fffffff; + return { + verbose: `Ethereum Account #${index}`, + accountIdx: index, + wholeAccount: true, + coin: "Ethereum", + isKnown: true, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function ethVerifyMessage(msg: core.ETHVerifyMessage, ethereum: any): Promise { + console.error("Method ethVerifyMessage unsupported for Tally Ho wallet!"); + return null; +} + +export function ethGetAccountPaths(msg: core.ETHGetAccountPath): Array { + const slip44 = core.slip44ByCoin(msg.coin); + if (slip44 === undefined) return []; + return [ + { + addressNList: [0x80000000 + 44, 0x80000000 + slip44, 0x80000000 + msg.accountIdx, 0, 0], + hardenedPath: [0x80000000 + 44, 0x80000000 + slip44, 0x80000000 + msg.accountIdx], + relPath: [0, 0], + description: "Tally Ho", + }, + ]; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function ethSignTx(msg: core.ETHSignTx, ethereum: any, from: string): Promise { + console.error("Method ethSignTx unsupported for Tally Ho wallet!"); + return null; +} + +export async function ethSendTx(msg: core.ETHSignTx, ethereum: any, from: string): Promise { + try { + const utxBase = { + from: from, + to: msg.to, + value: msg.value, + data: msg.data, + chainId: msg.chainId, + nonce: msg.nonce, + // Tally Ho, like other Web3 libraries, derives its transaction schema from Ethereum's official JSON-RPC API specification + // (https://github.com/ethereum/execution-apis/blob/d63d2a02bcd2a8cef54ae2fc5bbff8b4fac944eb/src/schemas/transaction.json). + // That schema defines the use of the label `gas` to set the transaction's gas limit and not `gasLimit` as used in other + // libraries and as stated in the official yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf). + gas: msg.gasLimit, + }; + + const utx = msg.maxFeePerGas + ? { + ...utxBase, + maxFeePerGas: msg.maxFeePerGas, + maxPriorityFeePerGas: msg.maxPriorityFeePerGas, + } + : { ...utxBase, gasPrice: msg.gasPrice }; + + const signedTx = await ethereum.request({ + method: "eth_sendTransaction", + params: [utx], + }); + + return { + hash: signedTx, + } as core.ETHTxHash; + } catch (error) { + console.error(error); + return null; + } +} + +export async function ethSignMessage( + msg: core.ETHSignMessage, + ethereum: any, + address: string +): Promise { + try { + const signedMsg = await ethereum.request({ + method: "personal_sign", + params: [Buffer.from(msg.message).toString("hex"), address], + }); + + return { + address: address, + signature: signedMsg, + } as core.ETHSignedMessage; + } catch (error) { + console.error(error); + return null; + } +} + +export async function ethGetAddress(ethereum: any): Promise { + if (!(ethereum && ethereum.request)) { + return null; + } + try { + const ethAccounts = await ethereum.request({ + method: "eth_accounts", + }); + return ethAccounts[0]; + } catch (error) { + console.error(error); + return null; + } +} diff --git a/packages/hdwallet-tallyho/src/index.test.ts b/packages/hdwallet-tallyho/src/index.test.ts new file mode 100644 index 000000000..7913ea523 --- /dev/null +++ b/packages/hdwallet-tallyho/src/index.test.ts @@ -0,0 +1,11 @@ +import * as library from "./"; + +describe("Exports all expected classes", () => { + it("should export TallyHoAdapter", () => { + expect(library.TallyHoAdapter.name).toBe("TallyHoAdapter"); + }); + + it("should export XDeFiHDWallet", () => { + expect(library.TallyHoHDWallet.name).toBe("TallyHoHDWallet"); + }); +}); diff --git a/packages/hdwallet-tallyho/src/index.ts b/packages/hdwallet-tallyho/src/index.ts new file mode 100644 index 000000000..db2aa34ab --- /dev/null +++ b/packages/hdwallet-tallyho/src/index.ts @@ -0,0 +1,2 @@ +export * from "./adapter"; +export * from "./tallyho"; diff --git a/packages/hdwallet-tallyho/src/tallyho.test.ts b/packages/hdwallet-tallyho/src/tallyho.test.ts new file mode 100644 index 000000000..e14e34989 --- /dev/null +++ b/packages/hdwallet-tallyho/src/tallyho.test.ts @@ -0,0 +1,203 @@ +import * as core from "@shapeshiftoss/hdwallet-core"; + +import { TallyHoHDWallet, TallyHoHDWalletInfo } from "."; + +describe("HDWalletInfo", () => { + const info = new TallyHoHDWalletInfo(); + + it("should have correct metadata", async () => { + expect(info.getVendor()).toBe("Tally Ho"); + expect(info.hasOnDevicePinEntry()).toBe(false); + expect(info.hasOnDevicePassphrase()).toBe(true); + expect(info.hasOnDeviceDisplay()).toBe(true); + expect(info.hasOnDeviceRecovery()).toBe(true); + expect(await info.ethSupportsNetwork(1)).toBe(true); + expect(await info.ethSupportsSecureTransfer()).toBe(false); + expect(info.ethSupportsNativeShapeShift()).toBe(false); + expect(await info.ethSupportsEIP1559()).toBe(true); + expect(await info.supportsOfflineSigning()).toBe(false); + expect(await info.supportsBroadcast()).toBe(true); + }); + it("should produce correct path descriptions", () => { + expect(info.hasNativeShapeShift()).toBe(false); + [ + { + msg: { coin: "Ethereum", path: [44 + 0x80000000, 60 + 0x80000000, 0 + 0x80000000, 0, 0] }, + out: { coin: "Ethereum", verbose: "Ethereum Account #0", isKnown: true }, + }, + ].forEach((x) => expect(info.describePath(x.msg)).toMatchObject(x.out)); + expect(() => info.describePath({ coin: "foobar", path: [1, 2, 3] })).toThrowError("Unsupported path"); + }); +}); + +describe("TallyHoHDWallet", () => { + let wallet: TallyHoHDWallet; + beforeEach(() => { + wallet = new TallyHoHDWallet(core.untouchable("TallyHoHDWallet:provider")); + wallet.ethAddress = "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"; + }); + + it("should match the metadata", async () => { + expect(wallet.getVendor()).toBe("Tally Ho"); + expect(wallet.hasOnDevicePinEntry()).toBe(false); + expect(wallet.hasOnDevicePassphrase()).toBe(true); + expect(wallet.hasOnDeviceDisplay()).toBe(true); + expect(wallet.hasOnDeviceRecovery()).toBe(true); + expect(await wallet.ethSupportsNetwork(1)).toBe(true); + expect(await wallet.ethSupportsSecureTransfer()).toBe(false); + expect(wallet.ethSupportsNativeShapeShift()).toBe(false); + expect(await wallet.ethSupportsEIP1559()).toBe(true); + expect(await wallet.supportsOfflineSigning()).toBe(false); + expect(await wallet.supportsBroadcast()).toBe(true); + }); + + it("should test ethSignTx", async () => { + wallet.ethAddress = "0x123"; + wallet.provider = { + request: jest.fn().mockReturnValue({ + r: "0x63db3dd3bf3e1fe7dde1969c0fc8850e34116d0b501c0483a0e08c0f77b8ce0a", + s: "0x28297d012cccf389f6332415e96ee3fc0bbf8474d05f646e029cd281a031464b", + v: 38, + serialized: + "0xf86b018501dcd650008256229412ec06288edd7ae2cc41a843fe089237fc7354f0872c68af0bb140008026a063db3dd3bf3e1fe7dde1969c0fc8850e34116d0b501c0483a0e08c0f77b8ce0aa028297d012cccf389f6332415e96ee3fc0bbf8474d05f646e029cd281a031464b", + }), + }; + expect( + await wallet.ethSignTx({ + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + nonce: "0xDEADBEEF", + gasPrice: "0xDEADBEEF", + gasLimit: "0xDEADBEEF", + to: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + value: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + data: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + chainId: 1, + }) + ).toEqual(null); + }); + + it("should test ethSignMessage", async () => { + wallet.provider = { + request: jest.fn().mockReturnValue( + `Object { + "address": "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + "signature": "0x05f51140905ffa33ffdc57f46b0b8d8fbb1d2a99f8cd843ca27893c01c31351c08b76d83dce412731c846e3b50649724415deb522d00950fbf4f2c1459c2b70b1b", + }` + ), + }; + const msg = "super secret message"; + expect( + await wallet.ethSignMessage({ + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + message: msg, + }) + ).toMatchInlineSnapshot(` + Object { + "address": "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + "signature": "Object { + \\"address\\": \\"0x73d0385F4d8E00C5e6504C6030F47BF6212736A8\\", + \\"signature\\": \\"0x05f51140905ffa33ffdc57f46b0b8d8fbb1d2a99f8cd843ca27893c01c31351c08b76d83dce412731c846e3b50649724415deb522d00950fbf4f2c1459c2b70b1b\\", + }", + } + `); + }); + + it("ethSignMessage returns null on error", async () => { + wallet.provider = { + request: jest.fn().mockRejectedValue(new Error("An Error has occurred")), + }; + + const msg = "super secret message"; + const sig = await wallet.ethSignMessage({ + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + message: msg, + }); + + expect(sig).toBe(null); + }); + + it("ethGetAddress returns a valid address", async () => { + wallet.provider = { + request: jest.fn().mockReturnValue(["0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"]), + }; + + const msg = "super secret message"; + const sig = await wallet.ethSignMessage({ + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + message: msg, + }); + + expect(sig).toMatchObject({ + address: "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + signature: ["0x73d0385F4d8E00C5e6504C6030F47BF6212736A8"], + }); + }); + it("ethSendTx returns a valid hash", async () => { + wallet.provider = { + request: jest.fn().mockReturnValue("0x123"), + }; + + const hash = await wallet.ethSendTx({ + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + nonce: "0xDEADBEEF", + gasPrice: "0xDEADBEEF", + gasLimit: "0xDEADBEEF", + to: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + value: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + data: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + chainId: 1, + }); + expect(wallet.provider.request).toHaveBeenCalled(); + expect(hash).toMatchObject({ hash: "0x123" }); + }); + it("ethSendTx returns a valid hash if maxFeePerGas is present in msg", async () => { + wallet.provider = { + request: jest.fn().mockReturnValue("0x123"), + }; + + const hash = await wallet.ethSendTx({ + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + nonce: "0xDEADBEEF", + gasPrice: "0xDEADBEEF", + gasLimit: "0xDEADBEEF", + maxFeePerGas: "0xDEADBEEF", + to: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + value: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + data: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + chainId: 1, + }); + expect(wallet.provider.request).toHaveBeenCalled(); + expect(hash).toMatchObject({ hash: "0x123" }); + }); + it("ethSendTx returns null on error", async () => { + wallet.provider = { + request: jest.fn().mockRejectedValue(new Error("An Error has occurred")), + }; + + const hash = await wallet.ethSendTx({ + addressNList: core.bip32ToAddressNList("m/44'/60'/0'/0/0"), + nonce: "0xDEADBEEF", + gasPrice: "0xDEADBEEF", + gasLimit: "0xDEADBEEF", + to: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + value: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + data: "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + chainId: 1, + }); + expect(wallet.provider.request).toHaveBeenCalled(); + expect(hash).toBe(null); + }); + it("ethVerifyMessage returns null as its not implemented", async () => { + wallet.provider = { + request: jest.fn().mockReturnValue("0x3f2329C9ADFbcCd9A84f52c906E936A42dA18CB8"), + }; + expect( + await wallet.ethVerifyMessage({ + address: "0x3f2329C9ADFbcCd9A84f52c906E936A42dA18CB8", + message: "hello world", + signature: + "0x29f7212ecc1c76cea81174af267b67506f754ea8c73f144afa900a0d85b24b21319621aeb062903e856352f38305710190869c3ce5a1425d65ef4fa558d0fc251b", + }) + ).toEqual(null); + }); +}); diff --git a/packages/hdwallet-tallyho/src/tallyho.ts b/packages/hdwallet-tallyho/src/tallyho.ts new file mode 100644 index 000000000..6b80f6f05 --- /dev/null +++ b/packages/hdwallet-tallyho/src/tallyho.ts @@ -0,0 +1,269 @@ +import * as core from "@shapeshiftoss/hdwallet-core"; +import _ from "lodash"; + +import * as eth from "./ethereum"; + +export function isTallyHo(wallet: core.HDWallet): wallet is TallyHoHDWallet { + return _.isObject(wallet) && (wallet as any)._isTallyHo; +} + +export class TallyHoHDWalletInfo implements core.HDWalletInfo, core.ETHWalletInfo { + readonly _supportsETHInfo = true; + private _ethAddress: string | null = null; + + public getVendor(): string { + return "Tally Ho"; + } + + public hasOnDevicePinEntry(): boolean { + return false; + } + + public hasOnDevicePassphrase(): boolean { + return true; + } + + public hasOnDeviceDisplay(): boolean { + return true; + } + + public hasOnDeviceRecovery(): boolean { + return true; + } + + public hasNativeShapeShift(): boolean { + // It doesn't... yet? + return false; + } + + public supportsOfflineSigning(): boolean { + return false; + } + + public supportsBroadcast(): boolean { + return true; + } + + public describePath(msg: core.DescribePath): core.PathDescription { + switch (msg.coin) { + case "Ethereum": + return eth.describeETHPath(msg.path); + default: + throw new Error("Unsupported path"); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public ethNextAccountPath(_msg: core.ETHAccountPath): core.ETHAccountPath | undefined { + return undefined; + } + + public async ethSupportsNetwork(chainId = 1): Promise { + return chainId === 1; + } + + public async ethSupportsSecureTransfer(): Promise { + return false; + } + + public ethSupportsNativeShapeShift(): boolean { + return false; + } + + public async ethSupportsEIP1559(): Promise { + return true; + } + + public ethGetAccountPaths(msg: core.ETHGetAccountPath): Array { + return eth.ethGetAccountPaths(msg); + } +} + +export class TallyHoHDWallet implements core.HDWallet, core.ETHWallet { + readonly _supportsETH = true; + readonly _supportsETHInfo = true; + readonly _isTallyHo = true; + + info: TallyHoHDWalletInfo & core.HDWalletInfo; + ethAddress?: string | null; + provider: any; + + constructor(provider: unknown) { + this.info = new TallyHoHDWalletInfo(); + this.provider = provider; + } + + async getFeatures(): Promise> { + return {}; + } + + public async isLocked(): Promise { + return !this.provider.tallyHo.isUnlocked(); + } + + public getVendor(): string { + return "Tally Ho"; + } + + public async getModel(): Promise { + return "Tally Ho"; + } + + public async getLabel(): Promise { + return "Tally Ho"; + } + + public async initialize(): Promise { + // nothing to initialize + } + + public hasOnDevicePinEntry(): boolean { + return this.info.hasOnDevicePinEntry(); + } + + public hasOnDevicePassphrase(): boolean { + return this.info.hasOnDevicePassphrase(); + } + + public hasOnDeviceDisplay(): boolean { + return this.info.hasOnDeviceDisplay(); + } + + public hasOnDeviceRecovery(): boolean { + return this.info.hasOnDeviceRecovery(); + } + + public hasNativeShapeShift(srcCoin: core.Coin, dstCoin: core.Coin): boolean { + return this.info.hasNativeShapeShift(srcCoin, dstCoin); + } + + public supportsOfflineSigning(): boolean { + // Keep an eye on the status of the refactor PR here: https://github.com/tallycash/extension/pull/1165/files. This will add offline signing support to Tally Ho, at which point this should return true. + return false; + } + + public supportsBroadcast(): boolean { + return true; + } + + public async clearSession(): Promise { + // TODO: Can we lock Tally Ho from here? + } + + public async ping(msg: core.Ping): Promise { + // no ping function for Tally Ho, so just returning Core.Pong + return { msg: msg.msg }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async sendPin(pin: string): Promise { + // no concept of pin in Tally Ho + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async sendPassphrase(passphrase: string): Promise { + // cannot send passphrase to Tally Ho. Could show the widget? + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async sendCharacter(charater: string): Promise { + // no concept of sendCharacter in Tally Ho + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async sendWord(word: string): Promise { + // no concept of sendWord in Tally Ho + } + + public async cancel(): Promise { + // no concept of cancel in Tally Ho + } + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async wipe(): Promise {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars + public async reset(msg: core.ResetDevice): Promise {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async recover(msg: core.RecoverDevice): Promise { + // no concept of recover in Tally Ho + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async loadDevice(msg: core.LoadDevice): Promise { + // TODO: Does Tally Ho allow this to be done programatically? + } + + public describePath(msg: core.DescribePath): core.PathDescription { + return this.info.describePath(msg); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async getPublicKeys(msg: Array): Promise> { + // Ethereum public keys are not exposed by the RPC API + return []; + } + + public async isInitialized(): Promise { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async disconnect(): Promise {} + + public async ethSupportsNetwork(chainId = 1): Promise { + return chainId === 1; + } + + public async ethSupportsSecureTransfer(): Promise { + return false; + } + + public ethSupportsNativeShapeShift(): boolean { + return false; + } + + public async ethSupportsEIP1559(): Promise { + return true; + } + + public ethGetAccountPaths(msg: core.ETHGetAccountPath): Array { + return eth.ethGetAccountPaths(msg); + } + + public ethNextAccountPath(msg: core.ETHAccountPath): core.ETHAccountPath | undefined { + return this.info.ethNextAccountPath(msg); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async ethGetAddress(msg: core.ETHGetAddress): Promise { + this.ethAddress ??= await eth.ethGetAddress(this.provider); + return this.ethAddress; + } + + public async ethSignTx(msg: core.ETHSignTx): Promise { + const address = await this.ethGetAddress(this.provider); + return address ? eth.ethSignTx(msg, this.provider, address) : null; + } + + public async ethSendTx(msg: core.ETHSignTx): Promise { + const address = await this.ethGetAddress(this.provider); + return address ? eth.ethSendTx(msg, this.provider, address) : null; + } + + public async ethSignMessage(msg: core.ETHSignMessage): Promise { + const address = await this.ethGetAddress(this.provider); + return address ? eth.ethSignMessage(msg, this.provider, address) : null; + } + + public async ethVerifyMessage(msg: core.ETHVerifyMessage): Promise { + return eth.ethVerifyMessage(msg, this.provider); + } + + public async getDeviceID(): Promise { + return "tallyho:" + (await this.ethGetAddress(this.provider)); + } + + public async getFirmwareVersion(): Promise { + return "tallyho"; + } +} diff --git a/packages/hdwallet-tallyho/tsconfig.json b/packages/hdwallet-tallyho/tsconfig.json new file mode 100644 index 000000000..b9fea9e4f --- /dev/null +++ b/packages/hdwallet-tallyho/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../hdwallet-core" }] +} diff --git a/tsconfig.json b/tsconfig.json index 09798d13b..af1c9a3e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -58,6 +58,7 @@ { "path": "./packages/hdwallet-native" }, { "path": "./packages/hdwallet-native-vault" }, { "path": "./packages/hdwallet-portis" }, + { "path": "./packages/hdwallet-tallyho" }, { "path": "./packages/hdwallet-trezor" }, { "path": "./packages/hdwallet-trezor-connect" }, { "path": "./packages/hdwallet-xdefi" } diff --git a/yarn.lock b/yarn.lock index fa28901df..220304949 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3887,6 +3887,16 @@ web-encoding "^1.1.0" wif "^2.0.6" +"@shapeshiftoss/hdwallet-core@1.19.0": + version "1.19.0" + resolved "https://registry.yarnpkg.com/@shapeshiftoss/hdwallet-core/-/hdwallet-core-1.19.0.tgz#7eeae9be9f90fff53887611c6ac02c334781baa3" + integrity sha512-Dht99dolYQLDuunKsyQXuNcZH20MrgNrIcb4qG2XLrwuwMUsUOXguQ0MBxi6fFl81hz3G8481v83gOYA5L5tGw== + dependencies: + eventemitter2 "^5.0.1" + lodash "^4.17.21" + rxjs "^6.4.0" + type-assertions "^1.1.0" + "@shapeshiftoss/hdwallet-core@latest": version "1.18.4" resolved "https://registry.yarnpkg.com/@shapeshiftoss/hdwallet-core/-/hdwallet-core-1.18.4.tgz#7272baa4b43de0fbb5e651d47cdeb9554f4ddd33" @@ -10676,10 +10686,10 @@ jest@^26.6.3: import-local "^3.0.2" jest-cli "^26.6.3" -jose@^4.3.5: - version "4.3.5" - resolved "https://registry.yarnpkg.com/jose/-/jose-4.3.5.tgz#890ec0b3bf26db0b36946ca54087335200deb6f7" - integrity sha512-mdTu3En79OYMGBNHw4828hl6ZUOb+gQtNZVRgM+eVL3Rrs4ZYUv/yHPpfDh65GN2HhKBvJsvA0/6tKEcpkyzeg== +jose@^4.3.2: + version "4.7.0" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.7.0.tgz#f0a88709285e1a6c1ba7df0d19e33f64aca03cad" + integrity sha512-DJNm2vlcVW+0Fhl5LVxdhXK5Nw5VYZvL79uiq3ZCRm6vqzTuEVT9T5lIRfzmkGbaaiV+edefGog25TWVyJ4mRQ== jquery@^3.4.1: version "3.6.0" @@ -15189,6 +15199,13 @@ symbol-tree@^3.2.2, symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tallyho-onboarding@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tallyho-onboarding/-/tallyho-onboarding-1.0.2.tgz#afc7dc4eb05b3a7861ead215e798585e1cbe2e91" + integrity sha512-bdFT/fNrFrq1BYVgjl/JKtwDmeS+z2u0415PoxmGmmYYRfdcKqXtEPImMoEbVwGtOeN0iFVohuS8ESrrAe+w7w== + dependencies: + bowser "^2.9.0" + tar@^4.0.2, tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" @@ -15721,7 +15738,7 @@ typescript@^3.9.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== -typescript@^4.2.4, typescript@^4.3.2: +typescript@^4.3.2: version "4.3.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==