|
| 1 | +# QLX Multisig Configuration |
| 2 | + |
| 3 | +> **Audience:** Protocol operators — the people who deploy, configure, and manage QuickLendX Soroban contracts. |
| 4 | +
|
| 5 | +This document covers the on-chain multisig contract: how to initialize it, rotate signers, and use it to authorize critical operations. It complements the [Operator Handbook](OPERATOR_HANDBOOK.md) and the [Emergency Recovery](contracts/emergency-recovery.md) guide. |
| 6 | + |
| 7 | +## Overview |
| 8 | + |
| 9 | +The `MultisigContract` (`quicklendx-contracts/src/multisig.rs`) provides a generic ed25519 threshold-signature verifier. It stores a set of owner public keys and a quorum threshold in contract instance storage. Any off-chain or on-chain caller can submit a batch of signatures and have them verified against the stored owner set. |
| 10 | + |
| 11 | +| Concept | Detail | |
| 12 | +|---------|--------| |
| 13 | +| Owners | Ed25519 public keys (`BytesN<32>`), stored as a `Vec` | |
| 14 | +| Threshold | Minimum number of distinct signatures required (`u32`, range `[1, N-1]`) | |
| 15 | +| Signature scheme | Ed25519, 64-byte signatures | |
| 16 | +| Storage keys | `owners` (`OWNERS_KEY`), `thresh` (`THRESHOLD_KEY`) | |
| 17 | + |
| 18 | +## Error Codes |
| 19 | + |
| 20 | +| Error | Code | When it fires | |
| 21 | +|-------|------|---------------| |
| 22 | +| `InvalidThreshold` | 1 | Threshold is `< 1` or `>= N` (number of owners) | |
| 23 | +| `NotEnoughSignatures` | 2 | Fewer signatures submitted than the threshold | |
| 24 | +| `DuplicateSignature` | 3 | The same owner index appears more than once in a single request | |
| 25 | +| `InvalidOwnerIndex` | 4 | An `owner_index` in the signature batch is `>= N` | |
| 26 | + |
| 27 | +## 1. Initialize the Multisig |
| 28 | + |
| 29 | +Call `initialize` with the list of owner public keys and the quorum threshold. This is a one-time setup entrypoint; re-initialization overwrites the previous owner set and threshold. |
| 30 | + |
| 31 | +**Rust entrypoint signature:** |
| 32 | + |
| 33 | +```rust |
| 34 | +pub fn initialize( |
| 35 | + env: Env, |
| 36 | + owners: Vec<BytesN<32>>, |
| 37 | + threshold: u32, |
| 38 | +) -> Result<(), MultisigError>; |
| 39 | +``` |
| 40 | + |
| 41 | +**Constraints:** |
| 42 | + |
| 43 | +- `owners.len()` must be `>= 2` (a single-owner setup is not permitted because `threshold >= 1` and `threshold < N` would be unsatisfiable with `N = 1`). |
| 44 | +- `threshold` must be in `[1, N-1]`. |
| 45 | + |
| 46 | +### Concrete example — 3 owners, threshold 2 |
| 47 | + |
| 48 | +```rust |
| 49 | +use soroban_sdk::{BytesN, Env, Vec}; |
| 50 | +use quicklendx_contracts::multisig::{MultisigContract, MultisigContractClient}; |
| 51 | + |
| 52 | +let env = Env::default(); |
| 53 | +let contract_id = env.register(MultisigContract, ()); |
| 54 | +let client = MultisigContractClient::new(&env, &contract_id); |
| 55 | + |
| 56 | +// Generate three Ed25519 keypairs |
| 57 | +let (owner0_pub, _priv0) = generate_keypair(&env, 1); |
| 58 | +let (owner1_pub, _priv1) = generate_keypair(&env, 2); |
| 59 | +let (owner2_pub, _priv2) = generate_keypair(&env, 3); |
| 60 | + |
| 61 | +let mut owners = Vec::new(&env); |
| 62 | +owners.push_back(owner0_pub); |
| 63 | +owners.push_back(owner1_pub); |
| 64 | +owners.push_back(owner2_pub); |
| 65 | + |
| 66 | +// threshold = 2: any 2 of 3 owners must sign |
| 67 | +let res = client.initialize(&owners, &2); |
| 68 | +assert!(res.is_ok()); |
| 69 | +``` |
| 70 | + |
| 71 | +### Boundary checks (tested in `test_multisig.rs`) |
| 72 | + |
| 73 | +| Scenario | Result | |
| 74 | +|----------|--------| |
| 75 | +| `threshold = 0` | `Err(InvalidThreshold)` | |
| 76 | +| `threshold = 1`, `N = 3` | Ok | |
| 77 | +| `threshold = N - 1`, `N = 3` | Ok | |
| 78 | +| `threshold = N` | `Err(InvalidThreshold)` | |
| 79 | +| `threshold = N + 1` | `Err(InvalidThreshold)` | |
| 80 | +| `N = 1`, `threshold = 1` | `Err(InvalidThreshold)` | |
| 81 | + |
| 82 | +## 2. Rotate Signers |
| 83 | + |
| 84 | +To rotate an owner key or change the threshold, call `initialize` again with the updated owner list and threshold. Because `initialize` overwrites the stored state atomically, rotation is a single on-chain transaction. |
| 85 | + |
| 86 | +### Operator rotation workflow |
| 87 | + |
| 88 | +1. **Prepare the new owner list.** Gather the Ed25519 public keys of all new signers. Remove the compromised or departed signer's key; add the replacement key at the same or a new index. |
| 89 | +2. **Choose a new threshold** if needed. The new threshold must still satisfy `1 <= threshold < N` where `N` is the new owner count. |
| 90 | +3. **Execute the rotation transaction.** Call `initialize` from the admin account. |
| 91 | + |
| 92 | +```rust |
| 93 | +// Rotation: replace owner index 1 with a new key, keep threshold = 2 |
| 94 | +let (new_owner1_pub, _new_priv1) = generate_keypair(&env, 10); |
| 95 | + |
| 96 | +let mut new_owners = Vec::new(&env); |
| 97 | +new_owners.push_back(owner0_pub); // unchanged |
| 98 | +new_owners.push_back(new_owner1_pub); // replaced |
| 99 | +new_owners.push_back(owner2_pub); // unchanged |
| 100 | + |
| 101 | +let res = client.initialize(&new_owners, &2); |
| 102 | +assert!(res.is_ok()); |
| 103 | +``` |
| 104 | + |
| 105 | +4. **Verify the rotation.** Re-initialize a client and confirm the new threshold and owner count. |
| 106 | + |
| 107 | +```rust |
| 108 | +let stored_owners: Vec<BytesN<32>> = env |
| 109 | + .storage() |
| 110 | + .instance() |
| 111 | + .get(&OWNERS_KEY) |
| 112 | + .unwrap(); |
| 113 | +assert_eq!(stored_owners.len(), 3); |
| 114 | +``` |
| 115 | + |
| 116 | +> **See also:** The treasury rotation pattern used in [QLX_TREASURY_ROTATION.md](QLX_TREASURY_ROTATION.md) applies the same two-step principles (initiate, then confirm) but with a timelock. Multisig rotation does **not** use a timelock — it overwrites state immediately. If your deployment requires a delay, wrap the `initialize` call in a governance proposal or timelock contract. |
| 117 | +
|
| 118 | +## 3. Verify a Multisig Signature (verify_op) |
| 119 | + |
| 120 | +After initialization, any caller can submit a message hash and a batch of `OwnerSignature` entries. The contract checks that the number of signatures meets the threshold, that each `owner_index` is valid and non-duplicate, and that each signature is cryptographically valid against the corresponding owner's public key. |
| 121 | + |
| 122 | +**Rust entrypoint signature:** |
| 123 | + |
| 124 | +```rust |
| 125 | +pub fn verify_op( |
| 126 | + env: Env, |
| 127 | + message_hash: BytesN<32>, |
| 128 | + signatures: Vec<OwnerSignature>, |
| 129 | +) -> Result<(), MultisigError>; |
| 130 | +``` |
| 131 | + |
| 132 | +**`OwnerSignature` struct:** |
| 133 | + |
| 134 | +```rust |
| 135 | +pub struct OwnerSignature { |
| 136 | + pub owner_index: u32, // index into the owners Vec |
| 137 | + pub signature: BytesN<64>, // ed25519 signature bytes |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +### Concrete example — 2 of 3 owners sign a message hash |
| 142 | + |
| 143 | +```rust |
| 144 | +use ed25519_dalek::{Signer, SigningKey}; |
| 145 | +use soroban_sdk::{BytesN, Env, Vec}; |
| 146 | +use quicklendx_contracts::multisig::{MultisigContract, MultisigContractClient, OwnerSignature}; |
| 147 | + |
| 148 | +let env = Env::default(); |
| 149 | +let contract_id = env.register(MultisigContract, ()); |
| 150 | +let client = MultisigContractClient::new(&env, &contract_id); |
| 151 | + |
| 152 | +let (pub0, priv0) = generate_keypair(&env, 1); |
| 153 | +let (pub1, _priv1) = generate_keypair(&env, 2); |
| 154 | +let (pub2, priv2) = generate_keypair(&env, 3); |
| 155 | + |
| 156 | +let mut owners = Vec::new(&env); |
| 157 | +owners.push_back(pub0); |
| 158 | +owners.push_back(pub1); |
| 159 | +owners.push_back(pub2); |
| 160 | + |
| 161 | +client.initialize(&owners, &2); |
| 162 | + |
| 163 | +// The message hash to sign (32 bytes) |
| 164 | +let message_hash = BytesN::from_array(&env, &[9u8; 32]); |
| 165 | +let message_bytes: [u8; 32] = [9u8; 32]; |
| 166 | + |
| 167 | +// Owners 0 and 2 sign the message |
| 168 | +let sig0_bytes = priv0.sign(&message_bytes).to_bytes(); |
| 169 | +let sig2_bytes = priv2.sign(&message_bytes).to_bytes(); |
| 170 | + |
| 171 | +let sig0 = BytesN::from_array(&env, &sig0_bytes); |
| 172 | +let sig2 = BytesN::from_array(&env, &sig2_bytes); |
| 173 | + |
| 174 | +let mut signatures = Vec::new(&env); |
| 175 | +signatures.push_back(OwnerSignature { |
| 176 | + owner_index: 0, |
| 177 | + signature: sig0, |
| 178 | +}); |
| 179 | +signatures.push_back(OwnerSignature { |
| 180 | + owner_index: 2, |
| 181 | + signature: sig2, |
| 182 | +}); |
| 183 | + |
| 184 | +let res = client.verify_op(&message_hash, &signatures); |
| 185 | +assert!(res.is_ok()); |
| 186 | +``` |
| 187 | + |
| 188 | +### Common failure scenarios |
| 189 | + |
| 190 | +| Scenario | Error returned | |
| 191 | +|----------|---------------| |
| 192 | +| Submit 1 signature when threshold = 2 | `NotEnoughSignatures` | |
| 193 | +| Submit the same owner index twice | `DuplicateSignature` | |
| 194 | +| Use `owner_index = N` (out of bounds) | `InvalidOwnerIndex` | |
| 195 | +| Sign the wrong message (mismatched hash) | Panics with `HostError: Error(Crypto, InvalidHash)` | |
| 196 | +| Submit a signature for an owner not in the current set | `InvalidOwnerIndex` (if index >= N) | |
| 197 | + |
| 198 | +## 4. Using Multisig in Critical Operations |
| 199 | + |
| 200 | +The multisig contract is designed to be called by other contracts or by off-chain services that need threshold authorization for sensitive protocol actions. Typical patterns include: |
| 201 | + |
| 202 | +1. **Pre-authorization:** An admin builds a `message_hash` from the operation parameters (e.g., `sha256(treasury_address + amount + nonce)`), collects `threshold` signatures from the owner set off-chain, then calls `verify_op` on-chain before executing the action. |
| 203 | +2. **On-chain composability:** A governance or timelock contract calls `verify_op` as a sub-step before mutating state, ensuring that no single signer can unilaterally trigger critical changes. |
| 204 | +3. **Rotation safety:** Because `initialize` overwrites the entire owner set atomically, rotate signers in a single transaction rather than incremental updates to avoid intermediate states with reduced security. |
| 205 | + |
| 206 | +> **See also:** |
| 207 | +> - [Emergency Recovery](contracts/emergency-recovery.md) — describes where multisig fits into the incident-response path |
| 208 | +> - [QLX_TREASURY_ROTATION.md](QLX_TREASURY_ROTATION.md) — example of a protected rotation with timelock |
| 209 | +> - [Security](contracts/security.md) — reentrancy guard and pause circuit breaker that complement multisig controls |
| 210 | +> - [OPERATOR_HANDBOOK.md](OPERATOR_HANDBOOK.md) — full CLI reference for on-chain operations |
| 211 | +
|
| 212 | +## 5. Quick Reference — CLI Example |
| 213 | + |
| 214 | +```bash |
| 215 | +# Initialize multisig with 3 owners, threshold 2 |
| 216 | +soroban contract invoke \ |
| 217 | + --id $CONTRACT_ID \ |
| 218 | + --source admin \ |
| 219 | + --network testnet \ |
| 220 | + -- \ |
| 221 | + initialize \ |
| 222 | + --owners "$OWNER0,$OWNER1,$OWNER2" \ |
| 223 | + --threshold 2 |
| 224 | + |
| 225 | +# Verify a set of signatures against a message hash |
| 226 | +soroban contract invoke \ |
| 227 | + --id $CONTRACT_ID \ |
| 228 | + --network testnet \ |
| 229 | + -- \ |
| 230 | + verify_op \ |
| 231 | + --message_hash "$MESSAGE_HASH" \ |
| 232 | + --signatures "$SIGNATURES_JSON" |
| 233 | +``` |
0 commit comments