Skip to content

Commit

Permalink
Added signArb wallet (#88)
Browse files Browse the repository at this point in the history
* Added signArb wallet
* Added packages
* Added changeset
* Added testing
* Added necessary types
* Updated hook structure
* Updated hook return types
  • Loading branch information
BurntNerve authored Feb 21, 2024
1 parent e6f0696 commit 415f15a
Show file tree
Hide file tree
Showing 9 changed files with 2,311 additions and 100 deletions.
5 changes: 5 additions & 0 deletions .changeset/forty-cherries-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@burnt-labs/abstraxion": minor
---

Added new class for signArb functionality
4 changes: 4 additions & 0 deletions packages/abstraxion/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@ module.exports = {
"no-nested-ternary": "off",
"no-unnecessary-condition": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
},
// Don't run on the jest.config.js file
ignorePatterns: ["jest.config.js"],
};
4 changes: 4 additions & 0 deletions packages/abstraxion/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jest-environment-jsdom",
};
28 changes: 21 additions & 7 deletions packages/abstraxion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"lint": "eslint src/",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"clean": "rimraf ./dist"
"clean": "rimraf ./dist",
"test": "jest"
},
"peerDependencies": {
"react": "^18.2.0",
Expand All @@ -23,28 +24,41 @@
"@burnt-labs/eslint-config-custom": "workspace:*",
"@burnt-labs/tailwind-config": "workspace:*",
"@burnt-labs/tsconfig": "workspace:*",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@types/jest": "^29.5.12",
"@types/node": "^20",
"@types/react": "^18.2.47",
"autoprefixer": "^10.4.13",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.20",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rimraf": "^5.0.5",
"tailwindcss": "^3.2.4",
"ts-jest": "^29.1.2",
"tsup": "^6.0.1",
"typescript": "^5.2.2"
},
"dependencies": {
"@cosmjs/cosmwasm-stargate": "^0.31.3",
"@cosmjs/proto-signing": "^0.31.3",
"@cosmjs/stargate": "^0.31.3",
"cosmjs-types": "^0.8.0",
"@cosmjs/tendermint-rpc": "^0.31.1",
"@burnt-labs/constants": "workspace:*",
"@burnt-labs/signers": "workspace:*",
"@burnt-labs/abstraxion-core": "workspace:*",
"@burnt-labs/ui": "workspace:*",
"@cosmjs/amino": "^0.31.3",
"@cosmjs/cosmwasm-stargate": "^0.31.3",
"@cosmjs/crypto": "^0.31.3",
"@cosmjs/encoding": "^0.32.2",
"@cosmjs/proto-signing": "^0.31.3",
"@cosmjs/stargate": "^0.31.3",
"@cosmjs/tendermint-rpc": "^0.31.1",
"@cosmjs/utils": "^0.31.3",
"@keplr-wallet/cosmos": "^0.12.67",
"@keplr-wallet/crypto": "^0.12.69",
"@types/react-dom": "^18.2.18",
"cosmjs-types": "^0.8.0",
"graz": "^0.0.51",
"jose": "^5.1.3"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { TextEncoder, TextDecoder } from "node:util";
import { SignArbSecp256k1HdWallet } from "./index";

global.TextEncoder = TextEncoder;
// @ts-expect-error: TextDecoder is not available in testing environment by default.
global.TextDecoder = TextDecoder;

describe("SignArbSecp256k1HdWallet", () => {
let wallet: SignArbSecp256k1HdWallet;

beforeEach(async () => {
// DO NOT USE WALLET IN PRODUCTION
const serialization = JSON.stringify({
type: "directsecp256k1hdwallet-v1",
kdf: {
algorithm: "argon2id",
params: {
outputLength: 32,
opsLimit: 24,
memLimitKib: 12288,
},
},
encryption: {
algorithm: "xchacha20poly1305-ietf",
},
data: "8AV9HAqwKThQOZ/jW9HCkd89LNUo//W/+Rg+s1pzNp0TuFk3uut6pi9OgIRM2HRnLS68CjOCiZltc09EYmJBBBj5l0oVnPcAyJjcs1nlAPoppKiKqr1TWCYfNx/YhOmdFrghX9tWE9SWaAx5jwQFOvSbVZaWhv2shEShSvhZ/aUcZJDScN+TZFzwyvVFqE0TMpma8ZACDXmr1Mw+rWfy4KkiGV1+shiVsM9owpZfhrCKNzowpIYZJBn5xE/tMA==",
});
wallet = await SignArbSecp256k1HdWallet.deserialize(
serialization,
"abstraxion",
);
});

test("signArb returns a signature for a valid signer address and message", async () => {
const signerAddress = "xion1cgvua2mkvux6xaw20w4ltjcrs9u3kagfpqd3al"; // Empty test account
const message = "test";
const signature = await wallet.signArb(signerAddress, message);
expect(signature).toBeDefined();
expect(typeof signature).toBe("string");
});
});
247 changes: 247 additions & 0 deletions packages/abstraxion/src/SignArbSecp256k1HdWallet/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import type { AccountData } from "@cosmjs/proto-signing";
import type { KdfConfiguration } from "@cosmjs/amino";
import {
makeCosmoshubPath,
rawSecp256k1PubkeyToRawAddress,
} from "@cosmjs/amino";
import { assert, isNonNullObject } from "@cosmjs/utils";
import { Hash, PrivKeySecp256k1 } from "@keplr-wallet/crypto";
import type { HdPath, Secp256k1Keypair } from "@cosmjs/crypto";
import {
Secp256k1,
Slip10,
Slip10Curve,
stringToPath,
EnglishMnemonic,
Bip39,
} from "@cosmjs/crypto";
import { fromBase64, fromUtf8, toBech32 } from "@cosmjs/encoding";
import { makeADR36AminoSignDoc, serializeSignDoc } from "@keplr-wallet/cosmos";
import type { EncryptionConfiguration } from "@cosmjs/proto-signing/build/wallet";
import { decrypt, executeKdf } from "@cosmjs/proto-signing/build/wallet";

const serializationTypeV1 = "directsecp256k1hdwallet-v1";

export interface DirectSecp256k1HdWalletOptions {
/** The password to use when deriving a BIP39 seed from a mnemonic. */
readonly bip39Password: string;
/** The BIP-32/SLIP-10 derivation paths. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`. */
readonly hdPaths: readonly HdPath[];
/** The bech32 address prefix (human readable part). Defaults to "cosmos". */
readonly prefix: string;
}

interface DirectSecp256k1HdWalletConstructorOptions
extends Partial<DirectSecp256k1HdWalletOptions> {
readonly seed: Uint8Array;
}

interface AccountDataWithPrivkey extends AccountData {
readonly privkey: Uint8Array;
}

/**
* Derivation information required to derive a keypair and an address from a mnemonic.
*/
interface Secp256k1Derivation {
readonly hdPath: HdPath;
readonly prefix: string;
}

/**
* Derivation information required to derive a keypair and an address from a mnemonic.
* All fields in here must be JSON types.
*/
interface DerivationInfoJson {
readonly hdPath: string;
readonly prefix: string;
}

function isDerivationJson(thing: unknown): thing is DerivationInfoJson {
if (!isNonNullObject(thing)) return false;
if (typeof (thing as DerivationInfoJson).hdPath !== "string") return false;
if (typeof (thing as DerivationInfoJson).prefix !== "string") return false;
return true;
}

const defaultOptions: DirectSecp256k1HdWalletOptions = {
bip39Password: "",
hdPaths: [makeCosmoshubPath(0)],
prefix: "cosmos",
};

export class SignArbSecp256k1HdWallet {
/** Base secret */
private readonly secret: EnglishMnemonic;
/** BIP39 seed */
private readonly seed: Uint8Array;
/** Derivation instructions */
private readonly accounts: readonly Secp256k1Derivation[];

protected constructor(
mnemonic: EnglishMnemonic,
options: DirectSecp256k1HdWalletConstructorOptions,
) {
const prefix = options.prefix ?? defaultOptions.prefix;
const hdPaths = options.hdPaths ?? defaultOptions.hdPaths;
this.secret = mnemonic;
this.seed = options.seed;
this.accounts = hdPaths.map((hdPath) => ({
hdPath,
prefix,
}));
}
public static async fromMnemonic(
mnemonic: string,
options: Partial<DirectSecp256k1HdWalletOptions> = {},
): Promise<SignArbSecp256k1HdWallet> {
const mnemonicChecked = new EnglishMnemonic(mnemonic);
const seed = await Bip39.mnemonicToSeed(
mnemonicChecked,
options.bip39Password,
);
return new SignArbSecp256k1HdWallet(mnemonicChecked, {
...options,
seed,
});
}
/**
* Restores a wallet from an encrypted serialization.
*
* @param password - The user provided password used to generate an encryption key via a KDF.
* This is not normalized internally (see "Unicode normalization" to learn more).
*/
public static async deserialize(
serialization: string,
password: string,
): Promise<SignArbSecp256k1HdWallet> {
const root = JSON.parse(serialization) as { readonly type: string };
if (!isNonNullObject(root))
throw new Error("Root document is not an object.");
if (root.type === serializationTypeV1) {
return this.deserializeTypeV1(serialization, password);
}
throw new Error("Unsupported serialization type");
}
/**
* Restores a wallet from an encrypted serialization.
*
* This is an advanced alternative to calling `deserialize(serialization, password)` directly, which allows
* you to offload the KDF execution to a non-UI thread (e.g. in a WebWorker).
*
* The caller is responsible for ensuring the key was derived with the given KDF configuration. This can be
* done using `extractKdfConfiguration(serialization)` and `executeKdf(password, kdfConfiguration)` from this package.
*/
public static async deserializeWithEncryptionKey(
serialization: string,
encryptionKey: Uint8Array,
): Promise<SignArbSecp256k1HdWallet> {
const root = JSON.parse(serialization) as {
readonly type: string;
readonly data: string;
readonly encryption: EncryptionConfiguration;
};
if (!isNonNullObject(root))
throw new Error("Root document is not an object.");
const untypedRoot = root;
switch (untypedRoot.type) {
case serializationTypeV1: {
const decryptedBytes = await decrypt(
fromBase64(untypedRoot.data),
encryptionKey,
untypedRoot.encryption,
);
const decryptedDocument = JSON.parse(fromUtf8(decryptedBytes)) as {
mnemonic: string;
accounts: readonly Secp256k1Derivation[];
};
const { mnemonic, accounts } = decryptedDocument;
assert(typeof mnemonic === "string");
if (!Array.isArray(accounts))
throw new Error("Property 'accounts' is not an array");
if (!accounts.every((account) => isDerivationJson(account))) {
throw new Error("Account is not in the correct format.");
}
const firstPrefix = (accounts[0] as Secp256k1Derivation).prefix;
if (!accounts.every(({ prefix }) => prefix === firstPrefix)) {
throw new Error("Accounts do not all have the same prefix");
}
const hdPaths = accounts.map(({ hdPath }: { hdPath: string }) =>
stringToPath(hdPath),
);
return this.fromMnemonic(mnemonic, {
hdPaths,
prefix: firstPrefix,
});
}
default:
throw new Error("Unsupported serialization type");
}
}

private static async deserializeTypeV1(
serialization: string,
password: string,
): Promise<SignArbSecp256k1HdWallet> {
const root = JSON.parse(serialization) as {
readonly kdf: KdfConfiguration;
};
if (!isNonNullObject(root))
throw new Error("Root document is not an object.");
const encryptionKey = await executeKdf(password, root.kdf);
return this.deserializeWithEncryptionKey(serialization, encryptionKey);
}

private async getKeyPair(hdPath: HdPath): Promise<Secp256k1Keypair> {
const { privkey } = Slip10.derivePath(
Slip10Curve.Secp256k1,
this.seed,
hdPath,
);
const { pubkey } = await Secp256k1.makeKeypair(privkey);
return {
privkey,
pubkey: Secp256k1.compressPubkey(pubkey),
};
}

private async getAccountsWithPrivkeys(): Promise<
readonly AccountDataWithPrivkey[]
> {
return Promise.all(
this.accounts.map(async ({ hdPath, prefix }) => {
const { privkey, pubkey } = await this.getKeyPair(hdPath);
const address = toBech32(
prefix,
rawSecp256k1PubkeyToRawAddress(pubkey),
);
return {
algo: "secp256k1" as const,
privkey,
pubkey,
address,
};
}) as readonly Promise<AccountDataWithPrivkey>[],
);
}

signArb = async (
signerAddress: string,
message: string | Uint8Array,
): Promise<string> => {
const accounts = await this.getAccountsWithPrivkeys();
const account = accounts.find(({ address }) => address === signerAddress);
if (account === undefined) {
throw new Error(`Address ${signerAddress} not found in wallet`);
}
const { privkey } = account;
const signDoc = makeADR36AminoSignDoc(signerAddress, message);
const serializedSignDoc = serializeSignDoc(signDoc);
const digest = Hash.sha256(serializedSignDoc);
const cryptoPrivKey = new PrivKeySecp256k1(privkey);
const signature = cryptoPrivKey.signDigest32(digest);
return Buffer.from(
new Uint8Array([...signature.r, ...signature.s]),
).toString("base64");
};
}
Loading

0 comments on commit 415f15a

Please sign in to comment.