* clear

* feat: Add everything

* fix: Commenht
This commit is contained in:
Prad Nukala
2025-10-03 14:45:52 -04:00
committed by GitHub
parent 43b4a11c06
commit 13e6c3e84d
1935 changed files with 655061 additions and 40058 deletions
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { resolveBech32Address, translateEthToBech32Address } from './address';
const PUB_KEY_1 = 'A6Y9fcWSn5Av/HLHBwthTaVE/vdyRKvsTzi5U7j9bFj5'; // random pub key
const PUB_KEY_2 = 'Ag/a1BOl3cdwh67Z8iCbGmAu4WWmBwtuQlQMbDaN385V'; // coinhall.org val pubkey
const PUB_KEY_3 = 'AmGjuPKUsuIAuGgJ3xH7KGWlSU9cwVnsesrwWwyYLbMg'; // random pub key
const ETH_ADDRESS_1 = '0xd6E80d86483C0cF463E03cC95246bDc0FeF6cfbD'; // random eth address
describe('resolveBech32Address', () => {
it('should resolve stars address correctly', () => {
const translated = resolveBech32Address(PUB_KEY_1, 'stars');
expect(translated).toBe('stars14y420auq56p6xgt78sl8vwz3jxy77r9cuw900r');
});
it('should resolve cosmos address correctly', () => {
const translated = resolveBech32Address(PUB_KEY_1, 'cosmos');
expect(translated).toBe('cosmos14y420auq56p6xgt78sl8vwz3jxy77r9cgjjjyj');
});
it('should resolve terra address correctly', () => {
const translated = resolveBech32Address(PUB_KEY_2, 'terra');
expect(translated).toBe('terra1ge3vqn6cjkk2xkfwpg5ussjwxvahs2f6aytr5j');
});
it('should resolve terravaloper address correctly', () => {
const translated = resolveBech32Address(PUB_KEY_2, 'terravaloper');
expect(translated).toBe('terravaloper1ge3vqn6cjkk2xkfwpg5ussjwxvahs2f6at87yp');
});
it('should resolve ethsecp256k1 type address correctly', () => {
const translated = resolveBech32Address(PUB_KEY_3, 'inj', 'ethsecp256k1');
expect(translated).toBe('inj1ys3hr2a6sn3wwqsmmrk8pgrvk58e8wrn6zn44m');
});
});
describe('translateEthToBech32Address', () => {
it('should translate eth address correctly', () => {
const translated = translateEthToBech32Address(ETH_ADDRESS_1, 'inj');
expect(translated).toBe('inj16m5qmpjg8sx0gclq8ny4y34acrl0dnaantdev0');
});
});
+40
View File
@@ -0,0 +1,40 @@
import { ripemd160 } from '@noble/hashes/ripemd160';
import { keccak_256 } from '@noble/hashes/sha3';
import { sha256 } from '@noble/hashes/sha256';
import { ProjectivePoint } from '@noble/secp256k1';
import { base64, bech32 } from '@scure/base';
import { ethhex } from './ethhex';
/**
* Returns the bech32 address from the given `publicKey` and `prefix`. If needed,
* the `type` of the key should be appropriately set.
*
* @param publicKey Must be either a base64 encoded string or a `Uint8Array`.
*/
export function resolveBech32Address(
publicKey: string | Uint8Array,
prefix: string,
type: 'secp256k1' | 'ed25519' | 'ethsecp256k1' = 'secp256k1'
): string {
const pubKey = typeof publicKey === 'string' ? base64.decode(publicKey) : publicKey;
const address =
type === 'secp256k1'
? // For cosmos: take the ripemd160 of the sha256 of the public key
ripemd160(sha256(pubKey))
: type === 'ed25519'
? // For cosmos: take the first 20 bytes of the sha256 of the public key
sha256(pubKey).slice(0, 20)
: // For eth: take the last 20 bytes of the keccak of the uncompressed public key without the first byte
keccak_256(ProjectivePoint.fromHex(pubKey).toRawBytes(false).slice(1)).slice(-20);
return bech32.encode(prefix, bech32.toWords(address));
}
/**
* 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));
}
+13
View File
@@ -0,0 +1,13 @@
import { type 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;
+14
View File
@@ -0,0 +1,14 @@
// Re-export @scure/base for their codecs
export * from '@scure/base';
export { resolveBech32Address, translateEthToBech32Address } from './address';
export { ethhex } from './ethhex';
export { resolveKeyPair } from './key';
export { serialiseSignDoc } from './serialise';
export {
hashEthArbitraryMessage,
recoverPubKeyFromEthSignature,
signAmino,
signDirect,
} from './sign';
export { verifyADR36, verifyECDSA, verifyEIP191 } from './verify';
+32
View File
@@ -0,0 +1,32 @@
import { base64 } from '@scure/base';
import { describe, expect, it } from 'vitest';
import { resolveKeyPair } from './key';
// Randomly generated seed phrase
const SEED_PHRASE_1 =
'witness snack faint milk gesture memory exhibit oak require mountain hammer crawl innocent day library drum youth result mutual remove capable hour front connect';
describe('resolveKeyPair', () => {
it('should resolve 118 coin type correctly', () => {
const { publicKey, privateKey } = resolveKeyPair(SEED_PHRASE_1);
expect(base64.encode(publicKey)).toBe('AijdjMWZdjiXxSj0YCNbJHgnW6EsYwNyB9Yf7Wg5PcmE');
expect(base64.encode(privateKey)).toBe('SojKJzJhFNruMSceBq3Imw3qZ+kS4p/6+iEpxdPsNg0=');
});
it('should resolve 330 coin type correctly', () => {
const { publicKey, privateKey } = resolveKeyPair(SEED_PHRASE_1, {
coinType: 330,
});
expect(base64.encode(publicKey)).toBe('A5G4nX2MIYCsnEdm40NJx7Bb1Z+oUNbEWWcVMssrgI3n');
expect(base64.encode(privateKey)).toBe('kpvZKN+f7oWhVLLLk1pmKOazycgfECinugqQZgKRlXg=');
});
it('should resolve provided index correctly', () => {
const { publicKey, privateKey } = resolveKeyPair(SEED_PHRASE_1, {
index: 69,
});
expect(base64.encode(publicKey)).toBe('ArHwuHKnyiuPDbprTpWLVl3ZuomV70yzquzzlunGXlmj');
expect(base64.encode(privateKey)).toBe('Fdm+CxL/KnM35bjDx/wg3eZc3tZN2q83I+xcY2wSMwk=');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { HDKey } from '@scure/bip32';
import { mnemonicToSeedSync } from '@scure/bip39';
/**
* Resolves the given `mnemonic` (aka 12-24 words seed phrase) to its public and
* private key pair. Derivation path uses the default for Cosmos chains - provide
* the optional `opts` to override.
*/
export function resolveKeyPair(
mnemonic: string,
opts?: { coinType?: number | undefined; index?: number | undefined } | undefined
): {
publicKey: Uint8Array;
privateKey: Uint8Array;
} {
const seed = mnemonicToSeedSync(mnemonic);
const { publicKey, privateKey } = HDKey.fromMasterSeed(seed).derive(
`m/44'/${opts?.coinType ?? 118}'/0'/0/${opts?.index ?? 0}`
);
if (!publicKey || !privateKey) {
throw new Error('invalid mnemonic');
}
return {
publicKey,
privateKey,
};
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { sortObjectByKey } from './serialise';
describe('sortObjectByKey', () => {
it('should sort keys correctly', () => {
const obj = {
zzz: 1,
aaa: 1,
xxx: null,
bbb: {
ttt: {
ppp: true,
iii: undefined,
lll: '1',
},
ddd: [4, 8, 3, undefined, 4, 5, 7, 8],
},
};
const expected = {
aaa: 1,
bbb: {
ddd: [4, 8, 3, undefined, 4, 5, 7, 8], // arrays are not sorted
ttt: {
iii: undefined,
lll: '1',
ppp: true,
},
},
xxx: null,
zzz: 1,
};
// Before sorting, the stringified versions of the objects should NOT be equal
expect(JSON.stringify(obj)).not.toBe(JSON.stringify(expected));
// After sorting, the stringified versions of the objects should be equal
expect(JSON.stringify(sortObjectByKey(obj))).toBe(JSON.stringify(expected));
});
});
+35
View File
@@ -0,0 +1,35 @@
import { utf8 } from '@scure/base';
import type { StdSignDoc } from '@sonr.io/es/registry';
/**
* Escapes <,>,& in string.
* Golang's json marshaller escapes <,>,& by default.
* However, because JS doesn't do that by default, to match the sign doc with cosmos-sdk,
* we should escape <,>,& in string manually.
* @param str
*/
function escapeHtml(str: string): string {
return str.replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
}
export function sortObjectByKey<T>(obj: T): T {
if (typeof obj !== 'object' || obj == null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(sortObjectByKey) as T;
}
const sortedKeys = Object.keys(obj).sort();
const result: Record<string, unknown> = {};
for (const key of sortedKeys) {
result[key] = sortObjectByKey((obj as Record<string, unknown>)[key]);
}
return result as T;
}
/**
* Serialises the given sign doc to a `Uint8Array` in a deterministic manner.
*/
export function serialiseSignDoc(doc: StdSignDoc): Uint8Array {
return utf8.decode(escapeHtml(JSON.stringify(sortObjectByKey(doc))));
}
+64
View File
@@ -0,0 +1,64 @@
import type { StdSignDoc } from '@keplr-wallet/types';
import { base16, base64, utf8 } from '@scure/base';
import { describe, expect, it } from 'vitest';
import { ethhex } from './ethhex';
import { hashEthArbitraryMessage, recoverPubKeyFromEthSignature, signAmino } from './sign';
describe('signAmino', () => {
it('should sign Injective txs correctly', () => {
const stdSignDoc: StdSignDoc = {
chain_id: '',
account_number: '0',
sequence: '0',
fee: {
gas: '0',
amount: [],
},
msgs: [
{
type: 'sign/MsgSignData',
value: {
signer: 'inj1l8w4vvmhcku28ryntpeazm37umshetzzl2gc33',
data: base64.encode(
utf8.decode(
'Hi from CosmeES! This is a test message just to prove that the wallet is working.'
)
),
},
},
],
memo: '',
};
const privKey = base64.decode('o5di+2p2NdLgRYLtBIhJl9gsB9FWll8wKBaep3CmbI0=');
const expected = // Signature taken from keplr signArbitrary
'qrkZpuo1jpfXgbF3TtBtdR7DynE1nV3xd//bsGXm2FkS08waXeiJJ+FAvdtt9hvStyP/wGae07hxnyYPHEw+Uw==';
const actual = base64.encode(signAmino(stdSignDoc, privKey, 'ethsecp256k1'));
expect(actual).toStrictEqual(expected);
});
});
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);
});
});
+83
View File
@@ -0,0 +1,83 @@
import { hmac } from '@noble/hashes/hmac';
import { keccak_256 } from '@noble/hashes/sha3';
import { sha256 } from '@noble/hashes/sha256';
import * as secp256k1 from '@noble/secp256k1';
import { utf8 } from '@scure/base';
import type { CosmosTxV1beta1SignDoc as SignDoc } from '@sonr.io/es/protobufs';
import type { StdSignDoc } from '@sonr.io/es/registry';
import { serialiseSignDoc } from './serialise';
function sign(
bytes: Uint8Array,
privateKey: Uint8Array,
type: 'secp256k1' | 'ethsecp256k1'
): Uint8Array {
// Required polyfills for secp256k1 that must be called before any sign ops.
// See: https://github.com/paulmillr/noble-secp256k1?tab=readme-ov-file#usage
secp256k1.etc.hmacSha256Sync = (k, ...m) => hmac(sha256, k, secp256k1.etc.concatBytes(...m));
const hash = type === 'secp256k1' ? sha256(bytes) : keccak_256(bytes);
return secp256k1.sign(hash, privateKey).toCompactRawBytes();
}
/**
* Signs the given amino-encoded `stdSignDoc` with the given `privateKey` using
* secp256k1, and returns the signature bytes. For Injective, the `type` param
* must be set to `ethsecp256k1`.
*/
export function signAmino(
stdSignDoc: StdSignDoc,
privateKey: Uint8Array,
type: 'secp256k1' | 'ethsecp256k1' = 'secp256k1'
): Uint8Array {
return sign(serialiseSignDoc(stdSignDoc), privateKey, type);
}
/**
* Signs the given proto-encoded `signDoc` with the given `privateKey` using
* secp256k1, and returns the signature bytes. For Injective, the `type` param
* must be set to `ethsecp256k1`.
*/
export function signDirect(
signDoc: SignDoc,
privateKey: Uint8Array,
type: 'secp256k1' | 'ethsecp256k1' = 'secp256k1'
): Uint8Array {
return sign(signDoc.toBinary(), privateKey, type);
}
/**
* 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 r = signature.slice(0, 32);
const s = signature.slice(32, 64);
const v = signature[64];
// Adapted from https://github.com/ethers-io/ethers.js/blob/6017d3d39a4d428793bddae33d82fd814cacd878/src.ts/crypto/signature.ts#L255-L265
const yParity = v <= 1 ? v : (v + 1) % 2;
const secpSignature = secp256k1.Signature.fromCompact(
Uint8Array.from([...r, ...s])
).addRecoveryBit(yParity);
const digest = hashEthArbitraryMessage(message);
return secpSignature.recoverPublicKey(digest).toRawBytes(true);
}
+109
View File
@@ -0,0 +1,109 @@
import { base64, utf8 } from '@scure/base';
import { describe, expect, it } from 'vitest';
import { verifyADR36, verifyECDSA, verifyEIP191 } from './verify';
const DATA = utf8.decode(
'Hi from CosmeES! This is a test message just to prove that the wallet is working.'
);
// Generated using coin type "330" and seed phrase "poverty flat amazing draw goose clay sorry nothing erase switch law intact only invest find memory what weasel fan connect tilt detect trap viable"
const VALID_PUBKEY_1 = base64.decode('Ai7ZXTtRWFte/tX7Z6MlKWVd9XA49p3cDNqd61RuKTdT');
// Generated using coin type "118" and seed phrase "poverty flat amazing draw goose clay sorry nothing erase switch law intact only invest find memory what weasel fan connect tilt detect trap viable"
const VALID_PUBKEY_2 = base64.decode('A8i9vMNUGcTtUgpbmiZqcFtsIrPZ6n8ZYN4/PVRlQvGr');
// Generated using coin type "60" and seed phrase "poverty flat amazing draw goose clay sorry nothing erase switch law intact only invest find memory what weasel fan connect tilt detect trap viable"
const VALID_PUBKEY_3 = base64.decode('AmGjuPKUsuIAuGgJ3xH7KGWlSU9cwVnsesrwWwyYLbMg');
describe('verifyECDSA', () => {
it('should verify correctly', () => {
// Signed using Station wallet on Terra
const signature = base64.decode(
'Od87qNoOyXuDOVdLCGTXB6dFN7U0XF9Oegc8KDa+AWwX3jkrDXG++2nlPfsF4VJzlDHsoikPeZpxrB7v9PINnw=='
);
const res1 = verifyECDSA({
pubKey: VALID_PUBKEY_1,
data: DATA,
signature,
});
expect(res1).toBe(true);
// Different pub key
const res2 = verifyECDSA({
pubKey: VALID_PUBKEY_2,
data: DATA,
signature,
});
expect(res2).toBe(false);
});
});
describe('verifyADR36', () => {
it('should verify correctly', () => {
// Signed using Keplr wallet on Osmosis
const signature = base64.decode(
'nvlcV0x0Ge8ADXLSAQGtfMw6EJkOfpmkDxgP7UI79uR8MhnAOp9T+e+ofgW9kY4bEIr0yhyBG+vSVAZRv9uCxA=='
);
const res1 = verifyADR36({
bech32Prefix: 'osmo',
pubKey: VALID_PUBKEY_2,
data: DATA,
signature,
});
expect(res1).toBe(true);
// Different bech32 prefix
const res2 = verifyADR36({
bech32Prefix: 'terra',
pubKey: VALID_PUBKEY_2,
data: DATA,
signature,
});
expect(res2).toBe(false);
// Different pub key
const res3 = verifyADR36({
bech32Prefix: 'osmo',
pubKey: VALID_PUBKEY_1,
data: DATA,
signature,
});
expect(res3).toBe(false);
});
it('should verify ethsecp256k1 type signatures correctly', () => {
// Signed using Keplr wallet on Injective
const signature = base64.decode(
'+7PNZm4XxKtpvZA+HqxpMKJgZcqA2w3WVSheLGvzrrIBJZGOTdcpBT7wLUhluY46EokTeRRWUaBDSv2vVoEdfw=='
);
const res1 = verifyADR36({
bech32Prefix: 'inj',
pubKey: VALID_PUBKEY_3,
data: DATA,
signature,
type: 'ethsecp256k1',
});
expect(res1).toBe(true);
});
});
describe('verifyEIP191', () => {
it('should verify correctly', () => {
// Signed using MetaMask wallet on Injective
const signature = base64.decode(
'MpriWY0Kq7C+/jR3eOfNB5vUQM144tQk7KkzKyYCTFB5QHGLZjzJyeOSr8/ENFES0k+aaEF47Wepk7OHoZuLzxs='
);
const res1 = verifyEIP191({
pubKey: VALID_PUBKEY_3,
data: DATA,
signature,
});
expect(res1).toBe(true);
// Different pub key
const res2 = verifyEIP191({
pubKey: VALID_PUBKEY_2,
data: DATA,
signature,
});
expect(res2).toBe(false);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { keccak_256 } from '@noble/hashes/sha3';
import { sha256 } from '@noble/hashes/sha256';
import * as secp256k1 from '@noble/secp256k1';
import { base64 } from '@scure/base';
import { resolveBech32Address } from './address';
import { serialiseSignDoc } from './serialise';
import { recoverPubKeyFromEthSignature } from './sign';
type VerifyArbitraryParams = {
/** The public key which created the signature */
pubKey: Uint8Array;
/** The bech32 account address prefix of the signer */
bech32Prefix: string;
/** The arbitrary bytes that was signed */
data: Uint8Array;
/** The signature bytes */
signature: Uint8Array;
/** The type of the signature */
type?: 'secp256k1' | 'ethsecp256k1';
};
export function verifyECDSA({
pubKey,
data,
signature,
type,
}: Omit<VerifyArbitraryParams, 'bech32Prefix'>): boolean {
return secp256k1.verify(
signature,
type === 'ethsecp256k1' ? keccak_256(data) : sha256(data),
pubKey
);
}
export function verifyADR36({
pubKey,
bech32Prefix,
data,
signature,
type,
}: VerifyArbitraryParams): boolean {
const msg = serialiseSignDoc({
chain_id: '',
account_number: '0',
sequence: '0',
fee: {
gas: '0',
amount: [],
},
msgs: [
{
type: 'sign/MsgSignData',
value: {
signer: resolveBech32Address(pubKey, bech32Prefix, type),
data: base64.encode(data),
},
},
],
memo: '',
});
return verifyECDSA({
pubKey,
data: msg,
signature,
type,
});
}
export function verifyEIP191({
pubKey,
data,
signature,
}: Omit<VerifyArbitraryParams, 'bech32Prefix'>): boolean {
const recoveredPubKey = recoverPubKeyFromEthSignature(data, signature);
return (
pubKey.length === recoveredPubKey.length && pubKey.every((v, i) => v === recoveredPubKey[i])
);
}