Skip to main content

Deploy and test the multi-signature wallet

This test of the multi-signature wallet does the following:

  • Implements a special multi-signature signer and a 'helper' class representing the multi-signature wallet
  • Creates two new 'regular' wallets and uses one of them to deploy the multi-signature wallet
  • Creates a message signed by both wallets and sends it to the multi-signature wallet

Import statements

import { createRequire } from "node:module";

const require = createRequire(import.meta.url);

import {
ExternalMessageEnvelope,
type Hex,
HttpTransport,
type ISigner,
PublicClient,
type SendMessageParams,
bytesToHex,
calculateAddress,
convertEthToWei,
externalDeploymentMessage,
generateRandomPrivateKey,
generateWallet,
getPublicKey,
hexToBytes,
isHexString,
refineAddress,
refineSalt,
waitTillCompleted,
} from "@nilfoundation/niljs";
import { secp256k1 } from "@noble/curves/secp256k1";

import { concatBytes, numberToBytesBE } from "@noble/curves/abstract/utils";
import { type Abi, encodeFunctionData } from "viem";

Auxiliary functions

The refineFunctionHexData() function is a special 'helper' function that encodes calldata after passing several checks:

const refineFunctionHexData = ({
data,
abi,
functionName,
args,
}: {
data?: Uint8Array | Hex;
abi?: Abi;
functionName?: string;
args?: unknown[];
}): Hex => {
if (!data && !abi) {
return "0x";
}
if (data) {
return typeof data === "string" ? data : bytesToHex(data);
}
if (!functionName) {
throw new Error("Function name is required");
}
if (!abi) {
throw new Error("ABI is required");
}
return encodeFunctionData({
abi,
functionName: functionName,
args: args || [],
});
};

The multi-signature signer and wallet

The MultisigSigner class creates multi-signatures given an array of keys while the MultiSigWallet class provides an abstraction over sending external messages to the multi-signature wallet contracts:

/**
* MultisigSigner is a special signer that can create an array of signatures
* when given a the data to sign.
*
* @class MultisigSigner
* @typedef {MultisigSigner}
* @implements {ISigner}
*/
class MultisigSigner implements ISigner {
private keys: Uint8Array[];
constructor(keys: Uint8Array[]) {
for (let i = 0; i < keys.length; i++) {
if (keys[i].length !== 32) {
throw new Error("Invalid key length");
}
}
this.keys = keys;
}

async sign(data: Uint8Array): Promise<Uint8Array> {
const fullSignatures = new Uint8Array(this.keys.length * 65);
for (let i = 0; i < this.keys.length; i++) {
const signature = secp256k1.sign(data, this.keys[i]);
const { r, s, recovery } = signature;
fullSignatures.set(
concatBytes(
numberToBytesBE(r, 32),
numberToBytesBE(s, 32),
numberToBytesBE(recovery, 1),
),
i * 65,
);
}
return fullSignatures;
}
getPublicKey(): Uint8Array {
throw new Error("Method not implemented.");
}
getAddress(params: unknown): Uint8Array {
throw new Error("Method not implemented.");
}
}

/**
* MultiSigWallet is a 'helper' class for sending external messages
* to the multi-signature wallet.
*
* @class MultiSigWallet
* @typedef {MultiSigWallet}
*/
class MultiSigWallet {
private keys: Uint8Array[];
private salt: bigint;
private chainId: number;
private client: PublicClient;
public address: Hex;
constructor(
keys: (Uint8Array | Hex)[],
salt: bigint,
chainId: number,
shardId: number,
client: PublicClient,
) {
this.keys = keys.map((key) => {
if (isHexString(key)) {
return hexToBytes(key);
}
return key;
});
this.salt = salt;
this.address = MultiSigWallet.calculateAddress(chainId, shardId, keys, salt);
this.chainId = chainId;
this.client = client;
}
static calculateAddress(
chainId: number,
shardId: number,
keys: (Uint8Array | Hex)[],
salt: bigint,
) {
const msg = externalDeploymentMessage(
{
abi: MULTISIG_WALLET_ABI,
args: [keys],
bytecode: MULTISIG_WALLET_BYTECODE,
salt,
shard: shardId,
},
chainId,
);
return msg.hexAddress();
}

async sendTransaction({
to,
refundTo,
bounceTo,
data,
abi,
functionName,
args,
deploy,
seqno,
feeCredit,
value,
tokens,
chainId,
}: SendMessageParams) {
const refinedSeqno = seqno ?? (await this.client.getMessageCount(this.address, "latest"));

const hexTo = refineAddress(to);
const hexRefundTo = refineAddress(refundTo ?? this.address);
const hexBounceTo = refineAddress(bounceTo ?? this.address);
const hexData = refineFunctionHexData({ data, abi, functionName, args });

const callData = encodeFunctionData({
abi: MULTISIG_WALLET_ABI,
functionName: "asyncCall",
args: [hexTo, hexRefundTo, hexBounceTo, feeCredit, tokens ?? [], value ?? 0n, hexData],
});
const msg = new ExternalMessageEnvelope({
isDeploy: !!deploy,
data: hexToBytes(callData),
to: hexToBytes(this.address),
seqno: refinedSeqno,
chainId: chainId ?? this.chainId,
authData: new Uint8Array(0),
});

const { raw } = await msg.encodeWithSignature(signer);
const hash = await this.client.sendRawMessage(raw);
return hash;
}
}

Usage flows

The example creates three new private keys and two new wallets. Note that the second wallet does not have to be deployed:

const SALT = BigInt(Math.floor(Math.random() * 10000));

const client = new PublicClient({
transport: new HttpTransport({
endpoint: RPC_ENDPOINT,
}),
shardId: 1,
});

const pkOne = generateRandomPrivateKey();
const pkTwo = generateRandomPrivateKey();
const pkThree = generateRandomPrivateKey();

const wallet = await generateWallet({
shardId: 1,
rpcEndpoint: RPC_ENDPOINT,
faucetEndpoint: FAUCET_ENDPOINT,
});

const gasPrice = await client.getGasPrice(1);

// A random address
const dstAddress = refineAddress(calculateAddress(1, Uint8Array.of(1), refineSalt(SALT)));

The hexKeys array is an array of hex strings representing the generated private keys. It is passed to the constructor of the multi-signature wallet contract to deploy it:

const hexKeys = [pkOne, pkTwo, pkThree].map((key) => getPublicKey(key));

const { address: multiSigWalletAddress, hash: deploymentMessageHash } =
await wallet.deployContract({
bytecode: MULTISIG_WALLET_BYTECODE,
abi: MULTISIG_WALLET_ABI,
args: [hexKeys],
value: convertEthToWei(0.001),
feeCredit: 10_000_000n * gasPrice,
salt: SALT,
shardId: 1,
});

const signer = new MultisigSigner([pkOne, pkTwo, pkThree].map((x) => hexToBytes(x)));

const receipts = await waitTillCompleted(client, deploymentMessageHash);

After the multi-signature wallet is deployed, a transfer request is placed using the sendTransaction() abstraction:

const chainId = await client.chainId();

const multiWallet = new MultiSigWallet(hexKeys, SALT, chainId, 1, client);

const withdrawalHash = await multiWallet.sendTransaction({
to: dstAddress,
value: convertEthToWei(0.000001),
feeCredit: 10_000_000n * gasPrice,
});

await waitTillCompleted(client, withdrawalHash);

const balance = await client.getBalance(dstAddress, "latest");

The expected balance of walletTwo should be equal to convertEthToWei(0.000001).