Skip to main content

Multi-Sig in MOI

MOI supports multi-sig natively. There is no multi-sig contract to deploy. The protocol verifies the required signatures when it validates the Interaction.

MOI offers this at two levels. Account Multi-Sig decides how one account's signature reaches the threshold, by spreading its authority across weighted keys. Interaction Multi-Sig decides which accounts must sign, by declaring them as notaries. See Multi-Sig for the full model.

This tutorial walks through both levels using the same KMOI transfer. Part 1 splits the sender's authority across two weighted keys. Part 2 makes a third account co-sign the transfer.

The Notary Rule

The protocol validates the signature of every notary on an Interaction, and rejects it at submission if any is missing or falls short. A notary's signature is valid when the keys that produced it carry a combined weight of at least 1000, the default account threshold.

ParticipantIs a notary
the senderalways
any other participantonly if notary: true

Part 1 keeps the sender as the only notary and spreads its 1000 across two keys. Part 2 declares a second notary.

Prerequisites

We recommend reading:

The walkthrough assumes your code already has a provider and initialized wallet signers, as set up in Setting up JS-MOI-SDK.


The Scenario

The sender moves 1,000 KMOI to a beneficiary. Three accounts take part:

IdentifierIs
senderthe account that authors the transfer, and so a notary by default
beneficiarythe account that receives the 1,000 KMOI
approverthe account that co-signs the transfer in Part 2

Each has its own wallet:

import { LockType, MASNAssetLogic, KMOI_ASSET_ID } from "js-moi-sdk";

const participant = async (wallet) => ({
id: (await wallet.getIdentifier()).toHex(),
key_id: await wallet.getKeyId(),
sequence: await wallet.getNonce(),
});

const sender = await participant(senderWallet);
const beneficiary = await participant(beneficiaryWallet);
const approver = await participant(approverWallet);

const balance = async (id) => await provider.getBalance(id, KMOI_ASSET_ID);

Part 1: Account Multi-Sig (Key-Level)

In Part 1 the sender is the only notary. The Part replaces its single 1000-weight key with two keys of 500 each, so the sender's signature only reaches the threshold when both keys sign.

Step 1: Split the Sender's Authority Across Two Keys

Generate two keypairs and register them on the account at 500 weight each:

import { AccountConfigure, Wallet } from "js-moi-sdk";

const keyA = await Wallet.createRandom();
const keyB = await Wallet.createRandom();

const added = await new AccountConfigure(senderWallet)
.addKey(keyA.publicKey, 500)
.addKey(keyB.publicKey, 500)
.send();

await added.wait();

Revoke the original key 0 in a second Interaction - an AccountConfigure cannot add and revoke in the same one:

const revoked = await new AccountConfigure(senderWallet).revokeKey(0).send();

await revoked.wait();

Read the keys back to confirm:

console.log("keys: ", await provider.getAccountKeys(sender.id));

Two live keys of 0x1f4 (500) each. Now register key 1 on the wallet, make it the sender key, and drop the revoked key 0:

senderWallet.addKey(1, keyA.publicKey, keyA.privateKey);
senderWallet.setKeyId(1);
senderWallet.removeKey(0);

console.log("registered: ", senderWallet.getKeys()); // [ { key_id: 1, public_key: '0x02c1b6...' } ]

Step 2: One Key Falls Short

The wallet holds key 1 alone, so submitting contributes a single signature worth 500:

const kmoi = new MASNAssetLogic(senderWallet);
const ixObject = await kmoi.transfer(beneficiary.id, 1_000).ixData();

await senderWallet.sendInteraction(ixObject);

The rejection happens at submission, so no receipt is produced and no fuel is charged.

Step 3: Both Keys Clear the Threshold

Register key 2 on the same wallet. sendInteraction now signs with both keys:

senderWallet.addKey(2, keyB.publicKey, keyB.privateKey);

const senderBefore = await balance(sender.id);
const beneficiaryBefore = await balance(beneficiary.id);

const response = await senderWallet.sendInteraction(ixObject);

const receipt = await response.wait();
console.log("status: ", receipt.status, " fuel used:", receipt.fuel_used);

console.log("delta sender:", (await balance(sender.id)) - senderBefore);
console.log(
"delta beneficiary:",
(await balance(beneficiary.id)) - beneficiaryBefore,
);

500 + 500 meets the threshold and the transfer settles.


Part 2: Interaction Multi-Sig (Participant-Level)

Some workflows need approval from more than one account. Part 2 declares the approver as a second notary, so the network requires its signature alongside the sender's. See Interaction Multi-Sig for how a Coco logic cross-checks the inclusion of these signatures.

Step 1: Declare the Approver as a Notary

Pass the approver in participants with notary: true. ixData appends it to the participants the transfer already declares - the beneficiary and the asset:

const kmoi = new MASNAssetLogic(senderWallet);
const options = {
participants: [
{ id: approver.id, lock_type: LockType.MUTATE_LOCK, notary: true },
],
};

const ixObject = await kmoi.transfer(beneficiary.id, 1_000).ixData(options);

console.log("participants: ", ixObject.participants);

Step 2: Collect the Signature and Submit

The approver signs the same object, and its signatures are passed along with the sender's submission.

const sigAlgo = senderWallet.signingAlgorithms["ecdsa_secp256k1"];

const notarizedTransfer = async () => {
const approverSignatures = await approverWallet.signRawInteractionObject(
ixObject,
sigAlgo,
);

const response = await senderWallet.sendInteraction(
ixObject,
approverSignatures,
);

const receipt = await response.wait();
console.log("status: ", receipt.status, " fuel used:", receipt.fuel_used);
};

const senderBefore = await balance(sender.id);
const beneficiaryBefore = await balance(beneficiary.id);
const approverBefore = await balance(approver.id);

await notarizedTransfer();

console.log("delta sender:", (await balance(sender.id)) - senderBefore);
console.log(
"delta beneficiary:",
(await balance(beneficiary.id)) - beneficiaryBefore,
);
console.log("delta approver:", (await balance(approver.id)) - approverBefore);

The approver's balance did not move. The sender paid the 1,000 KMOI plus the fuel (0x12b = 299 units at the default fuel_price of 50).


Troubleshooting

SymptomLikely cause
A key was added but the account still signs aloneWeights accumulate. Revoke the key that already reaches 1000.
invalid notary participant signature: weight cannot be less than 1000A notary: true participant did not sign, or its signing keys weigh under 1000 combined.
a notary payer must hold a mutate lockA payer flagged notary: true was given any lock but MutateLock.
Signatures are present but the network rejects themThe co-signers signed a different object. Finalize ixObject before collecting signatures.

Where Next