Skip to content

Commit 1e0c834

Browse files
docs: add QLX_MULTISIG_CONFIG.md documenting multisig setup and rotation
1 parent 12dde78 commit 1e0c834

3 files changed

Lines changed: 235 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ npm run dev
9595
- [`docs/QLX_RISK_PARAMETERS.md`](docs/QLX_RISK_PARAMETERS.md): Complete catalog of risk-related parameters with min/max/default values — investor tiers, business supply limits, bid controls, fee ceilings, and operational bounds.
9696
- [`docs/QLX_GOVERNANCE_PROPOSALS.md`](docs/QLX_GOVERNANCE_PROPOSALS.md): Governance proposal lifecycle, status transitions, and operator workflow.
9797
- [`docs/QLX_TREASURY_ROTATION.md`](docs/QLX_TREASURY_ROTATION.md): Treasury address rotation flow with two-step validation and timelock.
98+
- [`docs/QLX_MULTISIG_CONFIG.md`](docs/QLX_MULTISIG_CONFIG.md): Multisig setup, signer rotation, and threshold-signature verification for critical operations.
9899
- [`docs/APPEALS.md`](docs/APPEALS.md): Appeals process — who reviews, timeline, outcomes, and how they affect funds — operator-facing.
99100
- [`docs/EVENT_DASHBOARDS.md`](docs/EVENT_DASHBOARDS.md): Standard operator dashboards — panel URLs, PromQL queries, SQLite indexer queries, and alert rules for protocol health, event throughput, disputes, and settlement pipeline.
100101

docs/QLX_MULTISIG_CONFIG.md

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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+
```

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
| [EVENT_DASHBOARDS.md](EVENT_DASHBOARDS.md) | Standard Grafana dashboards — panel URLs, PromQL queries, SQLite indexer queries, and alert rules for protocol health, event throughput, disputes, and settlement pipeline |
5252
| [QLX_GOVERNANCE_PROPOSALS.md](QLX_GOVERNANCE_PROPOSALS.md) | Governance proposal lifecycle status transitions and operator workflows |
5353
| [QLX_TREASURY_ROTATION.md](QLX_TREASURY_ROTATION.md) | Treasury address rotation flow with two-step validation and timelock |
54+
| [QLX_MULTISIG_CONFIG.md](QLX_MULTISIG_CONFIG.md) | Multisig setup, signer rotation, and threshold-signature verification for critical operations |
5455
| [MONITORING.md](MONITORING.md) | Per-event alert thresholds for contract events |
5556
| [DASHBOARD_QUERIES.md](DASHBOARD_QUERIES.md) | Full SQL reference for indexer health and workload queries |
5657
| [RUNBOOK_INCIDENT_RESPONSE.md](RUNBOOK_INCIDENT_RESPONSE.md) | Step-by-step operator playbook for unexpected contract behavior |

0 commit comments

Comments
 (0)