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.
| Participant | Is a notary |
|---|---|
| the sender | always |
| any other participant | only 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:
- Multi-Sig - the authorization lifecycle, key weights, and the
notaryflag - Submitting an Interaction - the signing and submission flow
- Interactions - the signature and weight rules every Interaction must meet
- Create Assets - transferring an asset between participants
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:
| Identifier | Is |
|---|---|
sender | the account that authors the transfer, and so a notary by default |
beneficiary | the account that receives the 1,000 KMOI |
approver | the 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:
- Code
- Output
console.log("keys: ", await provider.getAccountKeys(sender.id));
// Console Output
keys: [
{
id: '0x0',
publicKey: '0x1ec28dabfc3e4ac4dfc2084b45785b5e9cf1287b63a4f469c5051a29d5963bae',
weight: '0x3e8',
signature_algorithm: '0x0',
revoked: true,
sequence_id: '0x0'
},
{
id: '0x1',
publicKey: '0x02c1b6f8a5e94d3b0f7a2c85d6193e4fb8027ac35d9e16b4a8f30c72d5e19b6a41',
weight: '0x1f4',
signature_algorithm: '0x0',
revoked: false,
sequence_id: '0x0'
},
{
id: '0x2',
publicKey: '0x03e7d15a9c3b862f04ad71e5983c6b20fa4e8d97125b0c6ef83a49d10b7e52c8f3',
weight: '0x1f4',
signature_algorithm: '0x0',
revoked: false,
sequence_id: '0x0'
}
]
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:
- Code
- Output
const kmoi = new MASNAssetLogic(senderWallet);
const ixObject = await kmoi.transfer(beneficiary.id, 1_000).ixData();
await senderWallet.sendInteraction(ixObject);
// Console Output
Error: invalid sender's signature: weight cannot be less than 1000
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:
- Code
- Output
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,
);
// Console Output
status: 0 fuel used: 0x12b
delta sender: -15950
delta beneficiary: 1000
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:
- Code
- Output
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);
// Console Output
participants: [
{
id: '0x00000000e2253583b559663a6af90b29ebb02118f1553d3844bb74aa00000000', # beneficiary-id
lock_type: 0
},
{
id: '0x1080fffe4cd973c4eb83cdb8870c0de209736270491b7acc99873da100000000', # asset-id
lock_type: 2
},
{
id: '0x000000007c4a1f9d3e8b2065ca47d1f0b83e5c2971aa6d4438f0e91b00000000', # approver-id
lock_type: 0,
notary: true
}
]
Step 2: Collect the Signature and Submit
The approver signs the same object, and its signatures are passed along with the sender's submission.
- Code
- Output
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);
// Console Output
status: 0 fuel used: 0x12b
delta sender: -15950
delta beneficiary: 1000
delta approver: 0
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
| Symptom | Likely cause |
|---|---|
| A key was added but the account still signs alone | Weights accumulate. Revoke the key that already reaches 1000. |
invalid notary participant signature: weight cannot be less than 1000 | A notary: true participant did not sign, or its signing keys weigh under 1000 combined. |
a notary payer must hold a mutate lock | A payer flagged notary: true was given any lock but MutateLock. |
| Signatures are present but the network rejects them | The co-signers signed a different object. Finalize ixObject before collecting signatures. |
Where Next
- Multi-Sig - the authorization model, key weights, and the verification engine.
- Configure Accounts - adding, revoking, and reweighting an account's keys.
- Sponsor Interactions - handing the fuel bill to a third-party payer.
- Manage Access Policies - the complementary layer: multi-sig gates who signed, access control gates what may be touched.