Amun: Solving the last Cairo-Games challenge
For their second edition of the Cairo Games, StarkWare have proposed 5 challenges for testing your Cairo skills.
Cairo is a specialized language used for writing provable programs.
Its syntax is a mix of many languages (it is built on top of Python), but it has some strong limitations that we will need to work with. For me the two facts really set it apart:
- All objects in memory must be elements in the finite field $ \mathbb{F}_p $ where $p = 2^{251} + 17 \cdot 2^{192} + 1$.
- Memory is immutable, which means that Cairo borrows a lot of ideas from functional languages (using recursion instead of loops for example).
Cairo itself has many uses beyond simply solving challenges (like building an AMM or voting system). In these challenges, we are given a correct (but not yet compilable) Cairo program which asserts the validitity of some input. Our job will be to figure out what the program actually verifies, and build an apporopriate solution, all without modifying the original program.
We will write our solution inside of hints which are tiny snippets of code we embed in the source code. When the program gets built, these hints tell the compiler what values it should expect in certain memory locations.
This notebook is the write-up of my solution, which mirror my own thought process that led me to the solution.
We will be proceeding in 3 parts:
- Understanding what the program verifies
- Solving the problem
- Building the hints
If you want to try this code yourself, you will need to install the Cairo toolchain, as well as basic Python libraries (numpy, pandas, …).
1. Exploratory Challenge Analysis
In this section, we’ll try to understand what all the functions do, and what type of solution it is expecting.
1.1 main()
(1/2)
The Cairo program starts with the main()
function, and we’ll focus for now on the first half.
func main{output_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}():
alloc_locals
let (local data : felt*) = get_data()
let (local sol : felt*) = alloc()
local sol_size
let (state : DictAccess*) = alloc()
local state_start : DictAccess* = state
run{state=state}(data, sol, sol_size)
# ...
end
The main()
function seems to be doing a couple of things:
- Populate an array
data
withget_data()
- Expect a solution array
sol
of sizesol_size
. - Process the solution using
data
, and storing the result instate
. Here,state
is represented as a DictAccess list. We can think of it as a list of all updates to a dictionary, but we’ll come back to this later.
If we try to run the program as-is, we get the following error:
Error: code:258:33: While expanding the reference 'sol_size' in:
run{state=state}(data, sol, sol_size)
^******^
code:253:11: Error at pc=0:849:
Unknown value for memory cell at address 1:7.
local sol_size
^******^
At this stage, sol_size
is not initialized (neither are sol
and state
in fact), so this hints at the fact we may have to write our own hints inside this main
function.
Since sol is an array of felt
(field elements), we’ll need to write a hint like this just after the declaration of sol
and sol_size
:
%{
# our solution goes here
SOL = [???]
segments.write_arg(ids.sol, SOL)
ids.sol_size = len(SOL)
%}
If we input some dummy solution in SOL
, we see that our previous error disapears, and is replaced with another one later in the execution.
1.2 get_data()
At the very start of the program’s execution, the data
location gets populated by get_data()
:
func get_data() -> (res : felt*):
alloc_locals
local a0 = 0
local a = 2
local a = 4
# ...
local a = 28
local a = 36
local a = 12
let (__fp__, _) = get_fp_and_pc()
return (res=&a0)
end
At first sight, this function seems to be overwriting the a
variable many times.
In Cairo, memory is immutable, so we aren’t actually able to change the value of a
.
Instead, we are actually writing a new value in the next memory slot, so that we end up with one contiguous array of values.
The function then returns the adress of a0
which points to the start of the array.
To better understand how this works in more detail, I recommend reading How Cairo Works.
We’ll need to study this data later, so for now we’ll store these values in the DATA
variable and run some basic analysis.
DATA = [
0, 2, 4, 6, 1, 3, 5, 7, 8, 32, 24, 16, 9, 33, 25, 17, 10, 34, 26, 18, 24, 26, 28, 30, 25, 27, 29, 31, 4, 32, 44,
20, 3, 39, 43, 19, 2, 38, 42, 18, 16, 18, 20, 22, 17, 19, 21, 23, 6, 24, 42, 12, 5, 31, 41, 11, 4, 30, 40, 10,
8, 10, 12, 14, 9, 11, 13, 15, 0, 16, 40, 36, 7, 23, 47, 35, 6, 22, 46, 34, 32, 34, 36, 38, 33, 35, 37, 39, 2, 8,
46, 28, 1, 15, 45, 27, 0, 14, 44, 26, 40, 42, 44, 46, 41, 43, 45, 47, 22, 30, 38, 14, 21, 29, 37, 13, 20, 28, 36, 12
]
print(f"Item range: [{min(DATA)}, ...,{max(DATA)}]")
print(f"Item count: {len(DATA)}")
counts = [DATA.count(i) for i in range(max(DATA))]
print(f"Occurences of each i = 0, ..., {max(DATA)}: {counts}")
Item range: [0, ...,47]
Item count: 120
Occurences of each i = 0, ..., 47: [3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3, 2, 3]
1.3 run()
and cycle()
func cycle{state : DictAccess*}(values : felt*):
# Create local references to the 4 dictionary updates
let state0 = state
let state1 = state0 + DictAccess.SIZE
let state2 = state1 + DictAccess.SIZE
let state3 = state2 + DictAccess.SIZE
# We are operating on the keys defined in values
assert state0.key = [values + 0]
assert state1.key = [values + 1]
assert state2.key = [values + 2]
assert state3.key = [values + 3]
# Apply a cyclic permutation to the keys
assert state0.new_value = state3.prev_value
assert state1.new_value = state0.prev_value
assert state2.new_value = state1.prev_value
assert state3.new_value = state2.prev_value
let state = state + 4 * DictAccess.SIZE
return ()
end
func run{range_check_ptr, state : DictAccess*}(data : felt*, sol : felt*, sol_size : felt):
if sol_size == 0:
return ()
end
tempvar x = [sol]
assert_nn_le(x, 5)
cycle(data + 20 * x)
cycle(data + 20 * x + 4)
cycle(data + 20 * x + 8)
cycle(data + 20 * x + 12)
cycle(data + 20 * x + 16)
run(data=data, sol=sol + 1, sol_size=sol_size - 1)
return ()
end
run
is a recursive function that iterates over all elements in the array sol
.
At each iteration, it sets x
to the next element, and exits when it has visited the whole list.
The first thing to notice is assert_nn_le(x, 5)
, which gives us our first hint about the solution we need to construct:
Each element in sol
must be in the range ${0, …, 5}$.
Next, each loop of run()
calls cycle()
5 times with varying inputs.
We see that cycle()
only reads 4 elements of the array it is given, which means we are feeding it 5 blocks of 4 values, or 20 values in total.
Therefore, each element of sol
references a specific row of data
which will be applied to the state
.
We verify that each row in DATA
contains unique items:
BLOCKS = [DATA[i:i+20] for i in range(0, len(DATA), 20)]
for i in range(len(BLOCKS)):
row = BLOCKS[i]
assert len(set(row)) == len(row), f"row {i} contains duplicates"
print("no duplicates in rows")
no duplicates in rows
As we mentioned earlier, the state
variable is meant to represent an ‘append-only dictionary’.
Concretely, we can view it as a list of tuples (key, prev_value, new_value)
.
This corresponds with DictAccess
struct defined in starkware/cairo/common/dict_access.cairo.
First, we create 4 local references to the next DictAccess
elements, which represent the dictionary updates we are about to perform.
The keys that will be updated are defined by the 4 values in values
, so we set the key
s of each DictAccess
accordingly.
A cyclic permutation is then applied to the items referenced by the 4 keys.
To make this whole step easier to understand, we rewrite it in Python, and verify that it runs in a similar way. The main difference with the Cairo version is that we use a simple read-write dictionary to store the state.
# getKeys returns the block of 4 keys defined by DATA
def getKeys(row, block: int) -> list[int]:
assert 0 <= row and row <= 5, "row must be in [0,5]"
assert 0 <= block and block <= 4, "block must be in [0,4]"
block_idx = 20*row + 4*block
return DATA[block_idx:block_idx+4]
# run iterates over all elements in the solution, and
def run(state: dict[int], data: list[int], sol: list[int]):
for s in sol:
assert 0 <= s and s <= 5, "sol contains an element outside [0,5]"
cycle(state, getKeys(s, 0))
cycle(state, getKeys(s, 1))
cycle(state, getKeys(s, 2))
cycle(state, getKeys(s, 3))
cycle(state, getKeys(s, 4))
# cycle permutes the values of 4 elements in state[keys] in the following way:
#
# 0 -> 3
# 1 -> 0
# 2 -> 1
# 3 -> 2
def cycle(state: dict[int], keys: list[int]):
key0, key1, key2, key3 = keys
state[key0], state[key1], state[key2], state[key3] = state[key3], state[key0], state[key1], state[key2]
Taking a step back, we start to wonder what this run()
function actually does.
Let’s recap the information we learned up til now:
state
is a dictionary that contains some initial data referenced by the keys ${0, …, 48}$.data
can be seen as a 6x20 matrix where each row contains a subset of ${0, …, 48}$.- when iterating over
sol
, each element references row ofdata
which itself references the keys that will be permuted. - since all rows of
data
are duplicate-free, we can view them as permutations over ${0, …, 48}$. - each permutation is a composition of 5 cyclic permutations of order 4 each, so all permutation have order 4 (by order we mean that applying the permutation 4 times is the same as doing nothing).
1.4 main()
(2/2)
After generating the appropriate state
from the solution, we are interested in the second half of main()
which is responsible for verifying that our provided solution was correct.
Hopefully, understanding what this code does will give us more hints about how we should construct our solution.
func main{output_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}():
# ...
# 1.
let (local squashed_dict : DictAccess*) = alloc()
let (squashed_dict_end) = squash_dict(
dict_accesses=state_start, dict_accesses_end=state, squashed_dict=squashed_dict)
assert squashed_dict_end = squashed_dict + DictAccess.SIZE * 48
local range_check_ptr = range_check_ptr
# 2.
let (initial_value) = get_initial_value{hash_ptr=pedersen_ptr}()
local pedersen_ptr : HashBuiltin* = pedersen_ptr
# 3.
verify(squashed_dict=squashed_dict, initial_value=initial_value, n=0)
let output_ptr = output_ptr + 1
return ()
end
We separate the code snippet in 3 parts and briefly explain what each one does.
The
state
that we have produced isn’t particularly useful in its current form, since it represents all updates we performed along the way. Applyingsquash_dict()
tostate
create a list of tuples(key, initial_value, final_value)
sorted bykey
. Theassert
statement also tells us that the the size of thesquashed_dict
is 48, which means that our solution will affect all keys.get_initial_value()
most likely provides an array of elements which correspond to the initialstate
.verify()
should check wether our processed solution is coherent withinitial_value
.
So far, we didn’t learn a lot more, so let’s dig deeper into the functions.
1.5 get_initial_value()
func get_initial_value{hash_ptr : HashBuiltin*}() -> (initial_value : felt*):
alloc_locals
let (local initial_value : felt*) = alloc()
assert [initial_value] = 48
let (res) = hash_chain(initial_value)
assert res = 402684044838294963951952172461450293510735826065192598384325922359699836469
let (res) = hash_chain(initial_value + 1)
assert res = 1508108551069464286813785297355641266663485599320848393798932455588476865295
let (res) = hash_chain(initial_value + 7)
assert res = 2245701625176425331085101334837624242646502129018701371434984384296915870715
let (res) = hash_chain(initial_value + 12)
assert res = 3560520899812162122215526869789497390123010766571927682749531967294685134040
let (res) = hash_chain(initial_value + 18)
assert res = 196997208112053944281778155212956924860955084720008751336605214240056455402
let (res) = hash_chain(initial_value + 24)
assert res = 1035226353110224801512289478587695122129015832153304072590365512606504328818
let (res) = hash_chain(initial_value + 30)
assert res = 1501038259288321437961590173137394957125779122158200548115283728521438213428
let (res) = hash_chain(initial_value + 34)
assert res = 3537881782324467737440957567711773328493014027685577879465936840743865613662
let (res) = hash_chain(initial_value + 39)
assert res = 1039623306816876893268944011668782810398555904667703809415056949499773381189
let (res) = hash_chain(initial_value + 42)
assert res = 2508528289207660435870821551803296739495662639464901004905339054353214007301
return (initial_value=initial_value + 1)
end
What a mouth full! Let’s decompose this bit by bit.
First, let (local initial_value : felt*) = alloc()
indicates that we will most likely have to provide hints to the compiler about the actual values in the initial_value
array, since it is not given any initial value.
The first assertion: assert [initial_value] = 48
nicely tells us that initial_value[0] == 48
, which is going to help later.
Looking at the return statement however, we see that this value is not actually returned as part of initial_value
.
We’ll now try to understand the statements of the form:
let (res) = hash_chain(initial_value + idx)
assert res = ????????
The hash_chain(data_ptr : felt*)
function is defined in starkware/cairo/common/hash_chain.cairo, which provides the following comment:
Computes a hash chain of a sequence whose length is given at [data_ptr] and the data starts at data_ptr + 1. The hash is calculated backwards (from the highest memory address to the lowest).
For example, for the 3-element sequence [x, y, z] the hash is:
h(3, h(x, h(y, z)))
If data_length = 0, the function does not return (takes more than field prime steps).
The actual hash function used is the “Starkware Pedersen” one defined in starkware/crypto/signature/fast_pedersen_hash.py (it was mentioned in the hints near the end of the puzzle page). It essentially computes
$$ h(x,y) = S + [x \bmod 2^{248}] P_0 + [x » 248] P_1 + [y \bmod 2^{248}] P_3 + [y » 248] P_4, $$
where $S, P_0, P_1, P_2, P_3$ are elliptic curve points.
Since everything is slower in Cairo-land, we can use compute_hash_chain(data)
function from starkware/cairo/common/hash_chain.py to compute hashes in Python directly.
The two functions are not entirely the same, since the Cairo version assumes that the first element of the array is also its length, whereas the Python one simply computes
h(data[0], h(data[1], h(..., h(data[n-2], data[n-1]))))
.
Recalling that initial_value[0] == 48
, we can probably assume that len(initial_value) == 49
.
Moreover, the first hash is the result of computing:
FULL_HASH = h(48, h(initial_value[1], h(initial_value[2], h(..., h(initial_value[48], initial_value[49]))...)))
We might also guess that the values in initial_value
are all less than 48, otherwise the subsequent calls to hash_chain()
would overflow.
For now, that means there are $48^{48} \approx 2^{268}$ which is definitely intractable.
1.6 verify()
func verify(squashed_dict : felt*, initial_value : felt*, n):
if n == 6:
return ()
end
assert [squashed_dict + 0] = n * 8
assert [squashed_dict + 1] = [initial_value]
assert [squashed_dict + 2] = n
# ...
assert [squashed_dict + 21] = n * 8 + 7
assert [squashed_dict + 22] = [initial_value + 7]
assert [squashed_dict + 23] = n
verify(squashed_dict=squashed_dict + 24, initial_value=initial_value + 8, n=n + 1)
return ()
end
The verify()
function is another recursive function which iterates on both squashed_dict
and initial_value
,
for $n = 0, 1, \ldots, 5$.
As we might have guessed by now, the initial_value
array provided by get_initial_value()
is actually an array representing the starting state
.
Therefore, we can confirm that the size of initial_value
must be 48 as well.
Looking at the following Python code should give a better understanding of what this function is actually verifying.
FINAL_STATE_VALUES = [ i // 8 for i in range(48)]
def verify(squashed_dict: list[(int, int, int)], initial_value: list[int]):
assert len(squashed_dict) == len(initial_value) == 48, "squashed_dict and initial_value must contain 48 elements"
for idx in range(48):
n = idx//8
key, prev_value, new_value = squashed_dict[idx]
assert n == FINAL_STATE_VALUES[key], "FINAL_STATE_VALUES is not correct"
assert key == idx, f"squashed_dict is not sorted at key {i}"
assert prev_value == initial_value[key], f"prev_value for key {key} does not correspond with initial_value"
assert new_value == FINAL_STATE_VALUES[key], f"prev_value for key {key} does not correspond with initial_value"
# verify that our verify function is working correctly
# we create squashed_dict which is already the final state
fake_squashed_dict = [(i, v, v) for i, v in enumerate(FINAL_STATE_VALUES)]
verify(fake_squashed_dict, FINAL_STATE_VALUES)
# print the 48 values that make up the final state
print(FINAL_STATE_VALUES)
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5]
The above snippet contained the last clue we are going to extract from the source code.
Up until now, we knew the keys in our state
were the integers in ${ 0, 1, \ldots, 47}$, but we had little information about what values they held.
By looking at the final expected state, we realize that the values are simply integers between 0 and 5 (which all appear exactly 8 times).
Therefore, the same must be true for the elements of initial_value
since they correspond to a permutation of the final state!
This really restricts the possible outputs of get_initial_value()
, which means we might actually be able to bruteforce something!
2. Solving
2.1 Breaking get_initial_value()
import itertools
from starkware.cairo.common.hash_chain import compute_hash_chain
from starkware.crypto.signature.fast_pedersen_hash import pedersen_hash
We can now start seriously attacking this problem head-on!
Our goal in this section is to intelligently bruteforce the array returned by get_initial_value()
.
Here’s what we have figured out:
- At the start of the function
initial_value
is an array of length 49, andinitial_value[0] == 48
. - The array returned is
initial_value[1:]
, and it contains only numbers in ${ 0, 1, \ldots, 5 }$. - More specifically, each number in ${ 0, 1, \ldots, 5 }$ appears exactly 8 times in
initial_value[1:]
Let’s start by formatting the problem’s data into Python.
FULL_HASH = 402684044838294963951952172461450293510735826065192598384325922359699836469
GIVEN_HASHES = {
1: 1508108551069464286813785297355641266663485599320848393798932455588476865295,
7: 2245701625176425331085101334837624242646502129018701371434984384296915870715,
12: 3560520899812162122215526869789497390123010766571927682749531967294685134040,
18: 196997208112053944281778155212956924860955084720008751336605214240056455402,
24: 1035226353110224801512289478587695122129015832153304072590365512606504328818,
30: 1501038259288321437961590173137394957125779122158200548115283728521438213428,
34: 3537881782324467737440957567711773328493014027685577879465936840743865613662,
39: 1039623306816876893268944011668782810398555904667703809415056949499773381189,
42: 2508528289207660435870821551803296739495662639464901004905339054353214007301
}
Recalling the definitition of hash_chain
(Cairo) and compute_hash_chain
(Python),
we can link them together like this:
hash_chain(data)
= compute_hash_chain([data[0], data[1], ..., data[data[0]])
The data
array must contain only integers from ${ 0, 1, \ldots, 5 }$ and data[0]
defines how many elements to hash.
In other words, data
is an element from the following set:
$$ { ( 0 ), ( 1, i_{1,1} ), ( 2, i_{2,1}, i_{2,2} ), ( 3, i_{3,1}, i_{3,2}, i_{3,3} ), ( 4, i_{4,1}, i_{4,2}, i_{4,3}, i_{4,4} ) ,( 5, i_{5,1}, i_{5,2}, i_{5,3}, i_{5,4}, i_{5,5} ) , |, i_{j,k} \in { 0, 1, \ldots, 5 } } $$
A simple calculation reveals that there are exactly 9331 possiblities, a very reasonable amount of iteration we are willing to sacrifice our CPU cycles for.
%%time
working_values = [48] + 48*[-1]
hashes = [h for idx, h in GIVEN_HASHES.items()]
hash_preimages = {}
# create all tuples (j, i_1, i_2, ..., i_j) with i, j in [0,5]
def tuple_iterator():
for j in range(6):
for t in itertools.product(range(6), repeat=j):
yield (j, *t)
for preimage in tuple_iterator():
h = compute_hash_chain(preimage, hash_func=pedersen_hash)
if h in hashes:
print(f"found preimage \t{h}:\t{preimage}")
hash_preimages[h] = preimage
if len(hash_preimages) == len(hashes):
print("\nFound all preimages for the hashes in GIVEN_HASHES!\n")
break
# update working_values with the preimages we found
for idx, h in GIVEN_HASHES.items():
preimage = hash_preimages[h]
for i, val in enumerate(preimage):
working_values[idx+i] = val
# find indices of values we haven't found yet
missing_idx = [i for i, v in enumerate(working_values) if v == -1]
# find the values we are missing, knowing that each number in [0,5] appears 8 times in the solution
missing_values = []
for v in range(6):
# number of occurences of v=0,...,5
c = working_values.count(v)
# add v to missing_values as many times as necessary
for _ in range(8-c):
missing_values.append(v)
print(f"We are missing {len(missing_values)} values: {missing_values} at indices {missing_idx}\n")
found preimage 1501038259288321437961590173137394957125779122158200548115283728521438213428: (2, 2, 5)
found preimage 1039623306816876893268944011668782810398555904667703809415056949499773381189: (2, 3, 4)
found preimage 1035226353110224801512289478587695122129015832153304072590365512606504328818: (3, 2, 2, 1)
found preimage 196997208112053944281778155212956924860955084720008751336605214240056455402: (4, 0, 1, 5, 5)
found preimage 2245701625176425331085101334837624242646502129018701371434984384296915870715: (4, 0, 4, 1, 0)
found preimage 3560520899812162122215526869789497390123010766571927682749531967294685134040: (4, 0, 5, 4, 2)
found preimage 3537881782324467737440957567711773328493014027685577879465936840743865613662: (4, 1, 1, 3, 2)
found preimage 2508528289207660435870821551803296739495662639464901004905339054353214007301: (4, 3, 0, 5, 5)
found preimage 1508108551069464286813785297355641266663485599320848393798932455588476865295: (5, 0, 0, 3, 3, 1)
Found all preimages for the hashes in GIVEN_HASHES!
We are missing 7 values: [0, 1, 1, 2, 3, 3, 5] at indices [17, 23, 28, 29, 33, 47, 48]
CPU times: user 8.67 s, sys: 36.9 ms, total: 8.7 s
Wall time: 8.81 s
We were able to find pre-images for all the hashes, but unfortunately we are still missing 7 values in the initial_value
array.
Since we know how many times each number must appear in the array, we deduce that the missing values are $(0, 1, 1, 2, 3, 3, 5)$.
The last step will consist in trying all $7!$ (5040) permutation of these values at all the missing indices of initial_value
.
While this is half the number of iterations required for breaking all hashes in GIVEN_HASHES
,
each call to compute_hash_chain(initial_value)
now performs 48 different hashes.
With our fingers crossed, we run the final bruteforce.
%%time
for possibility in itertools.permutations(missing_values):
for i, idx in enumerate(missing_idx):
working_values[idx] = possibility[i]
h = compute_hash_chain(working_values, hash_func=pedersen_hash)
if h == FULL_HASH:
break
print("FINISHED\ninitial_value = ", working_values[1:],"\n")
FINISHED
initial_value = [5, 0, 0, 3, 3, 1, 4, 0, 4, 1, 0, 4, 0, 5, 4, 2, 3, 4, 0, 1, 5, 5, 1, 3, 2, 2, 1, 0, 1, 2, 2, 5, 2, 4, 1, 1, 3, 2, 2, 3, 4, 4, 3, 0, 5, 5, 5, 3]
CPU times: user 3min 50s, sys: 495 ms, total: 3min 51s
Wall time: 3min 51s
Success!
We are able to find the whole initial_value
array in less than 5 minutes (along with a full day to come up with the solution).
Let’s verify that everything is correct:
INITIAL_VALUES = [5, 0, 0, 3, 3, 1, 4, 0, 4, 1, 0, 4, 0, 5, 4, 2, 3, 4, 0, 1, 5, 5, 1, 3, 2, 2, 1, 0, 1, 2, 2, 5, 2, 4, 1, 1, 3, 2, 2, 3, 4, 4, 3, 0, 5, 5, 5, 3]
FULL_INITIAL_VALUES = [48] + INITIAL_VALUES
assert compute_hash_chain(FULL_INITIAL_VALUES) == FULL_HASH, "first hash is incorrect"
for idx, h in GIVEN_HASHES.items():
num_vals = FULL_INITIAL_VALUES[idx]
hash_block = FULL_INITIAL_VALUES[idx:idx+1+num_vals]
assert compute_hash_chain(hash_block) == h, f"hash at index {idx} is incorrect"
print("all good!")
all good!
2.2 Analyzing run()
Now that we have the initial values of the state
disctionaly, we need to find the actual solution array which solves the problem.
Again, we’ll reiterate some of the facts we learned up to now:
- The solution constists of a sequence of numbers in ${ 0, 1, \ldots, 5}$
- Each of these elements references one of the 6 permutations that can be applied to the current state.
- Each permuations has order 4, and affect exactly 20 of the 48 elements.
This step will involve analyzing data, so we’ll import the usual Python data science stuff.
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
pd.set_option('display.max_columns', None) # make sure we can see all 48 element in a row
ROWS = 6
COLS = 48
BLOCK_SIZE = 8
BLOCK_COUNT = 6
PERMUTATIONS = np.empty((ROWS,COLS), dtype=np.int8)
for perm_idx in range(ROWS):
# initialize each row to the identity permutation [0, 1, ..., 47]
PERMUTATIONS[perm_idx] = np.arange(COLS)
row = PERMUTATIONS[perm_idx]
for i in range(5):
perm = getKeys(perm_idx, i)
row[perm[0]], row[perm[1]], row[perm[2]], row[perm[3]] = row[perm[3]], row[perm[0]], row[perm[1]], row[perm[2]]
PERMUTATIONS_DF = pd.DataFrame(PERMUTATIONS)
We start by looking at what elements are actually affected by each permutation.
In the following matrix, each row defines a permutation of ${0, 1, \ldots, 47}$, and we color the elements in red if the element is moved, and green if it is left intact.
def color_identity(row):
return [f"background-color: {'red' if row[i] != i else 'green'}" for i in range(len(row))]
display(PERMUTATIONS_DF.style\
.set_caption('Fixed points of the permutations. 🟩 = fixed, 🟥 = moved')\
.apply(color_identity,axis=1))
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
U | 6 | 7 | 0 | 1 | 2 | 3 | 4 | 5 | 16 | 17 | 18 | 11 | 12 | 13 | 14 | 15 | 24 | 25 | 26 | 19 | 20 | 21 | 22 | 23 | 32 | 33 | 34 | 27 | 28 | 29 | 30 | 31 | 8 | 9 | 10 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
R | 0 | 1 | 18 | 19 | 20 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 42 | 43 | 44 | 21 | 22 | 23 | 30 | 31 | 24 | 25 | 26 | 27 | 28 | 29 | 4 | 33 | 34 | 35 | 36 | 37 | 2 | 3 | 40 | 41 | 38 | 39 | 32 | 45 | 46 | 47 |
F | 0 | 1 | 2 | 3 | 10 | 11 | 12 | 7 | 8 | 9 | 40 | 41 | 42 | 13 | 14 | 15 | 22 | 23 | 16 | 17 | 18 | 19 | 20 | 21 | 6 | 25 | 26 | 27 | 28 | 29 | 4 | 5 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 30 | 31 | 24 | 43 | 44 | 45 | 46 | 47 |
L | 36 | 1 | 2 | 3 | 4 | 5 | 34 | 35 | 14 | 15 | 8 | 9 | 10 | 11 | 12 | 13 | 0 | 17 | 18 | 19 | 20 | 21 | 6 | 7 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 46 | 47 | 40 | 37 | 38 | 39 | 16 | 41 | 42 | 43 | 44 | 45 | 22 | 23 |
B | 26 | 27 | 28 | 3 | 4 | 5 | 6 | 7 | 2 | 9 | 10 | 11 | 12 | 13 | 0 | 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 44 | 45 | 46 | 29 | 30 | 31 | 38 | 39 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 43 | 14 | 15 | 8 | 47 |
D | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 36 | 37 | 38 | 15 | 16 | 17 | 18 | 19 | 12 | 13 | 14 | 23 | 24 | 25 | 26 | 27 | 20 | 21 | 22 | 31 | 32 | 33 | 34 | 35 | 28 | 29 | 30 | 39 | 46 | 47 | 40 | 41 | 42 | 43 | 44 | 45 |
We already knew that each permutation only affects 20 items, but there does seem to be quite a bit more structure that we need to explore. Let’s note the following:
- Permutations affect big blocks consecutive elements, and leave other large blocks intact.
- Each row has one big block which is permuted, and several other tinier blocks of size < 4.
- Most blocks (tiny and large) seem to preserve some order.
- Groups of 8 columns seem to behave in a very structured way.
These observations by themselves are not so helpful, but together they make the solution incredibly obvious (with hindsight). What finally made it click was when we used colors to represent each group of 8 columns.
In the following plot, we assigned to each element a color depending on its value divided by 8. We also color in pink the labels of the elements that change position but stay in the same group.
COLORS = {
0: 'lightgrey', # ⬜️
1: 'orange', # 🟧
2: 'green', # 🟩
3: 'red', # 🟥
4: 'blue', # 🟦
5: 'yellow', # 🟨
-1: 'magenta' # 🟪
}
def color_group(val):
return f'background-color: {COLORS[val//BLOCK_SIZE]}'
def color_not_id_in_group(row):
return [f'color: {COLORS[-1]}' if row[i] != i and row[i] // BLOCK_SIZE == i // BLOCK_SIZE else '' for i in range(len(row))]
display(PERMUTATIONS_DF.style\
.set_caption('Permutations visualized with "arbitrary" colors. 🟪 = move to same block')\
.applymap(color_group)\
.apply(color_not_id_in_group,axis=1))
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 6 | 7 | 0 | 1 | 2 | 3 | 4 | 5 | 16 | 17 | 18 | 11 | 12 | 13 | 14 | 15 | 24 | 25 | 26 | 19 | 20 | 21 | 22 | 23 | 32 | 33 | 34 | 27 | 28 | 29 | 30 | 31 | 8 | 9 | 10 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
1 | 0 | 1 | 18 | 19 | 20 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 42 | 43 | 44 | 21 | 22 | 23 | 30 | 31 | 24 | 25 | 26 | 27 | 28 | 29 | 4 | 33 | 34 | 35 | 36 | 37 | 2 | 3 | 40 | 41 | 38 | 39 | 32 | 45 | 46 | 47 |
2 | 0 | 1 | 2 | 3 | 10 | 11 | 12 | 7 | 8 | 9 | 40 | 41 | 42 | 13 | 14 | 15 | 22 | 23 | 16 | 17 | 18 | 19 | 20 | 21 | 6 | 25 | 26 | 27 | 28 | 29 | 4 | 5 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 30 | 31 | 24 | 43 | 44 | 45 | 46 | 47 |
3 | 36 | 1 | 2 | 3 | 4 | 5 | 34 | 35 | 14 | 15 | 8 | 9 | 10 | 11 | 12 | 13 | 0 | 17 | 18 | 19 | 20 | 21 | 6 | 7 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 46 | 47 | 40 | 37 | 38 | 39 | 16 | 41 | 42 | 43 | 44 | 45 | 22 | 23 |
4 | 26 | 27 | 28 | 3 | 4 | 5 | 6 | 7 | 2 | 9 | 10 | 11 | 12 | 13 | 0 | 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 44 | 45 | 46 | 29 | 30 | 31 | 38 | 39 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 43 | 14 | 15 | 8 | 47 |
5 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 36 | 37 | 38 | 15 | 16 | 17 | 18 | 19 | 12 | 13 | 14 | 23 | 24 | 25 | 26 | 27 | 20 | 21 | 22 | 31 | 32 | 33 | 34 | 35 | 28 | 29 | 30 | 39 | 46 | 47 | 40 | 41 | 42 | 43 | 44 | 45 |
Hopefully you should now have some idea what this puzzle is about. If not, try to stare at these colors for a bit, focusing on each separate row.
Hint: something about cubes…
# update the index with somewhat random letters.
MOVES = ["U", "R", "F", "L", "B", "D"]
PERMUTATIONS_DF.index = MOVES
def applyborder(row):
left_borders = [f'border-left: 5px solid white' if i % 3 == 0 else '' for i in range(len(row))]
right_borders = [f'border-right: 5px solid white' if i % 3 == 2 else '' for i in range(len(row))]
return [";".join(x) for x in zip(left_borders, right_borders)]
CUBE_MAP = {
0: (0,0),
1: (0,1),
2: (0,2),
3: (1,2),
4: (2,2),
5: (2,1),
6: (2,0),
7: (1,0),
}
def display_row_as_cubes(row, title):
faces = np.zeros((6,3,3), dtype=np.int8)
# set the center block in each 3x3 face
for i in range(6):
faces[i, 1, 1] = i*8
# map the value to a 3x3 postition in the right face
for i, v in enumerate(row):
color = v
pos = i % 8
c = i //8
x, y = CUBE_MAP[pos]
faces[c,x, y] = color
display(pd.DataFrame(np.hstack(faces)).style\
.hide_index()\
.set_table_styles([
{'selector': 'thead', 'props': [('display', 'none')]},
])\
.set_properties(**{
'font-size': '1pt',
'width': '30px',
'height': '30px',
'color': 'transparent',
'border': '1px solid darkgrey',
'text-align': 'center',
})
.set_caption(title)\
.applymap(color_group)\
.apply(applyborder,axis=1)\
)
for move in MOVES:
display_row_as_cubes(PERMUTATIONS_DF.loc[move],f'Move {move}')
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
6 | 7 | 0 | 16 | 17 | 18 | 24 | 25 | 26 | 32 | 33 | 34 | 8 | 9 | 10 | 40 | 41 | 42 |
5 | 0 | 1 | 15 | 8 | 11 | 23 | 16 | 19 | 31 | 24 | 27 | 39 | 32 | 35 | 47 | 40 | 43 |
4 | 3 | 2 | 14 | 13 | 12 | 22 | 21 | 20 | 30 | 29 | 28 | 38 | 37 | 36 | 46 | 45 | 44 |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 18 | 8 | 9 | 10 | 16 | 17 | 42 | 30 | 31 | 24 | 4 | 33 | 34 | 40 | 41 | 38 |
7 | 0 | 19 | 15 | 8 | 11 | 23 | 16 | 43 | 29 | 24 | 25 | 3 | 32 | 35 | 47 | 40 | 39 |
6 | 5 | 20 | 14 | 13 | 12 | 22 | 21 | 44 | 28 | 27 | 26 | 2 | 37 | 36 | 46 | 45 | 32 |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 8 | 9 | 40 | 22 | 23 | 16 | 6 | 25 | 26 | 32 | 33 | 34 | 30 | 31 | 24 |
7 | 0 | 3 | 15 | 8 | 41 | 21 | 16 | 17 | 5 | 24 | 27 | 39 | 32 | 35 | 47 | 40 | 43 |
12 | 11 | 10 | 14 | 13 | 42 | 20 | 19 | 18 | 4 | 29 | 28 | 38 | 37 | 36 | 46 | 45 | 44 |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
36 | 1 | 2 | 14 | 15 | 8 | 0 | 17 | 18 | 24 | 25 | 26 | 32 | 33 | 46 | 16 | 41 | 42 |
35 | 0 | 3 | 13 | 8 | 9 | 7 | 16 | 19 | 31 | 24 | 27 | 39 | 32 | 47 | 23 | 40 | 43 |
34 | 5 | 4 | 12 | 11 | 10 | 6 | 21 | 20 | 30 | 29 | 28 | 38 | 37 | 40 | 22 | 45 | 44 |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
26 | 27 | 28 | 2 | 9 | 10 | 16 | 17 | 18 | 24 | 25 | 44 | 38 | 39 | 32 | 40 | 41 | 42 |
7 | 0 | 3 | 1 | 8 | 11 | 23 | 16 | 19 | 31 | 24 | 45 | 37 | 32 | 33 | 47 | 40 | 43 |
6 | 5 | 4 | 0 | 13 | 12 | 22 | 21 | 20 | 30 | 29 | 46 | 36 | 35 | 34 | 8 | 15 | 14 |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 8 | 9 | 10 | 16 | 17 | 18 | 24 | 25 | 26 | 32 | 33 | 34 | 46 | 47 | 40 |
7 | 0 | 3 | 15 | 8 | 11 | 23 | 16 | 19 | 31 | 24 | 27 | 39 | 32 | 35 | 45 | 40 | 41 |
6 | 5 | 4 | 38 | 37 | 36 | 14 | 13 | 12 | 22 | 21 | 20 | 30 | 29 | 28 | 44 | 43 | 42 |
Hopefully you figured out that these permutations are 6 of the possible moves that you can perform on a Rubik’s Cube!
This means that the elements of the sol
array define a sequence of moves that we apply on the initial state that will transform it into the final solved state.
Let’s plot both of these states to get a better idea of how we should proceed.
display_row_as_cubes([8*i for i in INITIAL_VALUES], "initial state")
display_row_as_cubes([8*i for i in FINAL_STATE_VALUES], "final state")
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
40 | 0 | 0 | 32 | 8 | 0 | 24 | 32 | 0 | 16 | 16 | 8 | 16 | 32 | 8 | 32 | 32 | 24 |
0 | 0 | 24 | 16 | 8 | 32 | 24 | 16 | 8 | 40 | 24 | 0 | 24 | 32 | 8 | 24 | 40 | 0 |
32 | 8 | 24 | 32 | 40 | 0 | 8 | 40 | 40 | 16 | 16 | 8 | 16 | 16 | 24 | 40 | 40 | 40 |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 8 | 8 | 8 | 16 | 16 | 16 | 24 | 24 | 24 | 32 | 32 | 32 | 40 | 40 | 40 |
0 | 0 | 0 | 8 | 8 | 8 | 16 | 16 | 16 | 24 | 24 | 24 | 32 | 32 | 32 | 40 | 40 | 40 |
0 | 0 | 0 | 8 | 8 | 8 | 16 | 16 | 16 | 24 | 24 | 24 | 32 | 32 | 32 | 40 | 40 | 40 |
We won’t write our own solver, since many great ones already exist. The one we use is rubiks-cube-solver.com which has a nice interface for inputting the faces (the link should take you to a page where the cube is already input).
We get the following 20 move solution from the solver site. Unfortunately, this includes counter-clockwise and double moves, which we can’t perform directly with the given set of moves.
CUBE_SOL_SHORT = [
"L", "U", "L'", "U2", "F'", "D'", "F2", "B'", "U'", "L'",
"F2", "U'", "D'", "L2","U", "B2", "U", "B2", "R2", "L2"]
This little snippet should convert any *“2”*s and *" ’ “*s (counter-clockwise moves) to the appropriate number of repetitions of the move. We also map the move letter to the original matrix row.
This will give us our final solution vector.
MOVES_MAP = { v:i for i, v in enumerate(MOVES)}
def convert_moves(m: str) -> list[str]:
if len(m) == 2:
if m[1] == "'":
return 3*[m[0]]
if m[1] == "2":
return 2*[m[0]]
else:
return [m]
SOL = list(map(lambda x: MOVES_MAP[x],(itertools.chain(*map(convert_moves, CUBE_SOL_SHORT)))))
print("sol:")
print(SOL)
sol:
[3, 0, 3, 3, 3, 0, 0, 2, 2, 2, 5, 5, 5, 2, 2, 4, 4, 4, 0, 0, 0, 3, 3, 3, 2, 2, 0, 0, 0, 5, 5, 5, 3, 3, 0, 4, 4, 0, 4, 4, 1, 1, 3, 3]
Finally, we can check that our solution works as intended with our Python translations of the original Cairo functions.
state = { i:v for i,v in enumerate(INITIAL_VALUES)}
run(state, DATA, SOL)
solved_state = [x for _, x in state.items()]
assert solved_state == list(FINAL_STATE_VALUES), "state is not solved"
display_row_as_cubes([8*i for i in solved_state], "solved state")
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 8 | 8 | 8 | 16 | 16 | 16 | 24 | 24 | 24 | 32 | 32 | 32 | 40 | 40 | 40 |
0 | 0 | 0 | 8 | 8 | 8 | 16 | 16 | 16 | 24 | 24 | 24 | 32 | 32 | 32 | 40 | 40 | 40 |
0 | 0 | 0 | 8 | 8 | 8 | 16 | 16 | 16 | 24 | 24 | 24 | 32 | 32 | 32 | 40 | 40 | 40 |
Success! Our moves can correctly solve the this intial cube.
3. Writing the hints
The last, final step is to write the appropriate solutions we found along the way as Cairo hints.
3.1 get_initial_value()
The hint we provide here is simply the initial_value
list we found previously.
We need to write the 49 elements (including the initial 48) in the initial_value
array.
func get_initial_value{hash_ptr : HashBuiltin*}() -> (initial_value : felt*):
alloc_locals
let (local initial_value : felt*) = alloc()
%{
initial_value = [48,
5, 0, 0, 3, 3, 1, 4, 0,
4, 1, 0, 4, 0, 5, 4, 2,
3, 4, 0, 1, 5, 5, 1, 3,
2, 2, 1, 0, 1, 2, 2, 5,
2, 4, 1, 1, 3, 2, 2, 3,
4, 4, 3, 0, 5, 5, 5, 3
]
segments.write_arg(ids.initial_value, initial_value)
%}
# ...
return (initial_value=initial_value + 1)
end
3.2 main()
The run
function expects an array sol
and its length sol_size
, which are easy to set using segements.write_arg
.
We also need to initialize the state
with all the state updates we performed along the way.
This took some time to figure out, but in the end I resorted to re-running the run
function and saving the 3-tuple of DictAccess values in a single long array.
This is a bit tedious, and there are most likely better ways to do this, but it works!
func main{output_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}():
alloc_locals
let (local data : felt*) = get_data()
let (local sol : felt*) = alloc()
local sol_size
let (state : DictAccess*) = alloc()
local state_start : DictAccess* = state
%{
FINAL_SOL = [
3, 0, 3, 3, 3, 0, 0, 2,
2, 2, 5, 5, 5, 2, 2, 4,
4, 4, 0, 0, 0, 3, 3, 3,
2, 2, 0, 0, 0, 5, 5, 5,
3, 3, 0, 4, 4, 0, 4, 4,
1, 1, 3, 3]
segments.write_arg(ids.sol, FINAL_SOL)
ids.sol_size = len(FINAL_SOL)
IV = [
5, 0, 0, 3, 3, 1, 4, 0,
4, 1, 0, 4, 0, 5, 4, 2,
3, 4, 0, 1, 5, 5, 1, 3,
2, 2, 1, 0, 1, 2, 2, 5,
2, 4, 1, 1, 3, 2, 2, 3,
4, 4, 3, 0, 5, 5, 5, 3
]
DATA = [
0, 2, 4, 6, 1, 3, 5, 7, 8, 32, 24, 16, 9, 33, 25, 17, 10, 34, 26, 18, 24, 26, 28, 30, 25, 27, 29, 31, 4, 32, 44,
20, 3, 39, 43, 19, 2, 38, 42, 18, 16, 18, 20, 22, 17, 19, 21, 23, 6, 24, 42, 12, 5, 31, 41, 11, 4, 30, 40, 10,
8, 10, 12, 14, 9, 11, 13, 15, 0, 16, 40, 36, 7, 23, 47, 35, 6, 22, 46, 34, 32, 34, 36, 38, 33, 35, 37, 39, 2, 8,
46, 28, 1, 15, 45, 27, 0, 14, 44, 26, 40, 42, 44, 46, 41, 43, 45, 47, 22, 30, 38, 14, 21, 29, 37, 13, 20, 28, 36, 12
]
# run iterates over all elements in the solution, and
def run(sol):
state = IV.copy()
da = []
for s in sol:
for i in range(5):
key0, key1, key2, key3 = DATA[20*s + 4*i:20*s + 4*i + 4]
da += [key0, state[key0], state[key3]]
da += [key1, state[key1], state[key0]]
da += [key2, state[key2], state[key1]]
da += [key3, state[key3], state[key2]]
state[key0], state[key1], state[key2], state[key3] = state[key3], state[key0], state[key1], state[key2]
return da
dict_access = run(FINAL_SOL)
segments.write_arg(ids.state.address_, dict_access)
memory[ids.output_ptr] = 0x0BADBEEF
%}
# ...
let output_ptr = output_ptr + 1
return ()
end
If you want to try this yourself, head over to the Cairo Playground and select the Amun puzzle.
Fill in the appropriate hints (and set 0x0BADBEEF
to your mainnet Etherium address) and watch everything compile!
4. Conclusion
This puzzle was an incredibly fun challenge to solve, but unfortunately I was just a bit slower that William Borgeaud who solved it a full 4 hours earlier, so hats off to you William!
Overall it was a fun to get a better understanding of Cairo, and I’ll be looking forward to doing more with it than solve puzzles. It felt like there’s a lot of potential in the language, but it definitely takes some getting used to. Hopefully STARKWARE will do another round of the Cairo Games soon!
P.S: Thanks to Taurus for supporting this work!