Breaking open a propAMM
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.
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:
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
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:
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, r2128: 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, r3158: rsh64 r4, 0x3160: ja +0x9 <.text+0x90>168: add64 r3, 0x8170: add64 r4, -0x1178: 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, r21a0: rsh64 r1, 0x31a8: ja +0x8 <.text+0xd0>1b0: ldxdw r5, [r3 + 0x0]1b8: jgt r0, r5, -0xb <.text+0x48>1c0: mov64 r0, r51c8: ja -0xd <.text+0x48>1d0: add64 r2, 0x81d8: add64 r1, -0x11e0: jne r1, 0x0, +0x1 <.text+0xd0>1e8: exit1f0: ldxdw r3, [r2 + 0x0]1f8: jgt r0, r3, -0x6 <.text+0xb0>200: mov64 r0, r3208: ja -0x8 <.text+0xb0>210: mov64 r6, r1218: jeq r3, 0x0, +0x30 <.text+0x280>220: ldxb r4, [r2 + 0x0]228: stxb [r10 - 0x59], r4230: jlt r4, 0x2, +0x18 <.text+0x1d8>238: lddw r1, 0x37a18248: stxdw [r10 - 0x40], r1250: mov64 r1, r10258: add64 r1, -0x10260: stxdw [r10 - 0x30], r1268: lddw r1, 0x32558278: stxdw [r10 - 0x8], r1280: mov64 r1, r10288: add64 r1, -0x59290: stxdw [r10 - 0x10], r1298: stdw [r10 - 0x20], 0x02a0: stdw [r10 - 0x38], 0x12a8: stdw [r10 - 0x28], 0x12b0: mov64 r7, r102b8: add64 r7, -0x582c0: mov64 r2, r102c8: add64 r2, -0x402d0: mov64 r1, r72d8: call 0x5bca2e0: mov64 r1, r72e8: call 0x1a12f0: ja +0x18 <.text+0x298>2f8: jlt r3, 0x9, +0x14 <.text+0x280>300: mov64 r1, r3308: add64 r1, -0x9310: jlt r1, 0x8, +0x11 <.text+0x280>318: mov64 r1, 0x0320: 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], r2348: jeq r2, 0x0, +0x2 <.text+0x240>350: jne r2, 0x1, +0x13 <.text+0x2d0>358: mov64 r1, 0x1360: mov64 r2, r5368: rsh64 r2, 0x20370: stxb [r6 + 0x10], r4378: stxdw [r6 + 0x8], r0380: stxw [r6 + 0x4], r2388: stxw [r6 + 0x0], r5390: mov64 r2, 0x18398: ja +0x2c <.text+0x3e0>3a0: lddw r1, 0x379e83b0: call 0x5a503b8: mov64 r1, r03c0: and64 r1, 0x33c8: 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, 0x37a18400: stxdw [r10 - 0x40], r1408: mov64 r1, r10410: add64 r1, -0x10418: stxdw [r10 - 0x30], r1420: lddw r1, 0x32558430: stxdw [r10 - 0x8], r1438: mov64 r1, r10440: add64 r1, -0x59448: stxdw [r10 - 0x10], r1450: stdw [r10 - 0x20], 0x0458: stdw [r10 - 0x38], 0x1460: stdw [r10 - 0x28], 0x1468: mov64 r7, r10470: add64 r7, -0x58478: mov64 r2, r10480: add64 r2, -0x40488: mov64 r1, r7490: call 0x5b93498: mov64 r1, r74a0: call 0x16a4a8: mov64 r1, r04b0: and64 r1, 0x34b8: 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 r24e8: stw [r6 + 0x0], 0x24f0: mov64 r1, 0x24f8: mov64 r2, 0x10500: add64 r6, r2508: stxb [r6 + 0x0], r1510: exit518: mov64 r6, r1520: ldxdw r1, [r6 + 0xc8]528: jeq r1, 0xc8, +0x1c <.text+0x4f0>530: jgt r1, 0xc8, +0x1e <.text+0x508>538: mov64 r8, r6540: add64 r8, r1548: mov64 r9, 0xc8550: sub64 r9, r1558: mov64 r7, r9560: jlt r9, r3, +0x1 <.text+0x450>568: mov64 r7, r3570: mov64 r1, r8578: stxdw [r10 - 0x8], r3580: mov64 r3, r7588: call 0x6540590: ldxdw r1, [r10 - 0x8]598: jge r9, r1, +0x2 <.text+0x490>5a0: add64 r8, r75a8: stb [r8 - 0x1], 0x405b0: ldxdw r3, [r6 + 0xc8]5b8: mov64 r1, r35c0: add64 r1, r75c8: mov64 r2, 0x15d0: jlt r1, r3, +0x1 <.text+0x4c0>5d8: mov64 r2, 0x05e0: and64 r2, 0x15e8: jne r2, 0x0, +0xb <.text+0x528>5f0: stxdw [r6 + 0xc8], r15f8: jgt r1, 0xc8, +0x1 <.text+0x4e8>600: ja +0x2 <.text+0x4f8>608: stdw [r6 + 0xc8], 0xc8610: stb [r6 + 0xc7], 0x40618: mov64 r0, r6620: exit628: mov64 r2, 0xc8630: lddw r3, 0x379a0640: call 0x60a1648: lddw r1, 0x37988658: call 0x635a660: mov64 r6, r1668: jeq r3, 0x163, +0x1 <.text+0x558>670: jgt r3, 0x51, +0x3 <.text+0x570>678: lddw r1, -0x7ffffffffffffffd688: ja +0x34 <.text+0x710>690: stxdw [r10 - 0x148], r3698: mov64 r1, r106a0: add64 r1, -0x586a8: stxdw [r10 - 0x150], r26b0: mov64 r3, 0x526b8: call 0x58946c0: ldxw r9, [r10 - 0x58]6c8: jeq r9, 0x2, +0x21 <.text+0x6b8>6d0: ldxdw r1, [r10 - 0x48]6d8: stxdw [r10 - 0x68], r16e0: ldxdw r1, [r10 - 0x40]6e8: stxdw [r10 - 0x60], r16f0: ldxdw r1, [r10 - 0x38]6f8: stxdw [r10 - 0x90], r1700: ldxdw r1, [r10 - 0x30]708: stxdw [r10 - 0x88], r1710: ldxb r1, [r10 - 0x28]718: stxb [r10 - 0x80], r1720: ldxdw r1, [r10 - 0x26]728: stxdw [r10 - 0xb8], r1730: ldxdw r1, [r10 - 0x1e]738: stxdw [r10 - 0xb0], r1740: ldxdw r1, [r10 - 0x16]748: stxdw [r10 - 0xa8], r1750: ldxdw r1, [r10 - 0xe]758: stxdw [r10 - 0xa0], r1760: ldxdw r1, [r10 - 0x8]768: stxdw [r10 - 0x9a], r1770: ldxdw r4, [r10 - 0x50]778: ldxw r5, [r10 - 0x54]780: ldxb r7, [r10 - 0x27]788: ldxdw r1, [r10 - 0x68]790: stxdw [r10 - 0x78], r1798: ldxdw r1, [r10 - 0x60]7a0: stxdw [r10 - 0x70], r17a8: mov64 r1, r77b0: and64 r1, 0x17b8: jne r1, 0x0, +0x11 <.text+0x728>7c0: lddw r1, -0x7ffffffffffffff77d0: ja +0x5 <.text+0x6e0>7d8: ldxdw r1, [r10 - 0x48]7e0: stxdw [r10 - 0xe0], r17e8: ldxdw r1, [r10 - 0x40]7f0: stxdw [r10 - 0xd8], r17f8: ldxdw r1, [r10 - 0x50]800: ldxdw r2, [r10 - 0xd8]808: stxdw [r10 - 0xe8], r2810: ldxdw r3, [r10 - 0xe0]818: stxdw [r10 - 0xf0], r3820: stxdw [r6 + 0x18], r2828: stxdw [r6 + 0x10], r3830: stxdw [r6 + 0x8], r1838: stw [r6 + 0x0], 0x2840: exit848: ldxdw r1, [r10 - 0x148]850: add64 r1, -0x52858: ldxdw r2, [r10 - 0x70]860: stxdw [r10 - 0xd8], r2868: ldxdw r2, [r10 - 0x78]870: stxdw [r10 - 0xe0], r2878: ldxdw r2, [r10 - 0x90]880: stxdw [r10 - 0xd0], r2888: ldxdw r2, [r10 - 0x88]890: stxdw [r10 - 0xc8], r2898: ldxb r2, [r10 - 0x80]8a0: stxb [r10 - 0xc0], r28a8: ldxdw r2, [r10 - 0xb8]8b0: stxdw [r10 - 0x140], r28b8: ldxdw r2, [r10 - 0xb0]8c0: stxdw [r10 - 0x138], r28c8: ldxdw r2, [r10 - 0xa8]8d0: stxdw [r10 - 0x130], r28d8: ldxdw r2, [r10 - 0xa0]8e0: stxdw [r10 - 0x128], r28e8: ldxdw r2, [r10 - 0x9a]8f0: stxdw [r10 - 0x122], r28f8: ldxb r2, [r10 - 0xc0]900: stxb [r10 - 0x108], r2908: ldxdw r2, [r10 - 0xc8]910: stxdw [r10 - 0x110], r2918: ldxdw r2, [r10 - 0xd0]920: stxdw [r10 - 0x118], r2928: ldxdw r2, [r10 - 0xd8]930: stxdw [r10 - 0xe8], r2938: stxdw [r10 - 0xf8], r2940: ldxdw r2, [r10 - 0xe0]948: stxdw [r10 - 0xf0], r2950: stxdw [r10 - 0x100], r2958: mov64 r3, 0x0960: mov64 r2, 0x1968: jeq r1, 0x0, +0x35 <.text+0x9f8>970: jlt r1, 0x55, +0x4f <.text+0xad0>978: stxdw [r10 - 0x158], r4980: lddw r1, 0x300000000990: ldxdw r1, [r1 + 0x0]998: mov64 r3, r19a0: add64 r3, -0x539a8: mov64 r2, 0x09b0: mov64 r4, 0x19b8: jgt r3, r1, +0x1 <.text+0x8a8>9c0: mov64 r4, 0x09c8: stxdw [r10 - 0x168], r79d0: ldxdw r8, [r10 - 0x150]9d8: jne r4, 0x0, +0x1 <.text+0x8c8>9e0: mov64 r2, r39e8: lddw r7, 0x300007fad9f8: jeq r1, 0x0, +0x1 <.text+0x8e8>a00: mov64 r7, r2a08: stxdw [r10 - 0x160], r5a10: lddw r1, 0x300000007a20: jgt r7, r1, +0x5 <.text+0x930>a28: mov64 r1, 0x1a30: mov64 r2, 0x53a38: lddw r3, 0x379b8a48: call 0x5ac5a50: add64 r8, 0x52a58: lddw r1, 0x300000000a68: stxdw [r1 + 0x0], r7a70: mov64 r1, r7a78: mov64 r2, 0x0a80: mov64 r3, 0x53a88: call 0x64a4a90: mov64 r1, r8a98: mov64 r2, r7aa0: mov64 r3, 0x53aa8: call 0x64a5ab0: lsh64 r0, 0x20ab8: rsh64 r0, 0x20ac0: 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, 0xa6b10: add64 r3, -0xa6b18: ldxdw r1, [r10 - 0xf8]b20: stxdw [r6 + 0x18], r1b28: ldxdw r1, [r10 - 0x100]b30: stxdw [r6 + 0x10], r1b38: ldxdw r1, [r10 - 0x118]b40: stxdw [r6 + 0x20], r1b48: ldxdw r1, [r10 - 0x110]b50: stxdw [r6 + 0x28], r1b58: ldxb r1, [r10 - 0x108]b60: stxb [r6 + 0x30], r1b68: ldxdw r1, [r10 - 0x128]b70: stxdw [r6 + 0x4a], r1b78: ldxdw r1, [r10 - 0x130]b80: stxdw [r6 + 0x42], r1b88: ldxdw r1, [r10 - 0x138]b90: stxdw [r6 + 0x3a], r1b98: ldxdw r1, [r10 - 0x140]ba0: stxdw [r6 + 0x32], r1ba8: ldxdw r1, [r10 - 0x122]bb0: stxw [r6 + 0x0], r9bb8: stxw [r6 + 0x4], r5bc0: stxb [r6 + 0x31], r7bc8: stxdw [r6 + 0x60], r3bd0: stxdw [r6 + 0x58], r2bd8: stxdw [r6 + 0x8], r4be0: stxdw [r6 + 0x50], r1be8: ja -0x76 <.text+0x720>bf0: lddw r1, -0x7ffffffffffffffdc00: stxdw [r6 + 0x8], r1c08: stdw [r6 + 0x18], 0x54c10: stdw [r6 + 0x10], 0x53c18: 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, -0x6c5e9a281e0922fac50: jne r4, r5, +0xc <.text+0xb98>c58: ldxdw r4, [r3 + 0x30]c60: lddw r5, -0x53861431b91e3427c70: jne r4, r5, +0x8 <.text+0xb98>c78: ldxdw r4, [r3 + 0x38]c80: lddw r5, -0x6ec8a4a0127a4be4c90: jne r4, r5, +0x4 <.text+0xb98>c98: ldxdw r3, [r3 + 0x40]ca0: lddw r4, -0x56ff00817a0a73c6cb0: jeq r3, r4, +0x6 <.text+0xbc8>cb8: stdw [r1 + 0x0], 0x0cc0: stw [r1 + 0x8], 0x16cc8: ja +0x2 <.text+0xbc0>cd0: stdw [r1 + 0x0], 0x0cd8: stw [r1 + 0x8], 0x3ce0: exitce8: mov64 r6, r1cf0: mov64 r1, r10cf8: add64 r1, -0x20d00: call 0x5963d08: 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], r3d30: stxdw [r6 + 0x8], r2d38: stxdw [r6 + 0x0], r1d40: ja -0xd <.text+0xbc0>d48: ldxdw r1, [r10 - 0x18]d50: stxdw [r6 + 0x8], r1d58: stdw [r6 + 0x0], 0x0d60: 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, -0x6c5e9a281e0922fad98: jne r4, r5, +0xc <.text+0xce0>da0: ldxdw r4, [r3 + 0x30]da8: lddw r5, -0x53861431b91e3427db8: jne r4, r5, +0x8 <.text+0xce0>dc0: ldxdw r4, [r3 + 0x38]dc8: lddw r5, -0x6ec8a4a0127a4be4dd8: jne r4, r5, +0x4 <.text+0xce0>de0: ldxdw r3, [r3 + 0x40]de8: lddw r4, -0x56ff00817a0a73c6df8: jeq r3, r4, +0x3 <.text+0xcf8>e00: stdw [r1 + 0x0], 0x0e08: stw [r1 + 0x8], 0x3e10: exite18: mov64 r6, r1e20: mov64 r1, r10e28: add64 r1, -0x20e30: call 0x593de38: 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], r3e60: stxdw [r6 + 0x8], r2e68: stxdw [r6 + 0x0], r1e70: ja -0xd <.text+0xcf0>e78: ldxdw r1, [r10 - 0x18]e80: stxdw [r6 + 0x8], r1e88: stdw [r6 + 0x0], 0x0e90: 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, -0x21708a111e0922faec8: jne r4, r5, +0xc <.text+0xe10>ed0: ldxdw r4, [r3 + 0x30]ed8: lddw r5, -0x2532931b43a2bde8ee8: jne r4, r5, +0x8 <.text+0xe10>ef0: ldxdw r4, [r3 + 0x38]ef8: lddw r5, 0x270db9834dfc1ab6f08: jne r4, r5, +0x4 <.text+0xe10>f10: ldxdw r3, [r3 + 0x40]f18: lddw r4, -0x3745e27d7064202f28: jeq r3, r4, +0x3 <.text+0xe28>f30: stdw [r1 + 0x0], 0x0f38: stw [r1 + 0x8], 0x3f40: exitf48: mov64 r6, r1f50: mov64 r1, r10f58: add64 r1, -0x20f60: call 0x5917f68: 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, 0xa5f90: lddw r3, 0x379d0fa0: call 0x5f76fa8: ldxb r2, [r10 - 0x8]fb0: ldxdw r3, [r10 - 0x10]fb8: stxb [r6 + 0x10], r2fc0: stxdw [r6 + 0x8], r3fc8: stxdw [r6 + 0x0], r1fd0: ja -0x13 <.text+0xe20>fd8: ldxdw r1, [r10 - 0x18]fe0: stxdw [r6 + 0x8], r1fe8: stdw [r6 + 0x0], 0x0ff0: ja -0x17 <.text+0xe20>ff8: lddw r2, 0x3000000001008: ldxdw r3, [r2 + 0x0]1010: mov64 r2, r31018: add64 r2, -0x181020: mov64 r4, 0x01028: mov64 r5, 0x11030: jgt r2, r3, +0x1 <.text+0xf20>1038: mov64 r5, 0x01040: jne r5, 0x0, +0x1 <.text+0xf30>1048: mov64 r4, r21050: lddw r2, 0x300007fe81060: jeq r3, 0x0, +0x2 <.text+0xf58>1068: and64 r4, -0x81070: mov64 r2, r41078: lddw r3, 0x3000000071088: jgt r2, r3, +0x3 <.text+0xf88>1090: mov64 r1, 0x81098: mov64 r2, 0x1810a0: call 0x59fe10a8: lddw r3, 0x30000000010b8: stxdw [r3 + 0x0], r210c0: ldxdw r3, [r1 + 0x10]10c8: stxdw [r2 + 0x10], r310d0: ldxdw r3, [r1 + 0x8]10d8: stxdw [r2 + 0x8], r310e0: ldxdw r1, [r1 + 0x0]10e8: stxdw [r2 + 0x0], r110f0: mov64 r1, 0x1410f8: lddw r3, 0x37a481108: call 0x591c1110: exit1118: lddw r1, 0x3000000001128: ldxdw r2, [r1 + 0x0]1130: mov64 r1, r21138: add64 r1, -0x121140: mov64 r3, 0x01148: mov64 r4, 0x11150: jgt r1, r2, +0x1 <.text+0x1040>1158: mov64 r4, 0x01160: jne r4, 0x0, +0x1 <.text+0x1050>1168: mov64 r3, r11170: lddw r1, 0x300007fee1180: jeq r2, 0x0, +0x1 <.text+0x1070>1188: mov64 r1, r31190: lddw r2, 0x30000000711a0: jgt r1, r2, +0x5 <.text+0x10b0>11a8: mov64 r1, 0x111b0: mov64 r2, 0x1211b8: lddw r3, 0x37a0011c8: call 0x59d511d0: lddw r2, 0x30000000011e0: stxdw [r2 + 0x0], r111e8: lddw r3, 0x657220736574796211f8: stxdw [r1 + 0x8], r31200: lddw r3, 0x206c6c6120746f4e1210: stxdw [r1 + 0x0], r31218: sth [r1 + 0x10], 0x64611220: ldxdw r3, [r2 + 0x0]1228: mov64 r2, r31230: add64 r2, -0x181238: mov64 r4, 0x01240: mov64 r5, 0x11248: jgt r2, r3, +0x1 <.text+0x1138>1250: mov64 r5, 0x01258: jne r5, 0x0, +0x1 <.text+0x1148>1260: mov64 r4, r21268: lddw r2, 0x300007fe81278: jeq r3, 0x0, +0x2 <.text+0x1170>1280: and64 r4, -0x81288: mov64 r2, r41290: lddw r3, 0x30000000712a0: jgt r2, r3, +0x3 <.text+0x11a0>12a8: mov64 r1, 0x812b0: mov64 r2, 0x1812b8: call 0x59bb12c0: lddw r3, 0x30000000012d0: stxdw [r3 + 0x0], r212d8: stxdw [r2 + 0x8], r112e0: stdw [r2 + 0x10], 0x1212e8: stdw [r2 + 0x0], 0x1212f0: mov64 r1, 0x1512f8: lddw r3, 0x37a481308: call 0x58dc1310: exit1318: mov64 r3, r21320: ldxdw r2, [r1 + 0x8]1328: ldxdw r1, [r1 + 0x0]1330: call 0x5eaa1338: exit1340: mov64 r7, r21348: mov64 r6, r11350: mov64 r1, r71358: lsh64 r1, 0x201360: rsh64 r1, 0x201368: jeq r1, 0x0, +0x89 <.text+0x1698>1370: mov64 r3, 0x01378: mov64 r2, 0x11380: mov64 r1, r71388: and64 r1, 0x11390: jeq r1, 0x0, +0x9 <.text+0x12c0>1398: mov64 r2, 0xa13a0: mov64 r1, r713a8: lsh64 r1, 0x2013b0: rsh64 r1, 0x2013b8: jeq r1, 0x1, +0x1 <.text+0x12a8>13c0: ja +0x3 <.text+0x12c0>13c8: stxdw [r6 + 0x8], r213d0: stxdw [r6 + 0x10], r313d8: ja +0x7d <.text+0x16a8>13e0: mov64 r1, r713e8: and64 r1, 0x213f0: jeq r1, 0x0, +0xc <.text+0x1338>13f8: mov64 r1, r101400: add64 r1, -0x101408: mov64 r4, 0x641410: mov64 r5, 0x01418: call 0x68f11420: lddw r1, 0xfffffffe1430: mov64 r4, r71438: and64 r4, r11440: ldxdw r3, [r10 - 0x8]1448: ldxdw r2, [r10 - 0x10]1450: jeq r4, 0x2, -0x12 <.text+0x12a8>1458: mov64 r1, r71460: and64 r1, 0x41468: jeq r1, 0x0, +0xc <.text+0x13b0>1470: mov64 r1, r101478: add64 r1, -0x201480: mov64 r4, 0x27101488: mov64 r5, 0x01490: call 0x68e21498: lddw r1, 0xfffffffc14a8: mov64 r4, r714b0: and64 r4, r114b8: ldxdw r3, [r10 - 0x18]14c0: ldxdw r2, [r10 - 0x20]14c8: jeq r4, 0x4, -0x21 <.text+0x12a8>14d0: mov64 r1, r714d8: and64 r1, 0x814e0: jeq r1, 0x0, +0xc <.text+0x1428>14e8: mov64 r1, r1014f0: add64 r1, -0x3014f8: mov64 r4, 0x5f5e1001500: mov64 r5, 0x01508: call 0x68d31510: lddw r1, 0xfffffff81520: mov64 r4, r71528: and64 r4, r11530: ldxdw r3, [r10 - 0x28]1538: ldxdw r2, [r10 - 0x30]1540: jeq r4, 0x8, -0x30 <.text+0x12a8>1548: mov64 r1, r71550: and64 r1, 0x101558: jeq r1, 0x0, +0xd <.text+0x14a8>1560: mov64 r1, r101568: add64 r1, -0x401570: lddw r4, 0x2386f26fc100001580: mov64 r5, 0x01588: call 0x68c31590: lddw r1, 0xfffffff015a0: mov64 r4, r715a8: and64 r4, r115b0: ldxdw r3, [r10 - 0x38]15b8: ldxdw r2, [r10 - 0x40]15c0: jeq r4, 0x10, -0x40 <.text+0x12a8>15c8: mov64 r1, r715d0: and64 r1, 0x2015d8: jeq r1, 0x0, +0x39 <.text+0x1688>15e0: mov64 r1, r1015e8: add64 r1, -0x7015f0: mov64 r8, r215f8: mov64 r9, r31600: mov64 r3, 0x01608: lddw r4, 0x4ee2d6d415b1618: mov64 r5, 0x01620: call 0x68b01628: mov64 r1, r101630: add64 r1, -0x601638: stxdw [r10 - 0x78], r91640: mov64 r2, r91648: mov64 r3, 0x01650: lddw r4, -0x7a53107f000000001660: mov64 r5, 0x01668: call 0x68a71670: mov64 r1, r101678: add64 r1, -0x501680: mov64 r2, r81688: mov64 r3, 0x01690: lddw r4, -0x7a53107f0000000016a0: mov64 r5, 0x016a8: call 0x689f16b0: mov64 r1, 0x116b8: ldxdw r3, [r10 - 0x68]16c0: mov64 r2, 0x116c8: jne r3, 0x0, +0x1 <.text+0x15b8>16d0: mov64 r2, 0x016d8: ldxdw r3, [r10 - 0x70]16e0: ldxdw r5, [r10 - 0x60]16e8: mov64 r4, 0x116f0: ldxdw r0, [r10 - 0x78]16f8: jne r0, 0x0, +0x1 <.text+0x15e8>1700: mov64 r4, 0x01708: add64 r5, r31710: ldxdw r3, [r10 - 0x58]1718: mov64 r0, 0x11720: jne r3, 0x0, +0x1 <.text+0x1610>1728: mov64 r0, 0x01730: ldxdw r8, [r10 - 0x48]1738: mov64 r3, r81740: add64 r3, r51748: jlt r3, r8, +0x1 <.text+0x1638>1750: mov64 r1, 0x01758: or64 r4, r01760: or64 r4, r21768: or64 r4, r11770: and64 r4, 0x11778: jne r4, 0x0, +0x5 <.text+0x1688>1780: ldxdw r2, [r10 - 0x50]1788: lddw r1, 0xffffffe01798: and64 r7, r117a0: jeq r7, 0x20, -0x7c <.text+0x12a8>17a8: stdw [r6 + 0x0], 0x017b0: ja +0x3 <.text+0x16b0>17b8: stdw [r6 + 0x10], 0x017c0: stdw [r6 + 0x8], 0x117c8: stdw [r6 + 0x0], 0x117d0: exit17d8: and64 r2, 0xff17e0: jgt r2, 0x7, +0xb <.text+0x1720>17e8: mov64 r3, 0x117f0: lsh64 r3, r217f8: and64 r3, 0xff1800: ldxb r2, [r1 + 0x0]1808: add64 r2, r31810: mov64 r3, r21818: and64 r3, 0xff1820: jne r3, r2, +0x0 <.text+0x1708>1828: jne r3, r2, +0x5 <.text+0x1738>1830: stxb [r1 + 0x0], r21838: exit1840: lddw r1, 0x37ad01850: call 0x613c1858: lddw r1, 0x37ae81868: call 0x61181870: ldxdw r2, [r1 + 0x0]1878: jne r2, 0x0, +0x1 <.text+0x1768>1880: exit1888: ldxb r2, [r1 + 0x10]1890: jgt r2, 0x7, +0xc <.text+0x17d8>1898: ldxdw r1, [r1 + 0x8]18a0: mov64 r3, 0x118a8: lsh64 r3, r218b0: and64 r3, 0xff18b8: 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:
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.
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.
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:
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(¶meters.bid_thresholds)); settings_bytes[0x520..0x570].copy_from_slice(bytemuck::bytes_of(¶meters.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(¶meters, &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.
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:
-
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. -
#[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,entrypointandcustom_panic. -
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. -
#[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.
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.rsmacro_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.rspub 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.rslet 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.
Footnotes
-
One of the many, since compilation is in no way injective. ↩
-
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. ↩
-
Forked from decomp.me, which shares the same goal but for console games. ↩

