diff --git a/.vscode/launch.json b/.vscode/launch.json index 93cb3f6..a5ab4fa 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -81,7 +81,7 @@ "starship/__tests__/token.test.ts" ], "console": "integratedTerminal", - "cwd": "${workspaceFolder}/networks/ethermint", + "cwd": "${workspaceFolder}/networks/injective", "internalConsoleOptions": "neverOpen" }, { @@ -97,7 +97,7 @@ "starship/__tests__/gov.test.ts" ], "console": "integratedTerminal", - "cwd": "${workspaceFolder}/networks/ethermint", + "cwd": "${workspaceFolder}/networks/injective", "internalConsoleOptions": "neverOpen" } ] diff --git a/networks/injective/package.json b/networks/injective/package.json index 0f485f9..c3a780d 100644 --- a/networks/injective/package.json +++ b/networks/injective/package.json @@ -38,6 +38,7 @@ "@interchainjs/ethereum": "^0.0.1-beta.8", "@interchainjs/types": "^0.0.1-beta.8", "@interchainjs/utils": "^0.0.1-beta.8", + "interchainjs": "^0.0.1-beta.12", "decimal.js": "^10.4.3" }, "keywords": [ diff --git a/networks/injective/src/accounts/inj-account.ts b/networks/injective/src/accounts/inj-account.ts index 9ff29a8..619d894 100644 --- a/networks/injective/src/accounts/inj-account.ts +++ b/networks/injective/src/accounts/inj-account.ts @@ -7,9 +7,9 @@ import { keccak_256 } from '@noble/hashes/sha3'; */ export class InjAccount extends AccountBase { /** - * Create inj address. + * Create inj address by pubkey. */ - getAddress(): string { + getAddressByPubKey(): string { const uncompressedPubKey = this.auth.getPublicKey(false); const pubkeyHex = uncompressedPubKey.toHex().substring(2); diff --git a/networks/injective/src/signers/amino.ts b/networks/injective/src/signers/amino.ts index 5aed04f..da95c4e 100644 --- a/networks/injective/src/signers/amino.ts +++ b/networks/injective/src/signers/amino.ts @@ -1,7 +1,7 @@ import { AminoSignerBase } from '@interchainjs/cosmos/signers/amino'; -import { BaseCosmosTxBuilder } from '@interchainjs/cosmos/base'; +import { BaseCosmosTxBuilder, CosmosDocSigner } from '@interchainjs/cosmos/base'; import { BaseCosmosTxBuilderContext } from '@interchainjs/cosmos/base/builder-context'; -import { AminoTxBuilder } from '@interchainjs/cosmos/builder/amino-tx-builder'; +import { AminoSigBuilder, AminoTxBuilder } from '@interchainjs/cosmos/builder/amino-tx-builder'; import { AminoConverter, CosmosAminoDoc, @@ -16,6 +16,15 @@ import { InjAccount } from '../accounts/inj-account'; import { defaultSignerOptions } from '../defaults'; import { InjectiveAminoSigner } from '../types'; +/** + * AminoDocSigner is a signer for Amino document. + */ +export class AminoDocSigner extends CosmosDocSigner { + getTxBuilder(): AminoSigBuilder { + return new AminoSigBuilder(new BaseCosmosTxBuilderContext(this)); + } +} + /** * AminoDocSigner is a signer for inj Amino document. */ diff --git a/networks/injective/src/signers/direct.ts b/networks/injective/src/signers/direct.ts index 5a78c73..f70ec8d 100644 --- a/networks/injective/src/signers/direct.ts +++ b/networks/injective/src/signers/direct.ts @@ -1,5 +1,5 @@ import { DirectSignerBase } from '@interchainjs/cosmos/signers/direct'; -import { Encoder, SignerOptions } from '@interchainjs/cosmos/types'; +import { CosmosDirectDoc, Encoder, SignerOptions } from '@interchainjs/cosmos/types'; import { DirectDocAuth } from '@interchainjs/cosmos/types/docAuth'; import { OfflineDirectSigner } from '@interchainjs/cosmos/types/wallet'; import { Auth, HttpEndpoint } from '@interchainjs/types'; @@ -7,6 +7,19 @@ import { Auth, HttpEndpoint } from '@interchainjs/types'; import { InjAccount } from '../accounts/inj-account'; import { defaultSignerOptions } from '../defaults'; import { InjectiveDirectSigner } from '../types'; +import { CosmosDocSigner } from '@interchainjs/cosmos/base'; +import { DirectSigBuilder } from '@interchainjs/cosmos/builder/direct-tx-builder'; +import { BaseCosmosTxBuilderContext } from '@interchainjs/cosmos/base/builder-context'; + +/** + * DirectDocSigner is a signer for Direct document. + */ +export class DirectDocSigner extends CosmosDocSigner { + getTxBuilder(): DirectSigBuilder { + return new DirectSigBuilder(new BaseCosmosTxBuilderContext(this)); + } +} + /** * DirectDocSigner is a signer for inj Direct document. diff --git a/networks/injective/src/signing-client.ts b/networks/injective/src/signing-client.ts new file mode 100644 index 0000000..fe3feae --- /dev/null +++ b/networks/injective/src/signing-client.ts @@ -0,0 +1,61 @@ +import { isOfflineAminoSigner, isOfflineDirectSigner, OfflineSigner } from "@interchainjs/cosmos/types/wallet"; +import { HttpEndpoint } from "@interchainjs/types"; +import { SigningClient } from "interchainjs/signing-client" +import { SignerOptions } from "interchainjs/types/signing-client"; +import { RpcClient } from '@interchainjs/cosmos/query/rpc'; +import { AminoSigner } from "./signers/amino"; +import { DirectSigner } from "./signers/direct"; + +/** + * signingClient for inj + */ +export class InjSigningClient extends SigningClient { + static async connectWithSigner( + endpoint: string | HttpEndpoint, + signer: OfflineSigner, + options: SignerOptions = {} + ): Promise { + const signingClient = new InjSigningClient( + new RpcClient(endpoint, options.prefix), + signer, + options + ); + + await signingClient.connect(); + + return signingClient; + } + + override async connect() { + if (isOfflineAminoSigner(this.offlineSigner)) { + const aminoSigners = await AminoSigner.fromWalletToSigners( + this.offlineSigner, + this.encoders, + this.converters, + this.endpoint, + { + prefix: this.options.prefix, + } + ); + + for (const signer of aminoSigners) { + this.aminoSigners[await signer.getAddress()] = signer; + } + } + + if (isOfflineDirectSigner(this.offlineSigner)) { + const directSigners = await DirectSigner.fromWalletToSigners( + this.offlineSigner, + this.encoders, + this.endpoint, + { + prefix: this.options.prefix, + } + ); + + for (const signer of directSigners) { + this.directSigners[await signer.getAddress()] = signer; + } + } + } +} diff --git a/networks/injective/src/wallets/ethEecp256k1hd.ts b/networks/injective/src/wallets/ethEecp256k1hd.ts new file mode 100644 index 0000000..2017c86 --- /dev/null +++ b/networks/injective/src/wallets/ethEecp256k1hd.ts @@ -0,0 +1,160 @@ +import { EthSecp256k1Auth } from '@interchainjs/auth/ethSecp256k1'; +import { AccountData, AddrDerivation, Auth, SignerConfig } from '@interchainjs/types'; + +import { AminoDocSigner } from '../signers/amino'; +import { defaultSignerOptions } from '../defaults'; +import { DirectDocSigner } from '../signers/direct'; +import { + CosmosAccount, + CosmosAminoDoc, + CosmosDirectDoc, + ICosmosAccount, + ICosmosWallet, +} from '@interchainjs/cosmos/types'; +import { + AminoSignResponse, + DirectSignResponse, + OfflineAminoSigner, + OfflineDirectSigner, + WalletOptions, +} from '@interchainjs/cosmos/types/wallet'; +import { InjAccount } from '../accounts/inj-account'; + +/** + * Cosmos HD Wallet for secp256k1 + */ +export class EthSecp256k1HDWallet +implements ICosmosWallet, OfflineAminoSigner, OfflineDirectSigner +{ + constructor( + public accounts: ICosmosAccount[], + public options: SignerConfig + ) { + this.options = { ...defaultSignerOptions.Cosmos, ...options }; + } + + /** + * Create a new HD wallet from mnemonic + * @param mnemonic + * @param derivations infos for derivate addresses + * @param options wallet options + * @returns HD wallet + */ + static fromMnemonic( + mnemonic: string, + derivations: AddrDerivation[], + options?: WalletOptions + ) { + const hdPaths = derivations.map((derivation) => derivation.hdPath); + + const auths: Auth[] = EthSecp256k1Auth.fromMnemonic(mnemonic, hdPaths, { + bip39Password: options?.bip39Password, + }); + + const accounts = auths.map((auth, i) => { + const derivation = derivations[i]; + return new InjAccount(derivation.prefix, auth); + }); + + return new EthSecp256k1HDWallet(accounts, options?.signerConfig); + } + + /** + * Get account data + * @returns account data + */ + async getAccounts(): Promise { + return this.accounts.map((acct) => { + return acct.toAccountData(); + }); + } + + /** + * Get one of the accounts using the address. + * @param address + * @returns + */ + private getAcctFromBech32Addr(address: string) { + const id = this.accounts.findIndex((acct) => acct.address === address); + if (id === -1) { + throw new Error('No such signerAddress been authed.'); + } + return this.accounts[id]; + } + + /** + * Sign direct doc for signerAddress + */ + async signDirect( + signerAddress: string, + signDoc: CosmosDirectDoc + ): Promise { + const account = this.getAcctFromBech32Addr(signerAddress); + + const docSigner = new DirectDocSigner(account.auth, this.options); + + const resp = await docSigner.signDoc(signDoc); + + return { + signed: resp.signDoc, + signature: { + pub_key: { + type: 'tendermint/PubKeySecp256k1', + value: { + key: account.publicKey.toBase64(), + }, + }, + signature: resp.signature.toBase64(), + }, + }; + } + + /** + * sign amino doc for signerAddress + */ + async signAmino( + signerAddress: string, + signDoc: CosmosAminoDoc + ): Promise { + const account = this.getAcctFromBech32Addr(signerAddress); + + const docSigner = new AminoDocSigner(account.auth, this.options); + + const resp = await docSigner.signDoc(signDoc); + + return { + signed: resp.signDoc, + signature: { + pub_key: { + type: 'tendermint/PubKeySecp256k1', + value: { + key: account.publicKey.toBase64(), + }, + }, + signature: resp.signature.toBase64(), + }, + }; + } + + /** + * Convert this to offline direct signer for hiding the private key. + */ + toOfflineDirectSigner(): OfflineDirectSigner { + return { + getAccounts: async () => this.getAccounts(), + signDirect: async (signerAddress: string, signDoc: CosmosDirectDoc) => + this.signDirect(signerAddress, signDoc), + }; + } + + /** + * Convert this to offline amino signer for hiding the private key. + */ + toOfflineAminoSigner(): OfflineAminoSigner { + return { + getAccounts: async () => this.getAccounts(), + signAmino: async (signerAddress: string, signDoc: CosmosAminoDoc) => + this.signAmino(signerAddress, signDoc), + }; + } +} diff --git a/networks/injective/starship/__tests__/gov.test.ts b/networks/injective/starship/__tests__/gov.test.ts index ec63c63..1803c65 100644 --- a/networks/injective/starship/__tests__/gov.test.ts +++ b/networks/injective/starship/__tests__/gov.test.ts @@ -1,9 +1,13 @@ import './setup.test'; +import { Msgs } from '@interchainjs/cosmos-types/cosmos'; import { Asset } from '@chain-registry/types'; import { EthSecp256k1Auth } from '@interchainjs/auth/ethSecp256k1'; import { AminoSigner } from '@interchainjs/cosmos/signers/amino'; import { DirectSigner } from '@interchainjs/cosmos/signers/direct'; +import { EthSecp256k1HDWallet } from '@interchainjs/injective/wallets/ethEecp256k1hd'; +import { InjSigningClient } from '@interchainjs/injective/signing-client'; +import { assertIsDeliverTxSuccess as assertIsSigningDeliverTxSuccess} from '@cosmjs/stargate'; import { assertIsDeliverTxSuccess, sleep, @@ -29,15 +33,22 @@ import { RpcQuery } from 'interchainjs/query/rpc'; import { useChain } from 'starshipjs'; import { generateMnemonic } from '../src'; +import { OfflineAminoSigner, OfflineDirectSigner } from '@interchainjs/cosmos/types/wallet'; const hdPath = "m/44'/60'/0'/0/0"; describe('Governance tests for injective', () => { let directSigner: DirectSigner, aminoSigner: AminoSigner, + signingClient: InjSigningClient, + directOfflineSigner: OfflineDirectSigner, + aminoOfflineSigner: OfflineAminoSigner, denom: string, + commonPrefix: string, directAddress: string, - aminoAddress: string; + aminoAddress: string, + directOfflineAddress: string, + aminoOfflineAddress: string; let chainInfo, getCoin: () => Promise, getRpcEndpoint: () => Promise, @@ -55,6 +66,8 @@ describe('Governance tests for injective', () => { denom = (await getCoin()).base; injRpcEndpoint = await getRpcEndpoint(); + commonPrefix = chainInfo?.chain?.bech32_prefix; + // Initialize auth const [directAuth] = EthSecp256k1Auth.fromMnemonic(generateMnemonic(), [ hdPath, @@ -66,18 +79,50 @@ describe('Governance tests for injective', () => { directAuth, toEncoders(MsgDelegate, TextProposal, MsgSubmitProposal, MsgVote), injRpcEndpoint, - { prefix: chainInfo.chain.bech32_prefix } + { prefix: commonPrefix } ); aminoSigner = new AminoSigner( aminoAuth, toEncoders(MsgDelegate, TextProposal, MsgSubmitProposal, MsgVote), toConverters(MsgDelegate, TextProposal, MsgSubmitProposal, MsgVote), injRpcEndpoint, - { prefix: chainInfo.chain.bech32_prefix } + { prefix: commonPrefix } ); directAddress = await directSigner.getAddress(); aminoAddress = await aminoSigner.getAddress(); + // Initialize wallet + const directWallet = EthSecp256k1HDWallet.fromMnemonic(generateMnemonic(), [ + { + prefix: commonPrefix, + hdPath: hdPath, + }, + ]); + const aminoWallet = EthSecp256k1HDWallet.fromMnemonic(generateMnemonic(), [ + { + prefix: commonPrefix, + hdPath: hdPath, + }, + ]); + directOfflineSigner = directWallet.toOfflineDirectSigner(); + aminoOfflineSigner = aminoWallet.toOfflineAminoSigner(); + directOfflineAddress = (await directOfflineSigner.getAccounts())[0].address; + aminoOfflineAddress = (await aminoOfflineSigner.getAccounts())[0].address; + + signingClient = await InjSigningClient.connectWithSigner( + await getRpcEndpoint(), + directOfflineSigner, + { + broadcast: { + checkTx: true, + deliverTx: true, + useLegacyBroadcastTxCommit: true, + }, + preferredSignType: 'direct', + registry: Msgs.map((g) => [g.typeUrl, g]) + } + ); + // Create custom cosmos interchain client queryClient = new RpcQuery(injRpcEndpoint); @@ -85,6 +130,8 @@ describe('Governance tests for injective', () => { for (let i = 0; i < 10; i++) { await creditFromFaucet(directAddress); await creditFromFaucet(aminoAddress); + await creditFromFaucet(directOfflineAddress); + await creditFromFaucet(aminoOfflineAddress); } await sleep(5000); @@ -108,6 +155,24 @@ describe('Governance tests for injective', () => { expect(balance!.amount).toEqual('1000000000000000000000'); }, 200000); + it('check direct offline address has tokens', async () => { + const { balance } = await queryClient.balance({ + address: directOfflineAddress, + denom, + }); + + expect(balance!.amount).toEqual('1000000000000000000000'); + }, 200000); + + it('check amino offline address has tokens', async () => { + const { balance } = await queryClient.balance({ + address: aminoOfflineAddress, + denom, + }); + + expect(balance!.amount).toEqual('1000000000000000000000'); + }, 200000); + it('query validator address', async () => { const { validators } = await queryClient.validators({ status: bondStatusToJSON(BondStatus.BOND_STATUS_BONDED), @@ -127,7 +192,7 @@ describe('Governance tests for injective', () => { it('stake tokens to genesis validator', async () => { const { balance } = await queryClient.balance({ - address: directAddress, + address: directOfflineAddress, denom, }); @@ -137,7 +202,7 @@ describe('Governance tests for injective', () => { const msg = { typeUrl: MsgDelegate.typeUrl, value: MsgDelegate.fromPartial({ - delegatorAddress: directAddress, + delegatorAddress: directOfflineAddress, validatorAddress: validatorAddress, amount: { amount: delegationAmount, @@ -156,22 +221,17 @@ describe('Governance tests for injective', () => { gas: '550000', }; - const result = await directSigner.signAndBroadcast( - { - messages: [msg], - fee, - memo: '', - }, - { - deliverTx: true, - } + const result = await signingClient.signAndBroadcast( + directOfflineAddress, + [msg], + fee ); - assertIsDeliverTxSuccess(result); + assertIsSigningDeliverTxSuccess(result); }, 200000); it('check direct address has tokens', async () => { const { balance } = await queryClient.balance({ - address: directAddress, + address: directOfflineAddress, denom, }); diff --git a/packages/types/src/account.ts b/packages/types/src/account.ts index 39ce2cb..147a353 100644 --- a/packages/types/src/account.ts +++ b/packages/types/src/account.ts @@ -1,4 +1,4 @@ -import { Algo, Auth, IAccount } from './auth'; +import { Algo, Auth, IAccount, isDocAuth } from './auth'; /** * AccountBase implements common parts of the IAccount interface. @@ -18,7 +18,15 @@ export abstract class AccountBase implements IAccount { return this.auth.getPublicKey(this.isPublicKeyCompressed); } - abstract getAddress(): string; + getAddress(): string { + if(isDocAuth(this.auth)){ + return this.auth.address; + } else { + return this.getAddressByPubKey(); + } + } + + abstract getAddressByPubKey(): string; toAccountData() { return {