diff --git a/avail-js/docs/advanced_examples/multisig.ts b/avail-js/docs/advanced_examples/multisig.ts new file mode 100644 index 000000000..959411bfa --- /dev/null +++ b/avail-js/docs/advanced_examples/multisig.ts @@ -0,0 +1,146 @@ +import { SDK, WaitFor, Keyring, BN, KeyringPair, Weight, ParsedTxResult, MultisigTimepoint } from "./../../src/index" + +const main = async () => { + const providerEndpoint = "ws://127.0.0.1:9944" + const sdk = await SDK.New(providerEndpoint) + + // Multisig Signatures + const alice = new Keyring({ type: "sr25519" }).addFromUri("//Alice") + const bob = new Keyring({ type: "sr25519" }).addFromUri("//Bob") + const charlie = new Keyring({ type: "sr25519" }).addFromUri("//Charlie") + + // Create Multisig Account + const threshold = 3 + const multisigAddress = sdk.util.generateMultisig([alice.address, bob.address, charlie.address], threshold) + await fundMultisigAccount(sdk, alice, multisigAddress) + + // Define what action will be taken by the multisig account + const amount = new BN(10).pow(new BN(18)) // one Avail + const call = sdk.api.tx.balances.transferKeepAlive(multisigAddress, amount) + // Data needed for multisig approval and execution + const callHash = call.method.hash.toString() + const callData = call.unwrap().toHex() + const maxWeight = (await call.paymentInfo(alice.address)).weight + + /* + The first signature creates and approves the multisig transaction. All the next signatures (besides the last one) should + use the `nextApproval` function to approve the tx. The last signature should use the `lastApproval` function to approve + and execute the multisig tx. + + In practice it means the following: + - If the threshold is 2 do the following: + - firstApproval + - lastApproval + - If the threshold is 4 do the following: + - firstApproval + - nextApproval + - nextApproval + - lastApproval + */ + + // Create New Multisig + const call1signatures = sdk.util.sortMultisigAddresses([bob.address, charlie.address]) + const firstResult = await firstApproval(sdk, alice, callHash, threshold, call1signatures, maxWeight) + + // Approve existing Multisig + const timepoint: MultisigTimepoint = { height: firstResult.blockNumber, index: firstResult.txIndex } + const call2signatures = sdk.util.sortMultisigAddresses([alice.address, charlie.address]) + const _secondResult = await nextApproval(sdk, bob, callHash, threshold, call2signatures, timepoint) + + // Execute Multisig + const call3signatures = sdk.util.sortMultisigAddresses([alice.address, bob.address]) + const _thirdResult = await lastApproval(sdk, charlie, threshold, call3signatures, timepoint, callData, maxWeight) + + process.exit() +} + +async function fundMultisigAccount(sdk: SDK, alice: KeyringPair, multisigAddress: string): Promise { + console.log("Funding multisig account...") + const amount = new BN(10).pow(new BN(18)).mul(new BN(100)) // 100 Avail + const result = await sdk.tx.balances.transferKeepAlive(multisigAddress, amount, WaitFor.BlockInclusion, alice) + if (result.isErr) { + console.log(result.reason) + process.exit(1) + } + + return multisigAddress +} + +async function firstApproval( + sdk: SDK, + account: KeyringPair, + callHash: string, + threshold: number, + otherSignatures: string[], + maxWeight: Weight, +): Promise { + console.log("Alice is creating a Multisig Transaction...") + + const maybeTxResult = await sdk.util.firstMultisigApproval( + callHash, + threshold, + otherSignatures, + maxWeight, + WaitFor.BlockInclusion, + account, + ) + if (maybeTxResult.isErr()) { + console.log(maybeTxResult.error) + process.exit(1) + } + return maybeTxResult.value +} + +async function nextApproval( + sdk: SDK, + account: KeyringPair, + callHash: string, + threshold: number, + otherSignatures: string[], + timepoint: MultisigTimepoint, +): Promise { + console.log("Bob is approving the existing Multisig Transaction...") + + const maybeTxResult = await sdk.util.nextMultisigApproval( + callHash, + threshold, + otherSignatures, + timepoint, + WaitFor.BlockInclusion, + account, + ) + if (maybeTxResult.isErr()) { + console.log(maybeTxResult.error) + process.exit(1) + } + return maybeTxResult.value +} + +async function lastApproval( + sdk: SDK, + account: KeyringPair, + threshold: number, + otherSignatures: string[], + timepoint: MultisigTimepoint, + callData: string, + maxWeight: Weight, +): Promise { + console.log("Charlie is approving and executing the existing Multisig Transaction...") + + const maybeTxResult = await sdk.util.lastMultisigApproval( + threshold, + otherSignatures, + timepoint, + callData, + maxWeight, + WaitFor.BlockInclusion, + account, + ) + if (maybeTxResult.isErr()) { + console.log(maybeTxResult.error) + process.exit(1) + } + return maybeTxResult.value +} + +main() diff --git a/avail-js/package-lock.json b/avail-js/package-lock.json index 9087c1e43..eaf13a764 100644 --- a/avail-js/package-lock.json +++ b/avail-js/package-lock.json @@ -1,12 +1,12 @@ { "name": "avail-js-sdk", - "version": "0.2.17", + "version": "0.2.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "avail-js-sdk", - "version": "0.2.17", + "version": "0.2.18", "license": "ISC", "dependencies": { "@polkadot/api": "^10.11.3", diff --git a/avail-js/src/sdk/index.ts b/avail-js/src/sdk/index.ts index a06ac9425..baef63a51 100644 --- a/avail-js/src/sdk/index.ts +++ b/avail-js/src/sdk/index.ts @@ -1,13 +1,18 @@ import { ApiPromise } from "@polkadot/api" import { initialize } from "../chain" import { Transactions } from "./transactions" +import { Utils } from "./utils" export * as sdkTransactions from "./transactions" export * as sdkTransactionData from "./transaction_data" export { BN } from "@polkadot/util" export { Keyring } from "@polkadot/api" +export { KeyringPair } from "@polkadot/keyring/types" export { Bytes } from "@polkadot/types-codec" +export { H256, Weight } from "@polkadot/types/interfaces" +export { ParsedTxResult, MultisigTimepoint } from "./utils" + export { WaitFor, StakingRewardDestination, @@ -21,6 +26,7 @@ export { export class SDK { api: ApiPromise tx: Transactions + util: Utils static async New(endpoint: string): Promise { const api = await initialize(endpoint) @@ -30,5 +36,6 @@ export class SDK { private constructor(api: ApiPromise) { this.api = api this.tx = new Transactions(api) + this.util = new Utils(api) } } diff --git a/avail-js/src/sdk/utils.ts b/avail-js/src/sdk/utils.ts deleted file mode 100644 index e6beb5258..000000000 --- a/avail-js/src/sdk/utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { err, ok, Result } from "neverthrow" - -/** - * Converts a commission percentage to a perbill format. - * - * @param {number} value - The commission percentage (0-100). - * @return {string} The commission value in perbill format. - * @throws {Error} If the value is not an integer or is out of the 0-100 range. - */ -export function commissionNumberToPerbill(value: number): Result { - if (!Number.isInteger(value)) { - return err("Commission cannot have decimal place. It needs to be a whole number.") - } - - if (value < 0 || value > 100) { - return err("Commission is limited to the following range: 0 - 100. It cannot be less than 0 or more than 100.") - } - - let commission = value.toString().concat("0000000") - // For some reason 0 commission is not defined as "0" but as "1". - if (commission == "00000000") { - commission = "1" - } - - return ok(commission) -} diff --git a/avail-js/src/sdk/utils/index.ts b/avail-js/src/sdk/utils/index.ts new file mode 100644 index 000000000..abbf942ce --- /dev/null +++ b/avail-js/src/sdk/utils/index.ts @@ -0,0 +1,223 @@ +import { ApiPromise } from "@polkadot/api" +import { err, ok, Result } from "neverthrow" +import { ISubmittableResult } from "@polkadot/types/types/extrinsic" +import { EventRecord, H256, Weight } from "@polkadot/types/interfaces" +import { decodeError } from "../../helpers" +import { getBlockHashAndTxHash, standardCallback, WaitFor } from "../transactions/common" +import { createKeyMulti, encodeAddress, sortAddresses } from "@polkadot/util-crypto" +import { KeyringPair } from "@polkadot/keyring/types" +import { SignerOptions } from "@polkadot/api/types" + +export class ParsedTxResult { + constructor( + public txResult: ISubmittableResult, + public events: EventRecord[], + public txHash: H256, + public txIndex: number, + public blockHash: H256, + public blockNumber: number, + ) {} +} + +export interface MultisigTimepoint { + height: number + index: number +} + +export class Utils { + private api: ApiPromise + + constructor(api: ApiPromise) { + this.api = api + } + + /// Parses a transaction result. Helper function to get transaction details on + /// transaction success or an error if the transaction failed + async parseTransactionResult( + txResult: ISubmittableResult, + waitFor: WaitFor, + ): Promise> { + return await parseTransactionResult(this.api, txResult, waitFor) + } + + /** + * Converts a commission percentage to a perbill format. + * + * @param {number} value - The commission percentage (0-100). + * @return {string} The commission value in perbill format. + * @throws {Error} If the value is not an integer or is out of the 0-100 range. + */ + commissionNumberToPerbill(value: number): Result { + return commissionNumberToPerbill(value) + } + + /// Generates a multisig account + generateMultisig(addresses: string[], threshold: number): string { + return generateMultisig(addresses, threshold) + } + + /// Creates and approves a multisig transaction + async firstMultisigApproval( + callHash: string, + threshold: number, + otherSignatures: string[], + maxWeight: Weight, + waitFor: WaitFor, + account: KeyringPair, + options?: Partial, + ): Promise> { + const optionWrapper = options || {} + const maybeTxResult = await new Promise>((res, _) => { + this.api.tx.multisig + .approveAsMulti(threshold, otherSignatures, null, callHash, maxWeight) + .signAndSend(account, optionWrapper, (result: ISubmittableResult) => { + standardCallback(result, res, waitFor) + }) + .catch((reason) => { + res(err(reason)) + }) + }) + + if (maybeTxResult.isErr()) { + return err(maybeTxResult.error) + } + const txResult = maybeTxResult.value + const maybeParsed = await this.parseTransactionResult(txResult, waitFor) + if (maybeParsed.isErr()) { + return err(maybeParsed.error) + } + const parsed = maybeParsed.value + + return ok(parsed) + } + + /// Approves an existing multisig transaction + async nextMultisigApproval( + callHash: string, + threshold: number, + otherSignatures: string[], + timepoint: MultisigTimepoint, + waitFor: WaitFor, + account: KeyringPair, + options?: Partial, + ): Promise> { + const maxWeight = { refTime: 0, proofSize: 0 } + const optionWrapper = options || {} + const maybeTxResult = await new Promise>((res, _) => { + this.api.tx.multisig + .approveAsMulti(threshold, otherSignatures, timepoint, callHash, maxWeight) + .signAndSend(account, optionWrapper, (result: ISubmittableResult) => { + standardCallback(result, res, waitFor) + }) + .catch((reason) => { + res(err(reason)) + }) + }) + + if (maybeTxResult.isErr()) { + return err(maybeTxResult.error) + } + const txResult = maybeTxResult.value + const maybeParsed = await this.parseTransactionResult(txResult, waitFor) + if (maybeParsed.isErr()) { + return err(maybeParsed.error) + } + const parsed = maybeParsed.value + + return ok(parsed) + } + + /// Approves and executes an existing multisig transaction + async lastMultisigApproval( + threshold: number, + otherSignatures: string[], + timepoint: MultisigTimepoint, + callData: string, + maxWeight: Weight, + waitFor: WaitFor, + account: KeyringPair, + options?: Partial, + ): Promise> { + const optionWrapper = options || {} + const maybeTxResult = await new Promise>((res, _) => { + this.api.tx.multisig + .asMulti(threshold, otherSignatures, timepoint, callData, maxWeight) + .signAndSend(account, optionWrapper, (result: ISubmittableResult) => { + standardCallback(result, res, waitFor) + }) + .catch((reason) => { + res(err(reason)) + }) + }) + + if (maybeTxResult.isErr()) { + return err(maybeTxResult.error) + } + const txResult = maybeTxResult.value + const maybeParsed = await this.parseTransactionResult(txResult, waitFor) + if (maybeParsed.isErr()) { + return err(maybeParsed.error) + } + const parsed = maybeParsed.value + + return ok(parsed) + } + + /// Sorts multisig address so that ce be used for other multisig functions + sortMultisigAddresses(addresses: string[]): string[] { + return sortMultisigAddresses(addresses) + } +} + +export async function parseTransactionResult( + api: ApiPromise, + txResult: ISubmittableResult, + waitFor: WaitFor, +): Promise> { + if (txResult.isError) { + return err("The transaction was dropped or something.") + } + + const failed = txResult.events.find((e) => api.events.system.ExtrinsicFailed.is(e.event)) + if (failed != undefined) { + return err(decodeError(api, failed.event.data[0])) + } + + const events = txResult.events + const [txHash, txIndex, blockHash, blockNumber] = await getBlockHashAndTxHash(txResult, waitFor, api) + + return ok(new ParsedTxResult(txResult, events, txHash, txIndex, blockHash, blockNumber)) +} + +export function commissionNumberToPerbill(value: number): Result { + if (!Number.isInteger(value)) { + return err("Commission cannot have decimal place. It needs to be a whole number.") + } + + if (value < 0 || value > 100) { + return err("Commission is limited to the following range: 0 - 100. It cannot be less than 0 or more than 100.") + } + + let commission = value.toString().concat("0000000") + // For some reason 0 commission is not defined as "0" but as "1". + if (commission == "00000000") { + commission = "1" + } + + return ok(commission) +} + +export function generateMultisig(addresses: string[], threshold: number): string { + const SS58Prefix = 42 + + const multiAddress = createKeyMulti(addresses, threshold) + const Ss58Address = encodeAddress(multiAddress, SS58Prefix) + + return Ss58Address +} + +export function sortMultisigAddresses(addresses: string[]): string[] { + const SS58Prefix = 42 + + return sortAddresses(addresses, SS58Prefix) +}