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.

Hey @Rusdiwireng - it looks like the dev team has fixed the issue you reported. Could you confirm whether it’s resolved on your end now, or continue the thread if it still persists?

We decided to simply cap the value, since the rest of the logic looks sound and we only needed to eliminate this edge case.

Checked e3a81e3. The clamp is exactly what I suggested: scaled_bounty.min(u32::MAX as u128) as u32. Only the 100% case changes (2^32 saturates instead of wrapping to 0); everything below is untouched by the min(). Confirmed resolved.