diff --git a/apps/docs/src/pages/utils.mdx b/apps/docs/src/pages/utils.mdx new file mode 100644 index 0000000..b2c8339 --- /dev/null +++ b/apps/docs/src/pages/utils.mdx @@ -0,0 +1,74 @@ +# Utils [Don't waste time on implementing these yourself.] + +We've implemented some utilities that you might find useful. You can find them in the `@mina-js/utils` package. + +## Units + +Utils library provides functions for unit conversion. + +### formatMina + +Formats micro-Mina to human-readable Mina value. + +```typescript twoslash +import { formatMina } from '@mina-js/utils' + +const mina = formatMina(5_000_000_000n) +// -> "5" +``` + +### parseMina + +Parses human-readable Mina value to micro-Mina. + +```typescript twoslash +import { parseMina } from '@mina-js/utils' + +const mina = parseMina('5') +// -> 5_000_000_000n +``` + +### formatUnit + +```typescript twoslash +import { formatUnits } from '@mina-js/utils' + +const formatted = formatUnits(4200000000000n, 10) +// -> "420" +``` + +### parseUnits + +```typescript twoslash +import { parseUnits } from '@mina-js/utils' + +const parsed = parseUnits("420", 10) +// -> 4200000000000n +``` + +## Web Workers + +Proof related computations can be heavy and can block the main thread. To avoid this, you can use Web Workers to run these computations in a separate thread. We've prepared a JSON-RPC protocol to easily connect the dots. + +```typescript twoslash +// @filename: worker.ts +import { createRpcHandler } from "@mina-js/utils"; + +const { messageHandler } = createRpcHandler({ + methods: { + ping: async () => 'pong', + } +}) + +self.onmessage = messageHandler + +// @filename: index.ts +import { createRpc } from "@mina-js/utils"; + +const worker = new Worker(new URL('./worker.ts', import.meta.url)) +const rpc = createRpc({ worker }) +const response = await rpc.request({ + method: 'ping', + params: [], +}) +``` diff --git a/apps/docs/vocs.config.ts b/apps/docs/vocs.config.ts index 827f167..e730530 100644 --- a/apps/docs/vocs.config.ts +++ b/apps/docs/vocs.config.ts @@ -81,6 +81,10 @@ export default defineConfig({ text: "Roadmap", link: "/roadmap", }, + { + text: "Utils", + link: "/utils", + }, { text: "MinaJS Connect", link: "/connect", diff --git a/apps/klesia/src/methods/mina.ts b/apps/klesia/src/methods/mina.ts index f6d0053..4effa54 100644 --- a/apps/klesia/src/methods/mina.ts +++ b/apps/klesia/src/methods/mina.ts @@ -78,7 +78,7 @@ const networkId = async () => { `, {}, ); - return data.networkID; + return data.networkID === "mina:testnet" ? "mina:devnet" : data.networkID; }; const sendTransaction = async ({ diff --git a/bun.lockb b/bun.lockb index 34fa9dc..1a6e5ca 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/accounts/src/accounts/__snapshots__/private-key-to-account.spec.ts.snap b/packages/accounts/src/accounts/__snapshots__/private-key-to-account.spec.ts.snap index 4abf2ea..605ab98 100644 --- a/packages/accounts/src/accounts/__snapshots__/private-key-to-account.spec.ts.snap +++ b/packages/accounts/src/accounts/__snapshots__/private-key-to-account.spec.ts.snap @@ -53,3 +53,32 @@ exports[`signs fields 1`] = ` "signature": "7mXCUvhLhFvG9ptrdfNceCrpThkCUyg1ct2z8uwY7eQbKz7UNmhv33TbuDjTznaypJtXRiMJyQWDnf27TH1FSXG7uJHTKAd9", } `; + +exports[`signs a zkapp command 1`] = ` +{ + "data": { + "feePayer": { + "fee": "100000000", + "feePayer": "B62qmWKtvNQTtUqo1LxfEEDLyWMg59cp6U7c4uDC7aqgaCEijSc3Hx5", + "memo": "Test", + "nonce": "0", + "validUntil": "null", + }, + "zkappCommand": { + "accountUpdates": [], + "feePayer": { + "authorization": "7mXWqNfmqMTM5uSCS2xLfsRBLTjGZKTtpEakdsrdQz1EUgYXogSvKxxtfGbBkqQ2mZRMA3uPAM8riaCF56pkqpZBLr2kNBLa", + "body": { + "fee": "100000000", + "nonce": "0", + "publicKey": "B62qmWKtvNQTtUqo1LxfEEDLyWMg59cp6U7c4uDC7aqgaCEijSc3Hx5", + "validUntil": null, + }, + }, + "memo": "E4YVT4x3A9rUhmjkjGn8ZYBLZn7zK4cfvnMtBYZFdWkg37n2s3nrP", + }, + }, + "publicKey": "B62qmWKtvNQTtUqo1LxfEEDLyWMg59cp6U7c4uDC7aqgaCEijSc3Hx5", + "signature": "7mXWqNfmqMTM5uSCS2xLfsRBLTjGZKTtpEakdsrdQz1EUgYXogSvKxxtfGbBkqQ2mZRMA3uPAM8riaCF56pkqpZBLr2kNBLa", +} +`; diff --git a/packages/accounts/src/accounts/private-key-to-account.spec.ts b/packages/accounts/src/accounts/private-key-to-account.spec.ts index 7ed0724..f370925 100644 --- a/packages/accounts/src/accounts/private-key-to-account.spec.ts +++ b/packages/accounts/src/accounts/private-key-to-account.spec.ts @@ -33,6 +33,35 @@ it("signs a transaction", async () => { expect(signedTransaction).toMatchSnapshot(); }); +it("signs a zkapp command", async () => { + const account = privateKeyToAccount({ + privateKey: Test.accounts[0].privateKey, + }); + const command = { + zkappCommand: { + accountUpdates: [], + memo: "E4YM2vTHhWEg66xpj52JErHUBU4pZ1yageL4TVDDpTTSsv8mK6YaH", + feePayer: { + body: { + publicKey: "B62qmWKtvNQTtUqo1LxfEEDLyWMg59cp6U7c4uDC7aqgaCEijSc3Hx5", + fee: "100000000", + validUntil: "100000", + nonce: "1", + }, + authorization: "", + }, + }, + feePayer: { + feePayer: "B62qmWKtvNQTtUqo1LxfEEDLyWMg59cp6U7c4uDC7aqgaCEijSc3Hx5", + fee: "100000000", + nonce: "0", + memo: "Test", + }, + }; + const signedTransaction = await account.signTransaction({ command }); + expect(signedTransaction).toMatchSnapshot(); +}); + it("creates a nullifier", async () => { const account = privateKeyToAccount({ privateKey: Test.accounts[0].privateKey, diff --git a/packages/accounts/src/accounts/private-key-to-account.ts b/packages/accounts/src/accounts/private-key-to-account.ts index 6d3ccb6..c276eb2 100644 --- a/packages/accounts/src/accounts/private-key-to-account.ts +++ b/packages/accounts/src/accounts/private-key-to-account.ts @@ -29,9 +29,14 @@ export function privateKeyToAccount({ async signMessage({ message }) { return SignedMessageSchema.parse(client.signMessage(message, privateKey)); }, - async signTransaction({ transaction }) { + async signTransaction(signable) { + if ("transaction" in signable) { + return SignedTransactionSchema.parse( + client.signTransaction(signable.transaction, privateKey), + ); + } return SignedTransactionSchema.parse( - client.signTransaction(transaction, privateKey), + client.signTransaction(signable.command as never, privateKey), ); }, async createNullifier({ message }) { diff --git a/packages/accounts/src/types.ts b/packages/accounts/src/types.ts index bc23ebc..f8f2be0 100644 --- a/packages/accounts/src/types.ts +++ b/packages/accounts/src/types.ts @@ -4,6 +4,7 @@ import type { SignedFields, SignedMessage, SignedTransaction, + TransactionOrZkAppCommandProperties, } from "@mina-js/utils"; import type { HDKey } from "@scure/bip32"; import type { Simplify } from "type-fest"; @@ -12,7 +13,6 @@ import type { CreateNullifierParamsSchema, SignFieldsParamsSchema, SignMessageParamsSchema, - SignTransactionParamsSchema, } from "./validation"; export enum MinaKeyConst { @@ -81,7 +81,6 @@ export type { HDKey }; export type SignFieldsParams = z.infer; export type SignMessageParams = z.infer; export type CreateNullifierParams = z.infer; -export type SignTransactionParams = z.infer; /** * Signer methods @@ -92,5 +91,5 @@ export type CreateNullifier = ( params: CreateNullifierParams, ) => Promise; export type SignTransaction = ( - params: SignTransactionParams, + params: TransactionOrZkAppCommandProperties, ) => Promise; diff --git a/packages/providers/src/validation.ts b/packages/providers/src/validation.ts index f853702..49cb748 100644 --- a/packages/providers/src/validation.ts +++ b/packages/providers/src/validation.ts @@ -102,10 +102,12 @@ export const StorePrivateCredentialRequestParamsSchema = export const PresentationRequestParamsSchema = RequestWithContext.extend({ method: z.literal("mina_requestPresentation"), params: z.array( - z.object({ - presentationRequest: PresentationRequestSchema, - zkAppAccount: zkAppAccountSchema.optional(), - }).strict(), + z + .object({ + presentationRequest: PresentationRequestSchema, + zkAppAccount: zkAppAccountSchema.optional(), + }) + .strict(), ), }).strict(); diff --git a/packages/utils/package.json b/packages/utils/package.json index c363571..aa55899 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -17,7 +17,9 @@ "cleanup": "rimraf dist .turbo" }, "dependencies": { - "mina-signer": "3.0.7" + "mina-signer": "3.0.7", + "serialize-error": "^11.0.3", + "superjson": "2.2.1" }, "devDependencies": { "zod": "3.23.8" diff --git a/packages/utils/src/src/format-mina.spec.ts b/packages/utils/src/format-mina.spec.ts similarity index 100% rename from packages/utils/src/src/format-mina.spec.ts rename to packages/utils/src/format-mina.spec.ts diff --git a/packages/utils/src/src/format-mina.ts b/packages/utils/src/format-mina.ts similarity index 100% rename from packages/utils/src/src/format-mina.ts rename to packages/utils/src/format-mina.ts diff --git a/packages/utils/src/src/format-units.spec.ts b/packages/utils/src/format-units.spec.ts similarity index 100% rename from packages/utils/src/src/format-units.spec.ts rename to packages/utils/src/format-units.spec.ts diff --git a/packages/utils/src/src/format-units.ts b/packages/utils/src/format-units.ts similarity index 100% rename from packages/utils/src/src/format-units.ts rename to packages/utils/src/format-units.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e0af0bf..c4581b7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,7 +1,8 @@ export * from "./types"; export * from "./validation"; -export { formatMina } from "./src/format-mina"; -export { formatUnits } from "./src/format-units"; -export { parseMina } from "./src/parse-mina"; -export { parseUnits } from "./src/parse-units"; +export { formatMina } from "./format-mina"; +export { formatUnits } from "./format-units"; +export { parseMina } from "./parse-mina"; +export { parseUnits } from "./parse-units"; +export { createRpc, createRpcHandler } from "./worker-rpc"; export * as Test from "./test/constants"; diff --git a/packages/utils/src/src/parse-mina.spec.ts b/packages/utils/src/parse-mina.spec.ts similarity index 100% rename from packages/utils/src/src/parse-mina.spec.ts rename to packages/utils/src/parse-mina.spec.ts diff --git a/packages/utils/src/src/parse-mina.ts b/packages/utils/src/parse-mina.ts similarity index 100% rename from packages/utils/src/src/parse-mina.ts rename to packages/utils/src/parse-mina.ts diff --git a/packages/utils/src/src/parse-units.spec.ts b/packages/utils/src/parse-units.spec.ts similarity index 100% rename from packages/utils/src/src/parse-units.spec.ts rename to packages/utils/src/parse-units.spec.ts diff --git a/packages/utils/src/src/parse-units.ts b/packages/utils/src/parse-units.ts similarity index 100% rename from packages/utils/src/src/parse-units.ts rename to packages/utils/src/parse-units.ts diff --git a/packages/utils/src/test/worker.ts b/packages/utils/src/test/worker.ts new file mode 100644 index 0000000..1048622 --- /dev/null +++ b/packages/utils/src/test/worker.ts @@ -0,0 +1,9 @@ +import { createRpcHandler } from "../worker-rpc"; + +const { messageHandler } = createRpcHandler({ + methods: { + ping: async () => "pong", + }, +}); + +self.onmessage = messageHandler; diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index c12ba4c..525626f 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -15,6 +15,7 @@ import type { SignedTransactionSchema, StoredCredentialSchema, TransactionBodySchema, + TransactionOrZkAppCommandSchema, TransactionPayloadSchema, TransactionReceiptSchema, ZkAppCommandBodySchema, @@ -33,6 +34,9 @@ export type TransactionPayload = z.infer; export type PartialTransaction = z.infer; export type ZkAppCommandBody = z.infer; export type ZkAppCommandProperties = z.infer; +export type TransactionOrZkAppCommandProperties = z.infer< + typeof TransactionOrZkAppCommandSchema +>; export type Sendable = z.infer; /** diff --git a/packages/utils/src/validation.ts b/packages/utils/src/validation.ts index f1633b5..2c661fd 100644 --- a/packages/utils/src/validation.ts +++ b/packages/utils/src/validation.ts @@ -87,6 +87,10 @@ export const FieldsAndPassphraseSchema = z passphrase: z.string(), }) .strict(); +export const TransactionOrZkAppCommandSchema = z.union([ + TransactionPayloadSchema, + ZkAppCommandPayload, +]); /** * Return type schemas @@ -131,7 +135,7 @@ export const NullifierSchema = z export const SignedTransactionSchema = z .object({ - signature: SignatureSchema, + signature: z.union([SignatureSchema, z.string()]), publicKey: PublicKeySchema, data: z.union([TransactionBodySchema, ZkAppCommandBodySchema]), }) diff --git a/packages/utils/src/worker-rpc.spec.ts b/packages/utils/src/worker-rpc.spec.ts new file mode 100644 index 0000000..6aee4e8 --- /dev/null +++ b/packages/utils/src/worker-rpc.spec.ts @@ -0,0 +1,34 @@ +import { beforeAll, describe, expect, it, mock } from "bun:test"; +import { createRpc, createRpcHandler } from "./worker-rpc"; + +describe("Worker RPC", () => { + let worker: Worker; + + beforeAll(() => { + worker = new Worker(new URL("./test/worker.ts", import.meta.url)); + }); + + it("creates RPC handler", async () => { + const mockedHandler = mock(async () => "pong"); + const { messageHandler } = createRpcHandler({ + methods: { + ping: mockedHandler, + }, + }); + await messageHandler( + new MessageEvent("message", { data: { method: "ping", params: [] } }), + ); + expect(mockedHandler).toHaveBeenCalled(); + }); + + it("exchanges messages with Web Worker", async () => { + const rpc = createRpc({ worker }); + const response = await rpc.request({ method: "ping", params: [] }); + expect(response.result).toBe("pong"); + }); + + it("calls non-existing method", async () => { + const rpc = createRpc({ worker }); + expect(rpc.request({ method: "pang", params: [] })).rejects.toThrow(); + }); +}); diff --git a/packages/utils/src/worker-rpc.ts b/packages/utils/src/worker-rpc.ts new file mode 100644 index 0000000..7bdeaa7 --- /dev/null +++ b/packages/utils/src/worker-rpc.ts @@ -0,0 +1,99 @@ +import { deserializeError, serializeError } from "serialize-error"; +import superjson from "superjson"; +import { z } from "zod"; + +const DEFAULT_TIMEOUT = 60000; + +export const RequestSchema = z.object({ + method: z.string(), + params: z.array(z.string()).optional(), +}); + +type RequestParams = z.infer; + +export const ResponseSchema = z + .object({ + id: z.string(), + result: z.any().optional(), + error: z.string().optional(), + }) + .strict(); + +type Response = z.infer; + +export type RequestFn = (params: RequestParams) => Promise; + +export const createRpc = ({ + worker, + timeout, +}: { + worker: Worker; + timeout?: number; +}) => { + const request: RequestFn = async ({ method, params }) => { + let resolved = false; + return new Promise((resolve, reject) => { + setTimeout(() => { + if (resolved) return; + return reject(new Error("[WorkerRPC] Timeout reached.")); + }, timeout ?? DEFAULT_TIMEOUT); + const responseListener = (event: MessageEvent) => { + resolved = true; + worker.removeEventListener("message", responseListener); + const data = superjson.parse(event.data); + const response = ResponseSchema.parse(data); + if (response.error) { + const errorObject = superjson.parse(response.error); + const deserializedError = deserializeError(errorObject); + return reject(deserializedError); + } + return resolve(response); + }; + worker.addEventListener("message", responseListener); + worker.postMessage({ method, params }); + }); + }; + return { + request, + }; +}; + +type Method = (params: string[]) => Promise; +type MethodsMap = Record; + +const respond = (data: unknown) => postMessage(superjson.stringify(data)); + +export const createRpcHandler = ({ methods }: { methods: MethodsMap }) => { + const methodKeys = Object.keys(methods); + if (methodKeys.length === 0) throw new Error("No methods provided."); + const MethodEnum = z.enum(["error", ...methodKeys]); + const ExtendedRequestSchema = RequestSchema.extend({ + method: MethodEnum, + }).strict(); + const ExtendedResponseSchema = ResponseSchema.extend({ + id: MethodEnum, + }).strict(); + const messageHandler = async (event: MessageEvent) => { + try { + const action = ExtendedRequestSchema.parse(event.data); + const callable = methods[action.method]; + if (!callable) throw new Error(`Method "${action.method}" not found.`); + const result = await callable(action.params ?? []); + const parsedResult = ExtendedResponseSchema.parse({ + id: action.method, + result, + }); + return respond(parsedResult); + // biome-ignore lint/suspicious/noExplicitAny: Error handling + } catch (error: any) { + const serializedError = superjson.stringify(serializeError(error)); + return respond( + ExtendedResponseSchema.parse({ + id: "error", + error: serializedError, + }), + ); + } + }; + return { messageHandler }; +};