diff --git a/.gitignore b/.gitignore index 661ad18..0841ed3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -/dist -/node_modules +dist +node_modules package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 343667f..ada8a0f 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,52 @@ A wrapper to enable easy usage of murmur wallets in web apps. -More coming soon. \ No newline at end of file +More coming soon. + +## Usage + +The Murmur Client depends on: + +- Axios for making HTTP requests to the Murmur API +- Polkadot-js for interacting with the Ideal Network + +You need to configure an `axios` and a `polkadot-js` instances to be injected in the Murmur Client. + +```javascript +import { ApiPromise, WsProvider, Keyring } from "@polkadot/api"; +import { KeyringPair } from "@polkadot/keyring/types"; +import axios from "axios"; +import { MurmurClient } from "murmur.js"; + +/* Polkadot API initialization */ +const provider = new WsProvider("ws://127.0.0.1:9944"); +console.log("Provider initialized"); +const api = await ApiPromise.create({ provider }); +console.log("API initialized"); + +/* Axios initialization */ +const httpClient = axios.create({ + baseURL: "https://api.example.com", + headers: { + "Content-Type": "application/json", + }, +}); + +/* Define the master account (optional, it falls back to `alice`) */ +const keyring = new Keyring({ type: "sr25519" }); +const alice = keyring.addFromUri("//Alice"); + +/* MurmurClient initialization */ +const murmurClient = new MurmurClient(httpClient, api, alice); +console.log("MurmurClient initialized"); + +// Use the MurmurClient instance to make requests +murmurClient + .authenticate("username", "password") + .then((response) => { + console.log(response); + }) + .catch((error) => { + console.error(error); + }); +``` diff --git a/examples/create-execute/package.json b/examples/create-execute/package.json new file mode 100644 index 0000000..ee9f4e9 --- /dev/null +++ b/examples/create-execute/package.json @@ -0,0 +1,21 @@ +{ + "name": "murmur-example-create-execute", + "version": "1.0.0", + "description": "", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "typescript": "^4.9.5" + }, + "dependencies": { + "@polkadot/api": "^13.2.1", + "axios": "^1.7.7", + "murmur.js": "file:../../../murmur.js" + } +} diff --git a/examples/create-execute/src/index.ts b/examples/create-execute/src/index.ts new file mode 100644 index 0000000..5bdb1a3 --- /dev/null +++ b/examples/create-execute/src/index.ts @@ -0,0 +1,40 @@ +import { ApiPromise, WsProvider } from "@polkadot/api"; +import axios from "axios"; +import { MurmurClient } from "murmur.js"; + +/* Polkadot API initialization */ +const provider = new WsProvider("ws://127.0.0.1:9944"); +console.log("Provider initialized"); +const api = await ApiPromise.create({ provider }); +console.log("API initialized"); +// Retrieve the chain & node information via rpc calls +const [chain, nodeName, nodeVersion] = await Promise.all([ + api.rpc.system.chain(), + api.rpc.system.name(), + api.rpc.system.version(), +]); +console.log( + `You are connected to chain ${chain} using ${nodeName} v${nodeVersion}` +); + +/* Axios initialization */ +const httpClient = axios.create({ + baseURL: "http://127.0.0.1:8000", + headers: { + "Content-Type": "application/json", + }, +}); + +/* MurmurClient initialization */ +const murmurClient = new MurmurClient(httpClient, api); +console.log("MurmurClient initialized"); + +const loguinResult = await murmurClient.authenticate("admin", "password"); +console.log(loguinResult); + +await murmurClient.new(100, async (result: any) => { + console.log(`Tx Block Hash: ${result.status.asFinalized}`); + const bob = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; + const call = api.tx.balances.transferAllowDeath(bob, 1000000000000); + await murmurClient.execute(call); +}); diff --git a/examples/create-execute/tsconfig.json b/examples/create-execute/tsconfig.json new file mode 100644 index 0000000..8a11fe7 --- /dev/null +++ b/examples/create-execute/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} diff --git a/examples/simple-connect/package.json b/examples/simple-connect/package.json new file mode 100644 index 0000000..bb7967b --- /dev/null +++ b/examples/simple-connect/package.json @@ -0,0 +1,21 @@ +{ + "name": "murmur-example-simple-connect", + "version": "1.0.0", + "description": "", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "typescript": "^4.9.5" + }, + "dependencies": { + "@polkadot/api": "^13.2.1", + "axios": "^1.7.7", + "murmur.js": "file:../../../murmur.js" + } +} diff --git a/examples/simple-connect/src/index.ts b/examples/simple-connect/src/index.ts new file mode 100644 index 0000000..df7dbef --- /dev/null +++ b/examples/simple-connect/src/index.ts @@ -0,0 +1,30 @@ +import { ApiPromise, WsProvider } from "@polkadot/api"; +import axios from "axios"; +import { MurmurClient } from "murmur.js"; + +/* Polkadot API initialization */ +const provider = new WsProvider("ws://127.0.0.1:9944"); +console.log("Provider initialized"); +const api = await ApiPromise.create({ provider }); +console.log("API initialized"); +// Retrieve the chain & node information via rpc calls +const [chain, nodeName, nodeVersion] = await Promise.all([ + api.rpc.system.chain(), + api.rpc.system.name(), + api.rpc.system.version(), +]); +console.log( + `You are connected to chain ${chain} using ${nodeName} v${nodeVersion}` +); + +/* Axios initialization */ +const httpClient = axios.create({ + baseURL: "https://api.example.com", + headers: { + "Content-Type": "application/json", + }, +}); + +/* MurmurClient initialization */ +new MurmurClient(httpClient, api); +console.log("MurmurClient initialized"); diff --git a/examples/simple-connect/tsconfig.json b/examples/simple-connect/tsconfig.json new file mode 100644 index 0000000..8a11fe7 --- /dev/null +++ b/examples/simple-connect/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} diff --git a/package.json b/package.json index 28e2095..26e2907 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "author": "Ideal Labs ", "dependencies": { + "@polkadot/api": "^13.2.1", "axios": "^1.7.7" }, "devDependencies": { diff --git a/src/MurmurClient.ts b/src/MurmurClient.ts new file mode 100644 index 0000000..60fbaf9 --- /dev/null +++ b/src/MurmurClient.ts @@ -0,0 +1,210 @@ +import { AxiosInstance } from "axios"; +import { ApiPromise, Keyring } from "@polkadot/api"; +import { KeyringPair } from "@polkadot/keyring/types"; +import type { + NewRequest, + ExecuteRequest, + CreateResponse, + ExecuteResponse, + Payload, + Extrinsic, +} from "./types"; +import type { BlockNumber } from "@polkadot/types/interfaces"; + +export class MurmurClient { + private http: AxiosInstance; + private idn: ApiPromise; + private masterAccount: KeyringPair; + + /** + * Creates an instance of MurmurClient. + * + * @param http - The AxiosInstance to be used for HTTP requests. + * @param idn - The ApiPromise to be used for interacting with the IDN blockchain. + */ + constructor( + http: AxiosInstance, + idn: ApiPromise, + masterAccount?: KeyringPair + ) { + this.http = http; + this.idn = idn; + this.masterAccount = masterAccount ?? this.defaultMasterAccount(); + } + + /** + * Authenticates the user with the specified username and password. + * + * @param username - The username of the user. + * @param password - The password of the user. + * @returns A promise that resolves to a string indicating the result of the authentication. + */ + async authenticate(username: string, password: string): Promise { + try { + const response = await this.http.post("/authenticate", { + username, + password, + }); + + // Extract the Set-Cookie header + const setCookieHeader = response.headers["set-cookie"]; + if (setCookieHeader) { + // Store the cookies in the Axios instance's default headers to keep the session + this.http.defaults.headers.Cookie = setCookieHeader.join("; "); + } + + return response.data; + } catch (error) { + throw new Error(`Authenticattion failed: ${error}`); + } + } + + /** + * Creates a new wallet with a specified validity period. + * + * @param validity - The number of blocks in which the wallet will be valid. + * @param callback - The callback function to be called when the transaction is finalized. + * @returns A promise that resolves to a string indicating the block hash of the block in which + * the transaction was executed, or in which was included if it didn't execute. + */ + async new( + validity: number, + callback: (result: any) => Promise = async () => {} + ): Promise { + const MAX_U32 = 2 ** 32 - 1; + if (!Number.isInteger(validity)) { + throw new Error("The validity parameter must be an integer."); + } + + if (validity < 0 || validity > MAX_U32) { + throw new Error( + `The validity parameter must be within the range of 0 to ${MAX_U32}.` + ); + } + const request: NewRequest = { + validity, + current_block: (await this.getCurrentBlock()).toNumber(), + round_pubkey: (await this.getRoundPublic()).toString(), + }; + + try { + const response = (await this.http.post("/create", request)) + .data as CreateResponse; + + const extrinsic = this.constructExtrinsic(response.payload); + + this.submitExtrinsic(extrinsic, callback); + + return Promise.resolve(); + } catch (error) { + throw new Error(`New failed: ${error}`); + } + } + + /** + * Executes a transaction to send a specified amount of tokens to a destination account. + * + * @param extrinsic - A submittable extrinsic. + * @param callback - The callback function to be called when the transaction is finalized. + * @returns A promise that resolves to a string indicating the result of the transaction. + */ + async execute( + extrinsic: Extrinsic, + callback: (result: any) => Promise = async () => {} + ): Promise { + const request: ExecuteRequest = { + runtime_call: this.encodeExtrinsic(extrinsic), + current_block: (await this.getCurrentBlock()).toNumber(), + }; + try { + const response = (await this.http.post("/execute", request)) + .data as ExecuteResponse; + + const outerExtrinsic = this.idn.tx.murmur.proxy( + response.payload.call_data.name, + response.payload.call_data.pos, + response.payload.call_data.commitment, + response.payload.call_data.ciphertext, + response.payload.call_data.proof_items, + response.payload.call_data.size, + extrinsic, + ) + + this.submitExtrinsic(outerExtrinsic, callback); + + return Promise.resolve(); + } catch (error) { + throw new Error(`Execute failed: ${error}`); + } + } + + private async getRoundPublic(): Promise { + await this.idn.isReady; + let roundPublic = await this.idn.query.etf.roundPublic(); + return roundPublic.toString(); + } + + private async getCurrentBlock(): Promise { + await this.idn.isReady; + const { number } = await this.idn.rpc.chain.getHeader(); + return number.unwrap(); + } + + private defaultMasterAccount(): KeyringPair { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + return alice; + } + + private async submitExtrinsic( + extrinsic: Extrinsic, + callback: (result: any) => Promise = async () => {} + ): Promise { + const unsub = await extrinsic.signAndSend( + this.masterAccount, + (result: any) => { + if (result.status.isInBlock) { + console.log( + `Transaction included at blockHash ${result.status.asInBlock}` + ); + } else if (result.status.isFinalized) { + console.log( + `Transaction finalized at blockHash ${result.status.asFinalized}` + ); + unsub(); + callback(result); + } + } + ); + return Promise.resolve(); + } + + private constructExtrinsic(payload: Payload): Extrinsic { + let extrinsicPath = + `this.idn.tx.${payload.pallet_name}.${payload.call_name}`.toLocaleLowerCase(); + let parametersPath = "("; + + for (const key in payload.call_data) { + if (Array.isArray(payload.call_data[key])) { + parametersPath += `[${payload.call_data[key]}], `; + } else if ( + isNaN(payload.call_data[key]) && + payload.call_data[key] != "true" && + payload.call_data[key] != "false" + ) { + parametersPath += `"${payload.call_data[key]}", `; + } else { + parametersPath += `${payload.call_data[key]}, `; + } + } + + parametersPath = parametersPath.slice(0, -2); + parametersPath += ")"; + extrinsicPath += parametersPath; + return eval(extrinsicPath); + } + + private encodeExtrinsic(ext: Extrinsic): number[] { + return Array.from(ext.inner.toU8a()); + } +} diff --git a/src/index.ts b/src/index.ts index 5a5af97..11239da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,81 +1 @@ -import axios, { AxiosInstance } from "axios"; - -class MurmurClient { - private client: AxiosInstance; - - constructor(baseURL: string) { - this.client = axios.create({ - baseURL, - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * Creates a new wallet with a specified validity period. - * - * @param validity - The number of blocks in which the wallet will be valid. - * @returns A promise that resolves to a string indicating the result of the wallet creation. - */ - async login(username: string, password: string): Promise { - try { - const response = await this.client.post("/login", { username, password }); - return response.data; - } catch (error) { - throw new Error(`Login failed: ${error}`); - } - } - - /** - * Creates a new wallet with a specified validity period. - * - * @param validity - The number of blocks in which the wallet will be valid. - * @returns A promise that resolves to a string indicating the result of the wallet creation. - */ - async new(validity: number): Promise { - const MAX_U32 = 2 ** 32 - 1; - if (!Number.isInteger(validity)) { - throw new Error("The validity parameter must be an integer."); - } - - if (validity < 0 || validity > MAX_U32) { - throw new Error( - `The validity parameter must be within the range of 0 to ${MAX_U32}.` - ); - } - try { - const response = await this.client.post("/new", { validity }); - return response.data; - } catch (error) { - throw new Error(`New failed: ${error}`); - } - } - - /** - * Executes a transaction to send a specified amount of tokens to a destination account. - * - * @param amount - The amount of tokens to send. - * @param to - The destination account. - * @returns A promise that resolves to a string indicating the result of the transaction. - */ - async execute(amount: bigint, to: string): Promise { - const MAX_U128 = BigInt(2 ** 128 - 1); - if (!Number.isInteger(amount)) { - throw new Error("The amount parameter must be an integer."); - } - if (amount < 0 || amount > MAX_U128) { - throw new Error( - `The amount parameter must be within the range of 0 to ${MAX_U128}.` - ); - } - try { - const response = await this.client.post("/execute", { amount, to }); - return response.data; - } catch (error) { - throw new Error(`Execute failed: ${error}`); - } - } -} - -export default MurmurClient; +export * from "./MurmurClient"; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..faff5a1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,30 @@ +import { SubmittableExtrinsic } from "@polkadot/api/types"; +import { ISubmittableResult } from "@polkadot/types/types"; + +export type NewRequest = { + validity: number; + current_block: number; + round_pubkey: string; +}; + +export type ExecuteRequest = { + // SCALE encoded runtime call + runtime_call: number[]; + current_block: number; +}; + +export type Payload = { + pallet_name: string; + call_name: string; + call_data: any; +}; + +export type CreateResponse = { + payload: Payload; +}; + +export type ExecuteResponse = { + payload: Payload; +}; + +export type Extrinsic = SubmittableExtrinsic<"promise", ISubmittableResult>; diff --git a/tsconfig.json b/tsconfig.json index fb695da..48109eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,16 @@ { - "compilerOptions": { - "target": "ES6", - "module": "commonjs", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"] -} \ No newline at end of file + "compilerOptions": { + "target": "ES6", + "module": "commonjs", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "typeRoots": ["src/types"] + }, + "include": ["./src/**/*"], + "exclude": ["node_modules"] +}