diff --git a/README.md b/README.md index 051a40d6..6f9913f9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A tree-shakeable, framework agnostic, [pure ESM](https://gist.github.com/sindres - [Using with TypeScript](#using-with-typescript) - [Using with Vite](#using-with-vite) - [Using Station wallet](#using-station-wallet) + - [Using Galaxy Station wallet](#using-galaxy-station-wallet) - [Examples](#examples) - [Modules](#modules) - [`cosmes/client`](#cosmesclient) @@ -109,6 +110,26 @@ See [`examples/solid-vite`](./examples/solid-vite) for a working example. > This can be removed once support for WalletConnect v1 is no longer required. +### Using Galaxy Station wallet + +The Galaxy Station wallet currently relies on WalletConnect v1. If you want to import and use `GalaxyStationController`, a polyfill for `Buffer` is required: + +```ts +// First, install the buffer package +npm install buffer + +// Then, create a new file 'polyfill.ts' +import { Buffer } from "buffer"; +(window as any).Buffer = Buffer; + +// Finally, import the above file in your entry file +import "./polyfill"; +``` + +See [`examples/solid-vite`](./examples/solid-vite) for a working example. + +> This can be removed once support for WalletConnect v1 is no longer required. + ## Examples Docs do not exist yet - see the [`examples`](./examples) folder for various working examples: @@ -145,6 +166,7 @@ This directory is a [Cosmos Kit](https://cosmoskit.com) alternative to interact **Wallets supported**: - [Station](https://docs.terra.money/learn/station/) +- [Galaxy Station](https://station.hexxagon.io) - [Keplr](https://www.keplr.app/) - [Leap](https://www.leapwallet.io/) - [Cosmostation](https://wallet.cosmostation.io/) diff --git a/examples/batch-query/package.json b/examples/batch-query/package.json index c7f8f47f..1fb3e894 100644 --- a/examples/batch-query/package.json +++ b/examples/batch-query/package.json @@ -6,7 +6,7 @@ "start": "tsx src/index.ts" }, "dependencies": { - "cosmes": "link:../.." + "cosmes": "file:../.." }, "devDependencies": { "@types/node": "^20.2.0", diff --git a/examples/batch-query/pnpm-lock.yaml b/examples/batch-query/pnpm-lock.yaml index 36a25e66..77d46d60 100644 --- a/examples/batch-query/pnpm-lock.yaml +++ b/examples/batch-query/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: cosmes: - specifier: link:../.. - version: link:../.. + specifier: file:../.. + version: file:../.. devDependencies: '@types/node': diff --git a/examples/mnemonic-wallet/package.json b/examples/mnemonic-wallet/package.json index 972008e3..5ed0f6c7 100644 --- a/examples/mnemonic-wallet/package.json +++ b/examples/mnemonic-wallet/package.json @@ -6,7 +6,7 @@ "start": "tsx src/index.ts" }, "dependencies": { - "cosmes": "link:../.." + "cosmes": "file:../.." }, "devDependencies": { "@types/node": "^20.2.0", diff --git a/examples/solid-vite/package.json b/examples/solid-vite/package.json index ff430ccd..898b5187 100644 --- a/examples/solid-vite/package.json +++ b/examples/solid-vite/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "buffer": "^6.0.3", - "cosmes": "link:../..", + "cosmes": "file:../..", "solid-js": "^1.7.3" }, "devDependencies": { diff --git a/examples/solid-vite/src/App.tsx b/examples/solid-vite/src/App.tsx index 49497d37..9f636693 100644 --- a/examples/solid-vite/src/App.tsx +++ b/examples/solid-vite/src/App.tsx @@ -12,6 +12,7 @@ import { NinjiController, OWalletController, StationController, + GalaxyStationController, UnsignedTx, WalletController, WalletName, @@ -40,6 +41,7 @@ const WALLETS: Record = { [WalletName.KEPLR]: "Keplr", [WalletName.COSMOSTATION]: "Cosmostation", [WalletName.STATION]: "Station", + [WalletName.GALAXYSTATION]: "Galaxy Station", [WalletName.LEAP]: "Leap", [WalletName.COMPASS]: "Compass", [WalletName.METAMASK_INJECTIVE]: "MetaMask", @@ -52,6 +54,7 @@ const TYPES: Record = { }; const CONTROLLERS: Record = { [WalletName.STATION]: new StationController(), + [WalletName.GALAXYSTATION]: new GalaxyStationController(), [WalletName.KEPLR]: new KeplrController(WC_PROJECT_ID), [WalletName.LEAP]: new LeapController(WC_PROJECT_ID), [WalletName.COMPASS]: new CompassController(), diff --git a/examples/verify-signatures/package.json b/examples/verify-signatures/package.json index d2fd693a..8af3b063 100644 --- a/examples/verify-signatures/package.json +++ b/examples/verify-signatures/package.json @@ -6,7 +6,7 @@ "start": "tsx src/index.ts" }, "dependencies": { - "cosmes": "link:../.." + "cosmes": "file:../.." }, "devDependencies": { "@types/node": "^20.2.0", diff --git a/package.json b/package.json index 09fda80d..5808c4ad 100644 --- a/package.json +++ b/package.json @@ -75,5 +75,8 @@ "tsx": "^3.12.7", "typescript": "^5.0.4", "vitest": "^0.31.0" + }, + "dependencies": { + "pnpm": "^8.3.0" } } diff --git a/src/wallet/constants/WalletName.ts b/src/wallet/constants/WalletName.ts index c7ca8649..1c77ad66 100644 --- a/src/wallet/constants/WalletName.ts +++ b/src/wallet/constants/WalletName.ts @@ -3,6 +3,7 @@ */ export const WalletName = { STATION: "station", + GALAXYSTATION: "galaxystation", KEPLR: "keplr", LEAP: "leap", COMPASS: "compass", diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 2c85cc66..6a3d051e 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -23,3 +23,4 @@ export { MnemonicWallet } from "./wallets/mnemonic/MnemonicWallet"; export { NinjiController } from "./wallets/ninji/NinjiController"; export { OWalletController } from "./wallets/owallet/OWalletController"; export { StationController } from "./wallets/station/StationController"; +export { GalaxyStationController } from "./wallets/galaxystation/GalaxyStationController"; diff --git a/src/wallet/wallets/galaxystation/GalaxyStationController.ts b/src/wallet/wallets/galaxystation/GalaxyStationController.ts new file mode 100644 index 00000000..99fcc6af --- /dev/null +++ b/src/wallet/wallets/galaxystation/GalaxyStationController.ts @@ -0,0 +1,152 @@ +import { Secp256k1PubKey, getAccount, toBaseAccount } from "cosmes/client"; +import { CosmosCryptoSecp256k1PubKey } from "cosmes/protobufs"; + +import { WalletName } from "../../constants/WalletName"; +import { WalletType } from "../../constants/WalletType"; +import { onWindowEvent } from "../../utils/window"; +import { WalletConnectV1 } from "../../walletconnect/WalletConnectV1"; +import { ConnectedWallet } from "../ConnectedWallet"; +import { ChainInfo, WalletController } from "../WalletController"; +import { WalletError } from "../WalletError"; +import { GalaxyStationExtension } from "./GalaxyStationExtension"; +import { GalaxyStationWalletConnectV1 } from "./GalaxyStationWalletConnectV1"; + +const COIN_TYPE_330_CHAINS = [ + "columbus-5", + "phoenix-1", + "octagon-1", + "pisco-1", +]; + +export class GalaxyStationController extends WalletController { + private readonly wc: WalletConnectV1; + + constructor() { + super(WalletName.GALAXYSTATION); + this.wc = new WalletConnectV1( + "cosmes.wallet.galaxyStation.wcSession", + { + name: "Galaxy Station", + android: "", + ios: "", + isStation: true, + }, + { + bridge: "https://walletconnect.terra.dev", + signingMethods: [], + } + ); + this.registerAccountChangeHandlers(); + } + + public async isInstalled(type: WalletType) { + return type === WalletType.EXTENSION ? "galaxyStation" in window : true; + } + + protected async connectWalletConnect( + chains: ChainInfo[] + ) { + for (const { chainId } of chains) { + // Galaxy Station mobile's WallectConnect only supports these chains + // TODO: update when Galaxy Station mobile supports more chains + if (COIN_TYPE_330_CHAINS.includes(chainId)) { + continue; + } + throw new Error(`${chainId} not supported`); + } + const wallets = new Map(); + const wc = await WalletError.wrap(this.wc.connect()); + // Galaxy Station mobile only returns 1 address for now + // TODO: update when Galaxy Station mobile supports more chains + const address = wc.accounts[0]; + for (let i = 0; i < chains.length; i++) { + const { chainId, rpc, gasPrice } = chains[i]; + try { + // Since Galaxy Station's WalletConnect doesn't support getting pub keys, we + // need to query the account to get it. However, if the wallet does + // not contain funds, the RPC will throw errors. + const key = await WalletError.wrap( + this.getPubKey(chainId, rpc, address) + ); + wallets.set( + chainId, + new GalaxyStationWalletConnectV1(wc, chainId, key, address, rpc, gasPrice) + ); + } catch (err) { + // We simply log and ignore the error for now + console.warn(err); + } + } + this.wc.cacheSession(wc); + return { wallets, wc: this.wc }; + } + + protected async connectExtension(chains: ChainInfo[]) { + const wallets = new Map(); + const ext = window.galaxyStation?.keplr; + if (!ext) { + throw new Error("Galaxy Station extension is not installed"); + } + // This method never throws on Galaxy Station + await WalletError.wrap(ext.enable(chains.map(({ chainId }) => chainId))); + for (const { chainId, rpc, gasPrice } of Object.values(chains)) { + try { + const { bech32Address, pubKey, isNanoLedger } = await WalletError.wrap( + ext.getKey(chainId) + ); + const key = new Secp256k1PubKey({ + key: pubKey, + chainId, + }); + wallets.set( + chainId, + new GalaxyStationExtension( + this.id, + ext, + chainId, + key, + bech32Address, + rpc, + gasPrice, + isNanoLedger + ) + ); + } catch (err) { + if (err instanceof Error) { + // The `getKey` method throws if the chain is not supported + console.warn(`Failed to get public key for ${chainId}`, err); + continue; + } + throw err; // Rethrow other stuff + } + } + return wallets; + } + + protected registerAccountChangeHandlers() { + onWindowEvent("galaxy_station_wallet_change", () => + this.changeAccount(WalletType.EXTENSION) + ); + onWindowEvent("galaxy_station_network_change", () => + this.changeAccount(WalletType.EXTENSION) + ); + // Galaxy Station's WalletConnect v1 doesn't support account change events + } + + private async getPubKey( + chainId: string, + rpc: string, + address: string + ): Promise { + const account = await getAccount(rpc, { address }); + const { pubKey } = toBaseAccount(account); + if (!pubKey) { + throw new Error("Unable to get pub key"); + } + // TODO: handle other key types (?) + return new Secp256k1PubKey({ + chainId, + key: CosmosCryptoSecp256k1PubKey.fromBinary(pubKey.value).key, + }); + } +} diff --git a/src/wallet/wallets/galaxystation/GalaxyStationExtension.ts b/src/wallet/wallets/galaxystation/GalaxyStationExtension.ts new file mode 100644 index 00000000..c72226f2 --- /dev/null +++ b/src/wallet/wallets/galaxystation/GalaxyStationExtension.ts @@ -0,0 +1,4 @@ +import { KeplrExtension } from "../keplr/KeplrExtension"; + +// Station's API is similar to Keplr. +export const GalaxyStationExtension = KeplrExtension; diff --git a/src/wallet/wallets/galaxystation/GalaxyStationWalletConnectV1.ts b/src/wallet/wallets/galaxystation/GalaxyStationWalletConnectV1.ts new file mode 100644 index 00000000..f688760a --- /dev/null +++ b/src/wallet/wallets/galaxystation/GalaxyStationWalletConnectV1.ts @@ -0,0 +1,99 @@ +import { PlainMessage } from "@bufbuild/protobuf"; +import WalletConnect from "@walletconnect/legacy-client"; +import { Secp256k1PubKey } from "cosmes/client"; +import { base64, utf8 } from "cosmes/codec"; +import { + CosmosBaseV1beta1Coin as Coin, + CosmosTxV1beta1Fee as Fee, +} from "cosmes/protobufs"; + +import { WalletName } from "../../constants/WalletName"; +import { WalletType } from "../../constants/WalletType"; +import { isMobile } from "../../utils/os"; +import { + ConnectedWallet, + SignArbitraryResponse, + UnsignedTx, +} from "../ConnectedWallet"; +import { WalletError } from "../WalletError"; +import { PostResponse, SignBytesResponse } from "./types"; +import { toGalaxyStationTx } from "./utils/toGalaxyStationTx"; + +export class GalaxyStationWalletConnectV1 extends ConnectedWallet { + private readonly wc: WalletConnect; + + constructor( + wc: WalletConnect, + chainId: string, + pubKey: Secp256k1PubKey, + address: string, + rpc: string, + gasPrice: PlainMessage + ) { + super( + WalletName.GALAXYSTATION, + WalletType.WALLETCONNECT, + chainId, + pubKey, + address, + rpc, + gasPrice + ); + this.wc = wc; + } + + public async signArbitrary(data: string): Promise { + const res = await this.sendRequest( + "signBytes", + base64.encode(utf8.decode(data)) + ); + return { + data, + pubKey: res.public_key, + signature: res.signature, + }; + } + + public async signAndBroadcastTx( + { msgs, memo }: UnsignedTx, + fee: Fee + ): Promise { + // Signing a tx without posting it isn't supported + const { txhash } = await this.sendRequest( + "post", + toGalaxyStationTx(this.chainId, fee, msgs, memo) + ); + return txhash; + } + + private async sendRequest(method: string, params: unknown): Promise { + const id = Date.now(); + if (isMobile()) { + const payload = base64.encode( + utf8.decode( + JSON.stringify({ + id, + handshakeTopic: this.wc.handshakeTopic, + method, + params, + }) + ) + ); + window.location.href = `galaxystation://walletconnect_confirm/?action=walletconnect_confirm&payload=${payload}`; + } + try { + return await this.wc.sendCustomRequest({ + id, + method, + params: [params], + }); + } catch (err) { + if (err instanceof Error) { + // Error messages are JSON stringified (eg. '{"code":1,"message":"Denied by user"}') + const { message } = JSON.parse(err.message); + throw new WalletError(message, err); + } + throw new WalletError("unknown error", err); + } + } +} diff --git a/src/wallet/wallets/galaxystation/types.ts b/src/wallet/wallets/galaxystation/types.ts new file mode 100644 index 00000000..2d20ad86 --- /dev/null +++ b/src/wallet/wallets/galaxystation/types.ts @@ -0,0 +1,89 @@ +import { Keplr } from "cosmes/registry"; + +export type Window = { + galaxyStation?: GalaxyStation | undefined; +}; + +/** + * A subset of the Galaxy Station extension API that is injected into the `window` object. + * + */ +export type GalaxyStation = { + keplr?: Keplr | undefined; + connect: () => Promise; + getPublicKey: () => Promise; + signBytes(bytes: string, purgeQueue?: boolean): Promise; + post: (tx: GalaxyStationTx, purgeQueue?: boolean) => Promise; + sign: (tx: GalaxyStationTx, purgeQueue?: boolean) => Promise; +}; + +export type GalaxyStationTx = { + chainID: string; + msgs: string[]; + fee?: string; + memo?: string; +}; + +export type ConnectResponse = { + addresses: Record; + /** + * Maps the coin type to the base64 encoded public key. + * Is `undefined` for legacy versions of the extension. + */ + pubkey?: + | { + "60": string; + "118": string; + "330": string; + } + | undefined; +}; + +export type GetPubKeyResponse = { + addresses: Record; + /** + * Maps the coin type to the base64 encoded public key. + * Is `undefined` for legacy versions of the extension. + */ + pubkey?: + | { + "118": string; + "330": string; + } + | undefined; +}; + +export type SignBytesResponse = { + public_key: string; + signature: string; + recid: number; +}; + +export type PostResponse = { + code?: number | undefined; + raw_log: string; + txhash: string; +}; + +// Unnecessary fields are omitted for brevity +export type SignResponse = { + auth_info: { + fee: { + amount: { + amount: string; + denom: string; + }[]; + gas_limit: string; + granter: string; + payer: string; + }; + signer_infos: { + mode_info: { + single: { + mode: string; + }; + }; + }[]; + }; + signatures: string[]; +}; diff --git a/src/wallet/wallets/galaxystation/utils/toGalaxyStationTx.ts b/src/wallet/wallets/galaxystation/utils/toGalaxyStationTx.ts new file mode 100644 index 00000000..a2d90eed --- /dev/null +++ b/src/wallet/wallets/galaxystation/utils/toGalaxyStationTx.ts @@ -0,0 +1,37 @@ +import { Adapter } from "cosmes/client"; +import { CosmosTxV1beta1Fee as Fee } from "cosmes/protobufs"; + +import { GalaxyStationTx } from "../types"; + +/** + * Translates the given args to a tx that can be sent to either + * the Galaxy Station extension wallet or WalletConnect wallet. + */ +export function toGalaxyStationTx( + chainId: string, + fee: Fee, + msgs: Adapter[], + memo?: string | undefined +): GalaxyStationTx { + return { + chainID: chainId, + fee: toGalaxyStationFee(fee), + msgs: msgs.map(toGalaxyStationMsg), + memo: memo, + }; +} + +function toGalaxyStationFee({ amount, gasLimit }: Fee): string { + return JSON.stringify({ + amount, + gas_limit: gasLimit.toString(), + }); +} + +function toGalaxyStationMsg(msg: Adapter): string { + const { value } = msg.toAmino(); + return JSON.stringify({ + "@type": "/" + msg.toProto().getType().typeName, + ...value, + }); +} diff --git a/src/wallet/wallets/window.d.ts b/src/wallet/wallets/window.d.ts index 783b9e8a..19cbd7cd 100644 --- a/src/wallet/wallets/window.d.ts +++ b/src/wallet/wallets/window.d.ts @@ -7,12 +7,14 @@ import { Window as EthereumWindow } from "./metamask-injective/types"; import { Window as NinjiWindow } from "./ninji/types"; import { Window as OWalletWindow } from "./owallet/types"; import { Window as StationWindow } from "./station/types"; +import { Window as GalaxyStationWindow } from "./galaxystation/types"; declare global { interface Window extends KeplrWindow, CosmostationWindow, StationWindow, + GalaxyStationWindow, LeapWindow, CompassWindow, EthereumWindow,