Skip to content

Commit ebd8635

Browse files
committed
add doc about config and custom cosmos signers
1 parent 4b6953c commit ebd8635

File tree

1 file changed

+365
-0
lines changed

1 file changed

+365
-0
lines changed
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
## Customize InterchainJS Cosmos Signers
2+
3+
This tutorial shows how to customize InterchainJS Cosmos signers to work with external wallet providers and network-specific requirements. We focus on practical, production-ready approaches that extend the existing architecture (signers + offline signers + configuration) instead of re-implementing signers from scratch.
4+
5+
What you’ll learn:
6+
- Create or adapt OfflineSigners (from browser wallets like Keplr/Leap or external providers like MetaMask)
7+
- Customize DirectSigner and AminoSigner with network-specific options (pubkeys, hash/signature format, gas/fees, prefixes)
8+
- Understand the end-to-end flow from messages to signed transactions and broadcast
9+
- Apply helper utilities and message encoders to streamline custom workflows
10+
11+
---
12+
13+
### 1) Quick Takeaway: The Standard Customization Pattern
14+
15+
Use the standard `DirectSigner` or `AminoSigner` from `@interchainjs/cosmos`, fed by an offline signer and a network-specific configuration. This customization pattern is portable across Cosmos-based networks.
16+
17+
Generic example:
18+
19+
```ts
20+
// Wallet -> OfflineSigner -> DirectSigner + config
21+
const offlineSigner = /* from extension or your wrapper */
22+
const signerConfig = {
23+
queryClient,
24+
chainId: 'your-chain-id',
25+
addressPrefix: 'your-prefix',
26+
// plus any network-specific options (see sections below)
27+
};
28+
const directSigner = new DirectSigner(offlineSigner, signerConfig);
29+
```
30+
31+
---
32+
33+
### 2) Customizing Offline Signers
34+
35+
There are two main ways to customize or obtain an offline signer:
36+
37+
- From browser wallet extensions (recommended for Keplr/Leap)
38+
- By wrapping an external provider (e.g., MetaMask) in an `OfflineAminoSigner` or `OfflineDirectSigner`
39+
40+
A) Customize with Browser Extensions (Keplr)
41+
42+
```ts
43+
import { DirectSigner, createCosmosQueryClient } from '@interchainjs/cosmos';
44+
45+
await window.keplr.enable(chainId);
46+
const offlineSigner = window.keplr.getOfflineSigner(chainId);
47+
48+
const queryClient = await createCosmosQueryClient(rpcEndpoint);
49+
const signer = new DirectSigner(offlineSigner, {
50+
chainId,
51+
queryClient,
52+
addressPrefix: 'cosmos'
53+
});
54+
```
55+
56+
B) Customize a MetaMask Offline Signer (pattern)
57+
58+
MetaMask exposes `personal_sign` (EIP-191). You can wrap it to satisfy `OfflineDirectSigner`/`OfflineAminoSigner` interfaces. Below is a minimal pattern you can adapt (you’ll need to implement address conversion and pubkey retrieval for your network):
59+
60+
```ts
61+
import type { OfflineAminoSigner, OfflineDirectSigner, AccountData } from '@interchainjs/cosmos';
62+
import type { StdSignDoc } from '@interchainjs/types';
63+
import { SignDoc, CosmosCryptoSecp256k1PubKey as PubKey } from '@interchainjs/cosmos-types';
64+
import { recoverPublicKey } from '@ethersproject/signing-key';
65+
import { keccak256 } from '@ethersproject/keccak256';
66+
67+
export class MetamaskOfflineSigner implements OfflineDirectSigner, OfflineAminoSigner {
68+
constructor(private ethereum: any, private bech32Prefix: string) {}
69+
70+
/**
71+
* Return AccountData[] with populated pubkey and getPublicKey()
72+
* - pubkey: compressed secp256k1 public key bytes (33 bytes)
73+
* - algo: 'secp256k1'
74+
* - getPublicKey(): EncodedMessage for default Cosmos pubkey type
75+
*/
76+
async getAccounts(): Promise<readonly AccountData[]> {
77+
const [ethAddress] = await this.ethereum.request({ method: 'eth_requestAccounts' });
78+
79+
// Derive the compressed secp256k1 pubkey from MetaMask by signing a known message
80+
const compressed = await this.deriveCompressedPubKey(ethAddress);
81+
82+
const bech32 = this.ethToBech32(ethAddress);
83+
84+
return [{
85+
address: bech32,
86+
pubkey: compressed, // used directly by InterchainJS (SignerInfoPlugin)
87+
algo: 'secp256k1',
88+
// Optional: return more detailed pubkey type if needed.
89+
getPublicKey: () => {
90+
return ({
91+
typeUrl: '/cosmos.crypto.secp256k1.PubKey',
92+
value: PubKey.encode(PubKey.fromPartial({ key: compressed })).finish()
93+
}) as any
94+
}
95+
}];
96+
}
97+
98+
async signDirect(signerAddress: string, signDoc: SignDoc) {
99+
const bytes = SignDoc.encode(signDoc).finish();
100+
const signatureB64 = await this.signWithPersonalSign(signerAddress, bytes);
101+
return { signed: signDoc, signature: { signature: signatureB64, pub_key: await this.pubkeyFor(signerAddress) } };
102+
}
103+
104+
async signAmino(signerAddress: string, signDoc: StdSignDoc) {
105+
const bytes = new TextEncoder().encode(JSON.stringify({
106+
account_number: signDoc.account_number,
107+
chain_id: signDoc.chain_id,
108+
fee: signDoc.fee,
109+
memo: signDoc.memo,
110+
msgs: signDoc.msgs,
111+
sequence: signDoc.sequence,
112+
}));
113+
const signatureB64 = await this.signWithPersonalSign(signerAddress, bytes);
114+
return { signed: signDoc, signature: { signature: signatureB64, pub_key: await this.pubkeyFor(signerAddress) } };
115+
}
116+
117+
// EIP-191 personal_sign wrapper -> base64 signature for Cosmos
118+
private async signWithPersonalSign(cosmosAddr: string, data: Uint8Array): Promise<string> {
119+
const ethAddr = this.bech32ToEth(cosmosAddr);
120+
const hexMsg = '0x' + Buffer.from(data).toString('hex');
121+
const sigHex = await this.ethereum.request({ method: 'personal_sign', params: [hexMsg, ethAddr] });
122+
return Buffer.from(sigHex.slice(2), 'hex').toString('base64');
123+
}
124+
125+
/**
126+
* Recover secp256k1 public key via EIP-191 signing and compress it (33 bytes)
127+
* Implementation details:
128+
* - Sign a fixed challenge message with personal_sign
129+
* - Compute EIP-191 hash of the message
130+
* - Recover uncompressed pubkey (0x04 + 64 bytes) and compress to 33 bytes
131+
*/
132+
private async deriveCompressedPubKey(ethAddress: string): Promise<Uint8Array> {
133+
const challenge = new TextEncoder().encode('interchainjs-pubkey-identity');
134+
const hexMsg = '0x' + Buffer.from(challenge).toString('hex');
135+
136+
// Ask MetaMask to sign the message with EIP-191
137+
const sigHex = await this.ethereum.request({ method: 'personal_sign', params: [hexMsg, ethAddress] });
138+
139+
// Create the EIP-191 hash of the original message (same as MetaMask does internally)
140+
const eip191Hash = this.createEip191Hash(challenge);
141+
142+
// Recover the uncompressed public key from signature
143+
const uncompressed = recoverPublicKey(eip191Hash, sigHex);
144+
145+
return this.compressUncompressedSecp256k1(uncompressed);
146+
}
147+
148+
private createEip191Hash(message: Uint8Array): string {
149+
const prefix = '\x19Ethereum Signed Message:\n';
150+
const prefixed = Buffer.concat([
151+
Buffer.from(prefix),
152+
Buffer.from(message.length.toString()),
153+
Buffer.from(message)
154+
]);
155+
return keccak256(prefixed);
156+
}
157+
158+
/**
159+
* Convert 0x04 + 64-byte uncompressed key to 33-byte compressed form
160+
*/
161+
private compressUncompressedSecp256k1(uncompressedHex: string): Uint8Array {
162+
const hex = uncompressedHex.startsWith('0x') ? uncompressedHex.slice(2) : uncompressedHex;
163+
const buf = Buffer.from(hex, 'hex');
164+
// Expect 65 bytes: 0x04 || X(32) || Y(32)
165+
if (buf.length !== 65 || buf[0] !== 0x04) {
166+
throw new Error('Unexpected public key format from recovery');
167+
}
168+
const x = buf.slice(1, 33);
169+
const y = buf.slice(33, 65);
170+
const prefix = (y[y.length - 1] % 2 === 0) ? 0x02 : 0x03;
171+
return new Uint8Array([prefix, ...x]);
172+
}
173+
174+
// Address conversion placeholders - implement for your network
175+
private ethToBech32(eth: string): string { throw new Error('implement'); }
176+
private bech32ToEth(addr: string): string { throw new Error('implement'); }
177+
178+
/**
179+
* Return StdSignature.pub_key structure for Amino/Direct responses
180+
* type: tendermint/PubKeySecp256k1, value: base64(compressedPubKey)
181+
*/
182+
private async pubkeyFor(cosmosAddr: string) {
183+
const ethAddr = this.bech32ToEth(cosmosAddr);
184+
const compressed = await this.deriveCompressedPubKey(ethAddr);
185+
return {
186+
type: 'tendermint/PubKeySecp256k1',
187+
value: Buffer.from(compressed).toString('base64')
188+
};
189+
}
190+
}
191+
```
192+
193+
Tip: If your app can use a wallet abstraction that implements `IWallet`, you can also leverage `signArbitrary()` paths built into the Cosmos workflows.
194+
195+
---
196+
197+
### 3) Customize Signer Configuration (Network-Specific)
198+
199+
Use configuration to enforce network behavior. Common options you may need:
200+
201+
- Custom pubkey encoding (network-specific typeUrl)
202+
- Message hashing (e.g., keccak256 for eth-style workflows)
203+
- Signature post-processing format (e.g., compact)
204+
- Address prefix and gas/fee defaults
205+
206+
Generic encoder example:
207+
208+
```ts
209+
import { DirectSigner, createCosmosQueryClient, type CosmosSignerConfig } from '@interchainjs/cosmos';
210+
import { CosmosCryptoSecp256k1PubKey as Secp256k1PubKey, SignDoc } from '@interchainjs/cosmos-types';
211+
212+
const encodeCustomPublicKey = (pubkey: Uint8Array) => ({
213+
typeUrl: '/your.network.crypto.v1beta1.ethsecp256k1.PubKey',
214+
value: Secp256k1PubKey.encode(Secp256k1PubKey.fromPartial({ key: pubkey })).finish(),
215+
});
216+
217+
async function createSigner(offlineSigner: any, rpc: string, chainId: string, prefix: string) {
218+
const queryClient = await createCosmosQueryClient(rpc);
219+
220+
const config: CosmosSignerConfig = {
221+
queryClient,
222+
chainId,
223+
addressPrefix: prefix,
224+
// Gas/fee defaults
225+
multiplier: 1.5,
226+
gasPrice: 'average',
227+
// Message/signature behavior
228+
message: { hash: 'keccak256' },
229+
signature: { format: 'compact' },
230+
// Custom pubkey type (adjust typeUrl for your network)
231+
encodePublicKey: encodeCustomPublicKey,
232+
};
233+
234+
return new DirectSigner(offlineSigner, config);
235+
}
236+
```
237+
238+
Derivation paths (examples):
239+
- Standard Cosmos chains: `m/44'/118'/0'/0/0`
240+
- Eth-style Cosmos networks: `m/44'/60'/0'/0/0`
241+
242+
If your network uses eth-style derivation, ensure your wallet/SDK supports that path. Replace with your own wallet factory if needed.
243+
244+
---
245+
246+
### 4) Real-world Customization Flow (Generalized)
247+
248+
The proven pattern in production networks follows this flow:
249+
250+
- Wallet derives keys and produces an OfflineSigner (from an extension or a wrapped provider)
251+
- `DirectSigner` or `AminoSigner` from `@interchainjs/cosmos` is used with a network-specific config
252+
- Message encoders are registered with `addEncoders(toEncoders(...))` when using helper utilities
253+
- Helper functions like `send` or `transfer` can build, sign, and broadcast
254+
255+
Flow overview:
256+
257+
1) Wallet -> OfflineSigner (Keplr/Leap, custom HD wallet, or MetaMask wrapper)
258+
2) OfflineSigner + Config -> DirectSigner/AminoSigner
259+
3) Encoders -> Encode messages into protobuf/amino
260+
4) Cosmos workflow builds sign doc -> signs via OfflineSigner
261+
5) TxRaw assembled and broadcast -> `result.wait()` to confirm
262+
263+
---
264+
265+
### 5) Practical Customization Examples
266+
267+
A) Keplr + DirectSigner (Cosmos)
268+
269+
```ts
270+
import { DirectSigner, createCosmosQueryClient, toEncoders } from '@interchainjs/cosmos';
271+
import { MsgSend } from 'interchainjs';
272+
import { send } from 'interchainjs';
273+
274+
await window.keplr.enable(chainId);
275+
const offlineSigner = window.keplr.getOfflineSigner(chainId);
276+
277+
const queryClient = await createCosmosQueryClient(rpc);
278+
const signer = new DirectSigner(offlineSigner, { queryClient, chainId, addressPrefix: 'cosmos' });
279+
signer.addEncoders(toEncoders(MsgSend));
280+
281+
const [{ address }] = await signer.getAccounts();
282+
const fee = { amount: [{ denom: 'uatom', amount: '5000' }], gas: '200000' };
283+
const msg = { fromAddress: address, toAddress: dest, amount: [{ denom: 'uatom', amount: '1000' }] };
284+
285+
const res = await send(signer, address, msg, fee, 'demo');
286+
await res.wait();
287+
console.log(res.transactionHash);
288+
```
289+
290+
B) MetaMask wrapper + DirectSigner (generic)
291+
292+
```ts
293+
const mmOfflineSigner = new MetamaskOfflineSigner(window.ethereum, 'your-prefix');
294+
const signer = await createSigner(mmOfflineSigner, yourRpcEndpoint, yourChainId, 'your-prefix');
295+
296+
// Register message encoders as needed
297+
// signer.addEncoders(toEncoders(MsgSend, MsgTransfer));
298+
299+
const [{ address }] = await signer.getAccounts();
300+
const fee = { amount: [{ denom: 'your-token', amount: '100000' }], gas: '550000' };
301+
302+
const res = await signer.signAndBroadcast({
303+
messages: [{
304+
typeUrl: '/cosmos.bank.v1beta1.MsgSend',
305+
value: { fromAddress: address, toAddress: dest, amount: [{ denom: 'your-token', amount: '1000000' }] }
306+
}],
307+
fee,
308+
memo: 'metamask eip-191'
309+
});
310+
console.log(res.transactionHash);
311+
```
312+
313+
C) Amino mode (any network)
314+
315+
```ts
316+
import { AminoSigner } from '@interchainjs/cosmos';
317+
const aminoSigner = new AminoSigner(offlineSigner, { queryClient, chainId, addressPrefix: 'cosmos' });
318+
const result = await aminoSigner.signAndBroadcast({ messages, fee, memo });
319+
```
320+
321+
---
322+
323+
### 6) Customization Patterns (Best Practices)
324+
325+
- Browser wallets (Keplr, Leap):
326+
- Get `OfflineSigner` directly from the extension
327+
- Use standard `DirectSigner`/`AminoSigner`
328+
- Minimal custom code
329+
330+
- Custom wallets (MetaMask EIP-191):
331+
- Wrap with `OfflineDirectSigner`/`OfflineAminoSigner`
332+
- Convert `personal_sign` hex signatures to base64 for Cosmos
333+
- Implement address conversion and pubkey encoding via config (or recovery)
334+
335+
- Network-specific configuration:
336+
- Pubkey type via `encodePublicKey` (e.g., `/your.network.crypto.v1beta1.ethsecp256k1.PubKey`)
337+
- `message.hash` (e.g., `keccak256` for eth-like networks)
338+
- `signature.format` (e.g., `compact`)
339+
- Gas/fee defaults (multiplier, gasPrice)
340+
- Prefix (e.g., `your-prefix`, `cosmos`)
341+
342+
- Error handling and validation:
343+
- Handle chainId mismatches and user rejection
344+
- Validate signature length/format (MetaMask returns 65-byte sig in hex)
345+
- Retry broadcast with proper modes (sync/commit)
346+
347+
---
348+
349+
### 7) Customization Checklist
350+
351+
- [ ] Can obtain an `OfflineSigner` (Keplr or wrapped MetaMask)
352+
- [ ] Config has correct `queryClient`, `chainId`, `addressPrefix`
353+
- [ ] Custom `encodePublicKey` set for networks that need it
354+
- [ ] Message encoders registered if using helper methods
355+
- [ ] Fees and gas configured reasonably for your chain
356+
- [ ] Can `await result.wait()` after broadcast
357+
358+
---
359+
360+
### 8) Summary
361+
362+
- Customize using the standard Cosmos signers (DirectSigner/AminoSigner)
363+
- Create or adapt offline signers from existing wallets or external providers
364+
- Push network-specific behavior into configuration (pubkey type, hash, signature format)
365+
- Follow the signer + offline signer + config pattern for a clean, maintainable setup tailored to your network

0 commit comments

Comments
 (0)