Skip to main content

Multi-sig accounts

info

Preview.

TL;DR

Convert a regular account into an M-of-N multi-sig: the master key is replaced by a signer set, every state-mutating action must collect threshold signatures from signers, and the conversion is irreversible. Designed for institutional custody, DAO treasuries, and joint-control trading desks.

Why multi-sig

Regular accounts have a single master key. Loss = total loss. Multi-sig spreads custody risk across signers:

  • 2-of-3: any two of three signers can act; one can be lost without locking the account.
  • 3-of-5: 3 signatures required; up to 2 lost keys are tolerated; up to 2 compromised keys cannot move funds.

This is the same primitive that backs every Gnosis Safe / institutional self-custody setup, native at the protocol layer rather than via a smart contract.

Lifecycle

Conversion

{
"type": "convert_to_multi_sig_user",
"params": {
"threshold": 2,
"signers": [ "0x...s1", "0x...s2", "0x...s3" ]
}
}

Signed by the current master key (single-sig, the last solo signature this account ever makes).

ConstraintValue
threshold[1, len(signers)]
len(signers)[2, 16]
signers[*]distinct addresses

After commit:

  • The account's is_multisig: true and multisig_set: { threshold, signers } are stored.
  • Subsequent direct (non-wrapped) actions signed by anyone (including the old master key) are rejected with PRECONDITION_FAILED.

Irreversible: there is no revert to a single-master key. The signer set can be rotated — or the multi-sig requirement retired — via a multi-sig-wrapped convert_to_multi_sig_user (see Updating the signer set), but the account cannot return to plain single-key control.

Acting as multi-sig

Every state-mutating action for the account is wrapped in a multi_sig action and posted to POST /exchange as a normal signed envelope. The wrapper carries four params:

{
"signature": "0x<submitter_sig>",
"nonce": 1735689600099,
"action": {
"type": "multi_sig",
"params": {
"user": "0x<multisig_addr>",
"inner_action_blob": "0x<hex of the canonical inner-action JSON>",
"signatures": [ "0x<roster_sig_1>", "0x<roster_sig_2>" ],
"nonce": 1735689600099
}
}
}
FieldMeaning
userThe multi-sig account whose state the inner action mutates.
inner_action_blob0x-hex of the canonical JSON bytes of the inner action (e.g. a submit_order). These exact bytes are what each signer signs and what the server hashes — they are never re-serialized.
signaturesA flat array of 0x-hex 65-byte roster signatures over the inner digest (see below). There is no per-entry signer field — the signer is recovered from each signature.
nonceThe wrapper nonce. It is also the nonce each signer folds into the inner digest, and it is the value that advances user's nonce window. Set the outer envelope nonce to this same value.

The wrapper is a normal EIP-712-signed /exchange envelope: the submitter signs the outer multi_sig action with their own key. The submitter can be any account — it does not need to be a member of the signer set. The authority that moves the multi-sig account's state comes entirely from the inner roster signatures, so a coordinator service (or any one signer) can broadcast the assembled bundle.

Server checks:

  1. Each entry in signatures is recovered over the inner digest of the raw inner_action_blob bytes.
  2. The recovered signers are all in the account's roster, distinct, and number ≥ threshold.
  3. The inner action is executable as a multi-sig inner (privileged / system actions, stake votes, and a nested multi_sig are rejected).
  4. If all checks pass, the inner action is dispatched as if user had signed it directly, and user's nonce advances to the wrapper nonce.

A failed check answers AUTH_UNAUTHORIZED, whose message names which one: threshold not met, duplicate signer, or signer not in set.

Signing the inner action

Each signer signs a standard EIP-712 digest over the exact inner_action_blob bytes (the canonical inner-action JSON), under the standard MetaFlux domain. The struct that is hashed depends on the network version:

  • nonce is the wrapper's nonce (they must match), big-endian in the final 32-byte word.
  • [user] — the multi-sig account address — is present only in the user-bound scheme (see the next section). The legacy scheme omits it.

The assembled signatures array is collected off-chain (a coordinator gathers each member's signature over the identical inner_action_blob) and submitted by any account.

Use the ordinary wire action as the blob. UTF-8 encode the same {type, params} object you would post to /exchange, and let every member sign those bytes. The node decodes the blob through the same lowering /exchange admission runs, so a member signs the action the chain executes.

Build the blob ONCE and distribute it. Every member must sign identical bytes, and two members who each serialize their own copy are trusting two JSON writers to agree on key order and spacing. One blob and one distribution cannot disagree.

The node also accepts the core Action JSON, which is what older bundles carry. Do not build that form by hand: its variant names live in the node and move when actions move, and a blob that drifts is refused after the member signatures are already spent.

User-bound inner signatures

info

One scheme, not two. The network accepts only the user-bound scheme. Exactly one scheme is valid at a time — there is no dual-accept window — so a signer must produce the user-bound digest for any bundle that will land at/after the upgrade.

Each signer signs the EIP-712 struct:

MetaFluxMultiSigInner(address user,string action,uint64 nonce)
FieldValue
userThe multi-sig account (the wrapper's user), left-padded to 32 bytes per EIP-712 address encoding.
actionThe inner action, hashed as keccak256 of the exact inner_action_blob bytes (not re-serialized).
nonceThe wrapper nonce.

Binding user into the signed struct means a roster signature now authorizes exactly one account. Two multi-sig accounts that share the same signer set (for example a hot/cold pair, or several desks under one custody policy) can no longer replay each other's bundles — a signature collected for one account is cryptographically useless against the other.

The previous scheme signed only MetaFluxAction(string action,uint64 nonce) (no user), so from the upgrade onward any bundle whose inner signatures were built under the old, unbound struct is rejected. Re-collect signatures under the user-bound struct for any bundle submitted at/after the upgrade.

Updating the signer set

info

Availability. Rotating or disabling the signer set through the quorum is available :::

There is no separate "update" action. To rotate the roster, the account's current signers re-run convert_to_multi_sig_user wrapped in multi_sig — it requires threshold signatures from the current set and overwrites the stored roster with the new { threshold, signers }:

{
"type": "convert_to_multi_sig_user",
"params": {
"threshold": 3,
"signers": [ "0x...s1", "0x...s2", "0x...s4", "0x...s5", "0x...s6" ]
}
}

To disable multi-sig entirely (remove the requirement), the current signers submit the same wrapped convert_to_multi_sig_user with an empty signers array and threshold: 0. Both the re-key and the disable path require a full quorum of the current set.

Use to:

  • Rotate compromised keys
  • Add or remove signers
  • Change threshold (e.g. moving from 2-of-3 to 3-of-5 as the desk grows)
  • Retire the multi-sig requirement (empty roster + threshold: 0)

Off-chain coordination

The protocol doesn't bundle the multi-sig flow — signers need an out-of-band way to share the message to sign and to collect signatures. Common patterns:

PatternMechanism
Internal coordinator serviceEach signer's wallet polls a shared inbox; serialises the inner action; signs; uploads signature back; coordinator submits when threshold reached
Shared private channelEncrypted group chat / email; each signer pastes their signature; one signer aggregates and submits
Multi-sig SDK (planned)Official SDK ships a signer-collection workflow that hides the coordination layer

Until the SDK lands, integrators implement their own coordinator. The on-chain side is unchanged — only the signatures matter.

Compatibility with sub-accounts and agents

QuestionAnswer
Can a multi-sig account have sub-accounts?Yes. create_sub_account is itself a multi-sig-wrapped action. Each sub inherits the multi-sig signing requirement.
Can a multi-sig account approve agent wallets?Yes. approve_agent is multi-sig-wrapped. Once approved, the agent can sign normally without further multi-sig collection — the agent's signature alone is enough for the actions it's allowed to perform. This is the typical institutional setup: multi-sig holds withdrawal authority + agent management; an agent runs the daily trading flow.
Can the multi-sig account itself sign as an agent for another account?Yes — multi-sig accounts can be approved as agents. Other accounts that approve them call approve_agent { agent: <multisig_addr> }. The multi-sig signer set then signs as needed.

Edge cases

Show edge cases
  • Lost keys: M-of-N tolerates up to N - M losses. Plan key custody to spread the loss surface (different jurisdictions, different HSMs, different humans).
  • Compromised key: M-of-N tolerates up to M - 1 compromises before funds can be moved. Detect early — but NOT from the order feeds. An inner order inside a multi-sig envelope used to produce no order_updates message, no fills message and no historical_orders record, leaving a watcher blind to exactly the action it looks for. Node 0.9.5 records it — see unrecorded fills. Against an older node, or for defence in depth, Watch ledger_updates and diff account_state and open_orders instead.
  • Nonce collisions: the multi-sig's nonce is per-account, monotonic, same as single-sig. Two parallel signing efforts that pick the same nonce: only one commits; the other is refused with INVALID_REQUEST. Coordinator should assign nonces.
  • Signature expiry: roster signatures over the inner blob don't expire on their own — a signature collected today is valid until the bundle is submitted. Some integrators add their own off-chain TTL. (The optional action expiresAfter applies to the outer /exchange envelope, not to the inner roster signatures.)

Querying

curl -X POST https://api.testnet.mtf.exchange/info \
-d '{"type":"account_state","address":"0x<multisig>","detail":"overview"}'
{
"type": "account_state",
"data": {
"address": "0x<multisig>",
"multisig": {
"is_multi_sig": true,
"threshold": 2,
"signers": ["0x...", "0x...", "0x..."]
}
}
}

multisig.is_multi_sig is false (and signers empty) for a plain account. The signer set + threshold come straight from the committed multi_sig_tracker config. See detail: "overview".

Sequence — multi-sig order

See also

FAQ

Show FAQ

Q: Can I do 1-of-N (an "any" sig)? A: Yes — threshold: 1. Useful for redundancy without coordination. Functionally equivalent to having N separate accounts with shared withdrawal authority, but cheaper on-chain.

Q: Are inner-action signatures shareable across different inner actions? A: No. Each signature is over a specific inner action + nonce. Trying to reuse a signature on a different inner action is refused with AUTH_UNAUTHORIZED.

Q: Is the multi-sig wrapping recursive? A: No. A multi_sig whose inner blob is itself a multi_sig is rejected. One layer only.

Q: Can multi-sig wrap a multi_sig? (Meta-question.) A: Same as above — recursion blocked. To act as a multi-sig on behalf of another multi-sig, the outer account approves the inner multi-sig as an agent.