diff --git a/CHANGELOG.md b/CHANGELOG.md index 1693bd6a..f6fa35cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## `v0.0.47` + +### Features + +- Added `MetamaskInjective` wallet to execute txs via MetaMask extension on Injective network (*does not support wallet connect yet*) + +### Improvements + +- Added `extensionOptions` to `Tx.toSignedProto` + ## `v0.0.46` ### Fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d431a89a..7797f63f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,6 +88,16 @@ pnpm build ### Publishing +To bump the package version prior to publishing, run: + +```sh +# To bump the patch number (most publishes should use this) +pnpm version patch --no-git-tag-version + +# To bump the prerelease number (if and only if a RC version is required) +pnpm version prerelease --no-git-tag-version --preid=rc +``` + To publish the package to NPM, run: ```sh diff --git a/README.md b/README.md index b39fbdfb..17fe51eb 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,18 @@ This directory contains various APIs, data, and types needed for wallet interact ### `cosmes/wallet` -This directory is a [Cosmos Kit](https://cosmoskit.com) alternative to manage various wallets (Keplr, Station, Cosmostation, Leap, etc.) across various different Cosmos SDK based blockchains. See [`examples/solid-vite`](./examples/solid-vite) for a working example. +This directory is a [Cosmos Kit](https://cosmoskit.com) alternative to interact with wallets across all Cosmos SDK based blockchains. See [`examples/solid-vite`](./examples/solid-vite) for a working example. + +**Wallets supported**: + +- [Station](https://docs.terra.money/learn/station/) +- [Keplr](https://www.keplr.app/) +- [Leap](https://www.leapwallet.io/) +- [Cosmostation](https://wallet.cosmostation.io/) +- [MetaMask](https://metamask.io/) (for Injective only) **Features**: -- Supports Station, Keplr, Leap, and Cosmostation wallets - Supports both browser extension (desktop) and WalletConnect (mobile) - Unified interface for connecting, signing, broadcasting, and event handling - Signing of arbitrary messages (for wallets that support it) diff --git a/examples/solid-vite/src/App.tsx b/examples/solid-vite/src/App.tsx index cc18f0d5..931f99e6 100644 --- a/examples/solid-vite/src/App.tsx +++ b/examples/solid-vite/src/App.tsx @@ -7,6 +7,7 @@ import { CosmostationController, KeplrController, LeapController, + MetamaskInjectiveController, StationController, UnsignedTx, WalletController, @@ -34,6 +35,7 @@ const WALLETS: Record = { [WalletName.COSMOSTATION]: "Cosmostation", [WalletName.STATION]: "Terra Station", [WalletName.LEAP]: "Leap", + [WalletName.METAMASK_INJECTIVE]: "MetaMask", }; const TYPES: Record = { [WalletType.EXTENSION]: "Extension", @@ -44,6 +46,7 @@ const CONTROLLERS: Record = { [WalletName.KEPLR]: new KeplrController(WC_PROJECT_ID), [WalletName.LEAP]: new LeapController(WC_PROJECT_ID), [WalletName.COSMOSTATION]: new CosmostationController(WC_PROJECT_ID), + [WalletName.METAMASK_INJECTIVE]: new MetamaskInjectiveController(), }; function getRpc(chain: string): string { @@ -116,7 +119,9 @@ function getDenom(chain: string): string { const App: Component = () => { const [chain, setChain] = createSignal("injective-1"); - const [wallet, setWallet] = createSignal(WalletName.KEPLR); + const [wallet, setWallet] = createSignal( + WalletName.METAMASK_INJECTIVE + ); const [wallets, setWallets] = createStore>( {} ); diff --git a/package.json b/package.json index 7fdc9e13..7b6b477f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cosmes", - "version": "0.0.46", + "version": "0.0.47", "private": false, "packageManager": "pnpm@8.3.0", "sideEffects": false, @@ -45,6 +45,7 @@ "@bufbuild/protoc-gen-es": "^1.2.0", "@bufbuild/protoplugin": "^1.2.0", "@keplr-wallet/types": "^0.11.62", + "@metamask/providers": "^14.0.2", "@noble/hashes": "^1.3.2", "@noble/secp256k1": "^2.0.0", "@scure/base": "^1.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c1ec726..a20d7f1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@keplr-wallet/types': specifier: ^0.11.62 version: 0.11.62 + '@metamask/providers': + specifier: ^14.0.2 + version: 14.0.2 '@noble/hashes': specifier: ^1.3.2 version: 1.3.2 @@ -730,6 +733,38 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@ethereumjs/common@3.2.0: + resolution: {integrity: sha512-pksvzI0VyLgmuEF2FA/JR/4/y6hcPq8OUail3/AvycBaW1d5VSauOZzqGvJ3RTmR4MU35lWE8KseKOsEhrFRBA==} + dependencies: + '@ethereumjs/util': 8.1.0 + crc-32: 1.2.2 + dev: true + + /@ethereumjs/rlp@4.0.1: + resolution: {integrity: sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw==} + engines: {node: '>=14'} + hasBin: true + dev: true + + /@ethereumjs/tx@4.2.0: + resolution: {integrity: sha512-1nc6VO4jtFd172BbSnTnDQVr9IYBFl1y4xPzZdtkrkKIncBCkdbgfdRV+MiTkJYAtTxvV12GRZLqBFT1PNK6Yw==} + engines: {node: '>=14'} + dependencies: + '@ethereumjs/common': 3.2.0 + '@ethereumjs/rlp': 4.0.1 + '@ethereumjs/util': 8.1.0 + ethereum-cryptography: 2.1.2 + dev: true + + /@ethereumjs/util@8.1.0: + resolution: {integrity: sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==} + engines: {node: '>=14'} + dependencies: + '@ethereumjs/rlp': 4.0.1 + ethereum-cryptography: 2.1.2 + micro-ftch: 0.3.1 + dev: true + /@humanwhocodes/config-array@0.11.8: resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==} engines: {node: '>=10.10.0'} @@ -807,6 +842,76 @@ packages: '@lit-labs/ssr-dom-shim': 1.1.1 dev: true + /@metamask/json-rpc-engine@7.3.1: + resolution: {integrity: sha512-OVxccX/IFOjPzCzSFAEceccPIAf7A7IwnvjyWjyHCkLrO+LWV4e7Tpe79JNXiORywNulHxrg+q6QrmrnGEwssQ==} + engines: {node: '>=16.0.0'} + dependencies: + '@metamask/rpc-errors': 6.1.0 + '@metamask/safe-event-emitter': 3.0.0 + '@metamask/utils': 8.2.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@metamask/object-multiplex@2.0.0: + resolution: {integrity: sha512-+ItrieVZie3j2LfYE0QkdW3dsEMfMEp419IGx1zyeLqjRZ14iQUPRO0H6CGgfAAoC0x6k2PfCAGRwJUA9BMrqA==} + engines: {node: ^16.20 || ^18.16 || >=20} + dependencies: + once: 1.4.0 + readable-stream: 3.6.2 + dev: true + + /@metamask/providers@14.0.2: + resolution: {integrity: sha512-6KuCLQVzE/8IA1r8LkTo0FbG4fgm7ryjzleda0lMsz5XMxinNGuXAoh7Y08bX5OHVpDEjkHREPhuLw4dFK9wIQ==} + engines: {node: '>=16.0.0'} + dependencies: + '@metamask/json-rpc-engine': 7.3.1 + '@metamask/object-multiplex': 2.0.0 + '@metamask/rpc-errors': 6.1.0 + '@metamask/safe-event-emitter': 3.0.0 + '@metamask/utils': 8.2.1 + detect-browser: 5.3.0 + extension-port-stream: 3.0.0 + fast-deep-equal: 3.1.3 + is-stream: 2.0.1 + json-rpc-middleware-stream: 5.0.1 + readable-stream: 3.6.2 + webextension-polyfill: 0.10.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@metamask/rpc-errors@6.1.0: + resolution: {integrity: sha512-JQElKxai26FpDyRKO/yH732wI+BV90i1u6pOuDOpdADSbppB2g1pPh3AGST1zkZqEE9eIKIUw8UdBQ4rp3VTSg==} + engines: {node: '>=16.0.0'} + dependencies: + '@metamask/utils': 8.2.1 + fast-safe-stringify: 2.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@metamask/safe-event-emitter@3.0.0: + resolution: {integrity: sha512-j6Z47VOmVyGMlnKXZmL0fyvWfEYtKWCA9yGZkU3FCsGZUT5lHGmvaV9JA5F2Y+010y7+ROtR3WMXIkvl/nVzqQ==} + engines: {node: '>=12.0.0'} + dev: true + + /@metamask/utils@8.2.1: + resolution: {integrity: sha512-dlnpow8r0YHDDL1xKCEwUoTGOAo9icdv+gaJG0EbgDnkD/BDqW2eH1XMtm9i7rPaiHWo/aLtcrh9WBhkCq/viw==} + engines: {node: '>=16.0.0'} + dependencies: + '@ethereumjs/tx': 4.2.0 + '@noble/hashes': 1.3.2 + '@scure/base': 1.1.3 + '@types/debug': 4.1.12 + debug: 4.3.4 + pony-cause: 2.1.10 + semver: 7.5.4 + superstruct: 1.0.3 + transitivePeerDependencies: + - supports-color + dev: true + /@motionone/animation@10.15.1: resolution: {integrity: sha512-mZcJxLjHor+bhcPuIFErMDNyrdb2vJur8lSfMCsuCB4UyV8ILZLvK+t+pg56erv8ud9xQGK/1OGPt10agPrCyQ==} dependencies: @@ -868,6 +973,12 @@ packages: tslib: 2.5.0 dev: true + /@noble/curves@1.1.0: + resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} + dependencies: + '@noble/hashes': 1.3.1 + dev: true + /@noble/curves@1.2.0: resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} dependencies: @@ -878,6 +989,11 @@ packages: resolution: {integrity: sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==} dev: true + /@noble/hashes@1.3.1: + resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} + engines: {node: '>= 16'} + dev: true + /@noble/hashes@1.3.2: resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} engines: {node: '>= 16'} @@ -962,6 +1078,14 @@ packages: resolution: {integrity: sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==} dev: true + /@scure/bip32@1.3.1: + resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==} + dependencies: + '@noble/curves': 1.1.0 + '@noble/hashes': 1.3.2 + '@scure/base': 1.1.3 + dev: true + /@scure/bip32@1.3.2: resolution: {integrity: sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA==} dependencies: @@ -1152,6 +1276,12 @@ packages: resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==} dev: true + /@types/debug@4.1.12: + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + dependencies: + '@types/ms': 0.7.34 + dev: true + /@types/degit@2.8.3: resolution: {integrity: sha512-CL7y71j2zaDmtPLD5Xq5S1Gv2dFoHl0/GBZm6s39Mj/ls28L3NzAOqf7H4H0/2TNVMgMjMVf9CAFYSjmXhi3bw==} dev: true @@ -1185,6 +1315,10 @@ packages: resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} dev: true + /@types/ms@0.7.34: + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + dev: true + /@types/node@10.12.18: resolution: {integrity: sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ==} dev: true @@ -2275,6 +2409,12 @@ packages: yargs: 17.7.2 dev: true + /crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + dev: true + /create-hash@1.2.0: resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} dependencies: @@ -2642,6 +2782,15 @@ packages: engines: {node: '>=0.10.0'} dev: true + /ethereum-cryptography@2.1.2: + resolution: {integrity: sha512-Z5Ba0T0ImZ8fqXrJbpHcbpAvIswRte2wGNR/KePnu8GbbvgJ47lMxT/ZZPG6i9Jaht4azPDop4HaM00J0J59ug==} + dependencies: + '@noble/curves': 1.1.0 + '@noble/hashes': 1.3.1 + '@scure/bip32': 1.3.1 + '@scure/bip39': 1.2.1 + dev: true + /event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} dependencies: @@ -2660,6 +2809,14 @@ packages: type: 2.7.2 dev: true + /extension-port-stream@3.0.0: + resolution: {integrity: sha512-an2S5quJMiy5bnZKEf6AkfH/7r8CzHvhchU40gxN+OM6HPhe7Z9T1FUychcf2M9PpPOO0Hf7BAEfJkw2TDIBDw==} + engines: {node: '>=12.0.0'} + dependencies: + readable-stream: 3.6.2 + webextension-polyfill: 0.10.0 + dev: true + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -2692,6 +2849,10 @@ packages: engines: {node: '>=6'} dev: true + /fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + dev: true + /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: @@ -2999,6 +3160,11 @@ packages: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} dev: true + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + dev: true + /is-typedarray@1.0.0: resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} dev: true @@ -3054,6 +3220,18 @@ packages: hasBin: true dev: true + /json-rpc-middleware-stream@5.0.1: + resolution: {integrity: sha512-PMrzifccjdilqU0xftUkusJq0J9O73q66YdVduEmu6vkiTh3V1akliYJGWBAbhg+vhFPC8btUSANa5FNo7a6bg==} + engines: {node: ^16.20 || ^18.16 || >=20} + dependencies: + '@metamask/json-rpc-engine': 7.3.1 + '@metamask/safe-event-emitter': 3.0.0 + '@metamask/utils': 8.2.1 + readable-stream: 3.6.2 + transitivePeerDependencies: + - supports-color + dev: true + /json-schema-to-typescript@13.1.1: resolution: {integrity: sha512-F3CYhtA7F3yPbb8vF7sFchk/2dnr1/yTKf8RcvoNpjnh67ZS/ZMH1ElLt5KHAtf2/bymiejLQQszszPWEeTdSw==} engines: {node: '>=12.0.0'} @@ -3237,6 +3415,10 @@ packages: engines: {node: '>= 8'} dev: true + /micro-ftch@0.3.1: + resolution: {integrity: sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==} + dev: true + /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} @@ -3553,6 +3735,11 @@ packages: engines: {node: '>=10.13.0'} dev: true + /pony-cause@2.1.10: + resolution: {integrity: sha512-3IKLNXclQgkU++2fSi93sQ6BznFuxSLB11HdvZQ6JW/spahf/P1pAHBQEahr20rs0htZW0UDkM1HmA+nZkXKsw==} + engines: {node: '>=12.0.0'} + dev: true + /postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} dev: true @@ -3847,6 +4034,14 @@ packages: lru-cache: 6.0.0 dev: true + /semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + /seroval@0.5.1: resolution: {integrity: sha512-ZfhQVB59hmIauJG5Ydynupy8KHyr5imGNtdDhbZG68Ufh1Ynkv9KOYOAABf71oVbQxJ8VkWnMHAjEHE7fWkH5g==} engines: {node: '>=10'} @@ -4004,6 +4199,11 @@ packages: acorn: 8.8.2 dev: true + /superstruct@1.0.3: + resolution: {integrity: sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg==} + engines: {node: '>=14.0.0'} + dev: true + /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -4366,6 +4566,10 @@ packages: - terser dev: true + /webextension-polyfill@0.10.0: + resolution: {integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==} + dev: true + /well-known-symbols@2.0.0: resolution: {integrity: sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==} engines: {node: '>=6'} diff --git a/src/client/index.ts b/src/client/index.ts index 0ac43a8e..860946b5 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -27,6 +27,7 @@ export { simulateTx, type SimulateTxParams } from "./apis/simulateTx"; export { RpcClient } from "./clients/RpcClient"; export { type Adapter } from "./models/Adapter"; export { MsgExecuteContract } from "./models/MsgExecuteContract"; +export { MsgExecuteContractInjective } from "./models/MsgExecuteContractInjective"; export { MsgIbcTransfer } from "./models/MsgIbcTransfer"; export { MsgOsmosisSinglePoolSwap } from "./models/MsgOsmosisSinglePoolSwap"; export { MsgSend } from "./models/MsgSend"; diff --git a/src/client/models/MsgExecuteContractInjective.ts b/src/client/models/MsgExecuteContractInjective.ts new file mode 100644 index 00000000..db72e946 --- /dev/null +++ b/src/client/models/MsgExecuteContractInjective.ts @@ -0,0 +1,37 @@ +import { PlainMessage } from "@bufbuild/protobuf"; +import { InjectiveWasmxV1MsgExecuteContractCompat as ProtoMsgExecuteContractCompat } from "cosmes/protobufs"; + +import { Adapter } from "./Adapter"; +import type { MsgExecuteContract } from "./MsgExecuteContract"; + +// Take in the same type as `MsgExecuteContract` to simplify consumer code +type Data = ConstructorParameters>[0]; + +/** + * **NOTE**: this message is only used on Injective when broadcasting txs via + * MetaMask or EVM wallets. Otherwise, use `MsgExecuteContract` instead! + */ +export class MsgExecuteContractInjective implements Adapter { + private readonly data: PlainMessage; + + constructor(data: Data) { + this.data = { + ...data, + msg: JSON.stringify(data.msg), + funds: data.funds + .map(({ amount, denom }) => `${amount}${denom}`) + .join(","), + }; + } + + public toProto() { + return new ProtoMsgExecuteContractCompat(this.data); + } + + public toAmino() { + return { + type: "wasmx/MsgExecuteContractCompat", + value: this.data, + }; + } +} diff --git a/src/client/models/Tx.ts b/src/client/models/Tx.ts index b5c07388..4637a143 100644 --- a/src/client/models/Tx.ts +++ b/src/client/models/Tx.ts @@ -1,4 +1,4 @@ -import { PlainMessage } from "@bufbuild/protobuf"; +import { Message, PlainMessage } from "@bufbuild/protobuf"; import { base64 } from "@scure/base"; import { CosmosTxV1beta1AuthInfo as ProtoAuthInfo, @@ -28,6 +28,7 @@ export type ToSignedProtoParams = { signature: Uint8Array; memo?: string | undefined; timeoutHeight?: bigint | undefined; + extensionOptions?: Message[] | undefined; }; export type ToUnsignedProtoParams = Pick< @@ -63,6 +64,7 @@ export class Tx { signature, memo, timeoutHeight, + extensionOptions, }: ToSignedProtoParams): ProtoTxRaw { return new ProtoTxRaw({ authInfoBytes: new ProtoAuthInfo({ @@ -73,6 +75,7 @@ export class Tx { messages: this.data.msgs.map((m) => toAny(m.toProto())), memo: memo, timeoutHeight: timeoutHeight, + extensionOptions: extensionOptions?.map(toAny), }).toBinary(), signatures: [signature], }); diff --git a/src/codec/address.test.ts b/src/codec/address.test.ts index 9a8dbcd2..d43da078 100644 --- a/src/codec/address.test.ts +++ b/src/codec/address.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; -import { resolveBech32Address } from "./address"; +import { resolveBech32Address, translateEthToBech32Address } from "./address"; -const PUB_KEY_1 = "A6Y9fcWSn5Av/HLHBwthTaVE/vdyRKvsTzi5U7j9bFj5"; // random pub pub key +const PUB_KEY_1 = "A6Y9fcWSn5Av/HLHBwthTaVE/vdyRKvsTzi5U7j9bFj5"; // random pub key const PUB_KEY_2 = "Ag/a1BOl3cdwh67Z8iCbGmAu4WWmBwtuQlQMbDaN385V"; // coinhall.org val pubkey +const ETH_ADDRESS_1 = "0xd6E80d86483C0cF463E03cC95246bDc0FeF6cfbD"; // random eth address + describe("resolveBech32Address", () => { it("should resolve stars address correctly", () => { const translated = resolveBech32Address(PUB_KEY_1, "stars"); @@ -28,3 +30,10 @@ describe("resolveBech32Address", () => { ); }); }); + +describe("translateEthToBech32Address", () => { + it("should translate eth address correctly", () => { + const translated = translateEthToBech32Address(ETH_ADDRESS_1, "inj"); + expect(translated).toBe("inj16m5qmpjg8sx0gclq8ny4y34acrl0dnaantdev0"); + }); +}); diff --git a/src/codec/address.ts b/src/codec/address.ts index 279ac76d..245a388b 100644 --- a/src/codec/address.ts +++ b/src/codec/address.ts @@ -2,6 +2,8 @@ import { ripemd160 } from "@noble/hashes/ripemd160"; import { sha256 } from "@noble/hashes/sha256"; import { base64, bech32 } from "@scure/base"; +import { ethhex } from "./ethhex"; + /** * Resolves the bech32 address from the given `publicKey` and `prefix`. * @param publicKey Must be either a base64 encoded string or a `Uint8Array`. @@ -14,3 +16,15 @@ export function resolveBech32Address( typeof publicKey === "string" ? base64.decode(publicKey) : publicKey; return bech32.encode(prefix, bech32.toWords(ripemd160(sha256(bytes)))); } + +/** + * Translates the given ethereum address to a bech32 address. + * @param ethAddress Must be a valid ethereum address (eg. `0x123...DeF`). + */ +export function translateEthToBech32Address( + ethAddress: string, + prefix: string +) { + const bytes = ethhex.decode(ethAddress); + return bech32.encode(prefix, bech32.toWords(bytes)); +} diff --git a/src/codec/ethhex.ts b/src/codec/ethhex.ts new file mode 100644 index 00000000..d3a0abb2 --- /dev/null +++ b/src/codec/ethhex.ts @@ -0,0 +1,13 @@ +import { BytesCoder, hex } from "@scure/base"; + +/** + * Convenience wrapper around `hex` that deals with hex strings typically + * seen in Ethereum, where strings start with `0x` and are lower case. + * + * - For `encode`, the resulting string will be lower case + * - For `decode`, the `str` arg can either be lower or upper case + */ +export const ethhex = { + encode: (bytes) => "0x" + hex.encode(bytes), + decode: (str) => hex.decode(str.replace(/^0x/, "").toLowerCase()), +} satisfies BytesCoder; diff --git a/src/codec/index.ts b/src/codec/index.ts index 3f224ccd..a04a4f5f 100644 --- a/src/codec/index.ts +++ b/src/codec/index.ts @@ -1,7 +1,13 @@ // Re-export @scure/base for their codecs export * from "@scure/base"; -export { resolveBech32Address } from "./address"; +export { resolveBech32Address, translateEthToBech32Address } from "./address"; +export { ethhex } from "./ethhex"; export { resolveKeyPair } from "./key"; export { serialiseSignDoc } from "./serialise"; -export { signAmino, signDirect } from "./sign"; +export { + hashEthArbitraryMessage, + recoverPubKeyFromEthSignature, + signAmino, + signDirect, +} from "./sign"; diff --git a/src/codec/sign.test.ts b/src/codec/sign.test.ts new file mode 100644 index 00000000..8a15f4cd --- /dev/null +++ b/src/codec/sign.test.ts @@ -0,0 +1,30 @@ +import { base16, utf8 } from "@scure/base"; +import { describe, expect, it } from "vitest"; + +import { ethhex } from "./ethhex"; +import { hashEthArbitraryMessage, recoverPubKeyFromEthSignature } from "./sign"; + +describe("hashEthArbitraryMessage", () => { + it("should hash correctly", () => { + const msg = utf8.decode("Hello World!"); + const expected = hashEthArbitraryMessage(msg); + const actual = ethhex.decode( + "0xec3608877ecbf8084c29896b7eab2a368b2b3c8d003288584d145613dfa4706c" + ); + expect(actual).toStrictEqual(expected); + }); +}); + +describe("recoverPubKeyFromEthSignature", () => { + it("should recover public key correctly from a personal_sign signature", () => { + const message = utf8.decode("Hello World"); + const signature = ethhex.decode( + "0x63da4222cbcc36f43b22cbe417aa78963c29d088f7db3c9c6d06417dc34cf2df2dc6ffe9a5c9072a12a16a71c93bebf42bf388357aff81190d7dce166e4fa7ad1c" + ); + const expected = base16.decode( + "03f73842e6959e5b79f7979f81016e1e4f4d9481a7351a492ddb0807d98bb31f19".toUpperCase() + ); + const actual = recoverPubKeyFromEthSignature(message, signature); + expect(expected).toStrictEqual(actual); + }); +}); diff --git a/src/codec/sign.ts b/src/codec/sign.ts index 810f5db4..d4eafbf9 100644 --- a/src/codec/sign.ts +++ b/src/codec/sign.ts @@ -1,6 +1,8 @@ import { hmac } from "@noble/hashes/hmac"; import { sha256 } from "@noble/hashes/sha256"; +import { keccak_256 } from "@noble/hashes/sha3"; import * as secp256k1 from "@noble/secp256k1"; +import { utf8 } from "@scure/base"; import { CosmosTxV1beta1SignDoc as SignDoc } from "cosmes/protobufs"; import { StdSignDoc } from "cosmes/registry"; @@ -36,3 +38,34 @@ export function signDirect( ): Uint8Array { return sign(signDoc.toBinary(), privateKey); } + +/** + * Hashes and returns the digest of the given EIP191 `message` bytes. + */ +export function hashEthArbitraryMessage(message: Uint8Array): Uint8Array { + return keccak_256( + Uint8Array.from([ + ...utf8.decode("\x19Ethereum Signed Message:\n"), + ...utf8.decode(message.length.toString()), + ...message, + ]) + ); +} + +/** + * Recovers and returns the secp256k1 public key of the signer given the arbitrary + * `message` and `signature` that was signed using EIP191. + */ +export function recoverPubKeyFromEthSignature( + message: Uint8Array, + signature: Uint8Array +): Uint8Array { + if (signature.length !== 65) { + throw new Error("Invalid signature"); + } + const digest = hashEthArbitraryMessage(message); + const secpSignature = secp256k1.Signature.fromCompact( + Uint8Array.from([...signature.slice(0, 32), ...signature.slice(32, 64)]) + ).addRecoveryBit(1); + return secpSignature.recoverPublicKey(digest).toRawBytes(true); +} diff --git a/src/wallet/constants/WalletName.ts b/src/wallet/constants/WalletName.ts index 395eaa31..7f270538 100644 --- a/src/wallet/constants/WalletName.ts +++ b/src/wallet/constants/WalletName.ts @@ -6,5 +6,6 @@ export const WalletName = { KEPLR: "keplr", LEAP: "leap", COSMOSTATION: "cosmostation", + METAMASK_INJECTIVE: "metamask-injective", } as const; export type WalletName = (typeof WalletName)[keyof typeof WalletName]; diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 076cd234..6a8e9e2c 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -15,5 +15,6 @@ export { export { CosmostationController } from "./wallets/cosmostation/CosmostationController"; export { KeplrController } from "./wallets/keplr/KeplrController"; export { LeapController } from "./wallets/leap/LeapController"; +export { MetamaskInjectiveController } from "./wallets/metamask-injective/MetamaskInjectiveController"; export { MnemonicWallet } from "./wallets/mnemonic/MnemonicWallet"; export { StationController } from "./wallets/station/StationController"; diff --git a/src/wallet/wallets/metamask-injective/MetamaskInjectiveController.ts b/src/wallet/wallets/metamask-injective/MetamaskInjectiveController.ts new file mode 100644 index 00000000..e06da7b3 --- /dev/null +++ b/src/wallet/wallets/metamask-injective/MetamaskInjectiveController.ts @@ -0,0 +1,122 @@ +import { Secp256k1PubKey, getAccount, toBaseAccount } from "cosmes/client"; +import { + ethhex, + recoverPubKeyFromEthSignature, + translateEthToBech32Address, + utf8, +} from "cosmes/codec"; +import { CosmosCryptoSecp256k1PubKey } from "cosmes/protobufs"; + +import { WalletName } from "../../constants/WalletName"; +import { WalletType } from "../../constants/WalletType"; +import { WalletConnectV1 } from "../../walletconnect/WalletConnectV1"; +import { WalletConnectV2 } from "../../walletconnect/WalletConnectV2"; +import { ConnectedWallet } from "../ConnectedWallet"; +import { ChainInfo, WalletController } from "../WalletController"; +import { MetamaskInjectiveExtension } from "./MetamaskInjectiveExtension"; +import { Ethereum } from "./types"; + +export class MetamaskInjectiveController extends WalletController { + constructor() { + super(WalletName.METAMASK_INJECTIVE); + this.registerAccountChangeHandlers(); + } + + public async isInstalled(type: WalletType) { + return type === WalletType.EXTENSION ? "ethereum" in window : false; + } + + protected async connectWalletConnect( + _chains: ChainInfo[] + ): Promise<{ + wallets: Map; + wc: WalletConnectV1 | WalletConnectV2; + }> { + // TODO: check if walletconnect can be supported + throw new Error("WalletConnect not supported"); + } + + protected async connectExtension(chains: ChainInfo[]) { + if (chains.length !== 1) { + throw new Error( + "Exactly one chain information for Injective is required" + ); + } + + const ext = window.ethereum; + if (!ext) { + throw new Error("MetaMask extension is not installed"); + } + + const ethAddresses = await ext.request({ + method: "eth_requestAccounts", + }); + const ethAddress = ethAddresses?.[0]; + if (!ethAddress) { + throw new Error("Failed to connect to MetaMask"); + } + const injAddress = translateEthToBech32Address(ethAddress, "inj"); + + const [chain] = chains; + const pubKey = await this.getPubKey(ext, chain.rpc, ethAddress, injAddress); + const wallets = new Map(); + wallets.set( + chain.chainId, + new MetamaskInjectiveExtension( + this.id, + ext, + chain.chainId, + pubKey, + ethAddress, + injAddress, + chain.rpc, + chain.gasPrice + ) + ); + return wallets; + } + + protected registerAccountChangeHandlers() { + if (typeof window !== "undefined" && window.ethereum) { + window.ethereum.on("accountsChanged", () => + this.changeAccount(WalletType.EXTENSION) + ); + } + } + + private async getPubKey( + ext: Ethereum, + rpc: string, + ethAddress: string, + injAddress: string + ): Promise { + // Try to get public key from RPC, but ignore any errors that occur + const account = await getAccount(rpc, { address: injAddress }).catch( + console.warn + ); + if (account) { + const { pubKey } = toBaseAccount(account); + if (pubKey) { + return new Secp256k1PubKey({ + key: CosmosCryptoSecp256k1PubKey.fromBinary(pubKey.value).key, + }); + } + } + + // Fallback to recovering pub key from a `personal_sign` signature + // TODO: This may not be desirable behaviour as querying RPC will always + // TODO: fail if the user's account has not been initialised, thereby making + // TODO: the user sign this message every time they reconnect to the wallet + const message = utf8.decode("Sign to allow retrieval of your public key"); + const signature = await ext.request({ + method: "personal_sign", + params: [ethhex.encode(message), ethAddress], + }); + if (!signature) { + throw new Error("Failed to retrieve pubic key"); + } + return new Secp256k1PubKey({ + key: recoverPubKeyFromEthSignature(message, ethhex.decode(signature)), + }); + } +} diff --git a/src/wallet/wallets/metamask-injective/MetamaskInjectiveExtension.ts b/src/wallet/wallets/metamask-injective/MetamaskInjectiveExtension.ts new file mode 100644 index 00000000..c2790777 --- /dev/null +++ b/src/wallet/wallets/metamask-injective/MetamaskInjectiveExtension.ts @@ -0,0 +1,187 @@ +import { PlainMessage } from "@bufbuild/protobuf"; +import { RpcClient, Secp256k1PubKey, Tx } from "cosmes/client"; +import { base16, base64, ethhex, utf8 } from "cosmes/codec"; +import { + CosmosBaseV1beta1Coin as Coin, + CosmosTxV1beta1Fee as Fee, + CosmosTxSigningV1beta1SignMode as SignMode, + InjectiveTypesV1beta1ExtensionOptionsWeb3Tx as Web3Tx, +} from "cosmes/protobufs"; +import type { StdSignDoc } from "cosmes/registry"; + +import { WalletName } from "../../constants/WalletName"; +import { WalletType } from "../../constants/WalletType"; +import { + ConnectedWallet, + SignArbitraryResponse, + UnsignedTx, +} from "../ConnectedWallet"; +import { Ethereum } from "./types"; + +export class MetamaskInjectiveExtension extends ConnectedWallet { + private readonly ext: Ethereum; + private readonly ethAddress: string; + + constructor( + walletName: WalletName, + ext: Ethereum, + chainId: string, + pubKey: Secp256k1PubKey, + ethAddress: string, + bech32Address: string, + rpc: string, + gasPrice: PlainMessage + ) { + super( + walletName, + WalletType.EXTENSION, + chainId, + pubKey, + bech32Address, + rpc, + gasPrice + ); + this.ext = ext; + this.ethAddress = ethAddress; + } + + public async signArbitrary(data: string): Promise { + const signature = await this.ext.request({ + method: "personal_sign", + params: [base16.encode(utf8.decode(data)), this.ethAddress], + }); + if (!signature) { + throw new Error("Failed to sign arbitrary message"); + } + const signatureBuffer = ethhex.decode(signature); + return { + data, + pubKey: this.pubKey.toAmino().value.key, + signature: base64.encode(signatureBuffer), + }; + } + + protected async signAndBroadcastTx( + { msgs, memo = "", timeoutHeight = 1_000_000_000_000_000n }: UnsignedTx, + fee: Fee, + accountNumber: bigint, + sequence: bigint + ): Promise { + const tx = new Tx({ + chainId: this.chainId, + pubKey: this.pubKey, + msgs: msgs, + }); + const stdSignDoc = tx.toStdSignDoc({ + accountNumber, + sequence, + fee, + memo, + timeoutHeight, + }); + const typedData = this.getTypedData(stdSignDoc); + + const signature = await this.ext.request({ + method: "eth_signTypedData_v4", + params: [this.ethAddress, JSON.stringify(typedData)], + }); + if (!signature) { + throw new Error("Failed to sign transaction"); + } + + const txRaw = tx.toSignedProto({ + fee, + sequence, + signMode: SignMode.LEGACY_AMINO_JSON, + signature: ethhex.decode(signature), + memo, + timeoutHeight, + extensionOptions: [new Web3Tx({ typedDataChainID: 1n })], + }); + return RpcClient.broadcastTx(this.rpc, txRaw); + } + + /** + * Returns the TypedData to be signed by MetaMask's `eth_signTypedData_v4` method. + * + * @see https://github.com/InjectiveLabs/injective-ts/blob/cd1e67f7fd039c93dd4c5134d2d8dbfe5d009d79/packages/sdk-ts/src/core/modules/tx/eip712/eip712.ts#L14 + */ + private getTypedData(stdSignDoc: StdSignDoc) { + // https://github.com/InjectiveLabs/injective-ts/blob/cd1e67f7fd039c93dd4c5134d2d8dbfe5d009d79/packages/sdk-ts/src/core/modules/tx/eip712/utils.ts#L9 + const domain = { + name: "Injective Web3", + version: "1.0.0", + chainId: "0x1", // hardcoded to mainnet + salt: "0", + verifyingContract: "cosmos", + }; + + // https://github.com/InjectiveLabs/injective-ts/blob/cd1e67f7fd039c93dd4c5134d2d8dbfe5d009d79/packages/sdk-ts/src/core/modules/tx/eip712/utils.ts#L21 + const types: Record = { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "string" }, + { name: "salt", type: "string" }, + ], + Tx: [ + { name: "account_number", type: "string" }, + { name: "chain_id", type: "string" }, + { name: "fee", type: "Fee" }, + { name: "memo", type: "string" }, + { name: "msgs", type: "Msg[]" }, + { name: "sequence", type: "string" }, + { name: "timeout_height", type: "string" }, + ], + Fee: [ + { name: "amount", type: "Coin[]" }, + { name: "gas", type: "string" }, + ], + Coin: [ + { name: "denom", type: "string" }, + { name: "amount", type: "string" }, + ], + Msg: [ + { name: "type", type: "string" }, + { name: "value", type: "MsgValue" }, + ], + }; + + // Only adding types for the first message likely means txs with different + // messages cannot be executed, but this is similar to Injective's behaviour. + // See: https://github.com/InjectiveLabs/injective-ts/blob/cd1e67f7fd039c93dd4c5134d2d8dbfe5d009d79/packages/sdk-ts/src/core/modules/tx/eip712/eip712.ts#L27 + const aminoType = stdSignDoc.msgs[0].type; + switch (aminoType) { + case "cosmos-sdk/MsgSend": + types.MsgValue = [ + { name: "from_address", type: "string" }, + { name: "to_address", type: "string" }, + { name: "amount", type: "TypeAmount[]" }, + ]; + types.TypeAmount = [ + { name: "denom", type: "string" }, + { name: "amount", type: "string" }, + ]; + break; + case "wasmx/MsgExecuteContractCompat": + types.MsgValue = [ + { name: "sender", type: "string" }, + { name: "contract", type: "string" }, + { name: "msg", type: "string" }, + { name: "funds", type: "string" }, + ]; + break; + default: + // TODO: support other amino types + throw new Error("Unsupported message type"); + } + + return { + primaryType: "Tx", + domain, + types, + message: stdSignDoc, + }; + } +} diff --git a/src/wallet/wallets/metamask-injective/types.ts b/src/wallet/wallets/metamask-injective/types.ts new file mode 100644 index 00000000..d36158f3 --- /dev/null +++ b/src/wallet/wallets/metamask-injective/types.ts @@ -0,0 +1,7 @@ +import type { MetaMaskInpageProvider } from "@metamask/providers"; + +export type Ethereum = MetaMaskInpageProvider; + +export type Window = { + ethereum: Ethereum; +}; diff --git a/src/wallet/wallets/window.d.ts b/src/wallet/wallets/window.d.ts index 499ce574..57ca4b7a 100644 --- a/src/wallet/wallets/window.d.ts +++ b/src/wallet/wallets/window.d.ts @@ -2,6 +2,7 @@ import { Window as KeplrWindow } from "cosmes/registry"; import { Window as CosmostationWindow } from "./cosmostation/types"; import { Window as LeapWindow } from "./leap/types"; +import { Window as EthereumWindow } from "./metamask-injective/types"; import { Window as StationWindow } from "./station/types"; declare global { @@ -9,5 +10,6 @@ declare global { extends KeplrWindow, CosmostationWindow, StationWindow, - LeapWindow {} + LeapWindow, + EthereumWindow {} }