[pallets/exodus] Bounty of 100% encodes as 0 in EVM calldata (as u32 wrap)

Component: pallets/exodus @ 0162da46, src/types/exodus_request.rs:383-391.

Description: The Perbill bounty is scaled to uint32 for the recall(uint256,uint256,uint256) metadata slot:

let scaled_bounty = multiply_by_rational_with_rounding(
    bounty.deconstruct() as u128, 1u128 << 32, 1_000_000_000, Rounding::Down)?;
let bounty_uint32 = scaled_bounty as u32;   // <-- wrap!

Perbill::from_percent(100) → deconstruct() = 1_000_000_000 → scaled_bounty = exactly 2^32 → as u32 truncates to 0. The existing tests only cover 1%.

Impact: A user setting a 100% bounty encodes 0 instead: the EVM relayer gets nothing. Self-inflicted; no other party can exploit it (the value is written by the submitter).

Reproduction: ExodusRequest::evm_bridge_out(..., Perbill::from_percent(100), ...) → get_message → the last 4 bytes of the metadata slot are 0x00000000 instead of 0xFFFFFFFF.

Severity: Low. Reason: a real correctness bug at the upper bound, with no attacker path and no cross-party fund loss.

Recommendation: Clamp before casting (scaled_bounty.min(u32::MAX as u128) as u32 or unique_saturated_into::<u32>()); add boundary tests for 0%/100%.

Verification: VERIFIED (static).

Thank you for your report!

Actually, you’re right. It looks like only 100% will be affected, correct?

One more thing - the fix I have at the top of my head is:

let scaled_bounty = multiply_by_rational_with_rounding(
    bounty.deconstruct() as u128,
    (1u128 << 32) - 1,
    1_000_000_000,
    sp_runtime::Rounding::Down
)?;

At the same time, do you think this way is overengineering? Maybe you could see a simpler way of doing it?

Yes, correct: only 100% is affected. Perbill::deconstruct() maxes out at 1_000_000_000 (100%), which scales to exactly 2^32; every smaller value scales to strictly less than 2^32 and survives the as u32 cast. So 100% is the single wrapping input.

On the fix: your (1u128 << 32) - 1 multiplier works, but it rescales every input slightly (all values map to a marginally smaller range). The simpler change is clamping before the cast, which leaves all existing behavior untouched:

let bounty_uint32 = scaled_bounty.min(u32::MAX as u128) as u32;

One line, only the 100% case changes (2^32 saturates to u32::MAX instead of wrapping to 0). I would go with the clamp over the multiplier.