// Decode Uniswap Universal Router execute() calldata into human-readable
// swap details.  Designed to be extended with other DEX decoders later.

const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { truncateAmountNeverZero } = require("./amountDisplay");
const {
    resolveTokenDecimals,
    unknownDecimalsAmount,
} = require("./approvalAmount");

const coder = AbiCoder.defaultAbiCoder();

const ROUTER_IFACE = new Interface([
    "function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);

// Universal Router command IDs (lower 5 bits of each command byte)
const COMMAND_NAMES = {
    0x00: "V3 Swap (Exact In)",
    0x01: "V3 Swap (Exact Out)",
    0x02: "Permit2 Transfer",
    0x03: "Permit2 Permit Batch",
    0x04: "Sweep",
    0x05: "Transfer",
    0x06: "Pay Portion",
    0x08: "V2 Swap (Exact In)",
    0x09: "V2 Swap (Exact Out)",
    0x0a: "Permit2 Permit",
    0x0b: "Wrap ETH",
    0x0c: "Unwrap WETH",
    0x0d: "Permit2 Transfer Batch",
    0x0e: "Balance Check",
    0x10: "V4 Swap",
    0x11: "V3 Position Mgr Permit",
    0x12: "V3 Position Mgr Call",
    0x13: "V4 Initialize Pool",
    0x14: "V4 Position Mgr Call",
    0x21: "Execute Sub-Plan",
};

// The swap's Amount and Min. received lines land on the same approval screen,
// and Amount is carried to the wait/success/error screens as the ERC-20 line
// is, so they take the same nonzero floor: a swap of an amount below 0.0001 is
// not "0.0000", and a slippage floor of one base unit does not read as "you may
// receive nothing".
function formatAmount(raw, decimals) {
    return truncateAmountNeverZero(formatUnits(raw, decimals));
}

// One wording for either side of the screen: a currency the calldata never
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes
// on a scale — not as a token name, and not as a quantity.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";

// Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
// value is the trap this decoder has now been bitten by five times.
function present(value) {
    return value !== null && value !== undefined;
}

// Uniswap V4 spells "use the whole open delta" as an amount of zero:
// v4-periphery `src/libraries/ActionConstants.sol` declares
// `uint128 internal constant OPEN_DELTA = 0` ("used to signal that an action
// should use the input value of the open delta on the pool manager or of the
// balance that the contract holds"), and `src/V4Router.sol` substitutes the
// full open credit whenever an exact-in swap action's `amountIn` equals it:
//
//     uint128 amountIn = params.amountIn;
//     if (amountIn == ActionConstants.OPEN_DELTA) {
//         amountIn = _getFullCredit(...).toUint128();
//     }
//
// in both `_swapExactInputSingle` and `_swapExactInput`. Sentinel and literal
// zero are the same uint128 word, so the encoding CANNOT distinguish them —
// and the router does not try: it reads every zero as the sentinel, so in V4
// there is no such thing as an exact-in swap of literally zero. The amount is
// therefore not stated by the calldata at all; it is whatever credit is open
// at execution time. It is carried as this sentinel rather than as 0n because
// printing "0.0000" would state the exact inverse of what will happen —
// "nothing is being swapped" for a step that swaps the entire balance.
//
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
// so a zero `amountIn` there is a literal zero and is displayed as one.
const OPEN_DELTA = Symbol("v4-open-delta");

// The two amount lines that state a fact instead of a quantity. Same register
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
// as a number — and deliberately not a third phrasing of "not named": these
// say different things.
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const NO_MINIMUM = "None (no minimum guaranteed)";

// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");

// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
//
// A null `address` means UNDETERMINED — the calldata named no currency for
// that side — and is refused rather than named. It is not native ETH: Uniswap
// V4 spells native ETH as `Currency.wrap(address(0))` (v4-core
// `type Currency is address`), and a Currency is ABI-encoded as a plain
// address word, so every decode site here gets back the truthy string
// "0x0000000000000000000000000000000000000000" for it — never null. WRAP_ETH
// sets that same explicit zero address, and an UNWRAP_WETH output is caught by
// its caller before this is consulted, so nothing that genuinely is ETH
// arrives null. Naming a null ETH states the wrong asset and formats its
// amount at the wrong scale.
function tokenInfo(address, sources) {
    if (!address) {
        return { symbol: null, decimals: null, address: null };
    }
    if (address === "0x0000000000000000000000000000000000000000") {
        return { symbol: "ETH", decimals: 18, address: null };
    }
    const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
    return {
        symbol: t ? t.symbol : null,
        decimals: resolveTokenDecimals(address, sources),
        address,
    };
}

// A swap amount line. With a scale it is the token quantity; with none it is
// the base-unit integer with the unknown scale stated, the same refusal the
// ERC-20 amount line makes, so the screen has one way of saying it. `display`
// is the line on the screen, `raw` is what the status screens carry.
function amountText(raw, info) {
    if (info.decimals === null) {
        const unknown = unknownDecimalsAmount(raw);
        return { raw: unknown, display: unknown };
    }
    const formatted = formatAmount(raw, info.decimals);
    return {
        raw: formatted,
        display: formatted + (info.symbol ? " " + info.symbol : ""),
    };
}

// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
// ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce),
//        address spender, uint256 sigDeadline), bytes signature
function decodePermit2(input) {
    try {
        const d = coder.decode(
            [
                "tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
                "bytes",
            ],
            input,
        );
        return { token: d[0][0][0], amount: d[0][0][1], spender: d[0][1] };
    } catch {
        return null;
    }
}

// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes.
// ABI: (address owner, address token, uint256 minBalance)
function decodeBalanceCheck(input) {
    try {
        const d = coder.decode(["address", "address", "uint256"], input);
        return { owner: d[0], token: d[1], minBalance: d[2] };
    } catch {
        return null;
    }
}

// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
//        address[] path, bool payerIsUser)
function decodeV2SwapExactIn(input) {
    try {
        const d = coder.decode(
            ["address", "uint256", "uint256", "address[]", "bool"],
            input,
        );
        return {
            amountIn: d[1],
            amountOutMin: d[2],
            tokenIn: d[3][0],
            tokenOut: d[3][d[3].length - 1],
        };
    } catch {
        return null;
    }
}

// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
//        address[] path, bool payerIsUser)
//
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
// its command name and no token or amount detail. Kept for the fix, which is
// https://git.eeqj.de/sneak/AutistMask/issues/283.
// eslint-disable-next-line no-unused-vars
function decodeV2SwapExactOut(input) {
    try {
        const d = coder.decode(
            ["address", "uint256", "uint256", "address[]", "bool"],
            input,
        );
        return {
            amountOut: d[1],
            amountInMax: d[2],
            tokenIn: d[3][0],
            tokenOut: d[3][d[3].length - 1],
        };
    } catch {
        return null;
    }
}

// Decode V3 swap path (packed: token(20) + fee(3) + token(20) ...)
function decodeV3Path(pathHex) {
    const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
    if (hex.length < 40) return null;
    const tokenIn = "0x" + hex.slice(0, 40);
    const tokenOut = "0x" + hex.slice(-40);
    return { tokenIn, tokenOut };
}

// Decode V3_SWAP_EXACT_IN (command 0x00) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
//        bytes path, bool payerIsUser)
function decodeV3SwapExactIn(input) {
    try {
        const d = coder.decode(
            ["address", "uint256", "uint256", "bytes", "bool"],
            input,
        );
        const path = decodeV3Path(d[3]);
        if (!path) return null;
        return {
            amountIn: d[1],
            amountOutMin: d[2],
            tokenIn: path.tokenIn,
            tokenOut: path.tokenOut,
        };
    } catch {
        return null;
    }
}

// Decode WRAP_ETH (command 0x0b) input bytes.
// ABI: (address recipient, uint256 amount)
function decodeWrapEth(input) {
    try {
        const d = coder.decode(["address", "uint256"], input);
        return { amount: d[1] };
    } catch {
        return null;
    }
}

// V4 inner action IDs
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;

// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
// comment above. The exact-OUT actions below decode no amounts at all, so
// their own OPEN_DELTA mapping on `amountOut` never reaches the screen.
function v4ExactInAmount(raw) {
    return raw === 0n ? OPEN_DELTA : raw;
}

// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
// and swap amounts from the swap sub-actions.
function decodeV4Swap(input) {
    try {
        const d = coder.decode(["bytes", "bytes[]"], input);
        const actions = getBytes(d[0]);
        const params = d[1];

        let settleToken = null;
        let takeToken = null;
        let amountIn = null;
        let amountOutMin = null;

        for (let i = 0; i < actions.length; i++) {
            const actionId = actions[i];
            try {
                if (actionId === V4_SETTLE) {
                    // SETTLE: (address currency, uint256 maxAmount, bool payerIsUser)
                    const s = coder.decode(
                        ["address", "uint256", "bool"],
                        params[i],
                    );
                    settleToken = s[0];
                } else if (actionId === V4_TAKE) {
                    // TAKE: (address currency, address recipient, uint256 amount)
                    const t = coder.decode(
                        ["address", "address", "uint256"],
                        params[i],
                    );
                    takeToken = t[0];
                } else if (
                    actionId === V4_SWAP_EXACT_IN ||
                    actionId === V4_SWAP_EXACT_IN_SINGLE
                ) {
                    // Extract amounts from exact-in swap actions
                    if (actionId === V4_SWAP_EXACT_IN) {
                        // ExactInputParams: (address currencyIn,
                        //   tuple(address,uint24,int24,address,bytes)[] path,
                        //   uint128 amountIn, uint128 amountOutMin)
                        try {
                            const s = coder.decode(
                                [
                                    "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
                                ],
                                params[i],
                            );
                            if (!present(settleToken)) settleToken = s[0][0];
                            const path = s[0][1];
                            if (path.length > 0 && !present(takeToken)) {
                                takeToken = path[path.length - 1][0];
                            }
                            if (!present(amountIn)) {
                                amountIn = v4ExactInAmount(s[0][2]);
                            }
                            if (!present(amountOutMin)) {
                                amountOutMin = s[0][3];
                            }
                        } catch {
                            // Fall through — SETTLE/TAKE will provide tokens
                        }
                    } else {
                        // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
                        //   bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
                        try {
                            const s = coder.decode(
                                [
                                    "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
                                ],
                                params[i],
                            );
                            const poolKey = s[0][0];
                            const zeroForOne = s[0][1];
                            if (!present(settleToken))
                                settleToken = zeroForOne
                                    ? poolKey[0]
                                    : poolKey[1];
                            if (!present(takeToken))
                                takeToken = zeroForOne
                                    ? poolKey[1]
                                    : poolKey[0];
                            if (!present(amountIn)) {
                                amountIn = v4ExactInAmount(s[0][2]);
                            }
                            if (!present(amountOutMin)) {
                                amountOutMin = s[0][3];
                            }
                        } catch {
                            // Fall through
                        }
                    }
                } else if (
                    actionId === V4_SWAP_EXACT_OUT ||
                    actionId === V4_SWAP_EXACT_OUT_SINGLE
                ) {
                    if (actionId === V4_SWAP_EXACT_OUT) {
                        try {
                            const s = coder.decode(
                                [
                                    "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
                                ],
                                params[i],
                            );
                            if (!present(takeToken)) takeToken = s[0][0];
                            const path = s[0][1];
                            if (path.length > 0 && !present(settleToken)) {
                                settleToken = path[path.length - 1][0];
                            }
                        } catch {
                            // Fall through
                        }
                    } else {
                        try {
                            const s = coder.decode(
                                [
                                    "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
                                ],
                                params[i],
                            );
                            const poolKey = s[0][0];
                            const zeroForOne = s[0][1];
                            if (!present(settleToken))
                                settleToken = zeroForOne
                                    ? poolKey[0]
                                    : poolKey[1];
                            if (!present(takeToken))
                                takeToken = zeroForOne
                                    ? poolKey[1]
                                    : poolKey[0];
                        } catch {
                            // Fall through
                        }
                    }
                }
            } catch {
                // Skip sub-actions we can't decode
            }
        }

        return {
            tokenIn: settleToken,
            tokenOut: takeToken,
            amountIn,
            amountOutMin,
        };
    } catch {
        return null;
    }
}

// Try to decode a Universal Router execute() call.
// Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute().
function decode(data, toAddress, sources) {
    try {
        const parsed = ROUTER_IFACE.parseTransaction({ data });
        if (!parsed) return null;

        const commandsBytes = getBytes(parsed.args[0]);
        const inputs = parsed.args[1];
        const deadline = parsed.args[2];

        let inputToken = null;
        let inputAmount = null;
        let inputEstablished = false;
        let outputToken = null;
        let minOutput = null;
        let hasUnwrapWeth = false;
        const commandNames = [];

        // THE INVARIANT: an amount and the token it is counted in always come
        // from the same hop. A figure is never rendered against a token that
        // did not supply it.
        //
        // Both sides are therefore set as a PAIR — never field by field, and
        // never on truthiness. An address is never falsy once set but an
        // amount of 0n is, so gating the two halves independently let a hop
        // with a zero amount fix the token and leave the amount open; the next
        // hop's figure was then displayed against the first hop's token, at the
        // first hop's scale. A V3 USDT->WETH hop with amountIn 0 followed by a
        // V2 WETH->USDC hop of 0.5e18 rendered "500000000000.0000 USDT".
        //
        // The input side is fixed by the first hop that states either half, the
        // output side by the last, because the final leg is what the user
        // receives. A half the establishing hop did not state stays null and
        // the line says so, rather than being filled in from a different hop.
        const setInput = (token, amount) => {
            inputToken = present(token) ? token : null;
            inputAmount = present(amount) ? amount : null;
            inputEstablished = true;
        };
        const setInputOnce = (token, amount) => {
            if (inputEstablished) return;
            if (!present(token) && !present(amount)) return;
            setInput(token, amount);
        };
        const setOutput = (token, amount) => {
            if (!present(token) && !present(amount)) return;
            outputToken = present(token) ? token : null;
            minOutput = present(amount) ? amount : null;
        };

        for (let i = 0; i < commandsBytes.length; i++) {
            const cmdId = commandsBytes[i] & 0x1f;
            commandNames.push(
                COMMAND_NAMES[cmdId] ||
                    "Command 0x" + cmdId.toString(16).padStart(2, "0"),
            );

            try {
                if (cmdId === 0x0a) {
                    const p = decodePermit2(inputs[i]);
                    // A permit states both halves itself, so it may replace an
                    // input side an earlier hop established without breaking
                    // the invariant.
                    if (p) setInput(p.token, p.amount);
                }

                if (cmdId === 0x0e) {
                    const b = decodeBalanceCheck(inputs[i]);
                    if (b) setOutput(b.token, b.minBalance);
                }

                if (cmdId === 0x00) {
                    const s = decodeV3SwapExactIn(inputs[i]);
                    if (s) {
                        setInputOnce(s.tokenIn, s.amountIn);
                        // Always update output: in multi-step swaps (V3 → V4),
                        // the last swap step determines the final output token
                        // and minimum received amount.
                        setOutput(s.tokenOut, s.amountOutMin);
                    }
                }

                if (cmdId === 0x08) {
                    const s = decodeV2SwapExactIn(inputs[i]);
                    if (s) {
                        setInputOnce(s.tokenIn, s.amountIn);
                        setOutput(s.tokenOut, s.amountOutMin);
                    }
                }

                if (cmdId === 0x0b) {
                    const w = decodeWrapEth(inputs[i]);
                    if (w) {
                        setInputOnce(
                            "0x0000000000000000000000000000000000000000",
                            w.amount,
                        );
                    }
                }

                if (cmdId === 0x10) {
                    const v4 = decodeV4Swap(inputs[i]);
                    if (v4) {
                        setInputOnce(v4.tokenIn, v4.amountIn);
                        // Always update output: last swap step wins. A step
                        // that carries the Min. received figure but decoded no
                        // output currency makes the output *undetermined* — it
                        // is neither ETH nor whatever an earlier step named,
                        // and that figure is no longer counted in that token.
                        // Equally, a step that names an output currency but no
                        // minimum leaves Min. received unstated rather than
                        // keeping an earlier step's figure beside the new
                        // token. setOutput() is both rules; a step that states
                        // neither half leaves the output alone.
                        setOutput(v4.tokenOut, v4.amountOutMin);
                    }
                }

                if (cmdId === 0x0c) {
                    hasUnwrapWeth = true;
                }
            } catch {
                // Skip commands we can't decode
            }
        }

        // Resolve token info. A null token on either side means the calldata
        // named no currency for it; tokenInfo() refuses rather than calling it
        // ETH. UNWRAP_WETH is the one output that is ETH without a currency to
        // decode, and it is answered here rather than left to that rule.
        const inInfo = tokenInfo(inputToken, sources);
        const outInfo = hasUnwrapWeth
            ? { symbol: "ETH", decimals: 18, address: null }
            : tokenInfo(outputToken, sources);

        const inSymbol = inInfo.symbol;
        const outSymbol = outInfo.symbol;

        const name =
            inSymbol && outSymbol
                ? "Swap " + inSymbol + " \u2192 " + outSymbol
                : "Uniswap Swap";

        const details = [];

        details.push({
            label: "Protocol",
            value: "Uniswap Universal Router",
            address: toAddress,
        });

        if (present(inputToken) && present(inInfo.address)) {
            const label = inSymbol
                ? inSymbol + " (" + inputToken + ")"
                : inputToken;
            details.push({
                label: "Token In",
                value: label,
                address: inputToken,
                isToken: true,
            });
        } else if (inSymbol === "ETH") {
            details.push({ label: "Token In", value: "ETH (native)" });
        } else {
            // Nothing established the input token, so the line says that
            // rather than going missing or naming a token by default. Same
            // wording as the Token Out refusal below: the two sides of this
            // screen must not describe the same condition in two ways.
            details.push({ label: "Token In", value: UNNAMED_CURRENCY });
        }

        if (present(inputAmount)) {
            // Two amounts need no scale to describe and are named rather than
            // formatted: V4's open delta, which is not a quantity at all (see
            // OPEN_DELTA), and an unbounded permit. The open-delta test comes
            // first — the sentinel is not a bigint and cannot be compared with
            // one.
            let amount;
            if (inputAmount === OPEN_DELTA) {
                amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
            } else if (inputAmount >= MAX_UINT160) {
                amount = { raw: "Unlimited", display: "Unlimited" };
            } else {
                amount = amountText(inputAmount, inInfo);
            }
            details.push({
                label: "Amount",
                value: amount.display,
                rawValue: amount.raw,
            });
        }

        // Keyed on the address, not the symbol: a token absent from the
        // bundled list has no symbol, and gating the line on one dropped it
        // entirely, leaving a Min. received figure with nothing saying what is
        // being received. The Token In line above already falls back to the
        // address; this does the same.
        if (present(outInfo.address)) {
            const label = outSymbol
                ? outSymbol + " (" + outInfo.address + ")"
                : outInfo.address;
            details.push({
                label: "Token Out",
                value: label,
                address: outInfo.address,
                isToken: true,
            });
        } else if (outSymbol) {
            details.push({ label: "Token Out", value: outSymbol });
        } else {
            // Nothing established the output token, so the line says that
            // rather than going missing or naming a token by default, and a
            // Min. received figure below it is never attached to a token the
            // calldata did not state.
            details.push({ label: "Token Out", value: UNNAMED_CURRENCY });
        }

        if (present(minOutput)) {
            // A zero floor is the one case the user most needs stated: the
            // swap guarantees nothing back. It is said in words, in the same
            // register as UNNAMED_CURRENCY, because "0.0000 WETH" reads as an
            // artifact of the four-decimal rule rather than as "this may
            // return nothing" — and because it is true at every scale, so it
            // holds even when the output token's decimals are unknown.
            details.push({
                label: "Min. received",
                value:
                    minOutput === 0n
                        ? NO_MINIMUM
                        : amountText(minOutput, outInfo).display,
            });
        }

        details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });

        const deadlineDate = new Date(Number(deadline) * 1000);
        details.push({
            label: "Deadline",
            value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
        });

        return {
            name,
            description: "Swap via Uniswap Universal Router",
            details,
        };
    } catch {
        return null;
    }
}

module.exports = { decode };
