Dagger-Hashimoto
Ultima modifica: @Herbie_23(opens in a new tab), 11 aprile 2024
Dagger-Hashimoto era l'implementazione e specifica di ricerca originale per l'algoritmo di mining di Ethereum. Dagger-Hashimoto è stato sostituito da Ethash. Il mining è stato disattivato completamente con La Fusione, il 15 settembre 2022. Da allora, Ethereum è stato assicurato utilizzando un meccanismo proof-of-of-stake. Questa pagina è di interesse storico - le informazioni qui non sono più rilevanti per post-Merge Ethereum.
Prerequisiti
Per meglio comprendere questa pagina, ti consigliamo prima di informarti sul consenso proof-of-work, sul mining e sugli algoritmi di mining.
Dagger-Hashimoto
Dagger-Hashimoto punta a soddisfare due obiettivi:
- Resistenza ASIC: la creazione di hardware specializzato per l'algoritmo dovrebbe apportare un beneficio minimo
- Verificabilità da un client leggero: un blocco dovrebbe essere efficientemente verificabile da un client leggero.
Con una modifica aggiuntiva, specifichiamo anche come raggiungere un terzo obiettivo se desiderato, ma al costo di una maggiore complessità:
Archiviazione della catena completa: il mining dovrebbe richiedere l'archiviazione dello stato completo della blockchain (a causa della struttura irregolare dell'albero di stato di Ethereum, prevediamo la possibilità di alcune potature (pruning), soprattutto dopo alcuni contratti usati spesso, che vogliamo comunque mantenere al minimo).
Generazione del DAG
Il codice per l'algoritmo sarà definito in Python, di seguito. Per prima cosa, diamo encode_int
per il marshaling in stringhe di interi non firmati con una precisione specificata. È dato anche il suo opposto:
1NUM_BITS = 51223def encode_int(x):4 "Encode an integer x as a string of 64 characters using a big-endian scheme"5 o = ''6 for _ in range(NUM_BITS / 8):7 o = chr(x % 256) + o8 x //= 2569 return o1011def decode_int(s):12 "Unencode an integer x from a string using a big-endian scheme"13 x = 014 for c in s:15 x *= 25616 x += ord(c)17 return xMostra tuttoCopia
Poi supponiamo che sha3
sia una funzione che prende un intero e produce un intero e che dbl_sha3
sia una funzione double-sha3; se vogliamo convertire questo codice di riferimento in un uso d'implementazione:
1from pyethereum import utils2def sha3(x):3 if isinstance(x, (int, long)):4 x = encode_int(x)5 return decode_int(utils.sha3(x))67def dbl_sha3(x):8 if isinstance(x, (int, long)):9 x = encode_int(x)10 return decode_int(utils.sha3(utils.sha3(x)))Mostra tuttoCopia
Parametri
I parametri usati per l'algoritmo sono:
1SAFE_PRIME_512 = 2**512 - 38117 # Largest Safe Prime less than 2**51223params = {4 "n": 4000055296 * 8 // NUM_BITS, # Size of the dataset (4 Gigabytes); MUST BE MULTIPLE OF 655365 "n_inc": 65536, # Increment in value of n per period; MUST BE MULTIPLE OF 655366 # with epochtime=20000 gives 882 MB growth per year7 "cache_size": 2500, # Size of the light client's cache (can be chosen by light8 # client; not part of the algo spec)9 "diff": 2**14, # Difficulty (adjusted during block evaluation)10 "epochtime": 100000, # Length of an epoch in blocks (how often the dataset is updated)11 "k": 1, # Number of parents of a node12 "w": w, # Used for modular exponentiation hashing13 "accesses": 200, # Number of dataset accesses during hashimoto14 "P": SAFE_PRIME_512 # Safe Prime for hashing and random number generation15}Mostra tuttoCopia
P
in questo caso è un numero primo scelto in modo che log₂(P)
sia solo di poco inferiore a 512, che corrisponde ai 512 bit che abbiamo usato per rappresentare i nostri numeri. Nota che in realtà deve essere memorizzata solo la seconda metà del DAG, quindi il requisito de-facto di RAM parte da 1 GB e cresce di 441 MB l'anno.
Costruzione del grafico dagger
Il primitivo di costruzione del grafico dagger è definito come segue:
1def produce_dag(params, seed, length):2 P = params["P"]3 picker = init = pow(sha3(seed), params["w"], P)4 o = [init]5 for i in range(1, length):6 x = picker = (picker * init) % P7 for _ in range(params["k"]):8 x ^= o[x % i]9 o.append(pow(x, params["w"], P))10 return oMostra tuttoCopia
Essenzialmente, avvia un grafico come un singolo nodo, sha3(seed)
e da lì si inizia ad aggiungere sequenzialmente gli altri nodi, a seconda dei nodi casuali precedenti. Quando viene creato un nuovo nodo, è calcolata una potenza modulare del seed per selezionare casualmente degli indici inferiori a i
(usando il suddetto x % i
) e i valori dei nodi a questi indici sono usati all'interno di un calcolo per generare un nuovo valore per x
, che viene poi passato a una piccola funzione di proof-of-work (basata su XOR) per generare, infine, il valore del grafico all'indice i
. La logica dietro questa costruzione particolare è forzare l'accesso sequenziale del DAG; il valore successivo del DAG che sarà accessibile non è determinabile finché non sia noto il valore corrente. Infine, l'esponenziazione modulare genera ulteriormente un hashing del risultato.
Questo algoritmo si basa su diversi risultati dalla teoria dei numeri. Vedere l'appendice più avanti per una discussione.
Valutazione da client leggero
Questa costruzione del grafico intende consentire a ogni nodo nel grafico di essere ricostruito calcolando solamente l'albero secondario di un piccolo numero di nodi, in modo da richiedere solo una piccola quantità di memoria ausiliaria. Nota che, con k=1, l'albero secondario è solo una catena di valori che cresce al primo elemento nel DAG.
La funzione di calcolo del client leggero per il DAG funziona così:
1def quick_calc(params, seed, p):2 w, P = params["w"], params["P"]3 cache = {}45 def quick_calc_cached(p):6 if p in cache:7 pass8 elif p == 0:9 cache[p] = pow(sha3(seed), w, P)10 else:11 x = pow(sha3(seed), (p + 1) * w, P)12 for _ in range(params["k"]):13 x ^= quick_calc_cached(x % p)14 cache[p] = pow(x, w, P)15 return cache[p]1617 return quick_calc_cached(p)Mostra tuttoCopia
essenzialmente, è semplicemente una riscrittura dell'algoritmo di cui sopra, che elimina il ciclo di calcolo dei valori per l'intero DAG e sostituisce la ricerca del nodo precedente con una chiamata ricorsiva o una ricerca della cache. Nota che per k=1
, la cache non è necessaria, anche se in realtà un'ulteriore ottimizzazione pre-calcola le prime migliaia di valori del DAG e li mantiene come una cache statica per i calcoli; vedi l'appendice per l'implementazione di un codice a riguardo.
Doppio buffer di DAG
In un client completo, è usato un doppio buffer(opens in a new tab) di 2 DAG prodotti dalla suddetta formula. L'idea è che i DAG siano prodotti ogni epochtime
numero di blocchi, secondo i parametri indicati sopra. Il client non usa l'ultimo DAG prodotto, ma quello precedente. Il beneficio è che consente ai DAG di essere sostituiti nel tempo senza dover prevedere un passaggio in cui i miner devono improvvisamente ricalcolare tutti i dati. In caso contrario vi sarebbe il rischio, a intervalli regolari, di un brusco rallentamento temporaneo nell'elaborazione della catena e l'aumento drastico della centralizzazione. In quei pochi minuti prima che tutti i dati siano ricalcolati sussiste quindi il rischio di un attacco 51%.
L'algoritmo usato per generare la serie di DAG usati per calcolare il lavoro per un blocco è il seguente:
1def get_prevhash(n):2 from pyethereum.blocks import GENESIS_PREVHASH3 from pyethereum import chain_manager4 if n <= 0:5 return hash_to_int(GENESIS_PREVHASH)6 else:7 prevhash = chain_manager.index.get_block_by_number(n - 1)8 return decode_int(prevhash)910def get_seedset(params, block):11 seedset = {}12 seedset["back_number"] = block.number - (block.number % params["epochtime"])13 seedset["back_hash"] = get_prevhash(seedset["back_number"])14 seedset["front_number"] = max(seedset["back_number"] - params["epochtime"], 0)15 seedset["front_hash"] = get_prevhash(seedset["front_number"])16 return seedset1718def get_dagsize(params, block):19 return params["n"] + (block.number // params["epochtime"]) * params["n_inc"]2021def get_daggerset(params, block):22 dagsz = get_dagsize(params, block)23 seedset = get_seedset(params, block)24 if seedset["front_hash"] <= 0:25 # No back buffer is possible, just make front buffer26 return {"front": {"dag": produce_dag(params, seedset["front_hash"], dagsz),27 "block_number": 0}}28 else:29 return {"front": {"dag": produce_dag(params, seedset["front_hash"], dagsz),30 "block_number": seedset["front_number"]},31 "back": {"dag": produce_dag(params, seedset["back_hash"], dagsz),32 "block_number": seedset["back_number"]}}Mostra tuttoCopia
Hashimoto
L'idea dietro all'Hashimoto originale è usare la blockchain come dataset, eseguendo un calcolo che selezioni N indici dalla blockchain, raccolga le transazioni a quegli indici, esegua uno XOR di questi dati e restituisca l'hash del risultato. L'algoritmo originale di Thaddeus Dryja, tradotto in Python per coerenza, è il seguente:
1def orig_hashimoto(prev_hash, merkle_root, list_of_transactions, nonce):2 hash_output_A = sha256(prev_hash + merkle_root + nonce)3 txid_mix = 04 for i in range(64):5 shifted_A = hash_output_A >> i6 transaction = shifted_A % len(list_of_transactions)7 txid_mix ^= list_of_transactions[transaction] << i8 return txid_mix ^ (nonce << 192)Copia
Sfortunatamente, anche se Hashimoto è considerato gravoso per la RAM, si affida a un'aritmetica a 256 bit, che richiede molti calcoli. Per risolvere questo problema, Dagger-Hashimoto usa comunque solo i 64 bit meno significativi, indicizzando il proprio dataset.
1def hashimoto(dag, dagsize, params, header, nonce):2 m = dagsize / 23 mix = sha3(encode_int(nonce) + header)4 for _ in range(params["accesses"]):5 mix ^= dag[m + (mix % 2**64) % m]6 return dbl_sha3(mix)Copia
L'uso di SHA3 doppi consente una forma di dati zero, una pre-verifica quasi istantanea, che verifica solo che sia stato fornito un valore intermedio corretto. Questo livello esterno di proof-of-work è altamente pro-ASIC e abbastanza debole, ma è pensato per rendere ancora più complicati gli attacchi DDoS, poiché per produrre un blocco che non sarà immediatamente scartato deve essere eseguito un po’ di lavoro. Ecco la versione del client leggero:
1def quick_hashimoto(seed, dagsize, params, header, nonce):2 m = dagsize // 23 mix = sha3(nonce + header)4 for _ in range(params["accesses"]):5 mix ^= quick_calc(params, seed, m + (mix % 2**64) % m)6 return dbl_sha3(mix)Copia
Mining e verifica
Mettiamo ora tutto insieme nell'algoritmo di mining:
1def mine(daggerset, params, block):2 from random import randint3 nonce = randint(0, 2**64)4 while 1:5 result = hashimoto(daggerset, get_dagsize(params, block),6 params, decode_int(block.prevhash), nonce)7 if result * params["diff"] < 2**256:8 break9 nonce += 110 if nonce >= 2**64:11 nonce = 012 return nonceMostra tuttoCopia
Ecco l'algoritmo di verifica:
1def verify(daggerset, params, block, nonce):2 result = hashimoto(daggerset, get_dagsize(params, block),3 params, decode_int(block.prevhash), nonce)4 return result * params["diff"] < 2**256Copia
Verifica adatta a un client leggero:
1def light_verify(params, header, nonce):2 seedset = get_seedset(params, block)3 result = quick_hashimoto(seedset["front_hash"], get_dagsize(params, block),4 params, decode_int(block.prevhash), nonce)5 return result * params["diff"] < 2**256Copia
Inoltre, nota che Dagger-Hashimoto impone anche altri requisiti sull'intestazione del blocco:
- Perché la verifica a due livelli funzioni, l'intestazione di un blocco deve avere sia il nonce che il valore medio di pre-sha3
- Da qualche parte, l'intestazione di un blocco deve memorizzare la sha3 del set di seed corrente
Letture consigliate
Conosci una risorsa della community che ti è stata utile? Modifica questa pagina e aggiungila!
Appendice
Come notato sopra, l'RNG usato per la generazione del DAG si affida ad alcuni risultati dalla teoria dei numeri. Per prima cosa, accertiamoci che l'RNG di Lehmer che è la base per la variabile picker
abbia un periodo ampio. In secondo luogo, mostriamo che pow(x,3,P)
non mapperà x
a 1
o P-1
, a condizione che all’inizio x ∈ [2,P-2]
. Infine, mostriamo che pow(x,3,P)
ha un basso tasso di collisione se trattato come funzione di hashing.
Generatore di numeri casuali di Lehmer
Sebbene la funzione produce_dag
non necessiti di produrre numeri casuali imparziali, un possibile rischio è dato dal fatto che seed**i % P
prende solo una manciata di valori. Questo potrebbe fornire un vantaggio ai miner che riconoscono lo schema, rispetto a quelli che non lo conoscono.
Per evitarlo, si è fatto ricorso a un risultato dalla teoria dei numeri. Un Numero primo sicuro(opens in a new tab) si definisce come numero primo P
tale per cui anche (P-1)/2
è un numero primo. L'ordine di un membro x
del gruppo moltiplicativo(opens in a new tab) ℤ/nℤ
è definito come il valore minimo di m
tale per cui
1xᵐ mod P ≡ 1
Osservazione 1. Ipotizziamo che
x
sia un membro del gruppo moltiplicativoℤ/Pℤ
per un numero primo sicuroP
. Sex mod P ≠ 1 mod P
ex mod P ≠ P-1 mod P
, allora l'ordine dix
èP-1
o(P-1)/2
.
Dimostrazione. Poiché P
è un numero primo sicuro, allora per il [Teorema di Lagrange][lagrange], l'ordine di x
è 1
, 2
, (P-1)/2
o P-1
.
L'ordine di x
non può essere 1
, poiché secondo il Piccolo teorema di Fermat:
1xP-1 mod P ≡ 1
Quindi, x
, deve essere un'identità moltiplicativa di ℤ/nℤ
, che è univoca. Poiché abbiamo presupposto che x ≠ 1
, ciò è impossibile.
L'ordine di x
non può essere 2
a meno che x = P-1
, poiché ciò violerebbe il fatto che P
sia un numero primo.
Dalla suddetta proposizione possiamo capire che iterando (picker * init) % P
, avrà una lunghezza del ciclo di almeno (P-1)/2
. Questo perché abbiamo selezionato P
come un numero primo sicuro, approssimativamente pari a una potenza superiore di due e che init
è nell'intervallo [2,2**256+1]
. Data la portata di P
, non dovremmo mai aspettarci un ciclo dall'elevamento a potenza modulare.
Quando assegniamo la prima cella nel DAG (la variabile etichettata come init
), calcoliamo pow(sha3(seed) + 2, 3, P)
. A prima vista, questo non garantisce che il risultato sia 1
né P-1
. Tuttavia, poiché P-1
è un numero primo sicuro, abbiamo la seguente garanzia aggiuntiva, che è un corollario dell'Osservazione 1:
Osservazione 2. Ipotizziamo che
x
sia un membro del gruppo moltiplicativoℤ/Pℤ
per un numero primo sicuroP
, e prendiamow
come numero naturale. Sex mod P ≠ 1 mod P
ex mod P ≠ P-1 mod P
, nonchéw mod P ≠ P-1 mod P
ew mod P ≠ 0 mod P
, alloraxʷ mod P ≠ 1 mod P
exʷ mod P ≠ P-1 mod P
Esponenziazione modulare come funzione di hash
Per certi valori di P
e w
, la funzione pow(x, w, P)
potrebbe avere molte collisioni. Ad esempio, pow(x,9,19)
prende solo i valori {1,18}
.
Dato che P
è primo, allora è possibile scegliere un'appropriata w
per una funzione di hashing di esponenziazione modulare usando il seguente risultato:
Osservazione 3. Prendiamo
P
come numero primo;w
eP-1
sono coprimi se e solo se per ognia
eb
inℤ/Pℤ
:`aʷ mod P ≡ bʷ mod P` se e solo se `a mod P ≡ b mod P`
Dunque, dato che P
è primo e w
è coprimo rispetto a P-1
, abbiamo che |{pow(x, w, P) : x ∈ ℤ}| = P
, e questo implica che la funzione di hashing ha la frequenza di collisione minima possibile.
Nel caso speciale in cui P
sia un numero primo sicuro, come da noi selezionato, allora P-1
ha solo i fattori 1, 2, (P-1)/2
e P-1
. Poiché P
> 7, sappiamo che 3 è primo rispetto a P-1
, quindi w=3
soddisfa la proposizione precedente.
Algoritmo di valutazione più efficiente basato sulla cache
1def quick_calc(params, seed, p):2 cache = produce_dag(params, seed, params["cache_size"])3 return quick_calc_cached(cache, params, p)45def quick_calc_cached(cache, params, p):6 P = params["P"]7 if p < len(cache):8 return cache[p]9 else:10 x = pow(cache[0], p + 1, P)11 for _ in range(params["k"]):12 x ^= quick_calc_cached(cache, params, x % p)13 return pow(x, params["w"], P)1415def quick_hashimoto(seed, dagsize, params, header, nonce):16 cache = produce_dag(params, seed, params["cache_size"])17 return quick_hashimoto_cached(cache, dagsize, params, header, nonce)1819def quick_hashimoto_cached(cache, dagsize, params, header, nonce):20 m = dagsize // 221 mask = 2**64 - 122 mix = sha3(encode_int(nonce) + header)23 for _ in range(params["accesses"]):24 mix ^= quick_calc_cached(cache, params, m + (mix & mask) % m)25 return dbl_sha3(mix)Mostra tuttoCopia