<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Projekte auf Nicky Reinert</title><link>https://nickyreinert.de/en/tags/projekte/</link><description>Blog &amp; projects by Nicky Reinert (Institute for Digital Challenges): web development &amp; software development, SEO &amp; analytics, hosting &amp; DevOps, WordPress &amp; Hugo, tools &amp; projects, data protection &amp; digital culture — plus content on AI and autism &amp; society.</description><generator>Hugo 0.167.0</generator><language>en</language><managingEditor>Nicky Reinert</managingEditor><webMaster>Nicky Reinert</webMaster><copyright/><lastBuildDate>Fri, 03 Jul 2026 12:34:56 +0000</lastBuildDate><atom:link href="https://nickyreinert.de/en/tags/projekte/index.xml" rel="self" type="application/rss+xml"/><item><title>9 – Simple, Secure Browser-Based Text Sharing</title><link>https://nickyreinert.de/en/2026/2026-07-03-nine-share-text/</link><pubDate>Fri, 03 Jul 2026 12:34:56 +0000</pubDate><author>Nicky Reinert</author><dc:creator>Nicky Reinert</dc:creator><guid isPermaLink="true">https://nickyreinert.de/en/2026/2026-07-03-nine-share-text/</guid><description>9 is a free, anonymous, browser-based tool that sends text directly between two devices via encrypted WebRTC – no server, no signup.</description><content:encoded><![CDATA[<p>You are setting up a new device and you need to get your complex password to it from you current working laptop? You want to quickly copy a private key from one PC to another? You want to quickly share a prompt with another device? You want to quickly share a text snippet with your MacBook?</p>
<p>Then you need 9. A simple browser based text sharing tool.</p>
<p>It&rsquo;s anonyous. You don&rsquo;t need to register.</p>
<p>It&rsquo;s secure. Once a connection is established, the text is encrypted and sent directly to the other device. No server in between.</p>
<p>It&rsquo;s quick. Quickly scan a QR code if you use your mobile phone. Or type in the 6-digit code if you use another computer (which shortly utilizes Cloudeflare&rsquo;s WebRTC implementation to establish a direct connection).</p>
<p>If your devices are not on the same network 9 utilizes STUN by Google to establish a direct connection. Still, the text is being exchanged directly between the two devices.</p>
<figure>
  <a href="https://nickyreinert.de/en/2026/2026-07-03-nine-share-text/image.png" target="_blank" class="image-link " title="Click to view full size">
    <img 
      src="https://nickyreinert.de/en/2026/2026-07-03-nine-share-text/image.png" 
      alt="Screenshot of the 9 browser-based text sharing tool"
      width="600"
      loading="lazy"
    />
  </a>
  <figcaption>Screenshot of the 9 browser-based text sharing tool</figcaption>
</figure>

<p>





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://github.com/nickyreinert/9?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">GitHub Repository</a>


</p>
<p>





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://9.1-1-1.de/?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">nine online</a>


</p>
]]></content:encoded><atom:updated>2026-10-06T10:59:33+02:00</atom:updated><category>tools</category><dc:subject>Lesezeit: 2 Minuten</dc:subject><dc:type>tool</dc:type></item><item><title>sudoku</title><link>https://nickyreinert.de/en/2024/2024-10-18-sudoku/</link><pubDate>Thu, 17 Oct 2024 12:34:56 +0100</pubDate><author>Nicky Reinert</author><dc:creator>Nicky Reinert</dc:creator><guid isPermaLink="true">https://nickyreinert.de/en/2024/2024-10-18-sudoku/</guid><description>How to build a brute force Sudoku solver Follow my journey on how to build an algorithm that lets you create or solve Sudoku puzzles!
Follow my steps or jump ahead — this is the Jupyter notebook: https://gist.github.com/nickyreinert/966299333429ef685338e5feb5056126 Warmup — introduction Before we …</description><content:encoded><![CDATA[<h3 id="how-to-build-a-brute-force-sudoku-solver">How to build a brute force Sudoku solver</h3>
<p>Follow my journey on how to build an algorithm that lets you create or solve Sudoku puzzles!</p>
<blockquote>
<p>Follow my steps or jump ahead — this is the Jupyter notebook: 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://gist.github.com/nickyreinert/966299333429ef685338e5feb5056126?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">https://gist.github.com/nickyreinert/966299333429ef685338e5feb5056126</a>


</p>
</blockquote>
<h1 id="warmup--introduction"><strong>Warmup — introduction</strong></h1>
<p>Before we start, we need to get familiar with how <strong>Sudoku</strong> works. I suggest we number each cell in the giant grid from <code>1</code> to <code>81</code>. This way, we can programmatically loop <strong>from the top-left to the bottom-right</strong> to find valid solutions:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_positions.png" alt="sudoku positions"><p><strong>Sudoku</strong> has three rules: a number can’t exist twice in the same <strong>column</strong>, <strong>row</strong>, or <strong>section</strong>. So, first, we need a way to identify the column, row, and section for each position.</p>
<p>Every row contains <code>9</code> cells, so the row index is simply:</p>
<pre><code>ceil(position / 9)
</code></pre>
<p>Or if you prefer double slash (<code>//</code>), which works like a “floor” function:</p>
<pre><code>((position — 1) // 9) + 1
</code></pre>
<p>Columns probably also have something to do with <code>9</code> (because <code>9</code> is kind of a big deal in Sudoku). The first nine cells are exactly addressing their corresponding columns, obviously. So, in the upcoming rows (positions <code>10</code> and onward), we just need to reduce it back to <code>1</code> to <code>9</code>. We can use <strong>modulo</strong> for that!</p>
<pre><code>((position — 1) % 9) + 1
</code></pre>
<p>The section index is a little trickier. It’s a combo of the row and column index and some tweaks. There’s probably a clever mathematical approach, or you can just trial-and-error your way through (my usual strategy). Here’s a starting point:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_cols_and_rows.png" alt="sudoku columns and rows"><p>We take the floor of a third of each position now. It seems like we could just sum them to get the fitting section index, with a little adjustment. Here’s the magic:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudouku_from_cols_and_rows_to_sections.png" alt="sudouku from cols and rows to sections"><p>And that’s the formula to get the section index:</p>
<pre><code>(col // 3) + (((row // 3) — 1) * 3)
</code></pre>
<h1 id="first-steps">First steps</h1>
<p>Well, now that we know how to address each row, column, and section, let’s dive into the actual algorithm.</p>
<h2 id="solution-approach">Solution approach</h2>
<p>The basic idea is to <strong>iterate through the positions</strong> up to <code>81</code>. At every step, we choose a number from <code>1</code> to <code>9</code> and check if it already exists in the corresponding row, column, or section (we’ll call these <strong>containers</strong>). If it does, the number is <strong>blocked</strong> for the current position, and we have to check the next number. If we can’t find a valid number for the current position, we need to <strong>roll back</strong> to the previous one and choose a <strong>different number</strong>. We repeat this process until we either reach position <code>81</code> — meaning we’ve found a valid Sudoku — or have to backtrack more.</p>
<h2 id="implementation-in-python">Implementation in Python</h2>
<p>We start by initializing four dictionaries to keep track of the numbers in each <strong>container</strong>. The <code>solutions</code> dictionary holds (surprise!) the valid numbers for the entire grid.</p>
<pre><code>def reset_containers():  
    global rows, cols, sections, solutions  
    rows = {i: [] for i in range(1, 10)}  
    cols = {i: [] for i in range(1, 10)}  
    sections = {i: [] for i in range(1, 10)}  
    solutions = {}  

reset_containers()  

print(sections, '\n', rows, '\n', cols)
</code></pre>
<p>Result:</p>
<pre><code>{1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 7: [], 8: [], 9: []}   
{1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 7: [], 8: [], 9: []}   
{1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 7: [], 8: [], 9: []}
</code></pre>
<p>The <code>visualize</code> function takes the solution dictionary and returns a nice grid, displaying the numbers — you&rsquo;ll find it in the notebook I referenced at the top!</p>
<pre><code>def visualize(solution):  
    # see Jupyter notebook referenced on the top
</code></pre>
<p>We’ve already figured out how to get the actual <strong>row</strong>, <strong>column</strong>, or <strong>section</strong> from a given position. Now, let’s translate into Python and check if we’re on the right track:</p>
<pre><code>print('pos', '\t', 'row', '\t', 'col', '\t', 'section')  
for position in range(1, 82):  
    idx_row = math.ceil(position / 9)  
    idx_col = ((position - 1) % 9) + 1  
    idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  
    print(position, '\t', idx_row, '\t', idx_col, '\t', idx_section)
</code></pre>
<p>Output:</p>
<pre><code>pos   row   col   section  
1   1   1   1  
2   1   2   1  
3   1   3   1  
4   1   4   2  
5   1   5   2  
6   1   6   2  
7   1   7   3  
8   1   8   3  
9   1   9   3  
10   2   1   1  
…
</code></pre>
<p>Yes, we are! Every position is correctly mapped to the correct <strong>container</strong>!</p>
<h2 id="solution-checking-and-iterating">Solution checking and iterating</h2>
<p>The first version is quite simple: We iterate through the positions and add a number that does not exist in one of the corresponding containers.</p>
<pre><code>reset_containers()  

number = 1  

for position in range(1, 82):  
    idx_row = math.ceil(position / 9)  
    idx_col = ((position - 1) % 9) + 1  
    idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  
    if (number not in rows[idx_row] and number not in cols[idx_col] and number not in sections[idx_section]):  
        solutions[position] = number  
        rows[idx_row].append(number)  
        cols[idx_col].append(number)  
        sections[idx_section].append(number)  
        number += 1  

visualize(solutions)
</code></pre>
<p>Seems like our basic loop is working fine. Let’s add some fine-tuning and limit it to the numbers from 1 to 9. We can just introduce an inner loop to keep things simple:</p>
<p>Output:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/suduko_first_attempt.png" alt="suduko first attempt"><p>Seems like our basic loop is working fine. Let’s add some fine-tuning and limit it to the numbers from 1 to 9. We can just introduce an inner loop to keep things simple:</p>
<pre><code>reset_containers()  

number = 1  

for position in range(1, 82):  
    idx_row = math.ceil(position / 9)  
    idx_col = ((position - 1) % 9) + 1  
    idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  
    for number in range(1, 10):  
        if (number not in rows[idx_row] and number not in cols[idx_col] and number not in sections[idx_section]):  
            solutions[position] = number  
            rows[idx_row].append(number)  
            cols[idx_col].append(number)  
            sections[idx_section].append(number)  
            break  

visualize(solutions)
</code></pre>
<p>Output:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_second_attempt.png" alt="sudoku second attempt"><p>There are a lot of empty spots in the grid, what are we missing? Let’s introduce a conditional that stops when there’s no solution:</p>
<pre><code>reset_containers()  

number = 1  

for position in range(1, 82):  
    idx_row = math.ceil(position / 9)  
    idx_col = ((position - 1) % 9) + 1  
    idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  
    for number in range(1, 10):  
        if (number not in rows[idx_row] and number not in cols[idx_col] and number not in sections[idx_section]):  
            solutions[position] = number  
            rows[idx_row].append(number)  
            cols[idx_col].append(number)  
            sections[idx_section].append(number)  
            break  

    if not position in solutions:  
        break  

visualize(solutions)
</code></pre>
<p>Output:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_third_attempt.png" alt="sudoku third attempt"><p>Quite good, but just stopping the loop when we hit a dead end probably won’t get us to a solution. We need to keep track of dead ends, and we need the ability to go back, choose another path, and try again. Let’s start with a container that holds “impossible” paths for each of our 81 positions.</p>
<p>We’ll also switch from a limited <code>for-loop</code> to an &ldquo;infinite&rdquo; <code>while-loop</code> because now we’re not just incrementing from 1 to 81 — we’ll be going back and forth. Of course, it’s always a good idea to use a stop condition, like <code>max_iterations</code>, so we don’t accidentally run into an actual endless loop when using <code>while</code>.</p>
<p>Let’s put all of that into a new “big” init function!</p>
<pre><code>def reset_containers():  
    global rows, cols, sections, solutions, dead_ends  
    rows = {i: [] for i in range(1, 10)}  
    cols = {i: [] for i in range(1, 10)}  
    sections = {i: [] for i in range(1, 10)}  
    solutions = {}  
    dead_ends = {i: [] for i in range(1, 82)}
</code></pre>
<p>Now, if the current iteration doesn’t result in a solution, we don’t just stop processing. We go back one step:</p>
<pre><code>position -= 1
</code></pre>
<p>And we archive the solution from the previous position as a “dead end solution”:</p>
<pre><code>dead_ends[position].append(solutions[position])
</code></pre>
<p>Then we have to update our containers, as the solution is no longer valid:</p>
<pre><code>rows[idx_row].remove(solutions[position])  
    cols[idx_col].remove(solutions[position])  
    sections[idx_section].remove(solutions[position])  
    del solutions[position]
</code></pre>
<p>Let’s see how this performs!</p>
<p>reset_containers()</p>
<pre><code>print('iter', '\t', 'pos', '\t', 'row', '\t', 'col', '\t', 'section', '\t\t', 'number')  

iteration = 1  
position = 1  
max_iterations = 10_000  
stop = False  

while True:  
    idx_row = math.ceil(position / 9)  
    idx_col = ((position - 1) % 9) + 1  
    idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  
    for number in range(1, 10):  
        print(iteration, '\t', position, '\t', idx_row, '\t', idx_col, '\t', idx_section, '\t\t', number)  

        iteration += 1  
        if (iteration &gt; max_iterations): stop = True; break  

        if (number not in rows[idx_row] and \  
            number not in cols[idx_col] and \  
            number not in sections[idx_section] and \  
            number not in dead_ends[position]):  
            solutions[position] = number  
            rows[idx_row].append(number)  
            cols[idx_col].append(number)  
            sections[idx_section].append(number)  
            break  

    if stop: break  

    if not position in solutions:  

        position -= 1  
        if (position &lt;= 0): stop = True; break  

        dead_ends[position].append(solutions[position])  

        print(f'rolling back {solutions[position]}')  

        idx_row = math.ceil(position / 9)  
        idx_col = ((position - 1) % 9) + 1  
        idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  

        rows[idx_row].remove(solutions[position])  
        cols[idx_col].remove(solutions[position])  
        sections[idx_section].remove(solutions[position])  
        del solutions[position]  
    
    else:  
        position += 1  

    if (position &gt; 81): break  

print(f'Done after {iteration - 1:,.0f} iterations.')  

visualize(solutions)
</code></pre>
<p>Output:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_fail.png" alt="sudoku fail"><p>An empty grid? Why for flux sake? Well, our housekeeping is incomplete — we didn’t properly clean up the dead end container:</p>
<pre><code>dead_ends[position] = []
</code></pre>
<p>Let’s take this chance to adjust our container reset function. It will now reset all containers or just the ones for a given position only:</p>
<pre><code>def reset_containers(position = None):  
    global rows, cols, sections, solutions, dead_ends  

    if position is None:  
        rows = {i: [] for i in range(1, 10)}  
        cols = {i: [] for i in range(1, 10)}  
        sections = {i: [] for i in range(1, 10)}  
        solutions = {}  
        dead_ends = {i: [] for i in range(1, 82)}  
    else:  
        idx_row = math.ceil(position / 9)  
        idx_col = ((position - 1) % 9) + 1  
        idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  

        dead_ends[position].append(solutions[position])  
        rows[idx_row].remove(solutions[position])  
        cols[idx_col].remove(solutions[position])  
        sections[idx_section].remove(solutions[position])  
        del solutions[position]
</code></pre>
<p>And now the main loop:</p>
<pre><code>def solve_game(max_iterations = 10_000):  

    global rows, cols, sections, solutions, dead_ends  
    
    reset_containers()  

    iteration = 1  
    position = 1  
    stop = False  

    # print('iter', '\t', 'pos', '\t', 'row', '\t', 'col', '\t', 'section', '\t\t', 'number')  

    while True:  
        idx_row = math.ceil(position / 9)  
        idx_col = ((position - 1) % 9) + 1  
        idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  
        for number in range(1, 10):  
            # print(iteration, '\t', position, '\t', idx_row, '\t', idx_col, '\t', idx_section, '\t\t', number)  

            iteration += 1  
            if (iteration &gt; max_iterations): stop = True; break  

            if (number not in rows[idx_row] and \  
                number not in cols[idx_col] and \  
                number not in sections[idx_section] and \  
                number not in dead_ends[position]):  
                solutions[position] = number  
                rows[idx_row].append(number)  
                cols[idx_col].append(number)  
                sections[idx_section].append(number)  
                break  

        if stop: break  

        if not position in solutions:  

            dead_ends[position] = [] # if there were any dead ends on this position, remove them now, because we roll back  

            position -= 1  
            if (position &lt;= 0): break  

            reset_containers(position) # and clean up containers for the previous position  

        else:  
            position += 1  

        if (position &gt; 81): break  

    print(f'Done after {iteration - 1:,.0f} iterations.')  

solve_game(max_iterations=10_000)  

visualize(solutions)
</code></pre>
<p>Output:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_finally_solved.png" alt="sudoku finally solved"><p>Wohaa! Looks like our little loop is already producing a valid result. But how does it perform when we add some numbers to the solution?</p>
<p>To work with a pre-filled grid, we need to implement another container — let’s call it <code>target</code>. We’ll add a simple check: If the target contains the current position, we’ll treat it as a constant. Copy it over to the other containers and skip to the next position:</p>
<pre><code>if position in target:   
        number = grid[position]  
        rows[idx_row].append(number)  
        cols[idx_col].append(number)  
        sections[idx_section].append(number)  
        position += 1  
        continue
</code></pre>
<p>We also need to adapt the part where we move back one step when no solution can be found. If we move back from a regular position to a pre-filled one, we should instantly jump back to the last regular position. Otherwise, we’d get stuck in a loop. So, let’s declare <code>last_regular_position</code>, which points to the last regular cell before a pre-filled one, and handle the backtracking that way.</p>
<p>This should help avoid unnecessary loops and keep things running smoothly.</p>
<p>Our loop is growing:</p>
<pre><code>def solve_game(debug = False, max_iterations = 10_000, target = {}):  

    global rows, cols, sections, solutions, dead_ends  

    reset_containers()  

    iteration = 1  
    position = 1  
    stop = False  
    start_of_occupied_area = 82  

    if debug: print('iter', '\t', 'pos', '\t', 'row', '\t', 'col', '\t', 'section', '\t\t', 'number')  

    # mandatroy: register all target position with our containers  
    for target_position in target:  
        idx_row = math.ceil(target_position / 9)  
        idx_col = ((target_position - 1) % 9) + 1  
        idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  
        rows[idx_row].append(target[target_position])  
        cols[idx_col].append(target[target_position])  
        sections[idx_section].append(target[target_position])  
        
    while True:  
        idx_row = math.ceil(position / 9)  
        idx_col = ((position - 1) % 9) + 1  
        idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  

        if (position in target):   
            number = target[position]  
            solutions[position] = number  
            rows[idx_row].append(number)  
            cols[idx_col].append(number)  
            sections[idx_section].append(number)  

            if position &lt; start_of_occupied_area: start_of_occupied_area = position  

            position +=1  
            continue  

        start_at = 1  
        if len(dead_ends[position]) &gt; 0:  
            start_at = max(dead_ends[position]) + 1  

        for number in range(start_at, 10):  
            if debug: print(iteration, '\t', position, '\t', idx_row, '\t', idx_col, '\t', idx_section, '\t\t', number)  

            iteration += 1  
            if (iteration &gt; max_iterations): stop = True; break  

            if (number not in rows[idx_row] and \  
                number not in cols[idx_col] and \  
                number not in sections[idx_section] and \  
                number not in dead_ends[position]):  
                solutions[position] = number  
                rows[idx_row].append(number)  
                cols[idx_col].append(number)  
                sections[idx_section].append(number)  

                break  

        if stop: break  

        if not position in solutions:  
            
            dead_ends[position] = []  
            
            if (position - 1) in target:  

                position = start_of_occupied_area - 1  

            else:  
                
                position -= 1  

            reset_containers(position)   

        else:  

            position += 1  

        if (position &gt; 81): break  

    print(f'Done after {iteration - 1:,.0f} iterations.')  

solve_game(debug = False, max_iterations = 5_000, target = {16: 2})  

visualize(solutions)
</code></pre>
<p>Output:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_finally_working.png" alt="sudoku finally working"><p>Farn-tastic! But don’t get too hyped, we’re not there yet. What about different solutions, variations? When we reach the last cell at position 81, in row 9 and column 9, we find exactly one solution for each previous cell. But how do we check for other options? To understand our next approach, imagine the solution as a huge 81-digit number, with all possible solutions branching out like a massive tree. The leftmost branch is the smallest number, and we’re going to find “higher” numbers, which are also valid solutions.</p>
<p>Let’s start with a simple idea: What if, as soon as we reach the end of the grid, we:</p>
<ul>
<li>Go back to the first item</li>
<li>Store all numbers from the current solution to ignore them in the next iteration</li>
<li>Switch to a new, empty solutions container</li>
<li>And start over from the first position?</li>
</ul>
<p>Well, I’ll spare us the effort of implementing it and instead ask you to think it over for yourself. You’ve got 5 milliseconds until the next paragraph starts…</p>
<p>Ka-ching! (Do you remember [Lockerz from 2009](Why did I invest my time into that scam instead of Bitcoin? Whatever…)</p>
<p>It wouldn’t work! The first cell accepts 1 to 9 as possible options — our main branches of a tree. At the second level, which is the second cell, again we have 1 to 9 as possible values. Solution 1 already gave us this path:</p>
<pre><code>1 - 2 - …
</code></pre>
<p>But if we ruled out the 1 when iterating through the second solution, we’d also rule out another possible path:</p>
<pre><code>1 - 3 - …
</code></pre>
<p>So, instead of resetting to the first cell, the top of the tree, we just move back to the last cell — or the last node in the path or branch, however you want to call it. Right now, it looks like this:</p>
<pre><code>… - 6 - 4 - 2
</code></pre>
<p>Think of the solution as a big number:</p>
<pre><code>…642
</code></pre>
<p>From the previous run, we know that the following paths (or numbers) aren’t possible:</p>
<pre><code>…611  
…621  
…631  
…641  
…642 - ka-ching!
</code></pre>
<p>Because, for some reason, the loop always took us back until we eventually hit the 4 and the 2. Now, what happens if we search for the next solution by going back to 81 again, but this time adding the 2 to our list of “dead ends”? The loop will run as it has before:</p>
<pre><code>…643 - not possible, step back  
…65 - not possible, step back  
…7 - not possible, step back
</code></pre>
<p>It will keep doing that until — spoiler alert — it reaches position 66 (row 8, col 3):</p>
<pre><code>…642
</code></pre>
<p>And it will try again, testing all possible numbers until it eventually hits:</p>
<pre><code>…648
</code></pre>
<p>Which is the start of a new possible solution!</p>
<p>Sounds about right, doesn’t it?</p>
<p>Let’s implement it! First, a small adjustment to our init methods and our solutions container, which now holds multiple solutions:</p>
<pre><code>solutions[solution_index][position]
</code></pre>
<p>Of course, we also need to adjust the function’s signature and how we reset our solution container. The way we address positions in the solution deck changes, too. It’s a list now, which makes it easier to slice the first <em>n</em> items of it (honestly, I don’t know why we didn’t do that from the beginning — it’s not really harder to handle. Guess that’s what they call “learning by doing” ;) ).</p>
<pre><code>def reset_containers(solution_index, position = None):  
    global rows, cols, sections, solutions, dead_ends, burned_numbers  


    if position is None and solution_index == 1:  
        rows = {i: [] for i in range(1, 10)}  
        cols = {i: [] for i in range(1, 10)}  
        sections = {i: [] for i in range(1, 10)}  
        solutions[solution_index] = {}  
        dead_ends = {i: [] for i in range(1, 82)}  
    else:  
        idx_row = math.ceil(position / 9)  
        idx_col = ((position - 1) % 9) + 1  
        idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  

        if position in solutions[solution_index]:  
            number = solutions[solution_index][position]  
            del solutions[solution_index][position]  
        elif position in burned_numbers:  
            number = burned_numbers[position]  
            
        dead_ends[position].append(number)  
        rows[idx_row].remove(number)  
        cols[idx_col].remove(number)  
        sections[idx_section].remove(number)
</code></pre>
<p>Our solution method changes a little. Instead of stopping the loop when <code>position &gt; 81</code>, we add the current solution to our &ldquo;dead end&rdquo; container and then go back to <code>position 81</code>.</p>
<pre><code>def solve_game(debug = False, max_iterations = 10_000, target = {}):  

    global rows, cols, sections, solutions, dead_ends, burned_numbers  

    solution_index = 1  
    solutions = {solution_index: {}}  
    reset_containers(solution_index)  

    report_success = False  

    iteration = 1  
    position = 1  
    stop = False  
    burned_numbers = {} # keeps the previous solution  

    start_of_occupied_area = 82  

    if debug: print('solution', '\t', 'iter', '\t', 'pos', '\t', 'row', '\t', 'col', '\t', 'section', '\t\t', 'number')  

    for target_position in target:  
        idx_row = math.ceil(target_position / 9)  
        idx_col = ((target_position - 1) % 9) + 1  
        idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  
        rows[idx_row].append(target[target_position])  
        cols[idx_col].append(target[target_position])  
        sections[idx_section].append(target[target_position])  
        
    while True:  

        if position &lt;= 81:  

            idx_row = math.ceil(position / 9)  
            idx_col = ((position - 1) % 9) + 1  
            idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  

            if (position in target):   
                solutions[solution_index][position] = target[position]  

                if position &gt; 1 and position &lt; start_of_occupied_area: start_of_occupied_area = position  

                position +=1;   
                continue  

            # if the current position has a burned number from the previous solution  
            # we can take this + 1 as the next starting point  
            # this should only happens once for each run when move backwards  
            start_at = 1  
            if position in burned_numbers:  
                last_found_number = burned_numbers[position]  
                start_at = last_found_number + 1  
                # note how we clean or contains only, not the actual burned_numbers record  
                # because if this iteration is not succesful, we need a reference!  
                del burned_numbers[position]  

            elif len(dead_ends[position]) &gt; 0:  
                start_at = max(dead_ends[position]) + 1  

            for number in range(start_at, 10):  
                if debug: print(solution_index, '\t\t', iteration, '\t', position, '\t', idx_row, '\t', idx_col, '\t', idx_section, '\t\t', number)  

                iteration += 1  
                if iteration % (max_iterations // 10) == 0: report_success = True  
                if (iteration &gt; max_iterations): stop = True; break  

                if (number not in rows[idx_row] and \  
                    number not in cols[idx_col] and \  
                    number not in sections[idx_section] and \  
                    number not in dead_ends[position]):  

                    solutions[solution_index][position] = number  
                    
                    rows[idx_row].append(number)  
                    cols[idx_col].append(number)  
                    sections[idx_section].append(number)  
                    break  

            if stop: break  
        
            if not position in solutions[solution_index]:  
        
                dead_ends[position] = []  

                # this is also very important: when we are at this point  
                # we couldn't find a solution for the current position  
                # so we step back to the next &quot;free&quot; cell  
                # that is not blocked by a &quot;target&quot;  

                if (position - 1) in target:  

                    position = start_of_occupied_area - 1  

                else:  
                    
                    position -= 1  

                # and then we clear the containers for this position, to have a fresh start  
                reset_containers(solution_index, position)  

            else:  

                position += 1  

        else:             

            idx_row = math.ceil((position - 1) / 9)  
            idx_col = ((position - 2) % 9) + 1  
            idx_section = math.ceil(idx_col/3) + ((math.ceil(idx_row/3) - 1) * 3)  

            # we found a complete solution, now record the numbers as &quot;burned&quot; for the next run  
            # for the next solution  
            
            if report_success == True: print(f'{solution_index} solutions completed after {iteration - 1:,.0f} iterations.'); report_success = False  

            # take over previous solutions as &quot;burned numbers&quot;  
            dead_ends = {i: [] for i in range(1, 82)}  

            # and also some house keeping, as   
            # we crawl back when trying additional solutions  
            # we only fill the part of the tree  
            # that actually changes, that's why we need to take over  
            # everything that did not change:  
            if solution_index &gt;= 2:  
                solutions[solution_index] = {pos: solutions[solution_index][pos] if pos in solutions[solution_index] else solutions[solution_index - 1].get(pos) for pos in solutions[solution_index - 1]}  
                
            burned_numbers = solutions[solution_index].copy()  

            # it took me some time to find that out:   
            # basically we are telling the script to rewind   
            # as we do when we don't find a solution  
            # so we also need to remove the current number from our  
            # containers  
            number = solutions[solution_index][81]  
            rows[idx_row].remove(number)  
            cols[idx_col].remove(number)  
            sections[idx_section].remove(number)  

            position = 81  
            solution_index += 1  
            solutions[solution_index] = {}  

    print(f'Done after {iteration - 1:,.0f} iterationsm found at least {solution_index - 1} solutions')  

solve_game(debug=False, max_iterations=1_000_000, target = {80: 7})  

visualize(solutions[len(solutions) - 1])
</code></pre>
<p>And that’s our achievment:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_multiple_solutions.png" alt="sudoku multiple solutions"><p>Our brute-force Sudoku solving algorithm is ready! Well, I bet there are a couple of steps to optimize — not only code-style-wise, but also to make it run faster. I’d say that’s your homework. There are <strong>6,670,903,752,021,072,936,960</strong> possible paths (that’s 6 sextillion and a bit more, it can be writte as ~6 x 10^21, which is a reference to Blade Runner: Dr. Ana Stelline was born on the 10/6/21). Can you catch them all? How long does it take on your machine to get 1 million solutions?</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/blade_runner_stelline.png" alt="blade runner stelline"><center><i>Blade Runner's `Dr. Ana Stelline` (c) Warner Bros https://www.warnerbros.com/movies/blade-runner-2049</i></center>
<p>Just one more thing… I asked ChatGPT to build a small function that checks if all solutions are valid. Wait, what? ChatGPT? Why haven’t we asked it earlier?</p>
<p>Because developing algorithms is fun?</p>
<p>You got it! Whatever, here we go:</p>
<pre><code>def check_sudoku(solutions):  

    print('All good!')
</code></pre>
<p>Just kidding — you’ll find the actual implementation in the notebook. Before I send you off into your well-deserved time off, let’s quickly go over some visualizations. I “discovered” 295,295 solutions (took me 100 million iterations and roughly 45 seconds), and this is how they look like in an image:</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_solutions_visualized.png" alt="sudoku solutions visualized"><p>Finding <strong>300,000 solutions</strong> out of <strong>6 sextillion possibilities</strong> is like picking a single grain of sand from all the sand on Earth.</p>
<p>For the following visualization, I calculated the difference between each solution — remember, they’re all essentially enormous numbers!</p>
<img src="https://nickyreinert.de/en/2024/2024-10-18-sudoku/sudoku_solutions_difference.png" alt="sudoku solutions difference"><p>There are some interesting points to observe. On the symlog scale of outliers, you can clearly see bands of numbers. There’s also one outlier way out of the region, meaning that from solution <strong>149,652</strong> to <strong>149,653</strong>, the loop has to revert to a very high position in our “solution tree.”</p>
<p>Can you find any more salient features? Let me know!</p>
]]></content:encoded><atom:updated>2026-10-06T10:59:33+02:00</atom:updated><category>tools</category><dc:subject>Lesezeit: 5 Minuten</dc:subject><dc:type>tutorial</dc:type></item><item><title>The Bitcoin Puzzle</title><link>https://nickyreinert.de/en/2024/2024-10-01-bitcoin-puzzle/</link><pubDate>Tue, 01 Oct 2024 12:34:56 +0100</pubDate><author>Nicky Reinert</author><dc:creator>Nicky Reinert</dc:creator><guid isPermaLink="true">https://nickyreinert.de/en/2024/2024-10-01-bitcoin-puzzle/</guid><description>In this post, I’ll explain how I fell into a deep rabbit hole. I’ll cover how the puzzle works and how the algorithm can be optimized in Python using techniques like parallelization and pre-compiling with Cython. Finally, I’ll reveal how you can increase computation speed by nearly 100x by …</description><content:encoded><![CDATA[<p>In this post, I’ll explain how I fell into a deep rabbit hole. I’ll cover how the puzzle works and how the algorithm can be optimized in Python using techniques like parallelization and pre-compiling with Cython. Finally, I’ll reveal how you can increase computation speed by nearly 100x by implementing Metal to fully harness the power of your Apple Silicon. Let’s go!</p>
<h2 id="the-puzzle">The Puzzle</h2>
<p>It all started with 256 transactions of roughly 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://www.blockchain.com/de/explorer/transactions/btc/08389f34c98c606322740c0be6a7125d9860bb8d5cb182c02f98461e5fa6cd15.?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">33 BTC in January 2015</a>


. The first transaction to wallet #1 contained 0.001 BTC, wallet #2 received 0.002 BTC, then 0.003 BTC to wallet #3 and so on — the final wallet #256 received an amount of 0.256 BTC. Back in those days, the 33 BTC were worth about $5,000.</p>
<p>In the following years, additional transactions were made to adjust each wallet’s balance. In 2017, the balances from wallets 161 to 256 were moved to the lower ranges. And in 2023, someone — allegedly the creator — released another 900 BTC to all wallets from #66 upwards, following a similar principle. Wallet #66 is now worth 66 BTC instead of 0.66 BTC, and so on. At this point, the value of the transferred BTC summed up to over $27 million.</p>
<p>The goal of this exercise is to guess the corresponding private key from the known public address of each wallet. You might think this is impossible because it’s based on a hashing algorithm and you can’t just reverse it or brute-force the whole range of possible combinations.</p>
<p>You are right. Basically…</p>
<p>That’s why the creator limited the complexity of each private key. The first private key has an upper range limit of 1. What does that mean? As the private key is basically just a (huge) number, in the case of the first wallet, you just have to test all numbers from 0 to the upper limit of 1. This means calculating the public key (or signing key) and the compressed wallet address, and comparing it with the known wallet address. If there’s a match, you have access to the wallet and can claim your reward.</p>
<p>These are the first 10 items that were solved quickly after the puzzle was released (





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://privatekeys.pw/puzzles/bitcoin-puzzle-tx?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">full list</a>


)</p>
<pre><code>Index 0:   Hash: 00[…]00   Address: C 1BgGZ9tcN4[…]Z26SAMH  
Index 1:   Hash: 00[…]03   Address: C 1CUNEBjYrC[…]4wpP326Lb  
Index 2:   Hash: 00[…]07   Address: C 19ZewH8Kk1P[…]EiCjTRaZMZQA  
Index 3:   Hash: 00[…]08   Address: C 1EhqbyUMvvs7[…]D6YKfPqb7e  
Index 4:   Hash: 00[…]15   Address: C 1E6NuFjCi2[…]q84zJeBW3k  
Index 5:   Hash: 00[…]31   Address: C 1PitScNLyp2H[…]fveTnfmpPbfp8  
Index 6:   Hash: 00[…]4c   Address: C 1McVt1vMtCC7[…]yCcLXzueeC  
Index 7:   Hash: 00[…]e0   Address: C 1M92tSqNmQLYw[…]d1ysMBxK  
Index 8:   Hash: 00[…]d3   Address: C 1CQFwcjw1dwht[…]L7ivBonGPV  
Index 9:   Hash: 00[…]202   Address: C 1LeBZP5QCwwgXR[…]PUokyLHqe
</code></pre>
<p>So what’s the challenge here? Well, the complexity increases with every wallet by the power of 2. While the first wallet was limited to one private key (2⁰ to 2¹) , the second wallet goes from 2¹ to 2² — 1 (2 possible private keys). Wallet #3’s private key features a range from 4 to 7, and so on. The ranges increase exponentially. The final wallet #160 covers a range from 2¹⁵⁹ to 2¹⁶⁰ — 1, resulting in 7,307,508,186,700,000,000,000,000,000,000,000,000 possible private keys — 73 octillions. Have fun with that. (FYI: The universe is estimated to have about 10⁸⁰ atoms.)</p>
<p>At the lower levels, this is quite a simple task that takes just seconds to solve. And that’s why the first 66 private keys have already been found, as well as some outliers in the upper ranges.</p>
<h2 id="trivia">Trivia</h2>
<p>First of all, it is not known who actually created this task, puzzle, or riddle — whatever you want to call it.</p>
<p>Second, there was a lot of volume going through this wallet: tens of thousands of transactions with millions of BTC. 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://www.blockchain.com/de/explorer/addresses/btc/173ujrhEVGqaZvPHXLqwXiSmPVMo225cqT?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">The last transaction took</a>


 place in 2017, ignoring the winner claiming their rewards.</p>
<p>Third, this has created a rabbit hole for some people, like me. In the chase for the big prize, they are not only throwing hardware into the ring but also knowledge and inventive minds. One of the 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://bitcointalk.org/index.php?topic=5218972.0&amp;utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">“official threads”</a>


 has dozens of pages where people share their findings; they built algorithms to improve the process further and further.</p>
<p>And fourth, this challenge reveals a drawback of the Bitcoin transaction system. A couple of days ago, someone found the private key for wallet #66. He or she tried to withdraw the prize from the wallet. An attacker who was watching the chain for transactions was able to “hijack” this transaction and steal the funds.</p>
<h2 id="bitcoins-double-spending-attack">Bitcoins double spending attack</h2>
<p>To understand how this attack works, you need to understand how the puzzle and Bitcoin transactions work. Each transaction requires three important components to identify the recipient: A private key, a verifying (signing) key and a wallet address.</p>
<p>By performing elliptic curve multiplication (SECP256k1), you can derive the verifying key from the private key Next, you apply several hashing operations to obtain the public wallet address:</p>
<ul>
<li>SHA-256</li>
<li>RIPEMD-160</li>
<li>2 x SHA-256</li>
<li>and finally a (simple) Binary-58 encoding</li>
</ul>
<img src="https://nickyreinert.de/en/2024/2024-10-01-bitcoin-puzzle/required_computation_steps.png" alt="required computation steps"><center><i>required computation steps from private key to wallet address</i></center>
<p>The ultimate goal is to find the private key for a known wallet address. As you can see, obtaining the verifying key requires only one operation, while calculating the wallet address is the more complex part. Solving this puzzle requires you to follow all the steps.</p>
<p>If you find a valid private key for a given wallet address, you can initiate a transaction signed with the now-known verifying key. You need to submit both the wallet address and the verifying key to get confirmation from the blockchain.</p>
<p>Typically, confirmation takes around 10 minutes — that’s how Bitcoin works. An attacker can monitor the blockchain for transaction requests that contain one of the 160 known wallet addresses from the puzzle. Once a request appears, the attacker obtains the verifying key from it, and the clock starts ticking. You’ve already done the hard work of brute-forcing the wallet address from the verifying key. The attacker only needs to go through all possible private keys — which is a limited range in this puzzle — and perform the relatively straightforward SECP256k1 operation to find the matching key (although it’s not as simple as it sounds).</p>
<p>If the attacker succeeds within the 10 minutes, they can submit their own transaction request with a much higher transaction fee. Due to the nature of Bitcoin, miners will prioritize the higher fee and confirm the attacker’s request.</p>
<p>Keep in mind that this method does not work universally for all transactions on the blockchain, as private keys are typically much more complex. Nonetheless, it highlights a significant drawback in the process.</p>
<p>Enough talk — let’s dive into the race and participate.</p>
<h2 id="the-algorithm">The algorithm</h2>
<p>First, we will create a public (or signing) key from the private key using the elliptic curve algorithm SECP256k1.</p>
<img src="https://nickyreinert.de/en/2024/2024-10-01-bitcoin-puzzle/eliptic_curve.png" alt="eliptic curve"><center><i>An eliptic curve (Source: https://en.bitcoin.it/wiki/File:Secp256k1.png)</i></center>
<p>Superficial Note on the Algorithm: SEC stands for Standards for Efficient Cryptography, P represents the prime number 2²⁵⁶ - 2³² - 977 on which this curve is based, 256 denotes the length of the private key in Bit and k1 refers to the Koblitz curve, a specific type of elliptic curve. The actual curve is defined by the equation:</p>
<pre><code>y²=x³ + 7
</code></pre>
<p>Of course there’s a Python Library for that:</p>
<pre><code>private_key_hex = f’{1:064x}’
private_key_bytes = binascii.unhexlify(private_key_hex)
public_key = ecdsa.SigningKey.from_string(private_key_bytes, curve=ecdsa.SECP256k1)
</code></pre>
<p>As mentioned earlier, we need to compress the public key. The details are not important right now; just trust me, this step brings us closer to our goal.</p>
<pre><code>verifying_key = public_key.verifying_key
x = vk.to_string()[:32]
y = vk.to_string()[32:]

y_int = int(binascii.hexlify(y), 16)

if y_int % 2 == 0:
    compressed_public_key = b'\x02' + x
else:
    compressed_public_key = b'\x03' + x

compressed_public_key binascii.hexlify(compressed_public_key).decode()
</code></pre>
<p>Finally, we perform SHA-256 followed by RIPEMD-160 to calculate the wallet address. If you’re interested in how SHA-256 works, I’ve written two extensive posts about it!</p>
<pre><code>compressed_public_key_bytes = binascii.unhexlify(compressed_public_key_hex)

sha256_hash = hashlib.sha256(compressed_public_key_bytes).digest()

ripemd160 = hashlib.new('ripemd160')

ripemd160.update(sha256_hash)  

ripemd160_hash = ripemd160.digest()

versioned_payload = b'\x00' + ripemd160_hash

bitcoin_address = base58check_encode(versioned_payload)
</code></pre>
<p>I’ve compiled everything into a Jupyter notebook, which you can find on GitHub. Let’s see how it performs for the 20th wallet. This will test 524,288 possible numbers, ranging from 2¹⁹ to 2²⁰-1:</p>
<pre><code>target_address = '15JhYXn6Mx3oF4Y7PcTAv2wVVAuCFFQNiP'  

measure_performance(19, 20, target_address)
</code></pre>
<p>On my Apple M3 with 36 GB RAM it took about 30 seconds:</p>
<pre><code>Private Key (Decimal): 863317 
Private Key (Hex): 00000000000000000000000000000000000000000000000000000000000d2c55 
Compressed Public Key: 033c4a45cbd643ff97d77f41ea37e843648d50fd894b864b0d52febc62f6454f7c 
Bitcoin Address (Base58Check): 1HsMJxNiV7TLxmoF6uJNkydxPFDog4NQum 
Total time taken: 24.530259 seconds 
Average time per iteration: 0.047 ms
Initial CPU Usage: 29.7% 
Final CPU Usage: 28.5% 
Initial Memory Usage: 162.12 MB 
Final Memory Usage: 162.25 MB
</code></pre>
<p>This looks like a great starting point for some fine-tuning</p>
<h2 id="parallel-threads">Parallel Threads</h2>
<p>(rel. 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://github.com/nickyreinert/Bitcoin-Puzzle/blob/main/bitcoinPuzzle-v1.py?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">bitcoinPuzzle-v1.py</a>


)</p>
<p>First, let’s move from Jupyter to a Python file and try to implement parallel threads. To effectively use parallel computing, we need to determine how many units we can actually utilize. We can leverage some built-in Python functions to find this out (also see section Measure available system resources in this Notebook):</p>
<pre><code>multiprocessing.cpu_count()
</code></pre>
<p>This returns 12, so that’s the number of units we’ll use. Implementing concurrent processes in Python is quite straightforward. You can use either multiprocessing.Pool or concurrent.futures.ProcessPoolExecutor, which serves as a wrapper class.</p>
<pre><code>  with concurrent.futures.ProcessPoolExecutor(max_workers=12) as executor:

  # Part 1 - starting parallel threads for generate_key()
  
  future_to_index = {
  
  executor.submit(generate_key, start_at, target_address): start_at
  
  for start_at in range(min_val, max_val + 1)}
  
    last_reported_percent = 0
  
  # Part 2 - watch each running thread for result
  
  for future in concurrent.futures.as_completed(future_to_index):
  
    if found: break
  
    result = future.result()
  
    if result: found = result; break
      current_percent = (i + 1) * 100 // n
  
  print('Done')
</code></pre>
<p>The idea is simple: at some point, we start a few threads and monitor the output of each process.</p>
<p>Try running it, and you’ll notice that it takes quite a long time. In our case, this approach isn’t very efficient. The generate_key function is relatively fast, but it gets called twelve times, resulting in significant overhead from thread management. If generate_key were a heavier function, we could maintain this structure. However, in this scenario, we need to divide the search range into batches.</p>
<h2 id="batching">Batching</h2>
<p>(rel. 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://github.com/nickyreinert/Bitcoin-Puzzle/blob/main/bitcoinPuzzle-v2.py?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">bitcoinPuzzle-v2.py</a>


)</p>
<p>To clarify things a bit, we can create a wrapper function called batch_key_generation.</p>
<pre><code>def batch_key_generation(start, end, target_address):

  found_key = None
  
  for i in range(start, end):

    res = generate_key(i, target_address)
    
    if res:
    
      found_key = res
      
      break
    
    return found_key
</code></pre>
<p>The optimal batch size is determined by the number of threads. We could use a bigger value, but then the last thread would have less data to work with. And if the batch size is to small, we’d had to launch more threads than availbl processing units. Which agains creates to much overhead.</p>
<pre><code>batch_size = n // num_workers # Assuming you want 12 workers  

# […]

future_to_index = {

  executor.submit(

    batch_key_generation, 

    start, 

    min(start + batch_size, max_val + 1), 

    target_address): start for start in range(min_val, max_val + 1, batch_size)

}
</code></pre>
<p>Now, the execution time has decreased dramatically to just 5 seconds!</p>
<pre><code>Processed 524289 out of 524288 keys (100%)…
Processed 1048569 out of 524288 keys (199%)…
Total time taken: 5.517847 seconds
Average time per iteration: 0.011 ms
Initial CPU Usage: 40.0%
Final CPU Usage: 96.8%
Initial Memory Usage: 15.20 MB
Final Memory Usage: 16.86 MB
</code></pre>
<p>Great! What’s next?</p>
<h2 id="stop-at-succes">Stop-At-Succes</h2>
<p>(rel. 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://github.com/nickyreinert/Bitcoin-Puzzle/blob/main/bitcoinPuzzle-v3.py?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">bitcoinPuzzle-v3.py</a>


)</p>
<p>Did you notice the slight delay between the success confirmation and the actual termination of the script? This occurs because the pool manager waits for all other threads to finish. We need a global flag to monitor the success state. Here’s how to do it:</p>
<p>First, we wrap our thread loop in a Manager, which acts as a global watchdog for shared state. We also provide a lock and our global state, found_flag, to our calculation function:</p>
<p>with Manager() as manager:</p>
<pre><code>found_flag = manager.Value('i', False)

lock = manager.Lock()

concurrent.futures.ProcessPoolExecutor(max_workers=num_workers) as executor:

future_to_batch = {

  executor.submit(

    batch_key_generation,
    start, 
    min(start + batch_size, max_val + 1), 
    target_address, 
    found_flag, 
    lock): start for start in range(min_val, max_val + 1, batch_size) }  
</code></pre>
<p>[…]</p>
<p>Of course, we should check this global variable in our wrapper function batch_key_generation so that it can stop iterating when needed.</p>
<pre><code>def batch_key_generation(start, end, target_address, found_flag, lock): 
  found_key = None 

  for i in range(start, end):

    if found_flag.value: # check if it's been set from anywhere else

      return None # and then exit early, if required

    res = generate_key(i, target_address) 

    if res: 

      with lock: 

        if not found_flag.value: # double-check

          found found_flag.value = True # and tell all other process, that we found a key

          found_key = res 

          break 

return found_key  
</code></pre>
<p>There’s an important drawback: accessing the lock is a “time-consuming” operation. You will notice a slower processing time when using the lock, which can increase the total execution time from 5 seconds to 11 seconds. You need to decide whether you want a quick result on the command line while the threads are still running or if you’re willing to add this extra time to ensure that all threads finish by the end of the script.</p>
<h2 id="cython--pre-compile-our-code">Cython — pre compile our code</h2>
<p>(rel. 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://github.com/nickyreinert/Bitcoin-Puzzle/blob/main/bitcoinPuzzle-v4.pyx?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">bitcoinPuzzle-v4.pyx</a>


 and 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://github.com/nickyreinert/Bitcoin-Puzzle/blob/main/bitcoinPuzzle-v4-setup.py?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">bitcoinPuzzle-v4-setup.py</a>


)</p>
<p>There’s another trick to speed up processing: precompiling the Python code. Don’t worry, it sounds more complicated than it actually is. We just need to rename the extension of our Python script to .pyx and create a setup.py file like this:</p>
<pre><code>from setuptools import setup
from Cython.Build import cythonize
setup(ext_modules = cythonize(&quot;bitcoinPuzzle-v4.pyx&quot;))
</code></pre>
<p>Now we compile the file into a binary library (you may ignore the warnings):</p>
<pre><code>python bitcoinPuzzle-v4_setup.py build_ext --inplace
</code></pre>
<p>After that, we can either run our script from the command line or use it in any other script:</p>
<pre><code>python -c &quot;import bitcoinPuzzle_v4; bitcoinPuzzle-v4.measure_performance(19, 20, '1HsMJxNiV7TLxmoF6uJNkydxPFDog4NQum', 12)&quot;
</code></pre>
<p>It may not seem significantly faster, likely due to the overhead. However, if you try larger ranges, you will definitely notice a difference. Also, remember that we implemented the global flag to exit the batch processing prematurely.</p>
<h2 id="random-batches">Random batches</h2>
<p>(rel. bitcoinPuzzle_v5.py)</p>
<p>Well, this approach is more of a hack than a true improvement. The idea is simple: as the range increases, processing all possible variations takes more time. However, since we are searching for a needle in a vast field of haystacks, why should we start iterating from the bottom to the top like everyone else? Instead, we could pick random locations, hoping to achieve a quicker win than our “opponents”!</p>
<p>The implementation is straightforward. First, we create a list containing all starting points based on the chunk size, and then we shuffle it.</p>
<pre><code>start_points = list(range(min_val, max_val + 1, chunk_size)) 

random.shuffle(start_points)
</code></pre>
<p>As this is a hack that cannot be measured in time, I’m right jumping to the final improvement:</p>
<h2 id="what-about-pytorch">What about Pytorch?</h2>
<p>(rel: 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://github.com/nickyreinert/Bitcoin-Puzzle/blob/main/bitcoinPuzzle-v5.ipynb?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">bitcoinPuzzle-v5.ipynb</a>


)</p>
<p>First, we need to identify the most expensive operations in this puzzle and then try to improve it further. Let’s determine how long it takes to run 10,000 private keys through the following processes:</p>
<pre><code>SECP256k1): 2.37 seconds
3 x SHA-256): 0.02 seconds
RIPEMD160): 0.01 seconds
BASE58: 0.09 seconds
</code></pre>
<p>The elliptic curve operation is clearly the bottleneck. Currently, we are only utilizing our CPU without taking advantage of the full power of more sophisticated processing units like a GPU or MPS. PyTorch could assist with that, but it doesn’t natively support SECP256k1. Therefore, we need to find another solution, and this is called:</p>
<h2 id="metal">Metal!</h2>
<p>(rel: 





  
  

  
  

  
  
  

  
    
      
    
  

  
  <a href="https://github.com/nickyreinert/Metal-To-Python-API/blob/main/computation.metal?utm_source=institut-fuer-digitale-herausforderungen&amp;utm_medium=allyourbasearebelongtous" target="_blank" rel="noopener noreferrer">computation.metal</a>


)</p>
<p>Metal is a high-performance graphics and computing API created by Apple that allows developers to harness the full power of the GPU for tasks beyond just rendering graphics, such as machine learning and data processing. It provides low-level access to the GPU, enabling more efficient parallel processing and better performance for applications on iOS and macOS devices.</p>
<p>Spoiler Alert: I wasn’t able to fully implement the working SECP256k1 algorithm in Metal, primarily because the elliptic curve algorithm requires 256-bit integers, which are not natively supported in Metal. This limitation necessitates significant adjustments. However, the performance gains are impressive, and I’m actively working on it!</p>
<p>For demonstration purposes, I chose a mathematical operation that can be implemented in PyTorch, pure Python, and Metal: the cosine!</p>
<p>In Metal we simply create a so called kernel function:</p>
<pre><code>kernel void benchmark(device const float *inputData [[buffer(0)]],
                      device float *outputData [[buffer(1)]],
                      uint id [[thread_position_in_grid]]) {
    
    // Get the input data for the current thread
    float inputValue = inputData[id];
    
    // Compute the cosine of the input value
    float result = cos(inputValue);
    
    // Store the result of the computation in the output buffer
    outputData[id] = result; // Store the computed cosine
    
}
</code></pre>
<p>We compile it using Xcode’s command line tools:</p>
<pre><code>xcrun -sdk macosx metal -fcikernel -c computation.metal -o computation.air
xcrun -sdk macosx metallib computation.air -o computation.metallib
</code></pre>
<p>This command will compile the source code into a Metal library. The most important flag here is -fcikernel, which is necessary to enable the kernel code to run on the GPU.</p>
<p>Here’s the complicated part: we need a wrapper in Swift to bridge the Metal source to Python later on.</p>
<p>First we initiate our metal device, the GPU:</p>
<pre><code>guard let device = MTLCreateSystemDefaultDevice() else {
    print(&quot;Error: Unable to create Metal device.&quot;)
    return 0
}
</code></pre>
<p>Now we reference the benchmark function from the external library we just compiled:</p>
<pre><code>let metallib =  &quot;\(#file.replacingOccurrences(of: &quot;/wrapper.swift&quot;, with: &quot;&quot;))/…/computation.metallib&quot;

guard let library = try? device.makeLibrary(filepath: metallib) else {
    print(&quot;Error: Unable to create Metal library from \(metallib).&quot;)
    return 0
}

guard let kernelFunction = library.makeFunction(name: &quot;benchmark&quot;) else {
    print(&quot;Error: Unable to load benchmark function.&quot;)
    return 0
}
</code></pre>
<p>After that we initiate our command queue as well es input and output buffers:</p>
<pre><code>// Create a command queue
guard let commandQueue = device.makeCommandQueue() else {
    print(&quot;Error: Unable to create command queue.&quot;)
    return 0
}

// Create a command buffer and encoder
guard let commandBuffer = commandQueue.makeCommandBuffer(),
      let encoder = commandBuffer.makeComputeCommandEncoder() else {
    print(&quot;Error: Unable to create command buffer or encoder.&quot;)
    return 0
}

// Create the compute pipeline state
let computePipelineState = try device.makeComputePipelineState(function: kernelFunction)

// Set the compute pipeline state
encoder.setComputePipelineState(computePipelineState)

// Create input buffer for GPU
let inputByteLength = numItems * MemoryLayout&lt;Float&gt;.size
let inputBuffer = device.makeBuffer(bytes: inputData, length: inputByteLength, options: [])
encoder.setBuffer(inputBuffer, offset: 0, index: 0)

// Create output buffer for GPU
let outVectorBuffer = device.makeBuffer(length: inputByteLength, options: .storageModeShared)
encoder.setBuffer(outVectorBuffer, offset: 0, index: 1)&lt;
</code></pre>
<p>And as we also want to utilize parallel computing, we need to define how many parallel threads we want to run:</p>
<pre><code>let threadsPerGroup = MTLSize(width: 64, height: 1, depth: 1)
let numThreadgroups = MTLSize(width: (numItems + threadsPerGroup.width - 1) / threadsPerGroup.width, height: 1, depth: 1)
encoder.dispatchThreadgroups(numThreadgroups, threadsPerThreadgroup: threadsPerGroup)
# tell the encoder, that we are &quot;done&quot; with our setup
encoder.endEncoding()
</code></pre>
<p>And this is how we run the process:</p>
<pre><code>commandBuffer.commit()
commandBuffer.waitUntilCompleted()

// Copy the GPU results to the output buffer passed from Python
if let outputPointer = outVectorBuffer?.contents().assumingMemoryBound(to: Float.self) {
    outputData.update(from: outputPointer, count: numItems)
} else {
    print(&quot;Error: Unable to get contents from output buffer.&quot;)
    return 1
}
</code></pre>
<p>(Note: I also included a Swift wrapper and some additional methods in the corresponding GitHub repository to allow for direct execution from the command line.)</p>
<p>Now we need to build our swift library (I am not going into the details about the Swift package configuration, check out the repo for an example):</p>
<pre><code>swift build
</code></pre>
<p>This will give us a couple of compiled files. The most important one is (usually) hidden in .build/debug: The dynamic library file libWrapper.dylib. Before we can pull it into your Python code, we have to sign it:</p>
<pre><code>// create a new private key
openssl genrsa -out developer.key 2048 
// create a signing certificate
openssl req -new -x509 -key developer.key -out developer.crt -days 365 -subj &quot;/CN=developer/O=NickyReinert/C=DE&quot;
// find the cert's ID
security find-identity -p codesigning
// sign the library
codesign -s &quot;YOUR_CERT_ID&quot; --deep --force .build/debug/libWrapper.dylib
</code></pre>
<p>In Python we load the library simply like that:</p>
<pre><code>swift_function = ctypes.CDLL(&quot;.build/debug/libWrapper.dylib&quot;)
</code></pre>
<p>Now we can already reference our benchmarkin function. As we are working with pointers to the output buffer, it’s a little more efforts to get the results:</p>
<pre><code>swift_function.benchmark.argtypes = [
    ctypes.POINTER(ctypes.c_float),
    ctypes.POINTER(ctypes.c_float),
    ctypes.c_int
]

# create some float values
numItems = 1_000_000_000
input_array = np.linspace(0, numItems - 1, 10, dtype=&quot;float32&quot;)  

# convert array to pointer
input_ptr = input_array.ctypes.data_as(ctypes.POINTER(ctypes.c_float))

# create output pointer
output_length = len(input_array)
output_mutable_ptr = (ctypes.c_float * output_length)()  # Create an output buffer

swift_function.benchmark(input_ptr, output_mutable_ptr, output_length)

output_array = np.ctypeslib.as_array(output_mutable_ptr)

combined_results = np.column_stack((input_array, output_array))

print(combined_results)

Finally we run the script as any other Python script:

python computationGPU.py
</code></pre>
<p>Our Metal implementation completed in just 0.06 seconds (be sure to run it several times to “warm up” your system) for 1,000,000,000 items! The pure Python implementation took about 7 seconds, while PyTorch finished in 4.7 seconds. We’ve improved the timing by a factor of 75!</p>
<p>Congratulations! You’ve just learned how to harness the full power of your GPU. Welcome to the bottom of my rabbit hole. Next stop: implement SECP256k1 and take part in the Bitcoin Puzzle Rally!</p>
<p>~</p>
<p>(Final Note: You can also use PyOpenCL to initialize the device and command queue from within Python, which may simplify and enhance the process. I preferred the Swift wrapper because it is easier to debug.)</p>]]></content:encoded><atom:updated>2026-10-06T10:59:33+02:00</atom:updated><category>tools</category><dc:subject>Lesezeit: 5 Minuten</dc:subject><dc:type>case_study</dc:type></item></channel></rss>