I am quite happy to share portions of the server code for external validation / improvements. The problems of the clients (all clients must be considered to be untrusted) I don't really have a bullet proof solution to the problems and am happy to explore options that other people might think of to make it less practical for a client to skim the private key from the pool. I am happy to add notifications to the workers if a solution is submitted, this is not a difficult thing to adjust in the programs. The pool checks the balance of the target puzzle address every couple of minutes to confirm that the prize is still in the account. It has not been implemented yet that if the balance goes to 0 that we shift to the next puzzle. We would do that if a valid solution was submitted to the pool.
I have considered a number of different options of masking / encryption of the search block, however in the end the clients must run the address check with the actual private key hex or they will never return any correct solutions. In order for that to happen they need to be sent the information that is required to decrypt / unmask the value and are left with the raw hex anyway. If anyone has any techniques that they think would be assistance to this problem it would be worth exploring.
There are a number of things that I would be more then happy to adjust / improve if there is sufficient interest in pursuing the pool. The share system right now is setup as a proportional share system, each client that completes 1 work increment (WORK_INCREMENT = 10,000,000,000 private keys) gets 1 share. The current calculation allocates shares to each puzzle independently so if puzzle 67 is solved, shares are logged for 67 and a new count starts for 68. I do see the merit in carrying over shares if the solution is found by a different party as it would still reward any workers for past work and only reset if the pool finds a solution.
The logic for the 60 second work increment is so that it is easer to track progress / update searched ranges / return non responsive work back to the pool.
The work is being distributed in a weighted random selection search method. The basis is that it will try and break up larger unsearched areas into smaller and smaller ranges. Once the ranges get to be smaller then the requested search size the pool will start returning the smaller ranges to close gaps between completed work. This process seemed to work well in testing with the easier puzzles.
This is the function that does the work assignments, yes I am just using SQLite for the time being as I am the only client, this will be migrated to a different database if/when the need arise.
def assign_random_range(puzzle_id, client_increment):
"""
Assigns a random range to the client, ensuring uniformly random selection
across the puzzle space for all "big-enough" ranges.
Steps:
1. Fetch all available ranges from the database.
2. Classify ranges into "big-enough" and "smaller."
3. If "big-enough" ranges exist:
- Perform weighted random selection based on range size.
- Assign a subrange of length `client_increment` and update the database.
4. Otherwise, assign the largest "smaller" range.
5. Return `None` if no suitable ranges are available.
Args:
puzzle_id (int): The ID of the puzzle to fetch ranges for.
client_increment (int): The size of the range to assign.
Returns:
tuple or None: Assigned range as (start, end) or None if no range is available.
"""
conn = None
def fetch_ranges(cursor, puzzle_id):
"""Retrieve and convert all available ranges for the given puzzle ID."""
cursor.execute('''
SELECT start_range, end_range
FROM available_work_ranges
WHERE puzzle_id = ?
''', (puzzle_id,))
return [
(int(start_hex, 16), int(end_hex, 16))
for (start_hex, end_hex) in cursor.fetchall()
]
def update_database(cursor, puzzle_id, chosen_start, chosen_end, assigned_start, assigned_end):
"""Remove the chosen range and reinsert remaining parts into the database."""
# Remove the old range
cursor.execute('''
DELETE FROM available_work_ranges
WHERE puzzle_id = ? AND start_range = ? AND end_range = ?
''', (puzzle_id, hex(chosen_start)[2:], hex(chosen_end)[2:]))
# Reinsert leftover front
if assigned_start > chosen_start:
cursor.execute('''
INSERT INTO available_work_ranges (puzzle_id, start_range, end_range)
VALUES (?, ?, ?)
''', (puzzle_id, hex(chosen_start)[2:], hex(assigned_start - 1)[2:]))
# Reinsert leftover back
if assigned_end < chosen_end:
cursor.execute('''
INSERT INTO available_work_ranges (puzzle_id, start_range, end_range)
VALUES (?, ?, ?)
''', (puzzle_id, hex(assigned_end + 1)[2:], hex(chosen_end)[2:]))
def weighted_random_pick(big_enough, client_increment):
"""Select a range weighted by size and assign a subrange."""
total_space = sum(rsize for (_, _, rsize) in big_enough)
pivot = random.randint(0, total_space - 1)
cumulative = 0
for (rstart, rend, rsize) in big_enough:
if pivot < cumulative + rsize:
max_start = rend - client_increment + 1
assigned_start = random.randint(rstart, max_start)
assigned_end = assigned_start + client_increment - 1
return rstart, rend, assigned_start, assigned_end
cumulative += rsize
try:
conn = get_db_connection()
cursor = conn.cursor()
# Step 1: Fetch all ranges
all_ranges = fetch_ranges(cursor, puzzle_id)
if not all_ranges:
logging.error(f"No available ranges for puzzle_id {puzzle_id}.")
return None
# Step 2: Classify ranges
big_enough, smaller = [], []
for rstart, rend in all_ranges:
size = rend - rstart + 1
(big_enough if size >= client_increment else smaller).append((rstart, rend, size))
# Step 3: Handle "big-enough" ranges
if big_enough:
chosen_start, chosen_end, assigned_start, assigned_end = weighted_random_pick(big_enough, client_increment)
update_database(cursor, puzzle_id, chosen_start, chosen_end, assigned_start, assigned_end)
conn.commit()
return assigned_start, assigned_end
# Step 4: Handle "smaller" ranges
if smaller:
smaller.sort(key=lambda x: x[1] - x[0], reverse=True)
sstart, send = smaller[0][:2]
cursor.execute('''
DELETE FROM available_work_ranges
WHERE puzzle_id = ? AND start_range = ? AND end_range = ?
''', (puzzle_id, hex(sstart)[2:], hex(send)[2:]))
conn.commit()
return sstart, send
# Step 5: No suitable ranges
logging.debug(f"No suitable range found for puzzle_id {puzzle_id}.")
return None
except Exception as e:
logging.error(f"Error assigning random range for puzzle_id {puzzle_id}: {e}")
if conn:
conn.rollback()
return None
finally:
if conn:
conn.close()
In regards to the solo vs pool, I cannot understand the logic of a solo pool that uses work from other workers? Its like saying I want to use the pool work but I get the full reward if I am the lucky one to find the key. A larger reward for the lucky person would make more sense if that was what people are looking for.