Stale set_payee calldata: reward destination shown in UI can differ from what gets signed

Component: ghost-lite @ a420393, src/hooks/useCalldata.tsx:193-232, consumed by src/containers/Nominations.tsx:357 (handleOnSetPayee).

Description: usePayeeCalldata(expectedPayee, destinationReceiver) builds the Staking.set_payee calldata via SWR, but the SWR key does NOT include destinationReceiver:

// useCalldata.tsx:197-198, destinationReceiver missing from the key
const { data: calldata } = useSWR(
    client && chainId && expectedPayee && metadata
        ? ["withdraw_unbonded", client, chainId, metadata, expectedPayee]  // <-- receiver absent
        : null,
    ([_, client, _chainId, metadata, expectedPayee]) => {
        ...
        case "Account":
            destination = RewardDestination().Account(destinationReceiver) // line 210, from closure

Trigger flow: (1) user selects payee “Account”, types valid receiver A → calldata_A computed; (2) user EDITS the input to valid receiver B → SWR key unchanged → NO refetch; (3) user clicks “Change Destination” → handleTransaction({ calldata: payeeCalldata /* still calldata_A */ }) → the wallet signs set_payee(Account(A)) while the screen shows B. The button is only disabled by !payeeCalldata / !destinationReceiverIsValid (Nominations.tsx:347-353), both still satisfied by value B. The only remaining defense is the wallet extension’s decode popup showing the real address (A). Systematic comparison: every other calldata hook includes its distinguishing parameters in the SWR key (useTransferCalldata: destination, amount; line 62; useNominateCalldata: addresses; line 160; bond/unbond/extra: amount; useWithdrawCalldata: numberOfSpans; line 176). Only usePayeeCalldata is defective. (Bonus: the "withdraw_unbonded" key label at line 198 is a copy-paste, functionally harmless.)

Impact: Staking rewards redirected to an address the user did not intend (reward stream only, not principal). Needs a specific interaction sequence (edit the field after initial computation, then click directly).

Reproduction:

Open /nominations with a bonded account (local/testnet) → change Destination to “Account” → fill valid receiver A → wait for payeeCalldata to populate → change the input to valid receiver B → immediately click “Change Destination” → compare the address in the wallet signing popup vs the input content.

Severity: Medium. Reason: violates the core “what you see is what you sign” property; impact is limited because (a) only the reward stream, not principal, (b) needs a specific interaction sequence, (c) the wallet decode popup still shows the correct value as a last defense. Not High because there is no direct fund-exfiltration path.

Recommendation: Add destinationReceiver to the SWR key: ["set_payee", client, chainId, metadata, expectedPayee, destinationReceiver] (and fix the wrong key label while at it).

Verification: VERIFIED.

That totally makes sense! It’s a small fix, but it will help users avoid pitfalls and unexpected behavior during calldata preparation and the transaction that gets signed down the road.

Does ghost-dao-interface have the same issue?

Good question. I checked ghost-dao-interface for the same pattern: it has no payee or set_payee flow at all (no nomination UI; staking there goes through the GhostStaking contracts, not native staking). So the stale-calldata issue is specific to ghost-lite’s usePayeeCalldata hook.

The fix is the one-liner from the report: add destinationReceiver to the SWR key (and fix the “withdraw_unbonded” key label copy-paste while at it).