diff --git a/.changeset/serious-pants-carry.md b/.changeset/serious-pants-carry.md new file mode 100644 index 00000000..be65df28 --- /dev/null +++ b/.changeset/serious-pants-carry.md @@ -0,0 +1,5 @@ +--- +"@nilfoundation/niljs": patch +--- + +Add simple polling implementation, allow to run publish workflow only manually, extend walletClient from publicClient diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 44227c41..301a091a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,10 +1,6 @@ name: Release on: - push: - branches: - - main - - master workflow_dispatch: concurrency: diff --git a/package-lock.json b/package-lock.json index 5de5d86d..0901e37e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nilfoundation/niljs", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nilfoundation/niljs", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@chainsafe/ssz": "^0.16.0", diff --git a/package.json b/package.json index 34b0cad1..c95f2b93 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "node": ">=18.0.0" }, "type": "module", - "main": "dist/niljs.cjs.cjs", + "main": "dist/niljs.cjs", "module": "dist/niljs.esm.js", "files": [ "dist" @@ -34,7 +34,8 @@ "lint": "biome check .", "biome:fix": "biome check --apply .", "changeset": "changeset", - "changeset:publish": "npm run build && changeset publish" + "changeset:publish": "changeset publish", + "git-hooks:update": "git config core.hooksPath .git/hooks/ && rm -rf .git/hooks && npx --no-install simple-git-hooks" }, "license": "MIT", "peerDependencies": { diff --git a/src/clients/PublicClient.ts b/src/clients/PublicClient.ts index bec487a3..d98f02bd 100644 --- a/src/clients/PublicClient.ts +++ b/src/clients/PublicClient.ts @@ -1,3 +1,4 @@ +import type { IReceipt } from "../index.js"; import { BaseClient } from "./BaseClient.js"; import type { IPublicClientConfig } from "./types/ClientConfigs.js"; @@ -205,11 +206,47 @@ class PublicClient extends BaseClient { return res.result; } + + /** + * getMessageReceiptByHash returns the message receipt by the hash. + * @param shardId - The shard id. + * @param hash - The hash. + * @returns The message receipt. + */ + public async getMessageReceiptByHash( + shardId: number, + hash: Uint8Array, + ): Promise { + const res = await this.rpcClient.request({ + method: "eth_getMessageReceipt", + params: [shardId, hash], + }); + + return res.result; + } + + /** + * sendRawMessage sends a raw message to the network. + * @param message - The message to send. + * @returns The hash of the message. + * @example + * import { PublicClient } from '@nilfoundation/niljs'; + * + * const client = new PublicClient({ + * endpoint: 'http://127.0.0.1:8529' + * }) + * + * const message = Uint8Array.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + * const hash = await client.sendRawMessage(message); + */ + public async sendRawMessage(message: Uint8Array): Promise { + const res = await this.rpcClient.request({ + method: "eth_sendRawMessage", + params: [message], + }); + + return res.hash; + } } export { PublicClient }; - -// this client is subject to change a lot -// we need to add more methods to interact with the network -// and we need to know shard id before executing the request -// we won't have a single source of data for all shards so we need to know shard id. diff --git a/src/clients/WalletClient.ts b/src/clients/WalletClient.ts index 64dae5cb..1cccad29 100644 --- a/src/clients/WalletClient.ts +++ b/src/clients/WalletClient.ts @@ -1,10 +1,11 @@ import invariant from "tiny-invariant"; import { messageToSsz, signedMessageToSsz } from "../encoding/toSsz.js"; +import { type IReceipt, getShardIdFromAddress } from "../index.js"; import type { ISigner } from "../signers/index.js"; import type { IMessage } from "../types/IMessage.js"; -import type { IReceipt } from "../types/IReceipt.js"; import { assertIsValidMessage } from "../utils/assert.js"; -import { BaseClient } from "./BaseClient.js"; +import { startPollingUntilCondition } from "../utils/polling.js"; +import { PublicClient } from "./PublicClient.js"; import type { IWalletClientConfig } from "./types/ClientConfigs.js"; import type { ISendMessageOptions } from "./types/ISendMessageOptions.js"; import type { ISignMessageOptions } from "./types/ISignMessageOptions.js"; @@ -15,13 +16,15 @@ import type { ISignMessageOptions } from "./types/ISignMessageOptions.js"; * Wallet client alllows to use api that require signing data and private key usage. * @example * import { WalletClient } from '@nilfoundation/niljs'; + * import { LocalKeySigner } from '@nilfoundation/niljs'; * * const client = new WalletClient({ * endpoint: 'http://127.0.0.1:8529' + * signer: new LocalKeySigner({ privateKey: "xxx" }) * }) */ -class WalletClient extends BaseClient { - private signer?: ISigner; +class WalletClient extends PublicClient { + private signer: ISigner; constructor(config: IWalletClientConfig) { super(config); @@ -88,29 +91,6 @@ class WalletClient extends BaseClient { }); } - /** - * sendRawMessage sends a raw message to the network. - * @param message - The message to send. - * @returns The hash of the message. - * @example - * import { WalletClient } from '@nilfoundation/niljs'; - * - * const client = new WalletClient({ - * endpoint: 'http://127.0.0.1:8529' - * }) - * - * const message = Uint8Array.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - * const hash = await client.sendRawMessage(message); - */ - public async sendRawMessage(message: Uint8Array): Promise { - const res = await this.rpcClient.request({ - method: "eth_sendRawMessage", - params: [message], - }); - - return res.hash; - } - /** * deployContract deploys a contract to the network. * @param contract - The contract to deploy. @@ -127,17 +107,21 @@ class WalletClient extends BaseClient { */ public async deployContract(contract: Uint8Array): Promise { const hash = await this.sendRawMessage(contract); - // there will be a method to get receipt by hash, but it is not implemented yet - // it will use kinda polling to get receipt asap - // mocking it for now: - const receipt: Partial = { - success: true, - }; + const address = this.signer.getAddress(); + const shardId = getShardIdFromAddress(address); + + // in the future we want to use subscribe method to get the receipt + // for now it is simple short polling + const receipt = await startPollingUntilCondition( + async () => await this.getMessageReceiptByHash(shardId, hash), + (receipt) => receipt !== undefined, + 1000, + ); // ! compiling smart contract to the bytecode shall not be included in this library // it can be done by hardhat invariant( - receipt.success, + receipt?.success, "Contract deployment failed. Please check the contract bytecode.", ); diff --git a/src/clients/types/ClientConfigs.ts b/src/clients/types/ClientConfigs.ts index 18c3cd07..2c7f697d 100644 --- a/src/clients/types/ClientConfigs.ts +++ b/src/clients/types/ClientConfigs.ts @@ -27,7 +27,7 @@ type IWalletClientConfig = IClientBaseConfig & { * signer: signer * }) */ - signer?: ISigner; + signer: ISigner; }; export type { IClientBaseConfig, IPublicClientConfig, IWalletClientConfig }; diff --git a/src/signers/LocalKeySigner.ts b/src/signers/LocalKeySigner.ts index f2f8551f..8cd0e52e 100644 --- a/src/signers/LocalKeySigner.ts +++ b/src/signers/LocalKeySigner.ts @@ -1,7 +1,13 @@ +import type { Hex } from "@noble/curves/abstract/utils"; import { secp256k1 } from "@noble/curves/secp256k1"; import { toHex } from "../encoding/toHex.js"; -import { assertIsHexString, assertIsValidPrivateKey } from "../utils/assert.js"; -import { getPublicKey } from "./publicKey.js"; +import { + assertIsAddress, + assertIsHexString, + assertIsValidPrivateKey, +} from "../utils/assert.js"; +import { getAddressFromPublicKey, getPublicKey } from "./publicKey.js"; +import type { IAddress } from "./types/IAddress.js"; import type { ILocalKeySignerConfig } from "./types/ILocalKeySignerConfig.js"; import type { ISignature } from "./types/ISignature.js"; import type { ISigner } from "./types/ISigner.js"; @@ -17,17 +23,14 @@ import type { ISigner } from "./types/ISigner.js"; * const signer = new LocalKeySigner({ privateKey }); */ class LocalKeySigner implements ISigner { - private publicKey; private privateKey; + private publicKey?: Hex = undefined; + private address?: IAddress = undefined; constructor(config: ILocalKeySignerConfig) { const { privateKey } = config; assertIsValidPrivateKey(privateKey); - const publicKey = getPublicKey(privateKey); - assertIsHexString(publicKey); - - this.publicKey = publicKey; this.privateKey = privateKey; } @@ -44,8 +47,27 @@ class LocalKeySigner implements ISigner { } public getPublicKey() { + if (this.publicKey) { + return this.publicKey; + } + + const publicKey = getPublicKey(this.privateKey); + assertIsHexString(publicKey); + + this.publicKey = publicKey; return this.publicKey; } + + public getAddress() { + if (this.address) { + return this.address; + } + + this.address = getAddressFromPublicKey(this.getPublicKey()); + assertIsAddress(this.address); + + return this.address; + } } export { LocalKeySigner }; diff --git a/src/signers/publicKey.ts b/src/signers/publicKey.ts index 3387d2ad..9db23f54 100644 --- a/src/signers/publicKey.ts +++ b/src/signers/publicKey.ts @@ -4,8 +4,11 @@ import { type ISignature, addHexPrefix, removeHexPrefix, + toBytes, toHex, } from "../index.js"; +import { keccak_256 } from "../utils/keccak256.js"; +import type { IAddress } from "./types/IAddress.js"; import type { IPrivateKey } from "./types/IPrivateKey.js"; /** @@ -31,4 +34,19 @@ const recoverPublicKey = ( // }; -export { getPublicKey, generatePrivateKey, recoverPublicKey }; +/** + * Returns the address from the public key. + * @param publicKey - Public key in hex format + * @returns Address in hex format + */ +const getAddressFromPublicKey = (publicKey: Hex): IAddress => { + const bytes = keccak_256(toBytes(removeHexPrefix(publicKey))); + return toHex(bytes) as IAddress; +}; + +export { + getPublicKey, + generatePrivateKey, + recoverPublicKey, + getAddressFromPublicKey, +}; diff --git a/src/signers/types/IAddress.ts b/src/signers/types/IAddress.ts new file mode 100644 index 00000000..f9816ffc --- /dev/null +++ b/src/signers/types/IAddress.ts @@ -0,0 +1,6 @@ +/** + * Address type represents an address in hexadecimal format. + */ +type IAddress = `0x${string}`; + +export type { IAddress }; diff --git a/src/signers/types/ISigner.ts b/src/signers/types/ISigner.ts index 4f1cd6a5..023e70aa 100644 --- a/src/signers/types/ISigner.ts +++ b/src/signers/types/ISigner.ts @@ -1,4 +1,5 @@ import type { Hex } from "@noble/curves/abstract/utils"; +import type { IAddress } from "./IAddress.js"; import type { ISignature } from "./ISignature.js"; /** @@ -21,6 +22,13 @@ abstract class ISigner { * const publicKey = signer.getPublicKey(); */ abstract getPublicKey(): Hex; + /** + * Returns the address. + * @returns The address. + * @example + * const address = signer.getAddress(); + */ + abstract getAddress(): IAddress; } export { ISigner }; diff --git a/src/utils/assert.ts b/src/utils/assert.ts index 15d4ff10..175ce4b9 100644 --- a/src/utils/assert.ts +++ b/src/utils/assert.ts @@ -42,7 +42,7 @@ const assertIsValidPrivateKey = ( message?: string, ): void => { invariant( - isHexString(privateKey) && privateKey.length === 64, + isHexString(privateKey) && privateKey.length === 32 * 2 + 2, message ?? `Expected a valid private key, but got ${privateKey}`, ); }; diff --git a/src/utils/hex.ts b/src/utils/hex.ts index 07706a42..d228d5b0 100644 --- a/src/utils/hex.ts +++ b/src/utils/hex.ts @@ -7,8 +7,12 @@ const HEX_REGEX = /^[0-9a-fA-F]+$/; * Otherwise, it returns false. * @param value - The value to check. */ -const isHexString = (value: Hex): boolean => { - return typeof value === "string" && HEX_REGEX.test(value); +const isHexString = (value: Hex): value is Hex => { + return ( + typeof value === "string" && + value.startsWith("0x") && + HEX_REGEX.test(removeHexPrefix(value)) + ); }; /** @@ -16,8 +20,12 @@ const isHexString = (value: Hex): boolean => { * @param hex hex-string * @returns format: base16-string */ -const removeHexPrefix = (hex: string): string => { - return hex.replace(/^0x/i, ""); +const removeHexPrefix = (hex: Hex): string => { + if (typeof hex !== "string") { + throw new Error(`Expected a hex string but got ${hex}`); + } + + return hex.startsWith("0x") ? hex.slice(2) : hex; }; /** diff --git a/src/utils/index.ts b/src/utils/index.ts index 9a5cc367..bec19073 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from "./assert.js"; export * from "./hex.js"; export * from "./message.js"; +export * from "./keccak256.js"; diff --git a/src/utils/keccak256.ts b/src/utils/keccak256.ts new file mode 100644 index 00000000..6d52ee93 --- /dev/null +++ b/src/utils/keccak256.ts @@ -0,0 +1,13 @@ +import type { Hex } from "@noble/curves/abstract/utils"; +import { keccak_256 as keccak_256Module } from "@noble/hashes/sha3"; + +/** + * Returns the keccak-256 hash of the data. It is used in the Nil blockchain. + * @param data - The data to hash. + * @returns The keccak-256 hash. + */ +const keccak_256 = (data: Hex) => { + return keccak_256Module(data); +}; + +export { keccak_256 }; diff --git a/src/utils/message.ts b/src/utils/message.ts index 8e2a5e9b..1096009e 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -1,4 +1,5 @@ import type { Hex } from "@noble/curves/abstract/utils"; +import type { IAddress } from "../signers/types/IAddress.js"; const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; @@ -7,8 +8,20 @@ const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; * Otherwise, it returns false. * @param value - The value to check. */ -const isAddress = (value: Hex): boolean => { +const isAddress = (value: Hex): value is IAddress => { return typeof value === "string" && ADDRESS_REGEX.test(value); }; -export { isAddress }; +/** + * Returns the shard ID from the address. + * @param address - The address. + */ +const getShardIdFromAddress = (address: Hex): number => { + if (typeof address === "string") { + return Number.parseInt(address.slice(2, 6), 16); + } + + return (address[0] << 8) | address[1]; +}; + +export { isAddress, getShardIdFromAddress }; diff --git a/src/utils/polling.ts b/src/utils/polling.ts new file mode 100644 index 00000000..a17f65e7 --- /dev/null +++ b/src/utils/polling.ts @@ -0,0 +1,26 @@ +/** + * Polls a callback function until a condition is met. + * @param cb - Callback function to be executed + * @param condition - Condition to be met + * @param interval - Interval in milliseconds + * @returns Result of the callback function + */ +const startPollingUntilCondition = async ( + cb: () => Promise, + condition: (result: Result) => boolean, + interval: number, +) => { + let result: Result | undefined; + + while (true) { + result = await cb(); + + if (condition(result)) { + return result; + } + + await new Promise((resolve) => setTimeout(resolve, interval)); + } +}; + +export { startPollingUntilCondition };