<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ImmuneFOMO</title>
    <description>The latest articles on DEV Community by ImmuneFOMO (@immunefomo).</description>
    <link>https://dev.to/immunefomo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1301732%2F224647e7-8872-4b2d-b532-2356acc9e0ed.jpeg</url>
      <title>DEV Community: ImmuneFOMO</title>
      <link>https://dev.to/immunefomo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/immunefomo"/>
    <language>en</language>
    <item>
      <title>Postgres not using your index? It's counting pages, not rows</title>
      <dc:creator>ImmuneFOMO</dc:creator>
      <pubDate>Fri, 13 Nov 2026 14:07:46 +0000</pubDate>
      <link>https://dev.to/immunefomo/postgres-not-using-your-index-its-counting-pages-not-rows-k4g</link>
      <guid>https://dev.to/immunefomo/postgres-not-using-your-index-its-counting-pages-not-rows-k4g</guid>
      <description>&lt;p&gt;&lt;em&gt;Slow Postgres, part 1 of 8. The SQL and raw numbers for every test are &lt;a href="https://github.com/ImmuneFOMO/research/tree/main/postgres/slow-postgres/01-index-vs-seq-scan" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfsp6v7l417q5zug053o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfsp6v7l417q5zug053o.png" alt="Seq Scan reads heap pages in order; Index Scan reads the B-tree, then one heap page per TID" width="800" height="752"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A Seq Scan reads each page once, in order. An index lookup walks the B-tree and then reads one heap page per TID.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You add an index on &lt;code&gt;status&lt;/code&gt;, run &lt;code&gt;EXPLAIN&lt;/code&gt;, and Postgres reads the whole table anyway. &lt;code&gt;ANALYZE&lt;/code&gt; doesn't change the plan. Someone suggests &lt;code&gt;SET enable_seqscan = off&lt;/code&gt;, the plan finally shows your index, and the query gets slower.&lt;/p&gt;

&lt;p&gt;Usually the planner is right. It prices a plan by the 8 KB pages it has to read and by whether it reads them in order or jumps around between them, and once you count pages the same way, you can answer most "why is it ignoring my index" tickets yourself.&lt;/p&gt;

&lt;p&gt;This series started as my notes from an &lt;a href="https://www.youtube.com/watch?v=AZF9QZeJ-v8" rel="noopener noreferrer"&gt;open lesson on slow Postgres queries&lt;/a&gt; by balun.courses. The tests and numbers are my own.&lt;/p&gt;

&lt;p&gt;One note before the numbers: the AlphaVPS link at the end is a referral link, so I may get a commission at no extra cost to you, and the netcup vouchers come from my own account.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres prices a plan in pages, and in my test table a status with 0.9% of the rows sits on 94% of them, so its index saves 12 page reads out of 221.&lt;/li&gt;
&lt;li&gt;On 5 million rows in RAM, the index beat a full scan up to 10% of the rows when the matches were scattered, and at every share I tried (up to 50%) when they were stored together.&lt;/li&gt;
&lt;li&gt;Read cold from disk, the scattered lookups at 0.5% and 1% of rows ran 2 to 5 times slower than a full scan, and Postgres still picked them.&lt;/li&gt;
&lt;li&gt;A 200-row table gets a Seq Scan even when 2 rows match, so a plan from a tiny test copy says nothing about production.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;enable_seqscan&lt;/code&gt; out of app code, read &lt;code&gt;pg_stats&lt;/code&gt; before &lt;code&gt;CREATE INDEX&lt;/code&gt;, and if you only query the rare values, index only those rows.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What a B-tree index stores
&lt;/h2&gt;

&lt;p&gt;A regular B-tree index keeps only keys, the values it is sorted by, and next to each key a TID: the row's address in the heap, which is the table's main storage.&lt;/p&gt;

&lt;p&gt;A TID is a page number plus a slot on that page. You can see it as the hidden &lt;code&gt;ctid&lt;/code&gt; column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'chargeback'&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ctid   |  id  |   status
---------+------+------------
 (7,48)  | 1000 | chargeback
 (14,96) | 2000 | chargeback
 (22,8)  | 3000 | chargeback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Row 1000 is slot 48 on page 7. An index lookup for &lt;code&gt;'chargeback'&lt;/code&gt; returns a list of addresses like these, and Postgres then visits each of those heap pages to fetch the row and check that your transaction can see it.&lt;/p&gt;

&lt;p&gt;The second step is the one people forget. To fetch one row, Postgres has to read the whole 8 KB page it sits on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lab: 30,000 orders on 221 pages
&lt;/h2&gt;

&lt;p&gt;The lab runs on PostgreSQL 17.11 in Docker with default settings. My numbers come from a 4-vCPU, 8 GB cloud VM, but any small VPS or a laptop will do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="no"&gt;GENERATED&lt;/span&gt; &lt;span class="no"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="no"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="kt"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nb"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 minute'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'chargeback'&lt;/span&gt;  &lt;span class="c1"&gt;-- 30 rows, 0.1%&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;      &lt;span class="c1"&gt;-- 270 rows, 0.9%&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'created'&lt;/span&gt;     &lt;span class="c1"&gt;-- 2,700 rows, 9%&lt;/span&gt;
         &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;                          &lt;span class="c1"&gt;-- 27,000 rows, 90%&lt;/span&gt;
       &lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nf"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_status_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relpages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reltuples&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; relpages | reltuples
----------+-----------
      221 |     30000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;136 rows fit on a page, so 30,000 rows take 221 pages. I picked 30,000 because &lt;code&gt;ANALYZE&lt;/code&gt; samples 300 × &lt;code&gt;default_statistics_target&lt;/code&gt; rows, which is 30,000 at the default. On this table it reads every row, so the statistics are exact and your plans should come out the same as mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Seq Scan costs
&lt;/h2&gt;

&lt;p&gt;Ask for the most common value and the index goes unused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPLAIN SELECT * FROM orders WHERE status = 'paid';

Seq Scan on orders  (cost=0.00..596.00 rows=27000 width=25)
  Filter: (status = 'paid'::text)

221 pages  × seq_page_cost     1.0    = 221
30000 rows × cpu_tuple_cost    0.01   = 300
30000 rows × cpu_operator_cost 0.0025 =  75
                                        ---
                                        596
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost has no unit you can convert to milliseconds. It only means something next to another plan's cost.&lt;/p&gt;

&lt;p&gt;The value you filter on appears nowhere in that formula. Ask for &lt;code&gt;'chargeback'&lt;/code&gt; with the index paths switched off and the Seq Scan still costs 596. Only the index path's price moves with the value, so the planner is choosing between a fixed price and a sliding one.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;'paid'&lt;/code&gt;, every one of the 221 pages holds paid rows, so the index path would read the whole heap plus the index. Force it with &lt;code&gt;enable_seqscan = off&lt;/code&gt; and you get a Bitmap Heap Scan at cost 872.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many pages each status sits on
&lt;/h2&gt;

&lt;p&gt;Selectivity is the share of rows that pass the filter. The usual rule of thumb goes: one row, use the index; 1%, usually; 20%, it depends; 70%, Seq Scan. Here is the same table counted by pages as well as by rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="cm"&gt;(*)                               AS rows,
       count(DISTINCT (ctid::text::point)[0]) AS pages
FROM orders
GROUP BY status
ORDER BY rows DESC;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbuphl2j9uefx2fdhgp0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbuphl2j9uefx2fdhgp0.png" alt="Bar chart: paid 90% of rows on 100% of pages, created 9% on 100%, failed 0.9% on 94%, chargeback 0.1% on 14%" width="800" height="776"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Share of rows and share of pages for each status in the 30,000-row table. failed is 0.9% of the rows but sits on 94% of the pages.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The rule of thumb says index &lt;code&gt;failed&lt;/code&gt;, and Postgres agrees: Index Scan at cost 235 against 596. Run both with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;, though, and the index path touches 209 buffers to the Seq Scan's 221, a saving of twelve page reads.&lt;/p&gt;

&lt;p&gt;It still finished faster, 0.19 ms versus 0.93, because checking 270 rows is less CPU work than filtering 30,000. For &lt;code&gt;'chargeback'&lt;/code&gt;, 30 rows on 30 pages, the index path needs 32 buffers instead of 221.&lt;/p&gt;

&lt;p&gt;Two things decide how rows land on pages. Row width sets how many fit on one, and at 136 rows per page any value much more common than 1 in 136 ends up on nearly every page. Physical order is the other. I spread &lt;code&gt;failed&lt;/code&gt; evenly on purpose. If your failed orders came from one bad deploy on one afternoon, they'd sit on a few neighbouring pages and the index would look great.&lt;/p&gt;
&lt;h2&gt;
  
  
  Timing it on 5 million rows in RAM
&lt;/h2&gt;

&lt;p&gt;On 221 pages everything finishes in a couple of milliseconds, so for timings I built a bigger table: 5 million rows, 326 MB, about 120 rows per page.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;bucket&lt;/code&gt; column holds a random number from 0 to 999,999, so &lt;code&gt;WHERE bucket &amp;lt; 10000&lt;/code&gt; matches 1% of the rows. I loaded the same rows twice, once in random order and once sorted by &lt;code&gt;bucket&lt;/code&gt;, and forced each plan with the &lt;code&gt;enable_*&lt;/code&gt; settings at shares from 0.01% to 50%. &lt;code&gt;shared_buffers&lt;/code&gt; was 2 GB, which kept the whole table in memory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkn8g92h4o08jcg53dj28.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkn8g92h4o08jcg53dj28.png" alt="Line chart of query time against share of matching rows: Seq Scan between 114 and 159 ms, scattered bitmap scan crossing it between 10% and 25%, clustered Index Scan below it all the way to 50%" width="800" height="784"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Median of 5 warm runs on 5 million rows, one process, JIT off. Dots mark the plan Postgres chose on its own. Grey dotted line: Seq Scan on the clustered copy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For scattered rows Postgres picked a bitmap scan, which collects all the TIDs first and then reads each heap page once, in page order. Up to 10% of the rows it beat the Seq Scan, 98 ms to 124. At 25% it lost, 174 against 139, and the planner still chose it. A plain Index Scan, one heap visit per row, lost from 5% on.&lt;/p&gt;

&lt;p&gt;By 10% the scattered matches sit on every single page, and the bitmap scan still won. It reads all 41,667 pages but checks only the 499,000 rows the bitmap points to, while the Seq Scan checks all 5 million. With the table in RAM a page read costs next to nothing, and the time goes into checking rows.&lt;/p&gt;

&lt;p&gt;For clustered rows the index won at every share I tried, even 50%: 123 ms against 142.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same queries with a cold cache
&lt;/h2&gt;

&lt;p&gt;RAM is the friendly case. For the cold runs I restarted Postgres and dropped the OS page cache before every run, so every page came off the VM's disk.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftyt7ms73hggtnreodk80.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftyt7ms73hggtnreodk80.png" alt="Line chart of the same queries with a cold cache: Seq Scan flat around 250 to 350 ms, scattered bitmap scan peaking at 1.27 s at 1% of rows, clustered Index Scan below the Seq Scan everywhere" width="800" height="784"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same tables and queries read cold from disk, median of 5. Dots mark the plan Postgres chose. Grey dotted line: Seq Scan on the clustered copy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Seq Scan got about twice as slow, 250 to 350 ms at every share. The clustered index won everywhere again, 23 ms versus 263 at 5%.&lt;/p&gt;

&lt;p&gt;The scattered bitmap scan took 748 ms at 0.5% of rows, to the Seq Scan's 349. At 1% it took 1.27 seconds against 255 ms, five times slower, and that's the plan Postgres picks.&lt;/p&gt;

&lt;p&gt;The bitmap scan at 2% read more heap pages than at 1%, 37,939 versus 29,143, and still finished more than six times faster, 190 ms against 1,275. From 2% to 10% it beat the Seq Scan again.&lt;/p&gt;

&lt;p&gt;The 2% run touched 91% of the pages, so it was close to one long sequential read. The 1% run touched 70%, with holes in between. My best guess is OS readahead, which prefetches reads that look sequential and gives up when they're full of gaps. I haven't confirmed it yet.&lt;/p&gt;

&lt;p&gt;The planner has no way to see that difference. All it has is &lt;code&gt;random_page_cost = 4&lt;/code&gt;, a single guess at how much a jump costs next to a sequential read, and it used the same guess for both runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Seq Scans on a 200-row table
&lt;/h2&gt;

&lt;p&gt;A 200-row copy shows the other end of the scale, where the table is too small for any index to win.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_small&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders_small&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;orders_small&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders_small&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on orders_small  (cost=0.00..4.50 rows=2 width=25)
  Filter: (status = 'failed'::text)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only 2 of the 200 rows match, but reading two pages and checking 200 rows costs just 2 + 2 + 0.5 = 4.50. Turn off &lt;code&gt;enable_seqscan&lt;/code&gt; and the cheapest index plan costs 6.19, because its one index page alone is priced as a random read at &lt;code&gt;random_page_cost = 4&lt;/code&gt;. If you check plans on a 200-row test fixture, this is the arithmetic you're looking at, and it tells you nothing about the same query on 30 million rows in production.&lt;/p&gt;

&lt;p&gt;"We're on SSDs, set it to 1.1." Fair, and on this table that flips the plan to an Index Scan at cost 2.77. Both versions finish in 10 microseconds or less, so on a table this size it makes no difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  A partial index instead of enable_seqscan
&lt;/h2&gt;

&lt;p&gt;The one place for &lt;code&gt;enable_seqscan = off&lt;/code&gt; is a scratch session, where it shows you what the other plan would cost. It applies to every query in the session, so in app code it also overrides the plans that were right, like 596 against 872 for &lt;code&gt;'paid'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the queries you care about only ask for the rare values, say a failed-payments dashboard or a chargeback queue, index only those rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_status_not_paid_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It holds 3,000 entries instead of 30,000 and takes 5 pages, where the full index takes 28. Paid orders, 90% of the inserts here, never touch it.&lt;/p&gt;

&lt;p&gt;Postgres only uses it when it can prove your filter implies the index's &lt;code&gt;WHERE&lt;/code&gt;, and a literal &lt;code&gt;status = 'chargeback'&lt;/code&gt; does. With only the partial index in place, a prepared &lt;code&gt;status = $1&lt;/code&gt; under a generic plan falls back to a Seq Scan, because the planner can't prove anything about a value it hasn't seen. Generic plans get their own part later in the series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The planner's statistics in pg_stats
&lt;/h2&gt;

&lt;p&gt;Before adding an index, look at the column in &lt;code&gt;pg_stats&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;null_frac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_distinct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;most_common_vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;most_common_freqs&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stats&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;attname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; null_frac | n_distinct |         most_common_vals         |   most_common_freqs
-----------+------------+----------------------------------+------------------------
         0 |          4 | {paid,created,failed,chargeback} | {0.9,0.09,0.009,0.001}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's what the planner believes about &lt;code&gt;status&lt;/code&gt; as of the last &lt;code&gt;ANALYZE&lt;/code&gt;. If most of your queries filter on &lt;code&gt;paid&lt;/code&gt;, at 0.9, they get a Seq Scan, and no extra index will change that.&lt;/p&gt;

&lt;p&gt;A negative &lt;code&gt;n_distinct&lt;/code&gt; means a fraction of the row count. On tables bigger than the 30,000-row sample these numbers are estimates; you can raise the sample with &lt;code&gt;ALTER TABLE ... ALTER COLUMN ... SET STATISTICS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same view has a &lt;code&gt;correlation&lt;/code&gt; column, the planner's idea of physical order. On my 5-million-row tables it was 0.01 for the scattered copy and 1 for the clustered one, and that difference in physical order is what separates the scattered and clustered lines in both charts.&lt;/p&gt;

&lt;p&gt;Before &lt;code&gt;CREATE INDEX&lt;/code&gt;, run the &lt;code&gt;pg_stats&lt;/code&gt; query, &lt;code&gt;relpages&lt;/code&gt; and the rows-versus-pages count on your own table. If the value you care about sits on most of the pages and the table doesn't stay in memory, the index won't save you reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it on your own server
&lt;/h2&gt;

&lt;p&gt;All the numbers above come from one 4-vCPU AMD EPYC cloud VM with 8 GB of RAM, so yours will differ. The SQL, the scripts, the raw output and the chart code are &lt;a href="https://github.com/ImmuneFOMO/research/tree/main/postgres/slow-postgres/01-index-vs-seq-scan" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. Run them on your own server, and if you get a different picture or think I got something wrong, open an issue or a PR.&lt;/p&gt;

&lt;p&gt;My own projects run on netcup (VPS from €2.21 a month) and &lt;a href="https://alphavps.com/clients/aff.php?aff=1157" rel="noopener noreferrer"&gt;AlphaVPS&lt;/a&gt; (VPS from €2.54 a month). If you need a box for this, either works.&lt;/p&gt;

&lt;p&gt;For netcup I have a few €5 vouchers for new customers. Each one works once, so take one and leave the rest for the next person:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;36nc179418264998
36nc179418264997
36nc179418264996
36nc179418264995
36nc179418264994
36nc179418264993
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste it into the "Redeem voucher" field at the bottom of the netcup cart. If they're all gone, message me on &lt;a href="https://www.linkedin.com/in/anatolii-zhadan/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; and I'll send you a new one.&lt;/p&gt;

&lt;p&gt;Good luck with your plans. Part 2 is next: how to read Postgres EXPLAIN ANALYZE.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finish
&lt;/h2&gt;

&lt;p&gt;I hope you understood the instructions correctly. If you have any edits to this article, I will be waiting for your comments. If I really helped you, I kindly ask you to subscribe to my LinkedIn and provide feedback on this article.&lt;/p&gt;

&lt;p&gt;Link to follow me:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/in/anatolii-zhadan/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All my articles and links: &lt;a href="https://fomo.moe" rel="noopener noreferrer"&gt;fomo.moe&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to Deploy a Self-Hosted 2-of-3 MPC Signing Cluster (Without Losing Everyone's Keys)</title>
      <dc:creator>ImmuneFOMO</dc:creator>
      <pubDate>Tue, 14 Jul 2026 10:38:28 +0000</pubDate>
      <link>https://dev.to/immunefomo/how-to-deploy-a-self-hosted-2-of-3-mpc-signing-cluster-without-losing-everyones-keys-2mg1</link>
      <guid>https://dev.to/immunefomo/how-to-deploy-a-self-hosted-2-of-3-mpc-signing-cluster-without-losing-everyones-keys-2mg1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjvheh0da0sucj9kp958q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjvheh0da0sucj9kp958q.png" alt="Three independent MPC nodes, each holding one key share. The backend never holds a key. It sends a 32-byte digest and gets a signature back." width="800" height="426"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Three independent MPC nodes, each holding one key share. The backend never holds a key. It sends a 32-byte digest and gets a signature back.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A restored MPC cluster can boot up, report &lt;code&gt;[READY]&lt;/code&gt; on all three nodes, pass every health check — and have silently lost every private key it was supposed to hold. Ours did exactly that, once, during a disaster-recovery drill. A signature check caught it, with about thirty minutes to spare. In a real emergency, on a bad night, we might not have.&lt;/p&gt;

&lt;p&gt;That's the whole difference between a deploy guide and a production playbook, and it's why this one spends as much time restoring the cluster as standing it up.&lt;/p&gt;

&lt;p&gt;This is the hands-on half of my previous article, &lt;a href="https://medium.com/@Anatolii_Zhadan/how-to-store-your-users-crypto-wallets-so-nobody-can-steal-them-b0dd840211f6" rel="noopener noreferrer"&gt;*How to Store Your Users' Crypto Wallets So Nobody Can Steal Them&lt;/a&gt;&lt;em&gt;. There I argued *why&lt;/em&gt; the private key should never exist as a whole. Here we build the thing: a self-hosted 2-of-3 MPC signing cluster you can run on your own infrastructure, initialize with a distributed key-generation ceremony, back up, and recover from a real disaster.&lt;/p&gt;

&lt;p&gt;Everything below is from an actual deployment. The tools are open source, the numbers are measured, and the gotchas cost real hours. I've kept the architecture generic, a custodial Web3 backend that generates wallets and signs transactions for users, so you can map it onto your own stack.&lt;/p&gt;

&lt;p&gt;If you've deployed distributed systems before and just want the landmines, jump to &lt;strong&gt;Section 5&lt;/strong&gt; (the startup-order trap that can force you to rebuild your message bus) and &lt;strong&gt;Section 8&lt;/strong&gt; (the silent restore bug from the opening). Everything else builds up to those two.&lt;/p&gt;
&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#d400" rel="noopener noreferrer"&gt;Architecture overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#3400" rel="noopener noreferrer"&gt;The stack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#4580" rel="noopener noreferrer"&gt;Docker Compose — nine services&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#c200" rel="noopener noreferrer"&gt;Node configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#0240" rel="noopener noreferrer"&gt;The initialization ceremony (DKG)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#3700" rel="noopener noreferrer"&gt;One master wallet, infinite users&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#f138" rel="noopener noreferrer"&gt;The backup model&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#ab00" rel="noopener noreferrer"&gt;The disaster-recovery drill&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#f440" rel="noopener noreferrer"&gt;Deploying across separate hosts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#b000" rel="noopener noreferrer"&gt;Production hardening&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#4180" rel="noopener noreferrer"&gt;Scaling — splitting hosts and adding nodes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#d978" rel="noopener noreferrer"&gt;Troubleshooting cheat sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/c38bab5cb0b5#a000" rel="noopener noreferrer"&gt;Build it so you don't need the luck&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Section 1: Architecture overview
&lt;/h2&gt;

&lt;p&gt;The whole point is one structural property: no single machine can produce a signature alone, and no machine ever holds the whole private key.&lt;/p&gt;

&lt;p&gt;The shape that delivers it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three MPC nodes, each on its own isolated host, each holding one key share&lt;/li&gt;
&lt;li&gt;A 2-of-3 threshold: any two nodes can sign, one can be down or compromised without breaking anything&lt;/li&gt;
&lt;li&gt;A message bus for the nodes to exchange protocol messages&lt;/li&gt;
&lt;li&gt;A service-discovery / KV store for peer routing and wallet metadata&lt;/li&gt;
&lt;li&gt;A backend that holds zero keys and only ever sends a 32-byte digest&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trust boundary is the security model. The recommended topology puts the three nodes on three separate hosts over a private encrypted network, so they fail independently. A local Docker Compose file mirrors the same wiring for development — same services, same connections — and the local layout maps straight onto the multi-host production one in Section 9.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why not one box with three containers?&lt;/strong&gt; Because the isolation &lt;em&gt;is&lt;/em&gt; the security. Three containers share one kernel and one root, so host compromise = all three shares = the master key reconstructed. Co-located nodes give you the security of one machine, not true 2-of-3. There's an honest middle ground, because you won't always get three hosts on day one: run the nodes as separate users on a single memory-encrypted, attested VM, a Confidential VM (AMD SEV-SNP) or an enclave (AWS Nitro). Even host root can't read the nodes' memory there, and attestation proves the running image wasn't tampered with. It's a legitimate compensating control for a soft launch. Just be honest about what it is: it hardens the one box, it does not give you independent-failure 2-of-3. Treat multi-host as the target you graduate to.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Section 2: The stack
&lt;/h2&gt;

&lt;p&gt;I didn't want to build an MPC server from scratch. The cryptography is the easy part now; the hard part is the server around it, networking, retries, encrypted storage, backups, health checks, a client SDK. That's easily a quarter of an engineering budget before you sign your first transaction.&lt;/p&gt;

&lt;p&gt;So the stack sits on &lt;a href="https://github.com/fystack/mpcium" rel="noopener noreferrer"&gt;**Mpcium&lt;/a&gt;** (fystack), the most turnkey open-source MPC server there is, Apache-2.0, written in Go. It hands you a full t-of-n cluster out of the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mpcium — What it is: Open-source MPC server (Go) · Role here: Runs the nodes, the signing/keygen lifecycle, the SDK&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/fystack/tss-lib" rel="noopener noreferrer"&gt;tss-lib&lt;/a&gt;** (&lt;code&gt;fystack/tss-lib v2.0.3&lt;/code&gt;)** — What it is: Threshold-signature library (GG18/GG20) · Role here: The cryptographic core, backing Mpcium&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dgraph-io/badger" rel="noopener noreferrer"&gt;BadgerDB&lt;/a&gt; — What it is: Embedded KV store, AES-256 encrypted · Role here: Each node's local key-share storage&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.nats.io/nats-concepts/jetstream" rel="noopener noreferrer"&gt;NATS JetStream&lt;/a&gt; — What it is: Message bus · Role here: Carries keygen/signing request + result events&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.hashicorp.com/consul" rel="noopener noreferrer"&gt;Consul KV&lt;/a&gt; — What it is: Service discovery + KV · Role here: Peer routing and wallet metadata&lt;/li&gt;
&lt;li&gt;PostgreSQL — What it is: Relational DB · Role here: User rows, including each user's derivation index&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Out of the box you also get ECDSA (secp256k1) + EdDSA (Ed25519), Ed25519 mutual auth on every message, ECDH-encrypted peer channels, incremental encrypted backups, and a TypeScript client (&lt;code&gt;[@fystack/mpcium-ts&lt;/code&gt;](&lt;a href="https://www.npmjs.com/package/@fystack/mpcium-ts)" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@fystack/mpcium-ts)&lt;/a&gt;) that drops into a NestJS backend.&lt;/p&gt;

&lt;p&gt;Two things it deliberately doesn't have, and how we deal with them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No trusted-dealer key import.&lt;/strong&gt; You can't import an existing private key. Section 6 makes that a non-issue by deriving all user wallets from one generated master instead of importing anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No REST/gRPC API&lt;/strong&gt;, it's NATS pub/sub + SDK only. Fine for a backend already on a message bus; wrap it if you need HTTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing worth doing before you trust any of this. Mpcium runs GG18/GG20, the family with the &lt;a href="https://www.fireblocks.com/blog/gg18-and-gg20-paillier-key-vulnerability-technical-report" rel="noopener noreferrer"&gt;*BitForge&lt;/a&gt;* vulnerability class from the last article. It's acceptable here for two reasons. BitForge needs a malicious &lt;em&gt;signing party&lt;/em&gt;, which is irrelevant when you run all three nodes. And more importantly, I checked the patch is actually in the code path. The tss-lib v2 line carries the two hardening proofs, Paillier-Blum in &lt;code&gt;crypto/modproof/&lt;/code&gt; and no-small-factor in &lt;code&gt;crypto/facproof/&lt;/code&gt;, generated in GG18 keygen round 2 and verified in round 3. Mpcium pins the &lt;code&gt;fystack/tss-lib v2.0.3&lt;/code&gt; fork through a &lt;code&gt;go.mod&lt;/code&gt; replace, so I grepped its whole tree for the flags that would disable those proofs (&lt;code&gt;SetNoProofMod&lt;/code&gt;, &lt;code&gt;SetNoProofFac&lt;/code&gt;) and found them called nowhere in the keygen path, only in the library's own tests. That's the level of due diligence a signing stack earns: don't trust the protocol name, read the code path.&lt;/p&gt;

&lt;p&gt;(Starting fresh and want the newest protocol? &lt;code&gt;[cggmp24&lt;/code&gt;](&lt;a href="https://github.com/LFDT-Lockness/cggmp21" rel="noopener noreferrer"&gt;https://github.com/LFDT-Lockness/cggmp21&lt;/a&gt;) (the &lt;code&gt;cggmp21&lt;/code&gt; crate, which now implements CGGMP24 — patched, &lt;code&gt;v0.7.0-alpha.2+&lt;/code&gt;) and Coinbase's Cure53-audited &lt;code&gt;[cb-mpc&lt;/code&gt;](&lt;a href="https://github.com/coinbase/cb-mpc" rel="noopener noreferrer"&gt;https://github.com/coinbase/cb-mpc&lt;/a&gt;) are the forward-looking cores. But they're libraries, not servers, so you're back to building the server yourself.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Section 3: Docker Compose — nine services
&lt;/h2&gt;

&lt;p&gt;The full stack is nine services, eight long-running plus a one-shot bootstrap. Here's how they fit, and the two traps that will bite you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;**app&lt;/code&gt;** — Image / build: your backend (NestJS) · Role: Custodial backend + MPC client. Starts last.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**postgres&lt;/code&gt;** — Image / build: &lt;code&gt;postgres:15-alpine&lt;/code&gt; · Role: User rows, including each user's derivation index&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**redis&lt;/code&gt;** — Image / build: &lt;code&gt;redis:7-alpine&lt;/code&gt; · Role: Cache (not part of MPC state)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**nats&lt;/code&gt;** — Image / build: &lt;code&gt;nats:2.10-alpine&lt;/code&gt; (&lt;code&gt;-js&lt;/code&gt;) · Role: Message bus (JetStream enabled)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**consul&lt;/code&gt;** — Image / build: &lt;code&gt;hashicorp/consul:1.15&lt;/code&gt; · Role: Service discovery + routing KV&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**mpcium-bootstrap&lt;/code&gt;** — Image / build: &lt;code&gt;hashicorp/consul:1.15&lt;/code&gt; · Role: One-shot: seeds peer IDs into Consul, then exits&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**mpcium-node0/1/2&lt;/code&gt;** — Image / build: your Mpcium node image · Role: The three MPC nodes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb415efx22k623lic09sz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb415efx22k623lic09sz.png" alt=" raw `depends_on` endraw  enforces the order: Consul healthy → the one-shot bootstrap completes → nodes start → the backend connects last." width="800" height="366"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;code&gt;depends_on&lt;/code&gt; enforces the order: Consul healthy → the one-shot bootstrap completes → nodes start → the backend connects last.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The bootstrap pattern
&lt;/h3&gt;

&lt;p&gt;The nodes need to know about each other before they start. Rather than bake that in, a one-shot &lt;code&gt;mpcium-bootstrap&lt;/code&gt; container writes the three peer UUIDs into Consul and exits &lt;code&gt;0&lt;/code&gt;. Every node then depends on it with &lt;code&gt;service_completed_successfully&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mpcium-bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# one-shot: seed peer UUIDs into Consul, then exit 0&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/consul:1.15&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;CONSUL_HTTP_ADDR=consul&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;8500&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;consul kv put mpc_peers/node0 &amp;lt;node0-uuid&amp;gt; &amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="s"&gt;consul kv put mpc_peers/node1 &amp;lt;node1-uuid&amp;gt; &amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="s"&gt;consul kv put mpc_peers/node2 &amp;lt;node2-uuid&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;consul&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;service_healthy&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;

  &lt;span class="na"&gt;mpcium-node0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./mpc/node&lt;/span&gt;                &lt;span class="c1"&gt;# distroless image, runs as UID 65532&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-n"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node0"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/cluster/node0&lt;/span&gt;      &lt;span class="c1"&gt;# mpcium reads identity/ + peers.json CWD-relative&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./mpc/node0:/cluster/node0&lt;/span&gt;                              &lt;span class="c1"&gt;# host dir: chown 65532:65532&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./mpc/config/node0.yaml:/cluster/node0/config.yaml:ro&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mpcium-bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;service_completed_successfully&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;nats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;service_started&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;consul&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;service_healthy&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
  &lt;span class="c1"&gt;# node1, node2 are identical. The `app` service depends on all three and starts LAST.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few load-bearing lines in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;restart: "no"&lt;/code&gt; on &lt;code&gt;mpcium-bootstrap&lt;/code&gt; — it's a one-shot: seed the peer IDs, exit &lt;code&gt;0&lt;/code&gt;, done. Restart it and it re-seeds on every boot.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;condition: service_completed_successfully&lt;/code&gt; — the nodes block until that clean exit, so peers always exist before a node reads them.&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;:ro&lt;/code&gt; config mount — each node's &lt;code&gt;config.yaml&lt;/code&gt; is read-only; nothing at runtime should mutate it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;working_dir&lt;/code&gt; and the volume's owner are both load-bearing enough to be their own traps, below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(As of v0.3.5 there's a lighter option: pass &lt;code&gt;--peers=peers.json&lt;/code&gt; to each node and it syncs any new peers into Consul on startup, skipping ones already there — so you can drop the &lt;code&gt;mpcium-bootstrap&lt;/code&gt; container entirely, or run &lt;code&gt;mpcium-cli register-peers --peers peers.json&lt;/code&gt; once. The manual &lt;code&gt;consul kv put mpc_peers/…&lt;/code&gt; still works; it's just no longer required.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap #1 — the distroless UID
&lt;/h3&gt;

&lt;p&gt;The Mpcium node image is &lt;code&gt;[FROM gcr.io/distroless/base-debian12&lt;/code&gt;](&lt;a href="https://github.com/GoogleContainerTools/distroless" rel="noopener noreferrer"&gt;https://github.com/GoogleContainerTools/distroless&lt;/a&gt;) with &lt;code&gt;USER nonroot:nonroot&lt;/code&gt;, which is UID/GID 65532. Your host-mounted node dirs are owned by your user, so the moment a node tries to write its BadgerDB it hits permission-denied and crash-loops. One line fixes it, and you'll forget it exactly once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 65532:65532 ./mpc/node0 ./mpc/node1 ./mpc/node2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Trap #2 — &lt;code&gt;working_dir&lt;/code&gt; is load-bearing
&lt;/h3&gt;

&lt;p&gt;Mpcium resolves &lt;code&gt;identity/&lt;/code&gt; and &lt;code&gt;peers.json&lt;/code&gt; relative to the current working directory, not to a config path. Miss &lt;code&gt;working_dir&lt;/code&gt; (or &lt;code&gt;WorkingDirectory&lt;/code&gt; in a systemd unit) and the node starts in &lt;code&gt;/&lt;/code&gt; and can't find its identity files. Pin it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 4: Node configuration
&lt;/h2&gt;

&lt;p&gt;Each node gets its own &lt;code&gt;config.yaml&lt;/code&gt;. Most of it is boring. Two fields will bite you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;nats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nats://nats:4222"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;consul&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;consul:8500"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;mpc_threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;                   &lt;span class="c1"&gt;# tss-lib degree t -&amp;gt; t+1 = 2 signers (this is 2-of-3)&lt;/span&gt;
&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production"&lt;/span&gt;

&lt;span class="na"&gt;badger_password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;32&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hex&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;chars&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;32-byte&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;AES-256&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;key,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;DIFFERENT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;per&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;node&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;event_initiator_algorithm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ed25519"&lt;/span&gt;
&lt;span class="na"&gt;event_initiator_pubkey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;64&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hex,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SAME&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;nodes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;backend&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;chain_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;              &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;64&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hex,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SAME&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;nodes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;backend&amp;gt;"&lt;/span&gt;

&lt;span class="na"&gt;db_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./data"&lt;/span&gt;                  &lt;span class="c1"&gt;# mpcium appends /nodeN — remember this in Section 8&lt;/span&gt;
&lt;span class="na"&gt;backup_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;backup_period_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
&lt;span class="na"&gt;max_concurrent_keygen&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;max_concurrent_signing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the important fields do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mpc_threshold&lt;/code&gt; — the availability/security knob; &lt;code&gt;1&lt;/code&gt; means 2-of-3 (see the gotcha below).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db_path&lt;/code&gt; — where the shards live; Mpcium appends &lt;code&gt;/nodeN&lt;/code&gt; to it, which will matter enormously at restore time (Section 8).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;backup_enabled&lt;/code&gt; / &lt;code&gt;backup_period_seconds&lt;/code&gt; — Mpcium's own incremental encrypted backup, every 300 s.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_concurrent_keygen&lt;/code&gt; / &lt;code&gt;max_concurrent_signing&lt;/code&gt; — per-node concurrency caps.&lt;/li&gt;
&lt;li&gt;the three identity fields (&lt;code&gt;badger_password&lt;/code&gt;, &lt;code&gt;event_initiator_pubkey&lt;/code&gt;, &lt;code&gt;chain_code&lt;/code&gt;) have a shared-vs-per-node rule of their own, right below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The threshold gotcha (read this twice)
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;In tss-lib, &lt;code&gt;mpc_threshold&lt;/code&gt; is the polynomial degree &lt;code&gt;t&lt;/code&gt;, and the number of signers required is &lt;code&gt;t + 1&lt;/code&gt;. So a 2-of-3 cluster uses &lt;code&gt;mpc_threshold: 1&lt;/code&gt;, not &lt;code&gt;2&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And here's the part that makes it dangerous: the Mpcium dev &lt;code&gt;config.yaml.template&lt;/code&gt; ships with &lt;code&gt;mpc_threshold: 2&lt;/code&gt; by default (the &lt;code&gt;config.prod.yaml.template&lt;/code&gt; correctly ships &lt;code&gt;1&lt;/code&gt; — but the dev one is what most people copy first). Copy it unthinkingly and you've built a 3-of-3 cluster, which throws away the entire availability benefit of MPC, because now &lt;em&gt;every&lt;/em&gt; node must be online to sign. Set it to &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more thing: tss-lib only enforces that the degree is at least 1 and no larger than the node count minus one. For three nodes that leaves 2-of-3 (&lt;code&gt;mpc_threshold: 1&lt;/code&gt;) or 3-of-3 (&lt;code&gt;mpc_threshold: 2&lt;/code&gt;) — a 1-of-3 where a single node could sign alone is degenerate and rejected. (GG18 tolerates a dishonest majority, so the code allows any &lt;code&gt;1 ≤ t ≤ n−1&lt;/code&gt;; Mpcium's README still &lt;em&gt;recommends&lt;/em&gt; an honest-majority &lt;code&gt;t ≥ ⌊n/2⌋+1&lt;/code&gt;, and for three nodes there's no sensible quorum below 2-of-3 anyway.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared vs. per-node
&lt;/h3&gt;

&lt;p&gt;This catches people, so let me be blunt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different on every node:&lt;/strong&gt; &lt;code&gt;badger_password&lt;/code&gt; (each node encrypts its own BadgerDB with its own key).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identical on every node and the backend:&lt;/strong&gt; &lt;code&gt;chain_code&lt;/code&gt;, &lt;code&gt;event_initiator_pubkey&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;event_initiator_pubkey&lt;/code&gt; pins who's allowed to ask the cluster to sign, the Ed25519 public key of your backend's initiator identity. &lt;code&gt;chain_code&lt;/code&gt; is what makes deterministic derivation work (Section 6).&lt;/p&gt;

&lt;h3&gt;
  
  
  Identities and peers
&lt;/h3&gt;

&lt;p&gt;Each node dir has an &lt;code&gt;identity/&lt;/code&gt; folder with its own private key (&lt;code&gt;node0_private.key&lt;/code&gt;, mode &lt;code&gt;0600&lt;/code&gt;) plus every node's public identity (&lt;code&gt;nodeX_identity.json&lt;/code&gt;, mode &lt;code&gt;0644&lt;/code&gt;). And every node carries the same &lt;code&gt;peers.json&lt;/code&gt;, whose UUIDs must exactly match what &lt;code&gt;mpcium-bootstrap&lt;/code&gt; seeded into Consul:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"node0"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"00000000-0000-0000-0000-000000000000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"node1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"11111111-1111-1111-1111-111111111111"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"node2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"22222222-2222-2222-2222-222222222222"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Section 5: The initialization ceremony (DKG)
&lt;/h2&gt;

&lt;p&gt;This is the ceremony that generates the master key without ever assembling it. It runs once, on a fresh cluster, with the backend stopped.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one rule that governs everything: startup order
&lt;/h3&gt;

&lt;p&gt;The single most important operational rule in this whole deployment:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The MPC nodes must create the JetStream streams before anything else connects to NATS.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's why. The Go nodes create their &lt;code&gt;mpc-keygen&lt;/code&gt; and &lt;code&gt;mpc-signing&lt;/code&gt; streams with JetStream's &lt;strong&gt;default (limits)&lt;/strong&gt; retention, but the TypeScript client (&lt;code&gt;@fystack/mpcium-ts&lt;/code&gt;) creates those same streams with &lt;strong&gt;workqueue&lt;/strong&gt; retention (and the &lt;code&gt;mpc&lt;/code&gt; result stream the other way round — &lt;code&gt;interest&lt;/code&gt; on the client, &lt;code&gt;workqueue&lt;/code&gt; on the nodes). If the client wins the race and creates either one first, the nodes come up, refuse to reconcile the retention, and immediately crash-loop with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;err_code=10052 ... can not change retention policy to/from workqueue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And JetStream won't let you add or remove &lt;code&gt;workqueue&lt;/code&gt; retention on an existing stream, so no config change or API call fixes it in place. The simplest remedy is to recreate the NATS container (or drop just the two offending streams and let the nodes recreate them), either way rebuilding JetStream state you never planned to touch. So the sequence is non-negotiable:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx0ofdkcn88r576vgfdb8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx0ofdkcn88r576vgfdb8.png" alt="Get this order wrong and JetStream's fixed workqueue retention forces you to recreate NATS from scratch." width="799" height="374"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Get this order wrong and JetStream's fixed workqueue retention forces you to recreate NATS from scratch.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Bring up infra + the three nodes (NOT the app yet)&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; postgres redis nats consul &lt;span class="se"&gt;\&lt;/span&gt;
  mpcium-bootstrap mpcium-node0 mpcium-node1 mpcium-node2

&lt;span class="c"&gt;# 2. Wait until each node logs that it owns its streams, e.g.:&lt;/span&gt;
&lt;span class="c"&gt;#    "SigningConsumer: Subscribed to signing events"&lt;/span&gt;
&lt;span class="c"&gt;#    "Subscription created successfully"&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; mpcium-node0   &lt;span class="c"&gt;# watch for the stream-subscription logs above&lt;/span&gt;

&lt;span class="c"&gt;# 3. ONLY NOW run the init ceremony / start the app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a sneakier version of this trap too: a running backend steals the DKG result. If the app is up during init, its consumer grabs the keygen-result event off the queue before the init script can read it, and init hangs forever on "waiting for keygen result." Stop the app before you initialize.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the init script actually does
&lt;/h3&gt;

&lt;p&gt;The init is a one-time bootstrap script. Don't underestimate it, ours is 1,160 lines, and that's normal. A one-time bootstrap for a distributed system is never a ten-liner. Four phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate&lt;/strong&gt; — the compose file, every node config, the initiator key, the backend &lt;code&gt;.env&lt;/code&gt;, and that all three nodes plus NATS and Consul are actually running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKG the master wallet&lt;/strong&gt; — connect to NATS, create the Mpcium client, call &lt;code&gt;createWallet(masterWalletId)&lt;/code&gt;, wait for the keygen-result event (60-second timeout). Take the ECDSA public key, compressed to secp256k1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write env + back up&lt;/strong&gt; — persist &lt;code&gt;MPC_MASTER_WALLET_ID&lt;/code&gt;, &lt;code&gt;MPC_MASTER_PUBLIC_KEY&lt;/code&gt;, and &lt;code&gt;MPC_CHAIN_CODE&lt;/code&gt; to the backend's &lt;code&gt;.env&lt;/code&gt;; then copy each node's data dir, export the Consul KV, write a &lt;code&gt;metadata.json&lt;/code&gt; (&lt;code&gt;nodeCount: 3&lt;/code&gt;, &lt;code&gt;threshold: "2-of-3"&lt;/code&gt;). Move this backup offline immediately, it's hot-key-equivalent material.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; — derive addresses at indexes 0 and 1, sign a test 32-byte digest at each (30-second timeout, up to four attempts on transient errors), print &lt;code&gt;=== INITIALIZATION COMPLETE ===&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Timeouts and the slow part of boot
&lt;/h3&gt;

&lt;p&gt;Keygen needs all three nodes at once, so give it the most generous timeout (we use 60 s for keygen, 30 s for signing) — a transient hiccup shouldn't abort a ceremony. The one real cost at boot is Paillier preparameter generation, the bulk of a cold start. Given enough cores the three nodes generate their preparams in parallel; on a smaller host they contend for CPU, so start them serially to avoid starving each other. Either way you pay it once: the preparams are persisted to BadgerDB and reused on every restart, so restarts are cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 6: One master wallet, infinite users
&lt;/h2&gt;

&lt;p&gt;Give every user their own MPC wallet the obvious way, and every signup runs its own DKG across all three nodes — and your backups grow without bound: ten thousand users is ten thousand ceremonies and ten thousand more sets of shards you can never lose.&lt;/p&gt;

&lt;p&gt;We don't do that. We run DKG exactly once, for a single master wallet, and derive every user wallet from it with BIP-32 child key derivation (CKD):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each user gets an integer &lt;code&gt;wallet_index&lt;/code&gt;, stored as a column in Postgres&lt;/li&gt;
&lt;li&gt;Their address is derived at path &lt;code&gt;m/44/60/0/0/{wallet_index}&lt;/code&gt; (all levels non-hardened — hardened derivation needs the private key, which never exists here) — pure math, zero MPC nodes required online&lt;/li&gt;
&lt;li&gt;To sign, each node applies its own derivation delta to its master share; the child key, like the master, is never assembled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what makes the operational model sane, and it's the reason the backup story in the next section is so simple. One derivation path supports about 2.1 billion users (indexes 0 through 2³¹ − 1), so you'll run out of users long before you run out of key space. I covered CKD's security equivalence in the previous article; the short version is that it's &lt;em&gt;identical&lt;/em&gt; to per-user DKG under every node-compromise scenario.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 7: The backup model — back up three things, not one
&lt;/h2&gt;

&lt;p&gt;The most common way to lose an MPC cluster is to back up the key shares, feel safe, and find out mid-restore that shares alone are useless. You have to back up three things:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz17kuz54rm7mj3be0njh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz17kuz54rm7mj3be0njh.png" alt="Restore only BadgerDB and the cluster boots but can't find any wallet — signing fails with ERROR_KEY_NOT_FOUND." width="799" height="374"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Restore only BadgerDB and the cluster boots but can't find any wallet — signing fails with ERROR_KEY_NOT_FOUND.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1&lt;/strong&gt; — Item: BadgerDB shards · Where: each node's &lt;code&gt;data/&lt;/code&gt; dir · Why it's mandatory: the encrypted key-share state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2&lt;/strong&gt; — Item: Consul KV (&lt;code&gt;threshold_keyinfo/*&lt;/code&gt;, &lt;code&gt;mpc_peers/*&lt;/code&gt;) · Where: Consul export · Why it's mandatory: wallet routing; without it every sign fails &lt;code&gt;ERROR_KEY_NOT_FOUND&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3&lt;/strong&gt; — Item: Metadata (&lt;code&gt;chain_code&lt;/code&gt;, master wallet ID, master pubkey, node configs, identity keys) · Where: configs + backend &lt;code&gt;.env&lt;/code&gt; · Why it's mandatory: needed to derive addresses and boot the nodes&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  One backup, forever
&lt;/h3&gt;

&lt;p&gt;Here's the quiet superpower of CKD. The master shares, the &lt;code&gt;threshold_keyinfo/*&lt;/code&gt; routing entries, and the peer metadata never change after the initial DKG. New users are just new rows in Postgres. So your MPC backup strategy is: take one full MPC backup right after init, then back up Postgres normally from then on. That's it. No incremental shard sync, no window where a user created between backups is unrecoverable. Per-user DKG would need a fresh MPC backup after &lt;em&gt;every&lt;/em&gt; signup.&lt;/p&gt;

&lt;p&gt;And this is the whole disaster-recovery story in one sentence: lose the entire cluster and you still lose nothing. Stand up fresh nodes, restore that single encrypted master backup, and every user wallet comes back, because no user wallet was ever stored on its own. Each one is a deterministic derivation of the master, and its &lt;code&gt;wallet_index&lt;/code&gt; lives safely in your Postgres rows. The master backup plus your normal Postgres backups reconstruct every address and every signing key by index, on brand-new hardware, with nothing lost.&lt;/p&gt;
&lt;h3&gt;
  
  
  The copy-size trap
&lt;/h3&gt;

&lt;p&gt;How you copy BadgerDB matters more than you'd think. A &lt;em&gt;running&lt;/em&gt; node's BadgerDB is ~2.2 GB — SSTables, value logs, and the WAL, mostly churn. What you actually ship is Mpcium's own cold backup: with &lt;code&gt;backup_enabled: true&lt;/code&gt;, each node writes a pruned, encrypted export to &lt;code&gt;backup_dir&lt;/code&gt; (the &lt;code&gt;backup-*.enc&lt;/code&gt; files), a few tens of KB apiece. In the drill the full &lt;code&gt;age&lt;/code&gt;-wrapped bundle — all three nodes' exports plus every config, identity, and the Consul KV dump — came to &lt;strong&gt;164 KB&lt;/strong&gt;, four orders of magnitude below the live directory. Two consequences: never copy a live BadgerDB (and never stage a hot copy in a small &lt;code&gt;tmpfs&lt;/code&gt; — it can exceed 6 GB and blow out a ~2 GB &lt;code&gt;/tmp&lt;/code&gt;); ship the &lt;code&gt;backup_dir&lt;/code&gt; export instead.&lt;/p&gt;
&lt;h3&gt;
  
  
  Restore is a specific sequence
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Start fresh nodes, then STOP them&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose stop mpcium-node0 mpcium-node1 mpcium-node2
&lt;span class="c"&gt;# 2. For each node, decrypt + restore its export INTO the exact db_path/nodeN dir:&lt;/span&gt;
&lt;span class="c"&gt;#    mpcium-cli recover --backup-dir &amp;lt;node's backup-*.enc&amp;gt; \&lt;/span&gt;
&lt;span class="c"&gt;#      --recovery-path &amp;lt;db_path&amp;gt;/nodeN --force   # the nodeN subdir is load-bearing — Section 8&lt;/span&gt;
&lt;span class="c"&gt;#    (recover prompts for the badger_password; drive it with `expect` in automation)&lt;/span&gt;
&lt;span class="c"&gt;# 3. Import the Consul KV dump  (skip this and signing fails ERROR_KEY_NOT_FOUND)&lt;/span&gt;
consul kv import @backup/consul-kv.json
&lt;span class="c"&gt;# 4. Fix ownership, delete stale BadgerDB LOCK files&lt;/span&gt;
&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 65532:65532 ./mpc &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; find ./mpc &lt;span class="nt"&gt;-name&lt;/span&gt; LOCK &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;span class="c"&gt;# 5. Start the nodes, wait for all three to log [READY]&lt;/span&gt;
docker compose start mpcium-node0 mpcium-node1 mpcium-node2
&lt;span class="c"&gt;# 6. Verify with a signing smoke test — do NOT trust "[READY]" alone (Section 8)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Section 8: The disaster-recovery drill
&lt;/h2&gt;

&lt;p&gt;You don't have a backup until you've restored it. So before go-live we ran the full thing: destroy the cluster, rebuild it from backups on fresh hardware, and prove the recovered cluster signs with the same master key. The pass criterion was strict, recover a signature and confirm via &lt;code&gt;ecrecover&lt;/code&gt; that its master public key was byte-for-byte identical to the original.&lt;/p&gt;

&lt;p&gt;It passed. It also surfaced eleven runbook and config bugs in a single session. A clean re-run took 45–60 minutes; the first pass took about two hours, precisely because of what it caught. Three of them are worth the price of this whole article.&lt;/p&gt;
&lt;h3&gt;
  
  
  The silent empty-database restore (the one from the opening)
&lt;/h3&gt;

&lt;p&gt;This is the bug that would have quietly destroyed every user wallet in a real emergency.&lt;/p&gt;

&lt;p&gt;Mpcium reads BadgerDB from &lt;code&gt;${db_path}/nodeN/&lt;/code&gt;, it auto-appends the &lt;code&gt;nodeN&lt;/code&gt; subdirectory. Drop your restore into &lt;code&gt;.../data&lt;/code&gt; instead of &lt;code&gt;.../data/nodeN&lt;/code&gt; and the files land one level too high. Mpcium then looks in &lt;code&gt;.../data/nodeN/&lt;/code&gt;, finds it empty, and, this is the dangerous part, silently creates a brand-new, empty database.&lt;/p&gt;

&lt;p&gt;The cluster boots. All three nodes report &lt;code&gt;[READY]&lt;/code&gt;. Health checks are green. But the shares are gone — every existing wallet is orphaned, and the first real signing request fails with &lt;code&gt;ERROR_KEY_NOT_FOUND&lt;/code&gt; (newer v0.3.5 builds report the same thing as &lt;code&gt;ERROR_NOT_MAJORITY&lt;/code&gt;, &lt;em&gt;"not enough peers to process signing request"&lt;/em&gt;). Worse, the empty cluster will happily run a fresh DKG and mint a brand-new master key that has nothing to do with your users' funds. You find out on that first signature, long after the green health check lied to you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8nchweegldlm9w0ukdrv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8nchweegldlm9w0ukdrv.png" alt="" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;"[READY]" is not "recovered." Restore into the exact &lt;code&gt;db_path/nodeN&lt;/code&gt; subdirectory, then verify by signing, never by status alone.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The drill burned about 30 minutes on exactly this. The fix is trivial once you know it, restore into the explicit &lt;code&gt;.../data/nodeN&lt;/code&gt; subdir and &lt;code&gt;chown&lt;/code&gt; afterward, but you'd never guess it from a green health check. The lesson generalizes: &lt;code&gt;[READY]&lt;/code&gt; means "a database is present," not "the &lt;em&gt;right&lt;/em&gt; database is present." Verify recovery by signing and checking the recovered public key, never by status.&lt;/p&gt;
&lt;h3&gt;
  
  
  The documented command that didn't exist
&lt;/h3&gt;

&lt;p&gt;The first-draft runbook confidently referenced an &lt;code&gt;mpcium-cli init-dkg&lt;/code&gt; command that doesn't exist in the shipped binary, with a flag set to match. There is no keygen subcommand at all: DKG is triggered by publishing a signed keygen-request message onto NATS (subject &lt;code&gt;mpc.keygen_request.*&lt;/code&gt;). Recovery really uses &lt;code&gt;--backup-dir&lt;/code&gt;, &lt;code&gt;--recovery-path&lt;/code&gt;, and &lt;code&gt;--force&lt;/code&gt;, and the binary prompts for its password interactively via &lt;code&gt;term.ReadPassword&lt;/code&gt;, so you drive it with &lt;code&gt;expect&lt;/code&gt; in automation. Dry-run every command in your runbook against the actual binary before you trust it in an emergency.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;openssl rand&lt;/code&gt; will hand you an invalid key
&lt;/h3&gt;

&lt;p&gt;BadgerDB's encryption key must be exactly 16, 24, or 32 bytes. The instinctive command produces neither:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32     &lt;span class="c"&gt;# WRONG: 44 ASCII chars -&amp;gt; node fatals opening the encrypted BadgerDB:&lt;/span&gt;
                            &lt;span class="c"&gt;# the key must be exactly 16, 24, or 32 bytes.&lt;/span&gt;
openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 16        &lt;span class="c"&gt;# RIGHT: 32 hex characters = 32 bytes = AES-256&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-base64 32&lt;/code&gt; turns 32 random &lt;em&gt;bytes&lt;/em&gt; into a 44-&lt;em&gt;character&lt;/em&gt; string, and the password is used as raw key bytes — so that's a 44-byte key, not a valid AES length. &lt;code&gt;-hex 16&lt;/code&gt; gives 32 characters = 32 bytes: an AES-256-length key carrying 128 bits of entropy, ample for a local DB key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Five more the drill taught us
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never regenerate &lt;code&gt;badger_password&lt;/code&gt; after first start.&lt;/strong&gt; The KEYREGISTRY is encrypted with the &lt;em&gt;original&lt;/em&gt; password; change it and the database is permanently unreadable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A wrong or regenerated &lt;code&gt;chain_code&lt;/code&gt; silently orphans every wallet.&lt;/strong&gt; The cluster boots and signs perfectly happily, but it now derives a completely different address space, so none of your users' existing addresses come back. Like the empty-DB bug, it sails past &lt;code&gt;[READY]&lt;/code&gt; and only a sign-and-compare catches it. Treat &lt;code&gt;chain_code&lt;/code&gt; as recovery-critical metadata, not a rotatable secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regenerating node identity keys or peer UUIDs is a re-key, not a recovery.&lt;/strong&gt; Fresh identities make the restored shares reject each other, forcing a brand-new DKG, which produces a &lt;em&gt;new&lt;/em&gt; master public key and abandons every address derived from the old one. Same limit if a node is permanently lost: you can't just drop in a replacement, because a reshare needs the surviving shares as input. Mpcium ships that as &lt;code&gt;Resharing()&lt;/code&gt; (Section 11) — it rebuilds the replacement from the survivors while keeping the master key. A recovered 2-of-3 cluster missing its third node has zero remaining fault tolerance until you re-share, so plan node replacement rather than improvising it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consul KV doesn't contain the master public key.&lt;/strong&gt; It stores only &lt;code&gt;{participant_peer_ids, threshold, version}&lt;/code&gt;. So you can't verify a restore by reading KV, you sign and &lt;code&gt;ecrecover&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your cold bundle must include the supporting config&lt;/strong&gt; (&lt;code&gt;nats.conf&lt;/code&gt;, CA templates), or the restored NATS/services crash-loop and you never reach the signing test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Section 9: Deploying across separate hosts
&lt;/h2&gt;

&lt;p&gt;Everything so far ran from one Docker Compose file — one host, one kernel, one root. That's the right way to &lt;em&gt;learn&lt;/em&gt; the stack, and fine while you're testing, but it isn't 2-of-3: own the box and you own all three shares. Production means each node on its own machine, so a single host compromise costs the attacker exactly one share. None of the cryptography changes — same three nodes, same DKG, same backups. What changes is the wiring: instead of a Compose network, the nodes live on separate hosts joined by a private encrypted overlay, and they reach the bus over that overlay instead of a Docker bridge.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shape
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three node hosts&lt;/strong&gt; — one Mpcium node each. Ideally memory-encrypted, attested VMs (AMD SEV-SNP, AWS Nitro), each with &lt;strong&gt;no public IP&lt;/strong&gt; and a scoped machine identity. Run the node as its own locked-down user with a &lt;code&gt;0700&lt;/code&gt; data dir, so a compromised node process still can't read another node's BadgerDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One control host&lt;/strong&gt; — NATS + Consul (and a small internal CA, below). The bus and the KV store live here; every node and the backend point at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The backend&lt;/strong&gt; — sends digests, holds zero keys, also on the overlay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The nodes never talk to each other directly — all protocol traffic goes through NATS. So the only reachability requirement is: every node and the backend can reach the control host's NATS and Consul over the private network. That keeps the network model a simple star, not a full mesh.&lt;/p&gt;

&lt;h3&gt;
  
  
  The private overlay (WireGuard)
&lt;/h3&gt;

&lt;p&gt;Put every host on one &lt;a href="https://www.wireguard.com/" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt; network so nothing MPC-related ever touches a public interface. On each node host, one peer pointing at the control host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/wireguard/wg0.conf  (a node host)
&lt;/span&gt;[&lt;span class="n"&gt;Interface&lt;/span&gt;]
&lt;span class="n"&gt;Address&lt;/span&gt;    = &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;11&lt;/span&gt;/&lt;span class="m"&gt;24&lt;/span&gt;
&lt;span class="n"&gt;PrivateKey&lt;/span&gt; = &amp;lt;&lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&amp;gt;
&lt;span class="n"&gt;ListenPort&lt;/span&gt; = &lt;span class="m"&gt;51820&lt;/span&gt;
&lt;span class="n"&gt;MTU&lt;/span&gt;        = &lt;span class="m"&gt;1380&lt;/span&gt;          &lt;span class="c"&gt;# provider MTU minus WireGuard's 80-byte header
&lt;/span&gt;
[&lt;span class="n"&gt;Peer&lt;/span&gt;]                     &lt;span class="c"&gt;# control host (NATS + Consul)
&lt;/span&gt;&lt;span class="n"&gt;PublicKey&lt;/span&gt;           = &amp;lt;&lt;span class="n"&gt;control&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="n"&gt;public&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&amp;gt;
&lt;span class="n"&gt;AllowedIPs&lt;/span&gt;          = &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;/&lt;span class="m"&gt;32&lt;/span&gt;          &lt;span class="c"&gt;# just the control host — nodes reach each other via NATS, not directly
&lt;/span&gt;&lt;span class="n"&gt;Endpoint&lt;/span&gt;            = &amp;lt;&lt;span class="n"&gt;control&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="n"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&amp;gt;:&lt;span class="m"&gt;51820&lt;/span&gt;
&lt;span class="n"&gt;PersistentKeepalive&lt;/span&gt; = &lt;span class="m"&gt;25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The control host runs the mirror image — one &lt;code&gt;[Peer]&lt;/code&gt; block per node, each with that node's &lt;code&gt;/32&lt;/code&gt; — and forwards nothing else. Only public keys cross the wire; private keys never leave their host. Then lock the provider firewall so UDP/51820 is the &lt;em&gt;only&lt;/em&gt; inbound port and only from the overlay's peers, deny the rest, and bind SSH to the WG address instead of the public one. On a cloud MPC VM that means launching it with &lt;strong&gt;no external IP at all&lt;/strong&gt; and reaching it purely over WG.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-node config
&lt;/h3&gt;

&lt;p&gt;Each node's &lt;code&gt;config.yaml&lt;/code&gt; is identical to the single-host one, except the addresses now point at the control host over WG and the shared secrets stay shared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;nats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nats://10.10.0.1:4222"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# control host, over WG&lt;/span&gt;
&lt;span class="na"&gt;consul&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.10.0.1:8500"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;mpc_threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;                            &lt;span class="c1"&gt;# still 2-of-3&lt;/span&gt;
&lt;span class="na"&gt;badger_password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;per-node,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;32&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hex&amp;gt;"&lt;/span&gt;       &lt;span class="c1"&gt;# distinct per node&lt;/span&gt;
&lt;span class="na"&gt;chain_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;             &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;same&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;nodes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;backend&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;event_initiator_pubkey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;same&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;nodes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;backend&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;db_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/lib/mpcium/data"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lock the bus itself down with &lt;strong&gt;mTLS&lt;/strong&gt;: issue a client certificate per node from a small internal CA (&lt;a href="https://smallstep.com/docs/step-ca/" rel="noopener noreferrer"&gt;step-ca&lt;/a&gt; is plenty), so only your nodes and backend can speak to NATS — a stray connection that lands on the overlay still can't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bringing it up
&lt;/h3&gt;

&lt;p&gt;The startup order from Section 5 still holds, just spread across hosts: bring up NATS + Consul on the control host, &lt;code&gt;register-peers&lt;/code&gt; (or start the nodes with &lt;code&gt;--peers&lt;/code&gt;) so the UUIDs land in Consul, start the three nodes on their own hosts (streams first — the retention trap doesn't care that the nodes are remote now), wait for &lt;code&gt;[READY]&lt;/code&gt; on all three, then run the DKG exactly as before. The backend connects last.&lt;/p&gt;

&lt;p&gt;This is the topology every backup, restore, and reshare in this article already assumes — the wiring is all that stood between you and it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 10: Production hardening
&lt;/h2&gt;

&lt;p&gt;Once the cluster works, the remaining work is isolation and discipline.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split the nodes across hosts&lt;/strong&gt; — the from-scratch recipe is Section 9, migrating an existing single-box cluster is Section 11. Three separate machines mean a single host compromise yields at most one share; anything less is theater. Can't split yet? Fall back to a memory-encrypted attested VM (&lt;a href="https://www.amd.com/en/developer/sev.html" rel="noopener noreferrer"&gt;AMD SEV-SNP&lt;/a&gt;, &lt;a href="https://aws.amazon.com/ec2/nitro/nitro-enclaves/" rel="noopener noreferrer"&gt;AWS Nitro&lt;/a&gt;) — the Section 1 note covers exactly what that buys you and what it doesn't. One caveat that lands here: SEV-SNP VMs generally can't live-migrate, so an abrupt host termination becomes your disaster-recovery trigger — exactly why the drill in Section 8 isn't optional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private encrypted network.&lt;/strong&gt; Put inter-node and node-to-backend traffic on a private mesh (&lt;a href="https://www.wireguard.com/" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt;). The message bus and KV store should never be exposed publicly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress firewall.&lt;/strong&gt; MPC nodes have no business making arbitrary outbound connections. Lock egress to what the protocol needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt every backup with &lt;code&gt;[age&lt;/code&gt;](&lt;a href="https://github.com/FiloSottile/age" rel="noopener noreferrer"&gt;https://github.com/FiloSottile/age&lt;/a&gt;)&lt;/strong&gt; before it leaves the host, and store it on physically separate, offline media. A stolen backup is all shares plus metadata, equivalent to stealing the funds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for the memory profile.&lt;/strong&gt; Each node is lightweight at rest, on the order of tens of MB idle, but spikes during Paillier preparameter generation, so provision for the spike, not the idle state, and remember the serial-start caveat on smaller hosts. For reference, the entire dev stack (backend, three nodes, Postgres, Consul, NATS, Redis) fits comfortably in 3.8 GiB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make init lazy, not fatal.&lt;/strong&gt; The backend shouldn't crash if MPC is briefly unavailable at boot; log a warning and retry on first real use. And handle the result-before-request race: register the pending request &lt;em&gt;before&lt;/em&gt; you check for an early result, because a fast cluster can return a signature before your handler is registered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry the transient errors.&lt;/strong&gt; Treat these as retryable with a short backoff (&lt;code&gt;[0, 500, 2000] ms&lt;/code&gt;): &lt;code&gt;symmetrickey key not found&lt;/code&gt;, &lt;code&gt;failed to decrypt message&lt;/code&gt;, &lt;code&gt;encrypt tss message error&lt;/code&gt;, &lt;code&gt;not enough peers to process signing request&lt;/code&gt;, &lt;code&gt;timed out waiting for signing result&lt;/code&gt;. They're normal under load, not failures.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Section 11: Scaling the cluster — splitting hosts, and adding nodes
&lt;/h2&gt;

&lt;p&gt;Two operations get conflated here, and they cost wildly different amounts. Moving your three nodes onto separate hosts keeps the same key and costs only a maintenance window. &lt;em&gt;Adding&lt;/em&gt; a node changes the committee and needs a resharing ceremony — which Mpcium does ship, just not as a one-command move. Know which one you're doing before you touch production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Splitting one host into three (same key, just downtime)
&lt;/h3&gt;

&lt;p&gt;This is the common "started on one box, now you want real 2-of-3" move, and it is &lt;em&gt;not&lt;/em&gt; a re-key. The identities, the shares, the &lt;code&gt;chain_code&lt;/code&gt;, and the peer UUIDs all stay byte-for-byte the same — you're only changing where each node runs. Because none of the key material changes, the master public key and every derived address survive untouched. Mechanically it's the Section 8 restore, aimed at three hosts instead of one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stand up the private network first — a &lt;a href="https://www.wireguard.com/" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt; mesh across the three hosts and the backend. Nodes talk through NATS, not node-to-node, so what actually has to be reachable is one NATS and one Consul over that private network.&lt;/li&gt;
&lt;li&gt;Stop the cluster. Your downtime starts here, and it's a hard stop: nodes down, no signing.&lt;/li&gt;
&lt;li&gt;Move each node's data dir, its &lt;code&gt;identity/&lt;/code&gt; folder, and its &lt;code&gt;config.yaml&lt;/code&gt; onto its own host. Keep the peer UUIDs and identity keys identical — regenerating them is a re-key, not a move (Section 8), and it abandons every address.&lt;/li&gt;
&lt;li&gt;Point each node's config at the relocated &lt;code&gt;nats.url&lt;/code&gt; and &lt;code&gt;consul.address&lt;/code&gt;, re-import the Consul KV dump, &lt;code&gt;chown -R 65532:65532&lt;/code&gt;, and delete stale &lt;code&gt;LOCK&lt;/code&gt; files.&lt;/li&gt;
&lt;li&gt;Start the nodes, wait for three &lt;code&gt;[READY]&lt;/code&gt;, then &lt;em&gt;verify by signing&lt;/em&gt; and &lt;code&gt;ecrecover&lt;/code&gt; that the master public key is byte-identical to before. Never trust &lt;code&gt;[READY]&lt;/code&gt; alone — that's the whole lesson of Section 8.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Downtime is the stop-to-verified-restart window — minutes if you've rehearsed it. Rehearse it exactly like the DR drill, because mechanically it is one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding a node (2-of-3 → 2-of-4, or 3-of-5): resharing
&lt;/h3&gt;

&lt;p&gt;Reshaping the committee — a fourth node, a higher threshold — can't be a plain &lt;code&gt;docker run&lt;/code&gt;: a new node has no share, and the existing shares were generated for a three-party polynomial. The primitive that fixes this is &lt;strong&gt;key resharing&lt;/strong&gt; — the current shareholders jointly re-distribute the secret to a new set of parties, producing fresh shares for the new committee &lt;em&gt;while keeping the same master public key&lt;/em&gt;, so every user address is preserved and nothing moves on-chain.&lt;/p&gt;

&lt;p&gt;Mpcium ships resharing as a first-class, signed operation (it landed in v0.3.0 and was hardened for new-node joins in v0.3.5). You publish a &lt;code&gt;ResharingMessage&lt;/code&gt; and wait for the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;mpcClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Resharing&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;sessionID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;walletID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keyType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;nodeIDs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="nx"&gt;newCommitteePeerIDs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// include a brand-new peer ID to add a node&lt;/span&gt;
  &lt;span class="nx"&gt;newThreshold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// change t here&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;mpcClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OnResharingResult&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* new shares are written */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results arrive on &lt;code&gt;mpc.mpc_reshare_result.*&lt;/code&gt;; there's a runnable &lt;code&gt;examples/reshare/main.go&lt;/code&gt; and it's e2e-tested for both ECDSA and EdDSA. The redistribution stores the new shares under the same &lt;code&gt;walletID&lt;/code&gt; with only an internal share-version bump, so the master public key — and every CKD-derived address — survives untouched. Add a node by including its peer ID in &lt;code&gt;nodeIDs&lt;/code&gt;; remove one or raise the threshold by editing &lt;code&gt;nodeIDs&lt;/code&gt; / &lt;code&gt;newThreshold&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two honest caveats, because this is where "read the code, not the pitch" earns its keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a two-part move, not one command.&lt;/strong&gt; There's no &lt;code&gt;mpcium-cli reshare&lt;/code&gt;. A full membership change is: register the new peer first (generate its &lt;code&gt;identity.json&lt;/code&gt;, then &lt;code&gt;mpcium-cli register-peers&lt;/code&gt;), &lt;em&gt;then&lt;/em&gt; the &lt;code&gt;Resharing()&lt;/code&gt; call that actually hands it a share. &lt;a href="https://github.com/fystack/mpcium/issues/84" rel="noopener noreferrer"&gt;Issue #84&lt;/a&gt; is still open — but for a turnkey add/remove-node CLI and end-to-end docs, not because the crypto is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reshare rotates the master shares&lt;/strong&gt; (new version, new committee in &lt;code&gt;threshold_keyinfo&lt;/code&gt;), so it breaks Section 7's "one backup, forever": re-take the MPC backup after every reshare.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still, pick your &lt;code&gt;n&lt;/code&gt; and threshold before the master DKG when you can — a reshare is real coordinated work, and the fewer you run against a cluster holding real funds, the better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 12: Troubleshooting cheat sheet
&lt;/h2&gt;

&lt;p&gt;The failure modes you're most likely to hit, and the one-line fix for each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;err_code=10052 ... retention policy&lt;/code&gt; crash-loop&lt;/strong&gt; — Cause: App/client created NATS streams before the nodes (wrong retention) · Fix: Recreate NATS; always start nodes first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node permission-denied on &lt;code&gt;data/&lt;/code&gt;&lt;/strong&gt; — Cause: Distroless UID 65532 vs. host ownership · Fix: &lt;code&gt;chown -R 65532:65532&lt;/code&gt; the node dirs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signing fails &lt;code&gt;ERROR_KEY_NOT_FOUND&lt;/code&gt; (&lt;code&gt;Key info not found&lt;/code&gt;)&lt;/strong&gt; — Cause: Consul KV (&lt;code&gt;threshold_keyinfo/*&lt;/code&gt;) not restored · Fix: &lt;code&gt;consul kv import&lt;/code&gt; the KV dump&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster &lt;code&gt;[READY]&lt;/code&gt; but signing fails &lt;code&gt;ERROR_KEY_NOT_FOUND&lt;/code&gt; (&lt;code&gt;ERROR_NOT_MAJORITY&lt;/code&gt; on v0.3.5)&lt;/strong&gt; — Cause: Backup restored one dir too high → empty DB, shares gone · Fix: Restore into the exact &lt;code&gt;.../data/nodeN&lt;/code&gt; subdir; verify by signing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKG hangs on "waiting for keygen result"&lt;/strong&gt; — Cause: The backend consumed the result event · Fix: Stop the app before running init&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKG times out&lt;/strong&gt; — Cause: Not all three nodes were ready · Fix: Keygen needs all nodes; wait for three &lt;code&gt;[READY]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node fatals opening the encrypted BadgerDB&lt;/strong&gt; — Cause: &lt;code&gt;badger_password&lt;/code&gt; is not 16/24/32 bytes · Fix: Generate with &lt;code&gt;openssl rand -hex 16&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nodes misbehave after re-running &lt;code&gt;docker compose up&lt;/code&gt;&lt;/strong&gt; — Cause: An override recreated a stateful service (NATS/Consul), wiping its state · Fix: Restart the nodes after any infra bring-up; treat NATS/Consul as stateful&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Look at the pattern. Not one of these is a cryptography failure. They're orchestration, filesystem, and config failures. That's the real lesson of running MPC in production: the math is the part that works, and the operations are where you earn your keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 13: Build it so you don't need the luck
&lt;/h2&gt;

&lt;p&gt;A self-hosted 2-of-3 MPC signing cluster isn't exotic infrastructure. It's a handful of open-source components wired together with a few load-bearing rules: get the startup order right, get the threshold semantics right, back up three things instead of one, and drill your disaster recovery before you need it. Because the failure that actually hurts you isn't a dramatic one. It's a cluster that looks perfectly healthy while it has silently lost every key.&lt;/p&gt;

&lt;p&gt;So if you take one thing from all of this, take the opening. Run the drill. A cluster that boots &lt;code&gt;[READY]&lt;/code&gt; with every key silently gone isn't a hypothetical, it's the first thing that goes wrong the day you finally need your backup, and it stays invisible until you sign and check. We found ours with half an hour to spare and a lot of luck. Build the thing so you don't need the luck.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this was useful, I write about backend, crypto infrastructure, and shipping distributed systems to production.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Follow me:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: &lt;a href="https://www.linkedin.com/in/anatolii-zhadan/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/anatolii-zhadan/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Hackernoon: &lt;a href="https://hackernoon.com/u/zhadan" rel="noopener noreferrer"&gt;https://hackernoon.com/u/zhadan&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Github: &lt;a href="https://github.com/ImmuneFOMO" rel="noopener noreferrer"&gt;https://github.com/ImmuneFOMO&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Tags: MPC · Multi-Party Computation · DevOps · Docker · Self-Hosted · Threshold Signatures · Disaster Recovery · Backend · Web3&lt;/em&gt;&lt;/p&gt;

</description>
      <category>web3</category>
      <category>softwaredevelopment</category>
      <category>devops</category>
      <category>cryptography</category>
    </item>
    <item>
      <title>How to Store Your Users' Crypto Wallets So Nobody Can Steal Them</title>
      <dc:creator>ImmuneFOMO</dc:creator>
      <pubDate>Tue, 07 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/immunefomo/how-to-store-your-users-crypto-wallets-so-nobody-can-steal-them-5fli</link>
      <guid>https://dev.to/immunefomo/how-to-store-your-users-crypto-wallets-so-nobody-can-steal-them-5fli</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5kkzauf6j0zklumyo1qc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5kkzauf6j0zklumyo1qc.png" alt="Encrypting the private key still leaves one decryptable key behind one server — the honeypot that MPC later removes." width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Encrypting the private key still leaves one decryptable key behind one server — the honeypot that MPC later removes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your backend can decrypt every user's private key. So can your &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;. So can whoever gets far enough into your servers: a leaked cloud token, a poisoned CI job, one unlucky RCE.&lt;/p&gt;

&lt;p&gt;You encrypted the keys. You feel safe. You've quietly built the biggest honeypot in the company.&lt;/p&gt;

&lt;p&gt;I know, because that's how we started: &lt;code&gt;Wallet.createRandom()&lt;/code&gt;, AES-256-GCM, an encrypted blob in Postgres, one &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt; in the env. It ships fast and it passes the demo. It also means one stolen secret unlocks every user at once. That's the exact failure that drained Ronin (~$625M) and Harmony (~$100M). Nobody cracked the cryptography in either case. They found the one place that could.&lt;/p&gt;

&lt;p&gt;This article is the fix I wish I'd read first: MPC-based threshold signing, where the private key is never assembled anywhere. We'll cover what it actually changes, what it does &lt;em&gt;not&lt;/em&gt; solve, which protocols and libraries are safe to build on in 2026, and how to migrate an existing "encrypted key in a database" system without a big-bang cutover.&lt;/p&gt;

&lt;p&gt;If you already run custodial Web3 infra and know how MPC-TSS works, skip to &lt;strong&gt;Section 4&lt;/strong&gt; (the protocol reality check) and &lt;strong&gt;Section 7&lt;/strong&gt; (one master wallet for a million users). That's where the real decisions live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#4e40" rel="noopener noreferrer"&gt;The real problem — it's not encryption, it's centralization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#2c00" rel="noopener noreferrer"&gt;What MPC-TSS actually changes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#0200" rel="noopener noreferrer"&gt;MPC in 60 seconds, for engineers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#da00" rel="noopener noreferrer"&gt;The protocol reality check (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#5540" rel="noopener noreferrer"&gt;The open-source options: build your own vs. a turnkey server&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#c300" rel="noopener noreferrer"&gt;Licensing traps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#b900" rel="noopener noreferrer"&gt;One master wallet for a million users (CKD)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#f400" rel="noopener noreferrer"&gt;Who actually runs MPC in production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#5f00" rel="noopener noreferrer"&gt;Migrating from encrypted keys to MPC&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#0000" rel="noopener noreferrer"&gt;The mistakes teams make (and what MPC won't save you from)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b0dd840211f6#9a00" rel="noopener noreferrer"&gt;One strong primitive, not the whole system&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Section 1: The real problem — not encryption, but centralization
&lt;/h2&gt;

&lt;p&gt;Here's the custodial flow almost every team ships first. It works, and that's the trap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a wallet, a &lt;code&gt;secp256k1&lt;/code&gt; private key&lt;/li&gt;
&lt;li&gt;Encrypt it with AES-256-GCM&lt;/li&gt;
&lt;li&gt;Store the encrypted blob in PostgreSQL&lt;/li&gt;
&lt;li&gt;Decrypt it in the backend whenever you need to sign&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This protects you against exactly one thing: a database-only leak. Steal the Postgres dump and nothing else, and all you have is ciphertext.&lt;/p&gt;

&lt;p&gt;But your backend has to decrypt to function. So an attacker who gets enough control of it, through RCE, stolen cloud credentials, a CI/CD compromise, or a bad insider, doesn't need to break any cryptography. They use your own signing path. The AES does nothing, because your server was always going to decrypt.&lt;/p&gt;

&lt;p&gt;This keeps draining real money, and it always looks the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.elliptic.co/blog/540-million-stolen-from-the-ronin-defi-bridge" rel="noopener noreferrer"&gt;Ronin Bridge&lt;/a&gt; (2022, ~$625M):&lt;/strong&gt; attackers got 5 of 9 validator keys. Control was too concentrated; a Lazarus phishing campaign was enough to reach them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.halborn.com/blog/post/explained-the-harmony-horizon-bridge-hack" rel="noopener noreferrer"&gt;Harmony Horizon Bridge&lt;/a&gt; (2022, ~$100M):&lt;/strong&gt; a 2-of-5 multisig where the attacker collected just 2 keys and drained it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nobody cracked ECDSA. They collected enough key material from too few places. So the goal isn't "encrypt the private key better." It's this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Remove the possibility that any single machine, process, or operator can unilaterally use the key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's what multi-party computation gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 2: What MPC-TSS actually changes
&lt;/h2&gt;

&lt;p&gt;In threshold MPC signing (MPC-TSS), the private key is never assembled in one place. Not in memory, not on disk, not for a microsecond.&lt;/p&gt;

&lt;p&gt;The key is split into &lt;strong&gt;shares&lt;/strong&gt;, each share lives on an independent node, and a threshold of nodes cooperates to produce a signature. The blockchain sees a completely normal ECDSA signature and can't tell the difference.&lt;/p&gt;

&lt;p&gt;The canonical setup is three nodes with a 2-of-3 threshold:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2vpmv8wyuklk3br49hw8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2vpmv8wyuklk3br49hw8.png" alt="Any two of three nodes can jointly sign. One node, or the backend, is never enough." width="800" height="443"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Any two of three nodes can jointly sign. One node, or the backend, is never enough.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Walk the attack scenarios and the difference is structural, not cosmetic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The database dump&lt;/strong&gt; — Traditional (encrypted key): Ciphertext (safe, for now) · MPC 2-of-3: Nothing useful&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database + encryption key&lt;/strong&gt; — Traditional (encrypted key): All keys stolen · MPC 2-of-3: Nothing useful&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full backend / RCE&lt;/strong&gt; — Traditional (encrypted key): All keys stolen · MPC 2-of-3: No key exists to steal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One MPC node&lt;/strong&gt; — Traditional (encrypted key): — · MPC 2-of-3: Not enough to sign&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One disk snapshot of one node&lt;/strong&gt; — Traditional (encrypted key): — · MPC 2-of-3: Not enough to sign&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;One honest caveat on that last row: an attacker who owns the backend can't exfiltrate a key, because there isn't one. But they can still ask the cluster to sign while they hold the box. MPC removes key theft, not a hijacked signing path. We come back to that in Section 10.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The backend holds zero private keys. When it needs a signature, it hashes the payload locally and sends a 32-byte digest to the cluster, which returns &lt;code&gt;(r, s, v)&lt;/code&gt;. Own the application server completely, and you get nothing to steal, because the thing you came for was never there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 3: MPC in 60 seconds (for engineers)
&lt;/h2&gt;

&lt;p&gt;Two different things get called "MPC," and mixing them up causes half the confusion in this space:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;General MPC / SMPC&lt;/strong&gt; — parties jointly compute a function over private inputs without revealing them. Banks scoring fraud together, hospitals running joint analytics, private ML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threshold signature MPC (TSS)&lt;/strong&gt; — the specialized case for key management and signing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Wallet infra lives in the second one: threshold ECDSA (Ethereum, Bitcoin, most EVM/L2 chains) or threshold EdDSA (Solana and friends).&lt;/p&gt;

&lt;p&gt;There are only two ceremonies to understand:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DKG (distributed key generation).&lt;/strong&gt; The nodes jointly generate key shares. No node ever sees the whole private key; each ends up holding its own share plus the shared public key. Keygen needs every participating node online.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signing.&lt;/strong&gt; Your backend hashes the message and sends the 32-byte digest. A threshold of nodes runs the multi-round protocol and returns a standard &lt;code&gt;(r, s, v)&lt;/code&gt;, valid on-chain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxh8clqphh183go4x3xud.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxh8clqphh183go4x3xud.png" alt="DKG runs once. After that, signing only needs a threshold of nodes, not all of them." width="800" height="366"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;DKG runs once. After that, signing only needs a threshold of nodes, not all of them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One detail trips up everyone on day one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In &lt;code&gt;tss-lib&lt;/code&gt;-style implementations, &lt;code&gt;threshold&lt;/code&gt; is the polynomial degree &lt;code&gt;t&lt;/code&gt;, and the number of signers required is &lt;code&gt;t + 1&lt;/code&gt;. So a 2-of-3 wallet is configured with &lt;code&gt;threshold: 1&lt;/code&gt; (degree 1 → 2 signers), &lt;em&gt;not&lt;/em&gt; &lt;code&gt;threshold: 2&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Get that wrong and you've mis-provisioned your security by one. And remember the asymmetry: keygen needs every node, signing needs only the threshold. A node can be down and you can still sign, which is exactly the availability you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 4: The protocol reality check (2026)
&lt;/h2&gt;

&lt;p&gt;This is where naive advice goes wrong. People pick a protocol by name and stop. A protocol name is not a security guarantee. You have to evaluate implementation, version, and threat model together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55w1jfrvkrzqmdf6qy1f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55w1jfrvkrzqmdf6qy1f.png" alt="The same protocol family can be safe or unusable depending on the exact library and version. The name alone tells you nothing." width="799" height="340"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same protocol family can be safe or unusable depending on the exact library and version. The name alone tells you nothing.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  GG18 / GG20
&lt;/h3&gt;

&lt;p&gt;The most widely implemented family, and the basis of most turnkey servers. It also had serious holes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.fireblocks.com/blog/gg18-and-gg20-paillier-key-vulnerability-technical-report" rel="noopener noreferrer"&gt;**BitForge (CVE-2023-33241)&lt;/a&gt;*&lt;em&gt;, disclosed August 2023, CVSS 9.1–9.6. A malicious signer crafts a bad Paillier modulus and, in some implementations, extracts another party's key share in as few as 16 signing attempts (the real range runs from 16 up to ~a billion, depending on the implementation). The catch: it needs a malicious *participating&lt;/em&gt; party. If you control all your own nodes, an outside attacker can't mount it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://verichains.io/tsshock/" rel="noopener noreferrer"&gt;**TSSHOCK&lt;/a&gt;** (Verichains, Black Hat USA 2023), a related class against GG18/GG20/CGGMP21 that can "leave no trace and appear innocent to the other parties."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix, Paillier-Blum plus no-small-factor proofs, is in &lt;code&gt;tss-lib&lt;/code&gt; v2.x and maintained forks. So GG18/GG20 on a &lt;em&gt;patched&lt;/em&gt; implementation, with all nodes under your control, is a defensible self-hosted choice. On an unpatched one, it isn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  CGGMP21
&lt;/h3&gt;

&lt;p&gt;Newer, academically stronger, and carrying a live problem in Rust. &lt;a href="https://rustsec.org/advisories/RUSTSEC-2025-0127.html" rel="noopener noreferrer"&gt;**CVE-2025-66017 / RUSTSEC-2025-0127&lt;/a&gt;**: signature forgery through altered presignatures, CVSS 8.2. The advisory is blunt: "Patched: no patched versions" for the &lt;code&gt;cggmp21&lt;/code&gt; crate. There's a second twist that lands right on our path here: presignatures plus HD/BIP-32 derivation drop effective security to 85 bits, and this whole article is about to steer you toward HD derivation (Section 7).&lt;/p&gt;

&lt;p&gt;Every serious writeup says the same thing: don't start a new system on raw &lt;code&gt;cggmp21&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  CGGMP24
&lt;/h3&gt;

&lt;p&gt;The safer forward path. The same class of issue is patched in &lt;code&gt;[v0.7.0-alpha.2&lt;/code&gt;](&lt;a href="https://github.com/LFDT-Lockness/cggmp21" rel="noopener noreferrer"&gt;https://github.com/LFDT-Lockness/cggmp21&lt;/a&gt;), and the API hardening is real. If you're choosing a modern protocol today, this is the direction.&lt;/p&gt;

&lt;h3&gt;
  
  
  DKLS-family (DKLS23)
&lt;/h3&gt;

&lt;p&gt;Strong research, good performance in specific stacks. Watch the license (Section 6). Implementation quality and licensing vary wildly by vendor.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GG18 / GG20&lt;/strong&gt; — Status 2026: Mature, patched forks exist · New self-hosted system?: Yes, if patched and all nodes yours&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CGGMP21&lt;/strong&gt; — Status 2026: Unpatched forgery CVE · New self-hosted system?: No, don't start here&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CGGMP24&lt;/strong&gt; — Status 2026: Patched (&lt;code&gt;v0.7.0-alpha.2&lt;/code&gt;) · New self-hosted system?: Yes, modern forward path&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKLS23&lt;/strong&gt; — Status 2026: Strong, license-dependent · New self-hosted system?: Only after reading the LICENSE&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Never trust a "we use protocol X" claim. Check the exact library, the exact version, whether the CVE patch is actually in the code path you call, and whether your threat model even includes a malicious party.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 5: Build your own vs. a turnkey server
&lt;/h2&gt;

&lt;p&gt;If you want self-hosted MPC today, you have two real options.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A — build on a library
&lt;/h3&gt;

&lt;p&gt;You take a cryptographic library and build the server, networking, orchestration, storage, backups, and observability yourself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[**bnb-chain/tss-lib&lt;/code&gt;](&lt;a href="https://github.com/bnb-chain/tss-lib)**" rel="noopener noreferrer"&gt;https://github.com/bnb-chain/tss-lib)**&lt;/a&gt; — Language: Go · License: MIT · Audit: Kudelski (Oct 2019) · Notes: The de-facto GG18/GG20 lib&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[**coinbase/cb-mpc&lt;/code&gt;](&lt;a href="https://github.com/coinbase/cb-mpc)**" rel="noopener noreferrer"&gt;https://github.com/coinbase/cb-mpc)**&lt;/a&gt; — Language: C++ · License: MIT · Audit: Cure53 (Q4 2024) · Notes: High-quality, library only — no server/storage/API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LFDT-Lockness &lt;code&gt;[cggmp24&lt;/code&gt;](&lt;a href="https://github.com/LFDT-Lockness/cggmp21" rel="noopener noreferrer"&gt;https://github.com/LFDT-Lockness/cggmp21&lt;/a&gt;)&lt;/strong&gt; — Language: Rust · License: MIT/Apache-2.0 · Audit: Kudelski · Notes: Modern protocol, patched&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get total control and protocol-level flexibility. You also now own networking, retries, key storage, backups, disaster recovery, monitoring, and the entire incident-response surface. That's weeks to months of work before you sign your first production transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B — an open-source MPC server
&lt;/h3&gt;

&lt;p&gt;The most turnkey route right now is &lt;a href="https://github.com/fystack/mpcium" rel="noopener noreferrer"&gt;**Mpcium&lt;/a&gt;**: a Go server backed by &lt;code&gt;tss-lib&lt;/code&gt;, shipping a full cluster (nodes, message bus, service discovery, encrypted storage) plus client SDKs, under Apache-2.0.&lt;/p&gt;

&lt;p&gt;You get a real cluster out of the box and a dramatically lower starting cost. In exchange you accept an opinionated architecture, feature gaps you'll fork around (trusted-dealer key import, a REST façade over its NATS pub/sub), and a DevOps burden that's still yours.&lt;/p&gt;

&lt;p&gt;The heuristic I keep coming back to: weeks of building networking, storage, and DR from scratch, versus adding one feature to a server that already works. Unless you have a hard protocol requirement, extend the existing server.&lt;/p&gt;

&lt;p&gt;A quick note on the rest of the field, so you don't lose a week evaluating them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ZenGo&lt;/strong&gt; — the &lt;code&gt;[multi-party-ecdsa&lt;/code&gt;](&lt;a href="https://github.com/ZenGo-X/multi-party-ecdsa" rel="noopener noreferrer"&gt;https://github.com/ZenGo-X/multi-party-ecdsa&lt;/a&gt;) library is explicitly abandoned; &lt;code&gt;[gotham-city&lt;/code&gt;](&lt;a href="https://github.com/ZenGo-X/gotham-city" rel="noopener noreferrer"&gt;https://github.com/ZenGo-X/gotham-city&lt;/a&gt;) is 2-of-2 only, demo-grade, and effectively dormant (last commit 2024).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/safeheron" rel="noopener noreferrer"&gt;**Safeheron&lt;/a&gt;** — open crypto libraries, proprietary server.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.sodot.dev/" rel="noopener noreferrer"&gt;**Sodot&lt;/a&gt;** — self-hostable, but proprietary: a closed-source SDK you can't inspect or fork.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://litprotocol.com/" rel="noopener noreferrer"&gt;**Lit Protocol&lt;/a&gt;** — a decentralized node network you consume, not a private cluster you run yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Section 6: Licensing traps
&lt;/h2&gt;

&lt;p&gt;This one surprises people in legal review, not code review.&lt;/p&gt;

&lt;p&gt;A repo can be source-available or non-commercial while looking open at a glance. The protocol paper can be fully open while the implementation's license is restrictive.&lt;/p&gt;

&lt;p&gt;The cleanest example: one popular &lt;code&gt;dkls23&lt;/code&gt; implementation (&lt;a href="https://github.com/silence-laboratories/dkls23" rel="noopener noreferrer"&gt;Silence Labs&lt;/a&gt;) ships under a non-commercial license (SLL), and commercial use needs a separate paid license. A &lt;em&gt;different&lt;/em&gt;, independent DKLs23 implementation (&lt;a href="https://github.com/0xCarbon/DKLs23" rel="noopener noreferrer"&gt;0xCarbon&lt;/a&gt;) is dual MIT/Apache-2.0. Same protocol, completely different rights.&lt;/p&gt;

&lt;p&gt;Open the actual &lt;code&gt;LICENSE&lt;/code&gt; file, in the exact repo and version you plan to ship. Not the README, not the paper. The license, pinned to your version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 7: One master wallet for a million users (CKD)
&lt;/h2&gt;

&lt;p&gt;Give every user their own wallet the obvious way and each one gets its own DKG across your nodes. That's Mpcium's default, and it works fine. The catch isn't performance, it's what it does to your backups and your operations.&lt;/p&gt;

&lt;p&gt;Every per-user DKG needs all three nodes online, and every wallet it creates is another independent set of shards on every node. So the MPC state you have to protect grows with your user base: every signup is one more ceremony to run and one more set of shards you can never lose. At a handful of users that's nothing. At a few million, provisioning and backup are the whole problem.&lt;/p&gt;

&lt;p&gt;CKD collapses all of it into one ceremony. BIP-32 hierarchical deterministic wallets, the trick Bitcoin gave us in 2012, derive an effectively infinite tree of child keys from one master. Run the DKG once, and every user after that is a pure derivation off the master — no ceremony, no nodes online, and a single backup covers all of them. That's &lt;strong&gt;CKD (child key derivation)&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run one DKG at init → a single master MPC wallet (still split into 3 shards, still never assembled)&lt;/li&gt;
&lt;li&gt;For each user, derive &lt;code&gt;child = derive(masterPubKey, chainCode, userIndex)&lt;/code&gt;. Pure math, no ceremony, no nodes required online&lt;/li&gt;
&lt;li&gt;To sign, each node applies the derivation offset to its own share and signs. The child key, like the master, is never assembled&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmkw1l28q2kve6noymsu0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmkw1l28q2kve6noymsu0.png" alt="Per-user DKG on the left runs a full ceremony per user. CKD on the right derives every user from one master, with no per-user ceremony." width="800" height="409"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Per-user DKG on the left runs a full ceremony per user. CKD on the right derives every user from one master, with no per-user ceremony.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The operational gap isn't incremental, it's categorical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Creating a user&lt;/strong&gt; — Per-user DKG: a key ceremony, all nodes · CKD (one master): a derivation, no ceremony&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10,000 users&lt;/strong&gt; — Per-user DKG: 10k ceremonies, 30k shards · CKD (one master): 1 ceremony, 3 shards&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup cadence&lt;/strong&gt; — Per-user DKG: after every new user · CKD (one master): once, at init&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MPC node down&lt;/strong&gt; — Per-user DKG: new users wait · CKD (one master): new users still created&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disaster recovery&lt;/strong&gt; — Per-user DKG: restore everything · CKD (one master): restore master; users are DB rows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Is it as secure?" is the fair question, and the answer is yes, under the same threat model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 node compromised&lt;/strong&gt; — Per-user DKG: Safe · CKD: Safe&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 of 3 nodes compromised&lt;/strong&gt; — Per-user DKG: Compromised · CKD: Compromised&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup stolen&lt;/strong&gt; — Per-user DKG: Compromised · CKD: Compromised&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 nodes in a secure enclave&lt;/strong&gt; — Per-user DKG: Protected · CKD: Protected&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That equivalence is about &lt;em&gt;node compromise&lt;/em&gt;. Be clear-eyed about what CKD gives up, because this is the part the marketing pages skip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can't rotate or revoke a single user's key in isolation. Everyone derives from the same master; there's no per-user share to roll.&lt;/li&gt;
&lt;li&gt;You can't prove a user's key is cryptographically independent of the others, which some regulatory or audit regimes want.&lt;/li&gt;
&lt;li&gt;Anyone holding the master public key and chain code can enumerate every user address. For an opt-in custodial service that's a non-issue; for a privacy-sensitive product it's a real deanonymization vector.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of that bites your product, CKD is a clean way to scale. If one does — and for many products one will — per-user DKG is the safer default, and that's when it earns its cost.&lt;/p&gt;

&lt;p&gt;The BIP-32 caveat, stated honestly: with the parent public key, the chain code, and a full child private key, you can recover the parent key. In MPC that's defused, because the child private key never exists as a whole; each node holds only a share delta. The &lt;code&gt;chain_code&lt;/code&gt; isn't secret-shared either (every node has a full copy), but that doesn't change the picture: recovering the parent still needs a full child private key, and that needs a threshold of nodes, which is total compromise already.&lt;/p&gt;

&lt;p&gt;I didn't take any of this on faith. An end-to-end recovery test ran the full flow, create the master, derive several users, sign with each, back up, destroy the cluster, restore, verify, and passed 12 of 12 checks: every derived address matched, every signature still verified, and a brand-new user created &lt;em&gt;after&lt;/em&gt; the restore worked too. A separate cross-machine drill, destroy the cluster and rebuild on fresh hardware, is what flushed out eleven runbook bugs, one of which would have quietly lost every key in a real emergency. The next article takes that drill apart in full.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 8: Who actually runs MPC in production
&lt;/h2&gt;

&lt;p&gt;MPC is no longer a crypto-startup curiosity. The clearest sign of how seriously the industry takes it: &lt;strong&gt;Fireblocks&lt;/strong&gt;, one of the largest custody providers, is the team that discovered and disclosed the &lt;a href="https://www.fireblocks.com/blog/bitforge-fireblocks-researchers-uncover-vulnerabilities-in-over-15-major-wallet-providers" rel="noopener noreferrer"&gt;BitForge&lt;/a&gt; vulnerability class from Section 4. When your custody vendor is the one running the offensive research that finds MPC's own weak spots, the primitive has left the lab.&lt;/p&gt;

&lt;p&gt;Who else is standing on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://newsroom.ibm.com/ibm-digital-asset-haven" rel="noopener noreferrer"&gt;**IBM Digital Asset Haven&lt;/a&gt;** (2025), developed with &lt;strong&gt;Dfns&lt;/strong&gt;, combines Dfns's threshold signing with IBM's HSM and offline-signing models for institutional custody. MPC under an IBM-branded product is about as mainstream as it gets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dfns&lt;/strong&gt; runs its &lt;a href="https://dfns.co/article/ku23/" rel="noopener noreferrer"&gt;KU23 protocol&lt;/a&gt; on its key-management network — by its own figures 10M+ keys and ~$1B signed per month — and plans to open-source it and move governance to the Linux Foundation's LF Decentralized Trust (the Lockness project).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coinbase&lt;/strong&gt; open-sourced its own MPC library, &lt;code&gt;[cb-mpc&lt;/code&gt;](&lt;a href="https://github.com/coinbase/cb-mpc" rel="noopener noreferrer"&gt;https://github.com/coinbase/cb-mpc&lt;/a&gt;) (MIT, Cure53-audited). You don't open-source your custody crypto unless it's core to how you operate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://fordefi.com/" rel="noopener noreferrer"&gt;Fordefi&lt;/a&gt;, &lt;a href="https://www.bitgo.com/" rel="noopener noreferrer"&gt;BitGo&lt;/a&gt;, and &lt;a href="https://copper.co/" rel="noopener noreferrer"&gt;Copper&lt;/a&gt;&lt;/strong&gt; ship MPC/TSS custody to institutional desks and DeFi.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adopting MPC isn't an exotic bet. It's the same primitive regulated, multi-billion-dollar custodians standardized on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 9: Migrating from encrypted keys to MPC
&lt;/h2&gt;

&lt;p&gt;You rarely start clean. Here's a rollout that doesn't need a big-bang cutover.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1sg1veziabpliqfdpm6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1sg1veziabpliqfdpm6.png" alt="The safe path is a signer abstraction: your service code never changes, only what sits behind the interface does." width="800" height="366"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The safe path is a signer abstraction: your service code never changes, only what sits behind the interface does.&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory every signing call site.&lt;/strong&gt; Every place a key is created, decrypted, or used, across services, jobs, and scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Introduce a signer abstraction.&lt;/strong&gt; Replace direct wallet creation with a single signing interface so the rest of your code stops caring where the key lives.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;   &lt;span class="c1"&gt;// Before: the key exists in your process&lt;/span&gt;
   &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wallet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;ethers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Wallet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;encryptedKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
   &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;wallet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

   &lt;span class="c1"&gt;// After: the key never exists here — you send a digest out to sign&lt;/span&gt;
   &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSigner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;derivationPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
   &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// digest -&amp;gt; MPC -&amp;gt; (r, s, v)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add MPC signer adapters.&lt;/strong&gt; One thin adapter for &lt;code&gt;ethers&lt;/code&gt;, one for &lt;code&gt;viem&lt;/code&gt;, both just forwarding the digest to the cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual-run behind a flag.&lt;/strong&gt; &lt;code&gt;MPC_ENABLED&lt;/code&gt;, rolled out to a controlled cohort first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cut over new users first.&lt;/strong&gt; New wallets go through MPC; the legacy path becomes read-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate or rotate legacy keys.&lt;/strong&gt; Import if your server supports trusted-dealer import; otherwise move funds to MPC-managed wallets and retire the old ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the decryption path.&lt;/strong&gt; Rip out every &lt;code&gt;getPrivateKey()&lt;/code&gt;-style helper. If the code can't decrypt a key, neither can an attacker who owns the process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test disaster recovery before launch.&lt;/strong&gt; Restore drills are mandatory, not optional. (The next article is one long argument for why.)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Section 10: The mistakes teams make
&lt;/h2&gt;

&lt;p&gt;After shipping this to production, the most useful thing I learned is that the cryptography almost never breaks. The orchestration does. The mistakes repeat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Misreading threshold semantics, the &lt;code&gt;t&lt;/code&gt; vs &lt;code&gt;t + 1&lt;/code&gt; count from Section 3.&lt;/li&gt;
&lt;li&gt;Underestimating the message bus and metadata store. The signing protocol is chatty and stateful, and your NATS/Consul/BadgerDB layer is now load-bearing.&lt;/li&gt;
&lt;li&gt;Assuming one datastore backup is enough. It isn't; you need the key shares &lt;em&gt;and&lt;/em&gt; the routing metadata.&lt;/li&gt;
&lt;li&gt;Running keygen while application consumers race for the result events, silently stealing DKG output from your init flow.&lt;/li&gt;
&lt;li&gt;Treating MPC as "security complete" and skipping the policy layer, which brings us to the most important caveat here.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  MPC is not magic
&lt;/h3&gt;

&lt;p&gt;MPC dramatically reduces key-&lt;em&gt;theft&lt;/em&gt; risk. On its own it does nothing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;signing the wrong payload, if your backend is tricked into it&lt;/li&gt;
&lt;li&gt;compromised approval or policy logic&lt;/li&gt;
&lt;li&gt;frontend / UI supply-chain attacks that fool an operator into approving a bad action&lt;/li&gt;
&lt;li&gt;broad application-layer abuse after a backend compromise&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cautionary tale is &lt;a href="https://www.elliptic.co/blog/bybit-hack-largest-in-history" rel="noopener noreferrer"&gt;**Bybit (February 2025, ~$1.5B)&lt;/a&gt;*&lt;em&gt;, the largest crypto theft to date. Bybit *had&lt;/em&gt; multisig (Safe). The attackers didn't break the keys; they compromised the UI so operators approved a malicious transaction. MPC wouldn't have stopped it, because the failure was in intent, not custody.&lt;/p&gt;

&lt;p&gt;So treat MPC as a very strong primitive inside a layered system. Policy engine, transaction simulation, allowlists, anomaly detection, segregated duties, hardened ops: all still matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 11: One strong primitive, not the whole system
&lt;/h2&gt;

&lt;p&gt;If your backend can decrypt every user's private key today, you don't have a cryptography problem. You have a system-design problem: one key, one server, one blast radius.&lt;/p&gt;

&lt;p&gt;MPC-TSS is the most practical way I know to redesign that system so key theft becomes structurally hard and the blast radius of a compromise shrinks. Not impossible. Materially harder. In security architecture, that difference is the whole game.&lt;/p&gt;

&lt;p&gt;One warning before you run off and build it, though. The day I moved our first wallet onto MPC and deleted the old &lt;code&gt;getPrivateKey()&lt;/code&gt; helper felt great, right up until I realized MPC had solved the &lt;em&gt;theft&lt;/em&gt; problem and none of the others. It will faithfully sign the wrong transaction if something upstream tells it to. So ship it as one strong primitive in a layered system, not as the thing that lets you stop worrying.&lt;/p&gt;

&lt;p&gt;Next up, the hands-on half: deploying a self-hosted 2-of-3 MPC signing cluster with Docker. The DKG ceremony, the backup model, and the disaster-recovery drill that caught a silent, cluster-killing bug — the one I mentioned above, in full.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this was useful, I write about backend, crypto infrastructure, and shipping distributed systems to production.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Follow me:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;LinkedIn: &lt;a href="https://www.linkedin.com/in/anatolii-zhadan/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/anatolii-zhadan/&lt;/a&gt;&lt;br&gt;
Hackernoon: &lt;a href="https://hackernoon.com/u/zhadan" rel="noopener noreferrer"&gt;https://hackernoon.com/u/zhadan&lt;/a&gt;&lt;br&gt;
Github: &lt;a href="https://github.com/ImmuneFOMO" rel="noopener noreferrer"&gt;https://github.com/ImmuneFOMO&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mpc</category>
      <category>cryptowalletsecurity</category>
      <category>web3</category>
      <category>custody</category>
    </item>
  </channel>
</rss>
