Breaking open a propAMM

Renato Marziano Robert Chen Renato Marziano, Robert Chen #solana#reverse-engineering

At Accelerate 2025, we talked about making Solana programs open source. A year later, agents have taken over the world, so we wanted to see how far we could push things by applying our techniques to the historically secretive world of propAMMs.

Breaking open a propAMM Breaking open a propAMM

Abstract

To our surprise, we successfully used our internal tooling to decompile GoonFi V2 into Rust source code that produces the exact same binary as the on-chain program, effectively making it open source.

With that open source code, we were able to trivially find a critical vulnerability that put $6.1M at risk, which was responsibly disclosed to the team.

In 2026, no program is closed source

A wise man once said:

Reverse engineering, then and now

Turns out, that in 2026, you don’t even need to be good at reverse engineering, and everything is open source by default. Don’t believe in this utopia? Keep reading.

Reverse engineering might be solved

We already showed how AI has become quite powerful at reverse engineering when given the right tools.

Auto reverse-engineering the Hyperliquid risk engine, with some agentic help Renato Marziano

The boost we observe by integrating new tooling and AI is wider for binaries, like any Rust release binary, presenting higher level constructs in the source code and code generation and optimization that differs from traditional C compilers, since the output of standard decompilers is often worse in those cases.

One question came naturally to us during the latest language model release cycle: is reverse engineering actually solved? How far can we push these models?

The holy grail of reverse engineering

Models have long been able to explain a binary’s logic in words, and this has mostly been the definition of “doing reverse engineering” since the inception of the word.

So, what if we raise the bar and we give the models an exact stopping condition to deem the decompilation of a binary as “solved”?

A natural answer is to keep going until we find a combination of toolchains, dependencies and source code1 that produce the exact binary we’re decompiling as an output.2

Re-compilation, if you will.

Sounds easy right?

The game and the player

With an initial definition for this game, all that is left to do is to build the perfect player.

To prove that this is possible, let’s showcase the result for our final target, GoonFi V2, which you can visualize in our Solana diff viewer over at ibrl.golf3. You can check out the mapping and decompilation of every function by clicking on the widget below:

Every function in the program, sized by bytes. Select one to open its decompilation on ibrl.golf.

And this all began as this messy binary blob down here, as well as the contract IDL and on-chain transactions.

goon2.so (.text, 219,872 bytes, 26,432 instructions)
120: mov64 r0, r2
128: ldxdw r2, [r1 + 0x10]
130: ldxdw r3, [r1 + 0x0]
138: jeq r3, 0x0, +0x8 <.text+0x60>
140: ldxdw r4, [r1 + 0x8]
148: jeq r3, r4, +0x6 <.text+0x60>
150: sub64 r4, r3
158: rsh64 r4, 0x3
160: ja +0x9 <.text+0x90>
168: add64 r3, 0x8
170: add64 r4, -0x1
178: jne r4, 0x0, +0x6 <.text+0x90>
180: jeq r2, 0x0, +0xc <.text+0xc8>
188: ldxdw r1, [r1 + 0x18]
190: jeq r2, r1, +0xa <.text+0xc8>
198: sub64 r1, r2
1a0: rsh64 r1, 0x3
1a8: ja +0x8 <.text+0xd0>
1b0: ldxdw r5, [r3 + 0x0]
1b8: jgt r0, r5, -0xb <.text+0x48>
1c0: mov64 r0, r5
1c8: ja -0xd <.text+0x48>
1d0: add64 r2, 0x8
1d8: add64 r1, -0x1
1e0: jne r1, 0x0, +0x1 <.text+0xd0>
1e8: exit
1f0: ldxdw r3, [r2 + 0x0]
1f8: jgt r0, r3, -0x6 <.text+0xb0>
200: mov64 r0, r3
208: ja -0x8 <.text+0xb0>
210: mov64 r6, r1
218: jeq r3, 0x0, +0x30 <.text+0x280>
220: ldxb r4, [r2 + 0x0]
228: stxb [r10 - 0x59], r4
230: jlt r4, 0x2, +0x18 <.text+0x1d8>
238: lddw r1, 0x37a18
248: stxdw [r10 - 0x40], r1
250: mov64 r1, r10
258: add64 r1, -0x10
260: stxdw [r10 - 0x30], r1
268: lddw r1, 0x32558
278: stxdw [r10 - 0x8], r1
280: mov64 r1, r10
288: add64 r1, -0x59
290: stxdw [r10 - 0x10], r1
298: stdw [r10 - 0x20], 0x0
2a0: stdw [r10 - 0x38], 0x1
2a8: stdw [r10 - 0x28], 0x1
2b0: mov64 r7, r10
2b8: add64 r7, -0x58
2c0: mov64 r2, r10
2c8: add64 r2, -0x40
2d0: mov64 r1, r7
2d8: call 0x5bca
2e0: mov64 r1, r7
2e8: call 0x1a1
2f0: ja +0x18 <.text+0x298>
2f8: jlt r3, 0x9, +0x14 <.text+0x280>
300: mov64 r1, r3
308: add64 r1, -0x9
310: jlt r1, 0x8, +0x11 <.text+0x280>
318: mov64 r1, 0x0
320: ldxdw r0, [r2 + 0x9]
328: ldxdw r5, [r2 + 0x1]
330: jeq r3, 0x11, +0x5 <.text+0x240>
338: ldxb r2, [r2 + 0x11]
340: stxb [r10 - 0x59], r2
348: jeq r2, 0x0, +0x2 <.text+0x240>
350: jne r2, 0x1, +0x13 <.text+0x2d0>
358: mov64 r1, 0x1
360: mov64 r2, r5
368: rsh64 r2, 0x20
370: stxb [r6 + 0x10], r4
378: stxdw [r6 + 0x8], r0
380: stxw [r6 + 0x4], r2
388: stxw [r6 + 0x0], r5
390: mov64 r2, 0x18
398: ja +0x2c <.text+0x3e0>
3a0: lddw r1, 0x379e8
3b0: call 0x5a50
3b8: mov64 r1, r0
3c0: and64 r1, 0x3
3c8: jne r1, 0x1, +0x23 <.text+0x3c8>
3d0: ldxdw r1, [r0 + 0x7]
3d8: ldxdw r2, [r1 + 0x0]
3e0: jeq r2, 0x0, +0x20 <.text+0x3c8>
3e8: ja +0x1d <.text+0x3b8>
3f0: lddw r1, 0x37a18
400: stxdw [r10 - 0x40], r1
408: mov64 r1, r10
410: add64 r1, -0x10
418: stxdw [r10 - 0x30], r1
420: lddw r1, 0x32558
430: stxdw [r10 - 0x8], r1
438: mov64 r1, r10
440: add64 r1, -0x59
448: stxdw [r10 - 0x10], r1
450: stdw [r10 - 0x20], 0x0
458: stdw [r10 - 0x38], 0x1
460: stdw [r10 - 0x28], 0x1
468: mov64 r7, r10
470: add64 r7, -0x58
478: mov64 r2, r10
480: add64 r2, -0x40
488: mov64 r1, r7
490: call 0x5b93
498: mov64 r1, r7
4a0: call 0x16a
4a8: mov64 r1, r0
4b0: and64 r1, 0x3
4b8: jne r1, 0x1, +0x5 <.text+0x3c8>
4c0: ldxdw r1, [r0 + 0x7]
4c8: ldxdw r2, [r1 + 0x0]
4d0: jeq r2, 0x0, +0x2 <.text+0x3c8>
4d8: ldxdw r1, [r0 - 0x1]
4e0: callx r2
4e8: stw [r6 + 0x0], 0x2
4f0: mov64 r1, 0x2
4f8: mov64 r2, 0x10
500: add64 r6, r2
508: stxb [r6 + 0x0], r1
510: exit
518: mov64 r6, r1
520: ldxdw r1, [r6 + 0xc8]
528: jeq r1, 0xc8, +0x1c <.text+0x4f0>
530: jgt r1, 0xc8, +0x1e <.text+0x508>
538: mov64 r8, r6
540: add64 r8, r1
548: mov64 r9, 0xc8
550: sub64 r9, r1
558: mov64 r7, r9
560: jlt r9, r3, +0x1 <.text+0x450>
568: mov64 r7, r3
570: mov64 r1, r8
578: stxdw [r10 - 0x8], r3
580: mov64 r3, r7
588: call 0x6540
590: ldxdw r1, [r10 - 0x8]
598: jge r9, r1, +0x2 <.text+0x490>
5a0: add64 r8, r7
5a8: stb [r8 - 0x1], 0x40
5b0: ldxdw r3, [r6 + 0xc8]
5b8: mov64 r1, r3
5c0: add64 r1, r7
5c8: mov64 r2, 0x1
5d0: jlt r1, r3, +0x1 <.text+0x4c0>
5d8: mov64 r2, 0x0
5e0: and64 r2, 0x1
5e8: jne r2, 0x0, +0xb <.text+0x528>
5f0: stxdw [r6 + 0xc8], r1
5f8: jgt r1, 0xc8, +0x1 <.text+0x4e8>
600: ja +0x2 <.text+0x4f8>
608: stdw [r6 + 0xc8], 0xc8
610: stb [r6 + 0xc7], 0x40
618: mov64 r0, r6
620: exit
628: mov64 r2, 0xc8
630: lddw r3, 0x379a0
640: call 0x60a1
648: lddw r1, 0x37988
658: call 0x635a
660: mov64 r6, r1
668: jeq r3, 0x163, +0x1 <.text+0x558>
670: jgt r3, 0x51, +0x3 <.text+0x570>
678: lddw r1, -0x7ffffffffffffffd
688: ja +0x34 <.text+0x710>
690: stxdw [r10 - 0x148], r3
698: mov64 r1, r10
6a0: add64 r1, -0x58
6a8: stxdw [r10 - 0x150], r2
6b0: mov64 r3, 0x52
6b8: call 0x5894
6c0: ldxw r9, [r10 - 0x58]
6c8: jeq r9, 0x2, +0x21 <.text+0x6b8>
6d0: ldxdw r1, [r10 - 0x48]
6d8: stxdw [r10 - 0x68], r1
6e0: ldxdw r1, [r10 - 0x40]
6e8: stxdw [r10 - 0x60], r1
6f0: ldxdw r1, [r10 - 0x38]
6f8: stxdw [r10 - 0x90], r1
700: ldxdw r1, [r10 - 0x30]
708: stxdw [r10 - 0x88], r1
710: ldxb r1, [r10 - 0x28]
718: stxb [r10 - 0x80], r1
720: ldxdw r1, [r10 - 0x26]
728: stxdw [r10 - 0xb8], r1
730: ldxdw r1, [r10 - 0x1e]
738: stxdw [r10 - 0xb0], r1
740: ldxdw r1, [r10 - 0x16]
748: stxdw [r10 - 0xa8], r1
750: ldxdw r1, [r10 - 0xe]
758: stxdw [r10 - 0xa0], r1
760: ldxdw r1, [r10 - 0x8]
768: stxdw [r10 - 0x9a], r1
770: ldxdw r4, [r10 - 0x50]
778: ldxw r5, [r10 - 0x54]
780: ldxb r7, [r10 - 0x27]
788: ldxdw r1, [r10 - 0x68]
790: stxdw [r10 - 0x78], r1
798: ldxdw r1, [r10 - 0x60]
7a0: stxdw [r10 - 0x70], r1
7a8: mov64 r1, r7
7b0: and64 r1, 0x1
7b8: jne r1, 0x0, +0x11 <.text+0x728>
7c0: lddw r1, -0x7ffffffffffffff7
7d0: ja +0x5 <.text+0x6e0>
7d8: ldxdw r1, [r10 - 0x48]
7e0: stxdw [r10 - 0xe0], r1
7e8: ldxdw r1, [r10 - 0x40]
7f0: stxdw [r10 - 0xd8], r1
7f8: ldxdw r1, [r10 - 0x50]
800: ldxdw r2, [r10 - 0xd8]
808: stxdw [r10 - 0xe8], r2
810: ldxdw r3, [r10 - 0xe0]
818: stxdw [r10 - 0xf0], r3
820: stxdw [r6 + 0x18], r2
828: stxdw [r6 + 0x10], r3
830: stxdw [r6 + 0x8], r1
838: stw [r6 + 0x0], 0x2
840: exit
848: ldxdw r1, [r10 - 0x148]
850: add64 r1, -0x52
858: ldxdw r2, [r10 - 0x70]
860: stxdw [r10 - 0xd8], r2
868: ldxdw r2, [r10 - 0x78]
870: stxdw [r10 - 0xe0], r2
878: ldxdw r2, [r10 - 0x90]
880: stxdw [r10 - 0xd0], r2
888: ldxdw r2, [r10 - 0x88]
890: stxdw [r10 - 0xc8], r2
898: ldxb r2, [r10 - 0x80]
8a0: stxb [r10 - 0xc0], r2
8a8: ldxdw r2, [r10 - 0xb8]
8b0: stxdw [r10 - 0x140], r2
8b8: ldxdw r2, [r10 - 0xb0]
8c0: stxdw [r10 - 0x138], r2
8c8: ldxdw r2, [r10 - 0xa8]
8d0: stxdw [r10 - 0x130], r2
8d8: ldxdw r2, [r10 - 0xa0]
8e0: stxdw [r10 - 0x128], r2
8e8: ldxdw r2, [r10 - 0x9a]
8f0: stxdw [r10 - 0x122], r2
8f8: ldxb r2, [r10 - 0xc0]
900: stxb [r10 - 0x108], r2
908: ldxdw r2, [r10 - 0xc8]
910: stxdw [r10 - 0x110], r2
918: ldxdw r2, [r10 - 0xd0]
920: stxdw [r10 - 0x118], r2
928: ldxdw r2, [r10 - 0xd8]
930: stxdw [r10 - 0xe8], r2
938: stxdw [r10 - 0xf8], r2
940: ldxdw r2, [r10 - 0xe0]
948: stxdw [r10 - 0xf0], r2
950: stxdw [r10 - 0x100], r2
958: mov64 r3, 0x0
960: mov64 r2, 0x1
968: jeq r1, 0x0, +0x35 <.text+0x9f8>
970: jlt r1, 0x55, +0x4f <.text+0xad0>
978: stxdw [r10 - 0x158], r4
980: lddw r1, 0x300000000
990: ldxdw r1, [r1 + 0x0]
998: mov64 r3, r1
9a0: add64 r3, -0x53
9a8: mov64 r2, 0x0
9b0: mov64 r4, 0x1
9b8: jgt r3, r1, +0x1 <.text+0x8a8>
9c0: mov64 r4, 0x0
9c8: stxdw [r10 - 0x168], r7
9d0: ldxdw r8, [r10 - 0x150]
9d8: jne r4, 0x0, +0x1 <.text+0x8c8>
9e0: mov64 r2, r3
9e8: lddw r7, 0x300007fad
9f8: jeq r1, 0x0, +0x1 <.text+0x8e8>
a00: mov64 r7, r2
a08: stxdw [r10 - 0x160], r5
a10: lddw r1, 0x300000007
a20: jgt r7, r1, +0x5 <.text+0x930>
a28: mov64 r1, 0x1
a30: mov64 r2, 0x53
a38: lddw r3, 0x379b8
a48: call 0x5ac5
a50: add64 r8, 0x52
a58: lddw r1, 0x300000000
a68: stxdw [r1 + 0x0], r7
a70: mov64 r1, r7
a78: mov64 r2, 0x0
a80: mov64 r3, 0x53
a88: call 0x64a4
a90: mov64 r1, r8
a98: mov64 r2, r7
aa0: mov64 r3, 0x53
aa8: call 0x64a5
ab0: lsh64 r0, 0x20
ab8: rsh64 r0, 0x20
ac0: ldxdw r3, [r10 - 0x148]
ac8: ldxdw r4, [r10 - 0x158]
ad0: ldxdw r5, [r10 - 0x160]
ad8: ldxdw r7, [r10 - 0x168]
ae0: jne r0, 0x0, +0x21 <.text+0xad0>
ae8: ldxdw r1, [r10 - 0x150]
af0: ldxb r1, [r1 + 0xa5]
af8: jne r1, 0x1, +0x1e <.text+0xad0>
b00: ldxdw r2, [r10 - 0x150]
b08: add64 r2, 0xa6
b10: add64 r3, -0xa6
b18: ldxdw r1, [r10 - 0xf8]
b20: stxdw [r6 + 0x18], r1
b28: ldxdw r1, [r10 - 0x100]
b30: stxdw [r6 + 0x10], r1
b38: ldxdw r1, [r10 - 0x118]
b40: stxdw [r6 + 0x20], r1
b48: ldxdw r1, [r10 - 0x110]
b50: stxdw [r6 + 0x28], r1
b58: ldxb r1, [r10 - 0x108]
b60: stxb [r6 + 0x30], r1
b68: ldxdw r1, [r10 - 0x128]
b70: stxdw [r6 + 0x4a], r1
b78: ldxdw r1, [r10 - 0x130]
b80: stxdw [r6 + 0x42], r1
b88: ldxdw r1, [r10 - 0x138]
b90: stxdw [r6 + 0x3a], r1
b98: ldxdw r1, [r10 - 0x140]
ba0: stxdw [r6 + 0x32], r1
ba8: ldxdw r1, [r10 - 0x122]
bb0: stxw [r6 + 0x0], r9
bb8: stxw [r6 + 0x4], r5
bc0: stxb [r6 + 0x31], r7
bc8: stxdw [r6 + 0x60], r3
bd0: stxdw [r6 + 0x58], r2
bd8: stxdw [r6 + 0x8], r4
be0: stxdw [r6 + 0x50], r1
be8: ja -0x76 <.text+0x720>
bf0: lddw r1, -0x7ffffffffffffffd
c00: stxdw [r6 + 0x8], r1
c08: stdw [r6 + 0x18], 0x54
c10: stdw [r6 + 0x10], 0x53
c18: ja -0x7d <.text+0x718>
c20: ldxdw r3, [r2 + 0x0]
c28: ldxdw r4, [r3 + 0x50]
c30: jne r4, 0x52, +0x13 <.text+0xbb0>
c38: ldxdw r4, [r3 + 0x28]
c40: lddw r5, -0x6c5e9a281e0922fa
c50: jne r4, r5, +0xc <.text+0xb98>
c58: ldxdw r4, [r3 + 0x30]
c60: lddw r5, -0x53861431b91e3427
c70: jne r4, r5, +0x8 <.text+0xb98>
c78: ldxdw r4, [r3 + 0x38]
c80: lddw r5, -0x6ec8a4a0127a4be4
c90: jne r4, r5, +0x4 <.text+0xb98>
c98: ldxdw r3, [r3 + 0x40]
ca0: lddw r4, -0x56ff00817a0a73c6
cb0: jeq r3, r4, +0x6 <.text+0xbc8>
cb8: stdw [r1 + 0x0], 0x0
cc0: stw [r1 + 0x8], 0x16
cc8: ja +0x2 <.text+0xbc0>
cd0: stdw [r1 + 0x0], 0x0
cd8: stw [r1 + 0x8], 0x3
ce0: exit
ce8: mov64 r6, r1
cf0: mov64 r1, r10
cf8: add64 r1, -0x20
d00: call 0x5963
d08: ldxdw r1, [r10 - 0x20]
d10: jeq r1, 0x0, +0x6 <.text+0xc28>
d18: ldxdw r2, [r10 - 0x10]
d20: ldxb r3, [r10 - 0x8]
d28: stxb [r6 + 0x10], r3
d30: stxdw [r6 + 0x8], r2
d38: stxdw [r6 + 0x0], r1
d40: ja -0xd <.text+0xbc0>
d48: ldxdw r1, [r10 - 0x18]
d50: stxdw [r6 + 0x8], r1
d58: stdw [r6 + 0x0], 0x0
d60: ja -0x11 <.text+0xbc0>
d68: ldxdw r3, [r2 + 0x0]
d70: ldxdw r4, [r3 + 0x50]
d78: jne r4, 0xa5, +0x10 <.text+0xce0>
d80: ldxdw r4, [r3 + 0x28]
d88: lddw r5, -0x6c5e9a281e0922fa
d98: jne r4, r5, +0xc <.text+0xce0>
da0: ldxdw r4, [r3 + 0x30]
da8: lddw r5, -0x53861431b91e3427
db8: jne r4, r5, +0x8 <.text+0xce0>
dc0: ldxdw r4, [r3 + 0x38]
dc8: lddw r5, -0x6ec8a4a0127a4be4
dd8: jne r4, r5, +0x4 <.text+0xce0>
de0: ldxdw r3, [r3 + 0x40]
de8: lddw r4, -0x56ff00817a0a73c6
df8: jeq r3, r4, +0x3 <.text+0xcf8>
e00: stdw [r1 + 0x0], 0x0
e08: stw [r1 + 0x8], 0x3
e10: exit
e18: mov64 r6, r1
e20: mov64 r1, r10
e28: add64 r1, -0x20
e30: call 0x593d
e38: ldxdw r1, [r10 - 0x20]
e40: jeq r1, 0x0, +0x6 <.text+0xd58>
e48: ldxdw r2, [r10 - 0x10]
e50: ldxb r3, [r10 - 0x8]
e58: stxb [r6 + 0x10], r3
e60: stxdw [r6 + 0x8], r2
e68: stxdw [r6 + 0x0], r1
e70: ja -0xd <.text+0xcf0>
e78: ldxdw r1, [r10 - 0x18]
e80: stxdw [r6 + 0x8], r1
e88: stdw [r6 + 0x0], 0x0
e90: ja -0x11 <.text+0xcf0>
e98: ldxdw r3, [r2 + 0x0]
ea0: ldxdw r4, [r3 + 0x50]
ea8: jlt r4, 0xa5, +0x10 <.text+0xe10>
eb0: ldxdw r4, [r3 + 0x28]
eb8: lddw r5, -0x21708a111e0922fa
ec8: jne r4, r5, +0xc <.text+0xe10>
ed0: ldxdw r4, [r3 + 0x30]
ed8: lddw r5, -0x2532931b43a2bde8
ee8: jne r4, r5, +0x8 <.text+0xe10>
ef0: ldxdw r4, [r3 + 0x38]
ef8: lddw r5, 0x270db9834dfc1ab6
f08: jne r4, r5, +0x4 <.text+0xe10>
f10: ldxdw r3, [r3 + 0x40]
f18: lddw r4, -0x3745e27d7064202
f28: jeq r3, r4, +0x3 <.text+0xe28>
f30: stdw [r1 + 0x0], 0x0
f38: stw [r1 + 0x8], 0x3
f40: exit
f48: mov64 r6, r1
f50: mov64 r1, r10
f58: add64 r1, -0x20
f60: call 0x5917
f68: ldxdw r1, [r10 - 0x20]
f70: jeq r1, 0x0, +0xc <.text+0xeb8>
f78: ldxdw r2, [r10 - 0x18]
f80: jgt r2, 0xa4, +0x4 <.text+0xe88>
f88: mov64 r1, 0xa5
f90: lddw r3, 0x379d0
fa0: call 0x5f76
fa8: ldxb r2, [r10 - 0x8]
fb0: ldxdw r3, [r10 - 0x10]
fb8: stxb [r6 + 0x10], r2
fc0: stxdw [r6 + 0x8], r3
fc8: stxdw [r6 + 0x0], r1
fd0: ja -0x13 <.text+0xe20>
fd8: ldxdw r1, [r10 - 0x18]
fe0: stxdw [r6 + 0x8], r1
fe8: stdw [r6 + 0x0], 0x0
ff0: ja -0x17 <.text+0xe20>
ff8: lddw r2, 0x300000000
1008: ldxdw r3, [r2 + 0x0]
1010: mov64 r2, r3
1018: add64 r2, -0x18
1020: mov64 r4, 0x0
1028: mov64 r5, 0x1
1030: jgt r2, r3, +0x1 <.text+0xf20>
1038: mov64 r5, 0x0
1040: jne r5, 0x0, +0x1 <.text+0xf30>
1048: mov64 r4, r2
1050: lddw r2, 0x300007fe8
1060: jeq r3, 0x0, +0x2 <.text+0xf58>
1068: and64 r4, -0x8
1070: mov64 r2, r4
1078: lddw r3, 0x300000007
1088: jgt r2, r3, +0x3 <.text+0xf88>
1090: mov64 r1, 0x8
1098: mov64 r2, 0x18
10a0: call 0x59fe
10a8: lddw r3, 0x300000000
10b8: stxdw [r3 + 0x0], r2
10c0: ldxdw r3, [r1 + 0x10]
10c8: stxdw [r2 + 0x10], r3
10d0: ldxdw r3, [r1 + 0x8]
10d8: stxdw [r2 + 0x8], r3
10e0: ldxdw r1, [r1 + 0x0]
10e8: stxdw [r2 + 0x0], r1
10f0: mov64 r1, 0x14
10f8: lddw r3, 0x37a48
1108: call 0x591c
1110: exit
1118: lddw r1, 0x300000000
1128: ldxdw r2, [r1 + 0x0]
1130: mov64 r1, r2
1138: add64 r1, -0x12
1140: mov64 r3, 0x0
1148: mov64 r4, 0x1
1150: jgt r1, r2, +0x1 <.text+0x1040>
1158: mov64 r4, 0x0
1160: jne r4, 0x0, +0x1 <.text+0x1050>
1168: mov64 r3, r1
1170: lddw r1, 0x300007fee
1180: jeq r2, 0x0, +0x1 <.text+0x1070>
1188: mov64 r1, r3
1190: lddw r2, 0x300000007
11a0: jgt r1, r2, +0x5 <.text+0x10b0>
11a8: mov64 r1, 0x1
11b0: mov64 r2, 0x12
11b8: lddw r3, 0x37a00
11c8: call 0x59d5
11d0: lddw r2, 0x300000000
11e0: stxdw [r2 + 0x0], r1
11e8: lddw r3, 0x6572207365747962
11f8: stxdw [r1 + 0x8], r3
1200: lddw r3, 0x206c6c6120746f4e
1210: stxdw [r1 + 0x0], r3
1218: sth [r1 + 0x10], 0x6461
1220: ldxdw r3, [r2 + 0x0]
1228: mov64 r2, r3
1230: add64 r2, -0x18
1238: mov64 r4, 0x0
1240: mov64 r5, 0x1
1248: jgt r2, r3, +0x1 <.text+0x1138>
1250: mov64 r5, 0x0
1258: jne r5, 0x0, +0x1 <.text+0x1148>
1260: mov64 r4, r2
1268: lddw r2, 0x300007fe8
1278: jeq r3, 0x0, +0x2 <.text+0x1170>
1280: and64 r4, -0x8
1288: mov64 r2, r4
1290: lddw r3, 0x300000007
12a0: jgt r2, r3, +0x3 <.text+0x11a0>
12a8: mov64 r1, 0x8
12b0: mov64 r2, 0x18
12b8: call 0x59bb
12c0: lddw r3, 0x300000000
12d0: stxdw [r3 + 0x0], r2
12d8: stxdw [r2 + 0x8], r1
12e0: stdw [r2 + 0x10], 0x12
12e8: stdw [r2 + 0x0], 0x12
12f0: mov64 r1, 0x15
12f8: lddw r3, 0x37a48
1308: call 0x58dc
1310: exit
1318: mov64 r3, r2
1320: ldxdw r2, [r1 + 0x8]
1328: ldxdw r1, [r1 + 0x0]
1330: call 0x5eaa
1338: exit
1340: mov64 r7, r2
1348: mov64 r6, r1
1350: mov64 r1, r7
1358: lsh64 r1, 0x20
1360: rsh64 r1, 0x20
1368: jeq r1, 0x0, +0x89 <.text+0x1698>
1370: mov64 r3, 0x0
1378: mov64 r2, 0x1
1380: mov64 r1, r7
1388: and64 r1, 0x1
1390: jeq r1, 0x0, +0x9 <.text+0x12c0>
1398: mov64 r2, 0xa
13a0: mov64 r1, r7
13a8: lsh64 r1, 0x20
13b0: rsh64 r1, 0x20
13b8: jeq r1, 0x1, +0x1 <.text+0x12a8>
13c0: ja +0x3 <.text+0x12c0>
13c8: stxdw [r6 + 0x8], r2
13d0: stxdw [r6 + 0x10], r3
13d8: ja +0x7d <.text+0x16a8>
13e0: mov64 r1, r7
13e8: and64 r1, 0x2
13f0: jeq r1, 0x0, +0xc <.text+0x1338>
13f8: mov64 r1, r10
1400: add64 r1, -0x10
1408: mov64 r4, 0x64
1410: mov64 r5, 0x0
1418: call 0x68f1
1420: lddw r1, 0xfffffffe
1430: mov64 r4, r7
1438: and64 r4, r1
1440: ldxdw r3, [r10 - 0x8]
1448: ldxdw r2, [r10 - 0x10]
1450: jeq r4, 0x2, -0x12 <.text+0x12a8>
1458: mov64 r1, r7
1460: and64 r1, 0x4
1468: jeq r1, 0x0, +0xc <.text+0x13b0>
1470: mov64 r1, r10
1478: add64 r1, -0x20
1480: mov64 r4, 0x2710
1488: mov64 r5, 0x0
1490: call 0x68e2
1498: lddw r1, 0xfffffffc
14a8: mov64 r4, r7
14b0: and64 r4, r1
14b8: ldxdw r3, [r10 - 0x18]
14c0: ldxdw r2, [r10 - 0x20]
14c8: jeq r4, 0x4, -0x21 <.text+0x12a8>
14d0: mov64 r1, r7
14d8: and64 r1, 0x8
14e0: jeq r1, 0x0, +0xc <.text+0x1428>
14e8: mov64 r1, r10
14f0: add64 r1, -0x30
14f8: mov64 r4, 0x5f5e100
1500: mov64 r5, 0x0
1508: call 0x68d3
1510: lddw r1, 0xfffffff8
1520: mov64 r4, r7
1528: and64 r4, r1
1530: ldxdw r3, [r10 - 0x28]
1538: ldxdw r2, [r10 - 0x30]
1540: jeq r4, 0x8, -0x30 <.text+0x12a8>
1548: mov64 r1, r7
1550: and64 r1, 0x10
1558: jeq r1, 0x0, +0xd <.text+0x14a8>
1560: mov64 r1, r10
1568: add64 r1, -0x40
1570: lddw r4, 0x2386f26fc10000
1580: mov64 r5, 0x0
1588: call 0x68c3
1590: lddw r1, 0xfffffff0
15a0: mov64 r4, r7
15a8: and64 r4, r1
15b0: ldxdw r3, [r10 - 0x38]
15b8: ldxdw r2, [r10 - 0x40]
15c0: jeq r4, 0x10, -0x40 <.text+0x12a8>
15c8: mov64 r1, r7
15d0: and64 r1, 0x20
15d8: jeq r1, 0x0, +0x39 <.text+0x1688>
15e0: mov64 r1, r10
15e8: add64 r1, -0x70
15f0: mov64 r8, r2
15f8: mov64 r9, r3
1600: mov64 r3, 0x0
1608: lddw r4, 0x4ee2d6d415b
1618: mov64 r5, 0x0
1620: call 0x68b0
1628: mov64 r1, r10
1630: add64 r1, -0x60
1638: stxdw [r10 - 0x78], r9
1640: mov64 r2, r9
1648: mov64 r3, 0x0
1650: lddw r4, -0x7a53107f00000000
1660: mov64 r5, 0x0
1668: call 0x68a7
1670: mov64 r1, r10
1678: add64 r1, -0x50
1680: mov64 r2, r8
1688: mov64 r3, 0x0
1690: lddw r4, -0x7a53107f00000000
16a0: mov64 r5, 0x0
16a8: call 0x689f
16b0: mov64 r1, 0x1
16b8: ldxdw r3, [r10 - 0x68]
16c0: mov64 r2, 0x1
16c8: jne r3, 0x0, +0x1 <.text+0x15b8>
16d0: mov64 r2, 0x0
16d8: ldxdw r3, [r10 - 0x70]
16e0: ldxdw r5, [r10 - 0x60]
16e8: mov64 r4, 0x1
16f0: ldxdw r0, [r10 - 0x78]
16f8: jne r0, 0x0, +0x1 <.text+0x15e8>
1700: mov64 r4, 0x0
1708: add64 r5, r3
1710: ldxdw r3, [r10 - 0x58]
1718: mov64 r0, 0x1
1720: jne r3, 0x0, +0x1 <.text+0x1610>
1728: mov64 r0, 0x0
1730: ldxdw r8, [r10 - 0x48]
1738: mov64 r3, r8
1740: add64 r3, r5
1748: jlt r3, r8, +0x1 <.text+0x1638>
1750: mov64 r1, 0x0
1758: or64 r4, r0
1760: or64 r4, r2
1768: or64 r4, r1
1770: and64 r4, 0x1
1778: jne r4, 0x0, +0x5 <.text+0x1688>
1780: ldxdw r2, [r10 - 0x50]
1788: lddw r1, 0xffffffe0
1798: and64 r7, r1
17a0: jeq r7, 0x20, -0x7c <.text+0x12a8>
17a8: stdw [r6 + 0x0], 0x0
17b0: ja +0x3 <.text+0x16b0>
17b8: stdw [r6 + 0x10], 0x0
17c0: stdw [r6 + 0x8], 0x1
17c8: stdw [r6 + 0x0], 0x1
17d0: exit
17d8: and64 r2, 0xff
17e0: jgt r2, 0x7, +0xb <.text+0x1720>
17e8: mov64 r3, 0x1
17f0: lsh64 r3, r2
17f8: and64 r3, 0xff
1800: ldxb r2, [r1 + 0x0]
1808: add64 r2, r3
1810: mov64 r3, r2
1818: and64 r3, 0xff
1820: jne r3, r2, +0x0 <.text+0x1708>
1828: jne r3, r2, +0x5 <.text+0x1738>
1830: stxb [r1 + 0x0], r2
1838: exit
1840: lddw r1, 0x37ad0
1850: call 0x613c
1858: lddw r1, 0x37ae8
1868: call 0x6118
1870: ldxdw r2, [r1 + 0x0]
1878: jne r2, 0x0, +0x1 <.text+0x1768>
1880: exit
1888: ldxb r2, [r1 + 0x10]
1890: jgt r2, 0x7, +0xc <.text+0x17d8>
1898: ldxdw r1, [r1 + 0x8]
18a0: mov64 r3, 0x1
18a8: lsh64 r3, r2
18b0: and64 r3, 0xff
18b8: ldxb r2, [r1 + 0x0]

Who’s got time to read all of that?

An initial attempt at this might look a little something like this:

The initial “just reverse engineer this” prompt

You’d be surprised how much works this already gets done, as the agent is more than willing to make some sense of the binary mess from before, if we add the constraint that the code must be compilable Rust.

Starting from some basic harness engineering, in my testing I found Claude Opus 5.5 worked best as an orchestrator, spawning Astra subagents to do smaller, more surgical reverse engineering tasks.

The problem with this is that a single agent can get quite lost in such a huge binary: we need to split up the work while keeping the goal consistent and measurable.

Divide and conquer

The compilation of each function inside a program is pretty independent: the only way in which callers and callees influence each other when using Rust is through inlining and LLVM’s interprocedural passes, IPSCCP (constants and value ranges), dead-argument elimination, argument promotion at -O3, attribute inference, which are relatively negligible.

On GoonFi in particular, only 11 out of more than 200 functions seemed to be altered with those optimizations.

That means that producing matching bytecode function by function gets us pretty close. All we need is a source-to-binary mapping for each function!

programs/goonfi-v2/src/instructions/swap_with_route.rs (score 0)
#[inline(always)]
fn execution_price_deviation(
limit: u64,
bid_decimals: u8,
amount_in: u64,
amount_out: u64,
oracle_price: u64,
is_user_bid: bool,
) -> bool {
if limit == 0 {
return false;
}
let base_unit = match 10u128.checked_pow(u32::from(bid_decimals)) {
Some(unit) => unit,
None => return true,
};
let (exec_num, exec_denom): (u128, u128) = if is_user_bid {
let num = match u128::from(amount_in).checked_mul(base_unit) {
Some(num) => num,
None => return true,
};
(num, u128::from(amount_out))
} else {
let num = match u128::from(amount_out).checked_mul(base_unit) {
Some(num) => num,
None => return true,
};
(num, u128::from(amount_in))
};
let oracle_times_denom = match u128::from(oracle_price).checked_mul(exec_denom) {
Some(value) => value,
None => return true,
};
let difference = if exec_num > oracle_times_denom {
exec_num.saturating_sub(oracle_times_denom)
} else {
oracle_times_denom.saturating_sub(exec_num)
};
let lhs = match difference.checked_mul(1_000_000) {
Some(value) => value,
None => return true,
};
let rhs = match u128::from(limit)
.checked_mul(u128::from(oracle_price))
.and_then(|value| value.checked_mul(exec_denom))
{
Some(value) => value,
None => return true,
};
lhs > rhs
}

This is execution_price_deviation, reconstructed: every instruction lines up with the target, so the score is 0.

For each function, we compute the score by lining up the target function’s instructions with the compiled function’s and counting the differences: 100 points for each missing or extra instruction, 60 for one in the wrong order, 5 for each wrong register or immediate, and 1 per byte a stack offset is off.

Zero points means a perfect match.

Any mistake adds to the cost of a given decompiled function

The tools, feedback loops and hooks to be wired up to the agents, on the other hand, need a bit more reflection.

Tools for the job

To decompile, you need a decompiler. Duh?

Please check out our previous article and the setup used here to get a feeling for how that can be hooked up to an agent.

renato-osec/patina-research

It’s also useful to have tools that boil down complex tasks that require a lot of context across functions. For example we might want to know which PDAs each instruction uses, to group together different functions accessing them. Or even more importantly, we need a way to know which program instructions are actually there!

This can be done by doing some static analysis and scripting with Binary Ninja and a tool like our own bn-ebpf-solana, obtaining something a little like this:

otter-sec/bn-ebpf-solana Instructions on the left, the accounts and PDAs they touch on the right. Hover or focus an instruction to trace its accounts, and select it to open the function on ibrl.golf.

In general, beyond the basic scoring that uses the compiler as a deterministic feedback, we rely on ad hoc tooling for speed and detail that’s beyond what the binary can offer, for example to query on-chain information or complex aggregate data like rust crate function signatures.

We use some existing tooling to achieve that, like the Alchemy Solana Account Archive and Solrays.

Cheating, overfitting, lots of cheating

Great, now we have a closed loop for the agent to play in, and some tools to help it. What could go wrong?

Turns out, a lot: when we define binary similarity as the sole goal of the loop, it turns out that we just get a bunch of overfitted garbage like this:

src/instructions/market_admin.rs (score 289,245)
/// Creates the market PDA and its two token vaults from the validated curve.
#[no_mangle]
pub fn create_market(
payer: &AccountInfo,
market: &AccountInfo,
bid_mint: &AccountInfo,
ask_mint: &AccountInfo,
bid_vault: &AccountInfo,
ask_vault: &AccountInfo,
oracle: &AccountInfo,
bid_program: &AccountInfo,
ask_program: &AccountInfo,
parameters: std::boxed::Box<InitializeMarketParameters>,
) -> ProgramResult {
use crate::{
extension::{self, AccountMeta, Instruction, Seed},
state::token,
utils::cpi::create_or_allocate_account,
};
use solana_program::sysvar::Sysvar;
let class_tiers = [
parameters.class_0,
parameters.class_1,
parameters.class_2,
parameters.class_3,
parameters.class_4,
parameters.class_5,
parameters.class_6,
parameters.class_7,
parameters.class_8,
parameters.class_9,
parameters.class_10,
parameters.class_11,
parameters.class_12,
parameters.class_13,
parameters.class_14,
parameters.class_15,
];
if class_tiers.iter().any(|&tier| tier > 6) {
return Err(ProgramError::Custom(13));
}
227 collapsed lines
let initial_tiers = [parameters.initial_tier; 7];
let inventory_limit = parameters.inventory_limit;
let near_bid_denominator = parameters.near_bid_denominator;
let near_ask_denominator = parameters.near_ask_denominator;
let inventory_knee = parameters.inventory_knee;
let far_bid_denominator = parameters.far_bid_denominator;
let far_ask_denominator = parameters.far_ask_denominator;
let minimum_quote_threshold = parameters.minimum_quote_threshold;
let maximum_quote_age = parameters.maximum_quote_age;
let grace_slots = parameters.grace_slots;
let bid_rate_millionths = parameters.bid_rate_millionths;
let ask_rate_millionths = parameters.ask_rate_millionths;
let smoothing_period = parameters.smoothing_period;
let smoothing_interval = parameters.smoothing_interval;
let smoothing_mode = parameters.smoothing_mode;
let disable_staleness_adjustment = parameters.disable_staleness_adjustment;
let quote_flags = parameters.quote_flags;
let restart_slot = solana_program::sysvar::last_restart_slot::LastRestartSlot::get()
.map_err(|_| ProgramError::UnsupportedSysvar)?
.last_restart_slot;
let mut settings_words = [0u64; 217];
let settings_bytes = bytemuck::bytes_of_mut(&mut settings_words);
{
// PricingTier contains only byte data and has no padding.
let tier_bytes = unsafe {
core::slice::from_raw_parts(initial_tiers.as_ptr().cast::<u8>(), 0x4d0)
};
settings_bytes[..0x4d0].copy_from_slice(tier_bytes);
settings_bytes[0x4d0..0x520].copy_from_slice(bytemuck::bytes_of(&parameters.bid_thresholds));
settings_bytes[0x520..0x570].copy_from_slice(bytemuck::bytes_of(&parameters.ask_thresholds));
settings_bytes[0x5a8] = maximum_quote_age;
settings_bytes[0x5a9] = grace_slots.min(50);
settings_bytes[0x5aa] = parameters.class_0;
settings_bytes[0x5ab] = parameters.class_1;
settings_bytes[0x5ac] = parameters.class_2;
settings_bytes[0x5ad] = parameters.class_3;
settings_bytes[0x5ae] = parameters.class_4;
settings_bytes[0x5af] = parameters.class_5;
settings_bytes[0x5b0] = parameters.class_6;
settings_bytes[0x5b1] = parameters.class_7;
settings_bytes[0x5b2] = parameters.class_8;
settings_bytes[0x5b3] = parameters.class_9;
settings_bytes[0x5b4] = parameters.class_10;
settings_bytes[0x5b5] = parameters.class_11;
settings_bytes[0x5b6] = parameters.class_12;
settings_bytes[0x5b7] = parameters.class_13;
settings_bytes[0x5d8] = parameters.class_14;
settings_bytes[0x5d9] = parameters.class_15;
settings_bytes[0x610] = smoothing_mode;
settings_bytes[0x611] = disable_staleness_adjustment;
settings_bytes[0x612] = quote_flags;
}
settings_bytes[0x570..0x578].copy_from_slice(&minimum_quote_threshold.to_le_bytes());
settings_bytes[0x578..0x580].copy_from_slice(&near_bid_denominator.to_le_bytes());
settings_bytes[0x580..0x588].copy_from_slice(&near_ask_denominator.to_le_bytes());
settings_bytes[0x588..0x590].copy_from_slice(&inventory_knee.to_le_bytes());
settings_bytes[0x590..0x598].copy_from_slice(&far_bid_denominator.to_le_bytes());
settings_bytes[0x598..0x5a0].copy_from_slice(&far_ask_denominator.to_le_bytes());
settings_bytes[0x5a0..0x5a8].copy_from_slice(&inventory_limit.to_le_bytes());
settings_bytes[0x5b8..0x5c0].copy_from_slice(&bid_rate_millionths.to_le_bytes());
settings_bytes[0x5d0..0x5d8].copy_from_slice(&ask_rate_millionths.to_le_bytes());
settings_bytes[0x5e8..0x5f0].copy_from_slice(&smoothing_period.to_le_bytes());
settings_bytes[0x5f0..0x5f8].copy_from_slice(&smoothing_interval.to_le_bytes());
settings_bytes[0x630..0x638].copy_from_slice(&restart_slot.to_le_bytes());
validate_initial_market_parameters(&parameters, &settings_words)?;
if pubkey::equals(bid_mint.key(), ask_mint.key()) {
return Err(ProgramError::Custom(2));
}
let bid_decimals = if pubkey::equals(bid_program.key(), &pubkey::TOKEN_PROGRAM_ID) {
if bid_mint.data_len() != token::Mint::LEN
|| !bid_mint.is_owned_by(&pubkey::TOKEN_PROGRAM_ID)
{
return Err(ProgramError::InvalidAccountData);
}
let data = bid_mint
.try_borrow_data()
.map_err(|_| ProgramError::InvalidAccountData)?;
data[44]
} else if pubkey::equals(bid_program.key(), &pubkey::TOKEN_2022_PROGRAM_ID) {
let data = bid_mint
.try_borrow_data()
.map_err(|_| ProgramError::InvalidAccountData)?;
spl_token_2022::extension::StateWithExtensions::<spl_token_2022::state::Mint>::unpack(&data)
.map_err(|_| ProgramError::InvalidAccountData)?
.base
.decimals
} else {
return Err(ProgramError::IncorrectProgramId);
};
let ask_decimals = if pubkey::equals(ask_program.key(), &pubkey::TOKEN_PROGRAM_ID) {
token::try_borrow_mint(ask_mint)
.map_err(|_| ProgramError::InvalidAccountData)?
.decimals()
} else if pubkey::equals(ask_program.key(), &pubkey::TOKEN_2022_PROGRAM_ID) {
let data = ask_mint
.try_borrow_data()
.map_err(|_| ProgramError::InvalidAccountData)?;
spl_token_2022::extension::StateWithExtensions::<spl_token_2022::state::Mint>::unpack(&data)
.map_err(|_| ProgramError::InvalidAccountData)?
.base
.decimals
} else {
return Err(ProgramError::IncorrectProgramId);
};
if bid_decimals != parameters.bid_decimals || ask_decimals != parameters.ask_decimals {
return Err(ProgramError::Custom(1));
}
let market_id = parameters.market_id.to_le_bytes();
let market_bump = [parameters.market_bump];
let market_seeds = [
Seed::from(&b"market_v2"[..]),
Seed::from(bid_mint.key()),
Seed::from(ask_mint.key()),
Seed::from(&market_id),
Seed::from(&market_bump),
];
create_or_allocate_account(market, &pubkey::ID, 0x800, &market_seeds, payer, None)?;
{
let mut data = market.try_borrow_mut_data()?;
data[..8].copy_from_slice(&0x9a325834352fbc30u64.to_le_bytes());
let state = MarketConfigurationState::market_account_from_bytes(&mut data[8..0x800]);
let body = bytemuck::bytes_of_mut(state);
let keys = bytemuck::from_bytes_mut::<[Pubkey; 6]>(&mut body[0x28..0xe8]);
keys[0] = *payer.key();
keys[1] = *bid_mint.key();
keys[2] = *ask_mint.key();
keys[3] = *bid_vault.key();
keys[4] = *ask_vault.key();
keys[5] = *oracle.key();
body[0x7b5] = ask_decimals;
body[0x7b4] = bid_decimals;
body[0xe8..0x7b0].copy_from_slice(bytemuck::bytes_of(&settings_words));
body[0x7b0] = parameters.market_bump;
body[..8].copy_from_slice(&market_id);
body[0x7b1] = parameters.ask_vault_bump;
body[0x7b2] = parameters.bid_vault_bump;
body[0x7b6] = 1;
body[0x7b8..0x7d8].copy_from_slice(bid_program.key());
body[0x7d8..0x7f8].copy_from_slice(ask_program.key());
}
let bid_space = token::token_account_space(bid_mint, bid_program.key())? as u64;
let bid_bump = [parameters.bid_vault_bump];
let bid_seeds = [
Seed::from(&b"vault"[..]),
Seed::from(market.key()),
Seed::from(bid_mint.key()),
Seed::from(&bid_bump),
];
create_or_allocate_account(
bid_vault,
bid_program.key(),
bid_space,
&bid_seeds,
payer,
None,
)?;
let mut initialize_data = [core::mem::MaybeUninit::<u8>::uninit(); 33];
{
let bid_metas = [
AccountMeta {
pubkey: bid_vault.key(),
is_writable: true,
is_signer: false,
},
AccountMeta {
pubkey: bid_mint.key(),
is_writable: false,
is_signer: false,
},
];
write_initialize_bytes(&mut initialize_data, &[18]);
write_initialize_owner!(initialize_data, market.key());
let bid_initialize_data = unsafe {
core::slice::from_raw_parts(initialize_data.as_ptr().cast::<u8>(), 33)
};
extension::invoke_signed(
&Instruction {
program_id: bid_program.key(),
data: bid_initialize_data,
accounts: &bid_metas,
},
&[bid_vault, bid_mint],
&[],
)?;
}
let ask_space = token::token_account_space(ask_mint, ask_program.key())? as u64;
let ask_bump = [parameters.ask_vault_bump];
let ask_seeds = [
Seed::from(&b"vault"[..]),
Seed::from(market.key()),
Seed::from(ask_mint.key()),
Seed::from(&ask_bump),
];
create_or_allocate_account(
ask_vault,
ask_program.key(),
ask_space,
&ask_seeds,
payer,
None,
)?;
let ask_metas = [
AccountMeta {
pubkey: ask_vault.key(),
is_writable: true,
is_signer: false,
},
AccountMeta {
pubkey: ask_mint.key(),
is_writable: false,
is_signer: false,
},
];
write_initialize_bytes(&mut initialize_data, &[18]);
write_initialize_owner!(initialize_data, market.key());
let ask_initialize_data = unsafe {
core::slice::from_raw_parts(initialize_data.as_ptr().cast::<u8>(), 33)
};
extension::invoke_signed(
&Instruction {
program_id: ask_program.key(),
data: ask_initialize_data,
accounts: &ask_metas,
},
&[ask_vault, ask_mint],
&[],
)
}

Optimize for the score alone and you get overfitted garbage: half the instructions still don’t line up, and the source reads more like C than Rust.

Not only that, but, if we frame this as an adversarial scenario where the AI is just maximizing its own score, we can pretty logically imagine that the best strategy here is a very trivial one:

src/instructions/market_admin.rs (score 0)
// the "cheese": forge the exact bytes by hand, ignore what they mean
#[no_mangle]
unsafe extern "C" fn create_market(/* ... */) -> u64 {
core::arch::asm!(
"stxdw [r10 - 0xdc0], r3",
"stxdw [r10 - 0xd50], r1",
"mov64 r1, 0",
"stxdw [r10 - 0xd48], r1",
"mov64 r9, 0xd",
// ... 1,254 more instructions, copied verbatim from the target ...
"exit",
options(noreturn),
)
}

Flawless, but not very useful.

To be clear, no agent actually did this, but this is what I would do if I were an agent here…

This is exactly the kind of nuance that haunted this problem in the last few years. At Accelerate 2025, we showed one of our earlier attempts running into the same problem.

Back in the good ol' times of ChatGPT 4o

So, how did we end up preventing cheating? Our first experiment just straight out banned constructs used to cheat (using an LLVM pass plugin when compiling) and heavily disincentivized whatever hurt readability:

  1. any unnatural pattern like asm! black_box, volatile writes, transmute, cast and the sort… while these can show up in a program’s source code, they’re extremely rare and often not the right choice.

  2. #[no_mangle] on internal helpers. Exporting a function stops LLVM from inlining or specializing it, so its body survives as a standalone function that scores well. But Solana programs usually export only two symbols, entrypoint and custom_panic.

  3. Finally, a little bit of a fix to the scoring: we explicitly match and exclude library functions from the final source computation, as those need to be specified through Cargo.toml, and not be redefined through the source for free extra points.

  4. #[repr(C)], which was often abused whenever the agent had to conform to deterministic checks like the ones implemented in our tooling.

This led to way better typing and library function and type recall, which are probably the hardest parts of dealing with a Rust binary and of the best ways to improve, as a side effect, long term binary fidelity.

One judge to rule them all

Agents still get lost fast and duplicate each other’s work, so instead of pure independent parallelism, we split the program up: an architect agent maps out functions per program instruction, with the help of some static analysis, then each worker gets its own branch and its own slice of the call graph, starting from the leaves that don’t match any library function.

A very good heuristic here is splitting agents on top level instructions, which are easy to identify from the branches on the first byte of the instruction data.

Nothing gets added to the view without passing a judge, that combines the aforementioned tools and scoring: on every commit we rebuild the whole program with the original toolchain and rescore every function. It only gets merged if the total score goes up, no function gets worse, and no cheating shows up in the diff.

The first stage of the judge is just a deterministic script, without any LLM.

More nuanced aspects of the decompilation are left to a reviewer agent reading every merge, and to a global orchestrator, that is meant to point out edge cases and bizarre aspects of code, which are definitely present in GoonFi.

Case in point: GoonFi runs a loop that does nothing but burn compute units, and only for signers that aren’t on its whitelist.

programs/goonfi-v2/src/instructions/swap.rs
// defined in swap_with_route.rs:
pub(crate) static COMPUTE_EXEMPT_SIGNERS: [Pubkey; 2] = [
PERMITTED_ROUTE_AUTHORITIES[3],
PERMITTED_ROUTE_AUTHORITIES[4],
];
if !COMPUTE_EXEMPT_SIGNERS.contains(user.key()) {
let mut i = 0u64;
while i < 7000 {
core::hint::black_box(i);
i += 1;
}
}

This is clearly intentional, and yet this isn’t obvious at all looking at the binary, but it’s exactly the kind of thing that jumps out once a human or an agent are actually reading the reconstructed code.

Then, we have the prologue mystery. All of GoonFi’s top level instruction functions open with the same thing: a run of writes of ascending constants into a dead stack slot, never to be read back, and the values are re-rolled on every build on-chain.

We still don’t know for sure what this is meant to do, and reproducing it cleanly, without reaching for the compiler tricks that would be marked as cheating, was one of the harder parts of the whole process.

programs/goonfi-v2/src/utils/helpers.rs
macro_rules! prologue_padding {
($first:expr, $step:expr) => {{
let mut slot = 0u32;
let mut value: u32 = $first;
loop {
// SAFETY: `slot` is a live, properly aligned local `u32`.
unsafe { core::ptr::write_volatile(&mut slot, value) };
if value > 100 {
break;
}
value += $step;
}
}};
}

The final stretch

So, what is left to fix after each function perfectly matches the original source?

Even after reaching a 100% match per function, we still had a good 93k bytes diff on a 243k byte binary. One obvious source of this is the ordering of those matched functions between the original and reconstructed binary, as well as anything downstream of that like pointer tables or vtables in .data.rel.ro.

Function order matters for compilation, since rustc emits functions in the order the user defined them in: we can just take a look at the original binary and decide how to position internal symbols and crate imports.

You can see this in rustc itself, in items_in_deterministic_order: local items are sorted by their definition index, so they come out in source order, while items pulled in from other crates sort before them by symbol name.

compiler/rustc_middle/src/mir/mono.rs
pub fn items_in_deterministic_order(
&self,
tcx: TyCtxt<'tcx>,
) -> Vec<(MonoItem<'tcx>, MonoItemData)> {
// The codegen tests rely on items being process in the same order as
// they appear in the file, so for local items, we sort by node_id first
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct ItemSortKey<'tcx>(Option<usize>, SymbolName<'tcx>);
fn item_sort_key<'tcx>(tcx: TyCtxt<'tcx>, item: MonoItem<'tcx>) -> ItemSortKey<'tcx> {
ItemSortKey(
match item {
MonoItem::Fn(ref instance) => {
match instance.def {
// We only want to take HirIds of user-defined
// instances into account. The others don't matter for
// the codegen tests and can even make item order
// unstable.
InstanceKind::Item(def) => def.as_local().map(Idx::index),
InstanceKind::VTableShim(..)
| InstanceKind::ReifyShim(..)
| InstanceKind::Intrinsic(..)
| InstanceKind::FnPtrShim(..)
| InstanceKind::Virtual(..)
| InstanceKind::ClosureOnceShim { .. }
| InstanceKind::ConstructCoroutineInClosureShim { .. }
| InstanceKind::DropGlue(..)
| InstanceKind::CloneShim(..)
| InstanceKind::ThreadLocalShim(..)
| InstanceKind::FnPtrAddrShim(..)
| InstanceKind::AsyncDropGlueCtorShim(..) => None,
}
}
MonoItem::Static(def_id) => def_id.as_local().map(Idx::index),
MonoItem::GlobalAsm(item_id) => Some(item_id.owner_id.def_id.index()),
},
item.symbol_name(tcx),
)
}
let mut items: Vec<_> = self.items().iter().map(|(&i, &data)| (i, data)).collect();
items.sort_by_cached_key(|&(i, _)| item_sort_key(tcx, i));
items
}

One unexpected, and very useful, source of differences lies in the panic strings used by the program: they include both the relative path and line number of the source file by default, which allows for the recovery of the project layout, but also means that we must respect that same exact layout to get a matching result.

Every panic site is compiled into a core::panic::Location: the #[lang = "panic_location"] struct rustc fills with the file path, line, and column, then embeds as a static that the panic handler reads back.

library/core/src/panic/location.rs
#[lang = "panic_location"]
#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[stable(feature = "panic_hooks", since = "1.10.0")]
pub struct Location<'a> {
file: &'a str,
line: u32,
col: u32,
}

After clearing these two issues, we have a hash perfect recompilation of the source code, which is easily verifiable by compiling with the correct toolchain.

This code could even be uploaded to a verification service like our own Solana Verifiable Build to be tied to the on-chain program, given some adjustments to the build image.

Same old critical bugs

Now that we have the source code, reading and scanning through it are the next logical step. All that decompiling and decluttering was worth it, as we came upon a quite noteworthy bug, through an auditing agent with little supervision.

The critical bug we found is a simple accounting error, which classic static analysis alone struggles to find: the propAMM trusted its own token balance.

GoonFi priced each asset from an inventory skew, and it inferred that directly from the vault’s live SPL balance. But anyone can send tokens into a token account: the program couldn’t tell a real swap from an unsolicited donation. Donating enough into vault A and the skew floors the asset’s price to a single raw unit, at that point the other side of the market becomes basically free.

programs/goonfi-v2/src/instructions/swap.rs
let vault_amount =
goonfi_common::helpers::get_token_account_amount(bid_vault, &market.bid_token_program)
.map_err(|_| ProgramError::InvalidAccountData)?;
// ... vault_amount flows straight into the quote, where it becomes the inventory skew:
// state/market_v2.rs: let deviation = balance - inventory_limit;

Such an inflation attack, given considerable capital, could have drained or bricked several GoonFi vaults.

Conclusions

We want to turn our work here into something positive for the entire community, and we believe in an open future for the entire Solana ecosystem.

Stay tuned for updates and rejoice, because, while reverse engineering might be dead, this means that there will be plenty more eyes securing Solana programs, as well as many more prodding hands to safeguard against.

And of course, keep your tokens ready, because they will be needed.

Finally, we invite others to review the full decompilation, published in this repo.

renato-osec/goonfi-v2-decompiledByte-identical decompiled Rust source of the closed-source GoonFi V2 Solana program.

Footnotes

  1. One of the many, since compilation is in no way injective. ↩

  2. This concept has been explored before in amazing CTFs like decompetition.io, though there with an oracle that already knows the source. What we tackle here is harder. ↩

  3. Forked from decomp.me, which shares the same goal but for console games. ↩

Read more from our blog

See all

Introducing rCTF v2

We’re releasing rCTF v2, an open-source platform for hosting cybersecurity capture-the-flag competitions.
Arsenii, Jason T., Stepan

Announcing the Save CTFs Fund

OtterSec is committing $100,000 to keep CTFs competitive in the age of AI. We break down why Jeopardy scoring is breaking down, what better formats might look like, and how to apply for sponsorship.
Michael Debono

Subscribe to the blog

New posts from the OtterSec team, straight to your inbox. One email per post, unsubscribe any time.