Using the Multisig Validator

When installed with the Multisig Validator Module, the starknet modular account not only can have multiple signers registered but it can also require N of those signers to agree to sign a transaction. This Multisig feature takes place offchain and the SmartrAccount class provides the framework manage several signers on the same account to sign the a transaction. This is what is shown in this section.

Note 1: This section assumes the SmartrAccount class has been instantiated in the smartrAccount variable as shown in Using the modular account from the SDK. It also assumes the Counter contract that comes with the project has been deploys to the counterAddress and the CounterABI class are available. The 03-setup.ts script that comes with this project ensure those steps are executed.

Note 2: This multi-signer model has some limits: the exchange of the transaction between signers is not managed by the SDK and requires some synchronization between the actors. In addition, the fact the transaction is a regular transaction that involves the account Nonce generated by the network prevents from using it at a large scale. The starknet modular account can be used to implement more advanced models to workaround those 2 issues.

Interacting with the Multisig Validator

The SmartrAccount class, however, provides more than just the regular Account class. It can interact with functions that are part of the module and not part of the account. In the case of the Multisig Validator, those functions are:

#![allow(unused)]
fn main() {
fn get_public_keys(self: @TState) -> Array<felt252>;
fn add_public_key(ref self: TState, new_public_key: felt252);
fn remove_public_key(ref self: TState, old_public_key: felt252);
fn get_threshold(self: @TState) -> u8;
fn set_threshold(ref self: TState, new_threshold: u8);
}

To execute a function that is part of the module you need:

  • to figure out the Multisig Validator module class hash
  • to check the module is installed on the account. That is something that is setup at the account deployment time
  • to use one of callOnModule for view functions or executeOnModule for running transactions on the SmartrAccount.

The sections below dig into the details of these operations.

Getting the stark validator module class hash

This is something we have already done previously. You can use classHash("MultisigValidator") after your imported the classHash function from @0xknwn/starknet-module like below:

// file src/03-check-class.ts
import { classHash } from "@0xknwn/starknet-module";

console.log("MultisigValidator class hash:", classHash("MultisigValidator"));

To execute the script, make sure you have deployed the account in the network and run the following commands:

npx tsc --build

node dist/03-check-class.js

Check the module is installed on the account

The SmartrAccount provides a method isModule that can be used to know if a module is installed with the account.

// file src/03-module-installed.ts
import { SmartrAccount } from "@0xknwn/starknet-modular-account";
import { classHash } from "@0xknwn/starknet-module";
import { init } from "./03-init";
import { RpcProvider } from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, smartrAccountPrivateKey } = await init();
  const account = new SmartrAccount(
    provider,
    accountAddress,
    smartrAccountPrivateKey
  );
  const isInstalled = await account.isModule(classHash("MultisigValidator"));
  console.log(
    "module",
    classHash("MultisigValidator"),
    "is installed",
    isInstalled
  );
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Transpile and run the script:

npx tsc --build

node dist/03-module-installed.js

Calling views functions in the module

To execute a view function on the module, we must build the argumemt list with the CallData class. Thwn we can call the callOnModule function from SmartrAccount with the module class hash, the function name and the calldata like below:

// file src/03-registered-publickeys.ts
import { SmartrAccount } from "@0xknwn/starknet-modular-account";
import {
  MultisigValidatorABI,
  classHash as moduleClassHash,
} from "@0xknwn/starknet-module";
import { init } from "./03-init";
import { CallData, RpcProvider } from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, smartrAccountPrivateKey } = await init();
  const account = new SmartrAccount(
    provider,
    accountAddress,
    smartrAccountPrivateKey
  );
  const moduleCallData = new CallData(MultisigValidatorABI);
  const calldata = moduleCallData.compile("get_public_keys", {});
  const publickeysList = await account.callOnModule(
    moduleClassHash("MultisigValidator"),
    "get_public_keys",
    calldata
  );
  console.log("number of public keys for module", publickeysList.length);
  publickeysList.forEach((publickey, idx) => {
    console.log("publickey #", idx + 1, `0x${publickey.toString(16)}`);
  });
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Transpile and run the script:

npx tsc --build

node dist/03-registered-publickeys.js

Executing external functions in the module

To execute an external function on the module, we must build the argumemt list with the CallData class. Then we can call the executeOnModule function from SmartrAccount with the module class hash, the function name and the calldata like below. Here we will register a second public key for the same account:

// file src/03-add-publickeys.ts
import {
  SmartrAccountABI,
  SmartrAccount,
} from "@0xknwn/starknet-modular-account";
import {
  MultisigValidatorABI,
  classHash as moduleClassHash,
} from "@0xknwn/starknet-module";
import { init } from "./03-init";
import { CallData, RpcProvider, Signer, hash, type Call } from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";
const secondAccountPrivateKey = "0x2";
const thirdAccountPrivateKey = "0x3";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, smartrAccountPrivateKey } = await init();
  const account = new SmartrAccount(
    provider,
    accountAddress,
    smartrAccountPrivateKey
  );
  const module_class_hash = moduleClassHash("MultisigValidator");
  const calls: Call[] = [];
  for (const privateKey of [secondAccountPrivateKey, thirdAccountPrivateKey]) {
    const signer = new Signer(privateKey);
    const publicKey = await signer.getPubKey();
    console.log("new account public key", publicKey);
    const moduleCallData = new CallData(MultisigValidatorABI);
    const moduleCalldata = moduleCallData.compile("add_public_key", {
      new_public_key: publicKey,
    });
    const accountCallData = new CallData(SmartrAccountABI);
    const calldata = accountCallData.compile("execute_on_module", {
      class_hash: module_class_hash,
      call: {
        selector: hash.getSelectorFromName("add_public_key"),
        to: accountAddress,
        calldata: moduleCalldata,
      },
    });
    const call: Call = {
      entrypoint: "execute_on_module",
      contractAddress: accountAddress,
      calldata,
    };
    calls.push(call);
  }
  const { transaction_hash } = await account.execute(calls);
  const receipt = await account.waitForTransaction(transaction_hash);
  console.log("transaction succeeded", receipt.isSuccess());
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Transpile and run the script:

npx tsc --build

node dist/03-add-publickeys.js

You can re-run the script from the previous example to check the account has two registered public key:

node dist/03-registered-publickeys.js

The output should look like that:

number of public keys for module 3
publickey # 1 0x1ef15c18599971b7beced415a40f0c7deacfd9b0d1819e03d723d8bc943cfca
publickey # 2 0x759ca09377679ecd535a81e83039658bf40959283187c654c5416f439403cf5
publickey # 3 0x411494b501a98abd8262b0da1351e17899a0c4ef23dd2f96fec5ba847310b20

Interacting with a Contract with the new registered key

You now can interact with the SmartrAccount with your second private key like below:

// file src/03-execute-tx.ts
import { SmartrAccount } from "@0xknwn/starknet-modular-account";
import { init, CounterABI } from "./03-init";
import { RpcProvider, Contract } from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";
const secondAccountPrivateKey = "0x2";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, counterAddress } = await init();
  const account = new SmartrAccount(
    provider,
    accountAddress,
    secondAccountPrivateKey
  );
  const counter = new Contract(CounterABI, counterAddress, account);
  let currentCounter = await counter.call("get");
  console.log("currentCounter", currentCounter);
  const call = counter.populate("increment");
  const { transaction_hash } = await account.execute(call);
  const receipt = await account.waitForTransaction(transaction_hash);
  console.log("transaction succeeded", receipt.isSuccess());
  currentCounter = await counter.call("get");
  console.log("currentCounter", currentCounter);
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Transpile and run the script:

npx tsc --build

node dist/03-execute-tx-pk2.js

Changing the Account Threshold to 2

By changing the Multisig Validator Threshold to 2, you force transactions to be signed by 2 of the 3 signers of the account. Run a script like below to change the threshold:

// file src/03-increase-threshold.ts
import { SmartrAccount } from "@0xknwn/starknet-modular-account";
import {
  MultisigValidatorABI,
  classHash as moduleClassHash,
} from "@0xknwn/starknet-module";
import { init } from "./03-init";
import { CallData, RpcProvider } from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, smartrAccountPrivateKey } = await init();
  const account = new SmartrAccount(
    provider,
    accountAddress,
    smartrAccountPrivateKey
  );
  const moduleCallData = new CallData(MultisigValidatorABI);
  const calldata = moduleCallData.compile("set_threshold", {
    new_threshold: 2,
  });
  const { transaction_hash } = await account.executeOnModule(
    moduleClassHash("MultisigValidator"),
    "set_threshold",
    calldata
  );
  const receipt = await account.waitForTransaction(transaction_hash);
  console.log("transaction succeeded", receipt.isSuccess());
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Transpile and run the script:

npx tsc --build

node dist/03-increase-threshold.js

You can check the current threshold on the account with the script below:

// file src/04-get-threshold.ts
import { SmartrAccount } from "@0xknwn/starknet-modular-account";
import {
  MultisigValidatorABI,
  classHash as moduleClassHash,
} from "@0xknwn/starknet-module";
import { init } from "./03-init";
import { CallData, RpcProvider } from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, smartrAccountPrivateKey } = await init();
  const account = new SmartrAccount(
    provider,
    accountAddress,
    smartrAccountPrivateKey
  );
  const moduleCallData = new CallData(MultisigValidatorABI);
  const calldata = await moduleCallData.compile("get_threshold", {});
  const threshold = await account.callOnModule(
    moduleClassHash("MultisigValidator"),
    "get_threshold",
    calldata
  );
  threshold.forEach((threshold) => console.log("threshold", threshold));
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Run the script with the command below:

npx tsc --build

node dist/03-get-threshold.js

Checking you can NOT run a transaction with a single signer

The script below executes a transaction with a single signer as it was the case in the previous section:

// file src/03-execute-tx.ts
import { SmartrAccount } from "@0xknwn/starknet-modular-account";
import { init, CounterABI } from "./03-init";
import { RpcProvider, Contract } from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, counterAddress, smartrAccountPrivateKey } =
    await init();
  const account = new SmartrAccount(
    provider,
    accountAddress,
    smartrAccountPrivateKey
  );
  const counter = new Contract(CounterABI, counterAddress, account);
  let currentCounter = await counter.call("get");
  console.log("currentCounter", currentCounter);
  const call = counter.populate("increment");
  const { transaction_hash } = await account.execute(call);
  const receipt = await account.waitForTransaction(transaction_hash);
  console.log("transaction succeeded", receipt.isSuccess());
  currentCounter = await counter.call("get");
  console.log("currentCounter", currentCounter);
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Make sure you have deployed the account and the counter contract in the network and run the following commands:

npx tsc --build

node dist/03-execute-tx.js

You are now getting an error saying the signature is invalid like below:

Execution failed.
Failure reason: 0x4163636f756e743a20696e76616c6964207369676e6174757265 
('Account: invalid signature')

Running a Multiple Signer Transaction

To run a transaction with multiple signers, you need to instantiate several SmartrAccount, each one with a different signer. Because you have set the threshold, you need to instantiate 2 accounts.

Once done, proceed in 3 steps:

  • Step 1: generate the transaction details. This requires you create the calls but also you set some details about it, including: the Fees, the Nonce, the Version and the Chain. The SmartrAccount class uses the provider to get the chain id. To get the other details, you should run the prepareMultisig that returns the details associated with the transaction.
  • Step 2: have all the signers generate their part of the signature. The signMultisig takes the list of calls and the details you have generated and provides the signature as an array of string
  • Step 3: Execute the transaction with all the signatures from Step 2. This could be done by anyone, including one of the account you have already created. The executeMultisig function takes the list of calls, the details and an array that contains all the signatures.

The script below signs the transaction with 2 signers and to run the increment external function of the Counter contract. It shows the value of the counter before and after the call:

// file src/03-execute-tx-multiple-signers.ts
import { SmartrAccount } from "@0xknwn/starknet-modular-account";
import { init, CounterABI } from "./03-init";
import { RpcProvider, Contract, ArraySignatureType } from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";
const secondSmartrAccountPrivateKey = "0x2";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, counterAddress, smartrAccountPrivateKey } =
    await init();
  const firstAccount = new SmartrAccount(
    provider,
    accountAddress,
    smartrAccountPrivateKey
  );
  const secondAccount = new SmartrAccount(
    provider,
    accountAddress,
    secondSmartrAccountPrivateKey
  );

  // Before you start check the value of the counter
  const counter = new Contract(CounterABI, counterAddress, provider);
  let currentCounter = await counter.call("get");
  console.log("currentCounter value", currentCounter);

  // Step 1: Prepare the transaction and get the details
  const call = counter.populate("increment");
  const calls = [call];

  const detail = await firstAccount.prepareMultisig(calls);
  console.log("below are the details assciated with the transaction");
  console.log(detail);

  // Step 2: Sign the transaction with 2 signers
  // (because the threshold on the account is currently 2)
  const firstSignature: ArraySignatureType = await firstAccount.signMultisig(
    calls,
    detail
  );
  console.log("first signature is", firstSignature);
  const secondSignature: ArraySignatureType = await secondAccount.signMultisig(
    calls,
    detail
  );
  console.log("second signature is", secondSignature);

  // Step 3: Execute the transaction
  const { transaction_hash } = await firstAccount.executeMultisig(
    calls,
    detail,
    [...firstSignature, ...secondSignature]
  );
  const receipt = await firstAccount.waitForTransaction(transaction_hash);
  console.log("transaction succeeded", receipt.isSuccess());

  // Once finished, check the value of the counter again
  currentCounter = await counter.call("get");
  console.log("currentCounter value", currentCounter);
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Transpile and run the script:

npx tsc --build

node dist/03-execute-tx-multiple-signers.js

Reset the threshold to one

As for any transaction, you need to run a multi-signed transaction to reset the account threshold back to one. The script below build the call to execute_on_module and run it with multiple signer:

// file src/03-decrease-threshold.ts
import {
  SmartrAccount,
  SmartrAccountABI,
} from "@0xknwn/starknet-modular-account";
import {
  MultisigValidatorABI,
  classHash as moduleClassHash,
} from "@0xknwn/starknet-module";
import { init } from "./03-init";
import {
  CallData,
  RpcProvider,
  hash,
  type Call,
  type ArraySignatureType,
} from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";
const secondSmartrAccountPrivateKey = "0x2";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, smartrAccountPrivateKey } = await init();
  const firstAccount = new SmartrAccount(
    provider,
    accountAddress,
    smartrAccountPrivateKey
  );
  const secondAccount = new SmartrAccount(
    provider,
    accountAddress,
    secondSmartrAccountPrivateKey
  );

  // Before you start build the set_threshold call
  const moduleCallData = new CallData(MultisigValidatorABI);
  const moduleCalldata = moduleCallData.compile("set_threshold", {
    new_threshold: 1,
  });
  const accountCallData = new CallData(SmartrAccountABI);
  const calldata = accountCallData.compile("execute_on_module", {
    class_hash: moduleClassHash("MultisigValidator"),
    call: {
      selector: hash.getSelectorFromName("set_threshold"),
      to: accountAddress,
      calldata: moduleCalldata,
    },
  });
  const call: Call = {
    entrypoint: "execute_on_module",
    contractAddress: accountAddress,
    calldata,
  };
  const calls = [call];

  // Step 1: Prepare the transaction and get the details
  const detail = await firstAccount.prepareMultisig(calls);

  // Step 2: Sign the transaction with 2 signers
  // (because the threshold on the account is currently 2)
  const firstSignature: ArraySignatureType = await firstAccount.signMultisig(
    calls,
    detail
  );
  const secondSignature: ArraySignatureType = await secondAccount.signMultisig(
    calls,
    detail
  );

  // Step 3: Execute the transaction
  const { transaction_hash } = await firstAccount.executeMultisig(
    calls,
    detail,
    [...firstSignature, ...secondSignature]
  );
  const receipt = await firstAccount.waitForTransaction(transaction_hash);
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Execute the script with the following commands:

npx tsc --build

node dist/03-decrease-threshold.js

You can check the threshold is back to one:

node dist/03-get-threshold.js

Remove Registered Keys

You can now run transaction with a single signer on the account. The script below shows how to remove 2 public keys from a single call:

// file src/03-remove-publickeys.ts
import {
  SmartrAccountABI,
  SmartrAccount,
} from "@0xknwn/starknet-modular-account";
import {
  MultisigValidatorABI,
  classHash as moduleClassHash,
} from "@0xknwn/starknet-module";
import { init } from "./03-init";
import { CallData, RpcProvider, Signer, hash, type Call } from "starknet";

const providerURL = "http://127.0.0.1:5050/rpc";
const secondAccountPrivateKey = "0x2";
const thirdAccountPrivateKey = "0x3";

const main = async () => {
  const provider = new RpcProvider({ nodeUrl: providerURL });
  const { accountAddress, smartrAccountPrivateKey } = await init();
  const account = new SmartrAccount(
    provider,
    accountAddress,
    smartrAccountPrivateKey
  );
  const module_class_hash = moduleClassHash("MultisigValidator");
  const calls: Call[] = [];
  for (const privateKey of [secondAccountPrivateKey, thirdAccountPrivateKey]) {
    const signer = new Signer(privateKey);
    const publicKey = await signer.getPubKey();
    console.log("account public key to remove", publicKey);
    const moduleCallData = new CallData(MultisigValidatorABI);
    const moduleCalldata = moduleCallData.compile("remove_public_key", {
      old_public_key: publicKey,
    });
    const accountCallData = new CallData(SmartrAccountABI);
    const calldata = accountCallData.compile("execute_on_module", {
      class_hash: module_class_hash,
      call: {
        selector: hash.getSelectorFromName("remove_public_key"),
        to: accountAddress,
        calldata: moduleCalldata,
      },
    });
    const call: Call = {
      entrypoint: "execute_on_module",
      contractAddress: accountAddress,
      calldata,
    };
    calls.push(call);
  }
  const { transaction_hash } = await account.execute(calls);
  const receipt = await account.waitForTransaction(transaction_hash);
  console.log("transaction succeeded", receipt.isSuccess());
};

main()
  .then(() => {})
  .catch((e) => {
    console.warn(e);
  });

Transpile and run the script:

npx tsc --build

node dist/03-remove-publickeys.js

You can check the 2 of the 3 public keys have been removed:

node dist/03-registered-publickeys.js