// What the dApp approval screen says the output token of a V4 swap is when the
// calldata did not name one.
//
// Issue #353: `src/shared/uniswap.js` took a V4 step's `amountOutMin` while
// leaving `outputToken` null, and `tokenInfo(null)` answers
// `{symbol: "ETH", decimals: 18}`. An undetermined output was therefore stated
// to the user as ETH, with `Min. received` formatted at 18 decimals — the same
// class as https://git.eeqj.de/sneak/AutistMask/issues/340 and
// https://git.eeqj.de/sneak/AutistMask/issues/306, but naming the wrong asset
// rather than the wrong scale.
//
// The determination this file pins: null is NOT how V4 spells native ETH.
// v4-core declares `type Currency is address` and wraps `address(0)` for
// native ETH, and a Currency is ABI-encoded as a plain address word, so a
// native-ETH currency reaches the decoder as the string
// "0x0000000000000000000000000000000000000000" — truthy, and already named
// ETH by tokenInfo(). Both cases are asserted below: the zero address stays
// ETH, and null refuses.

const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");

const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ZERO = "0x0000000000000000000000000000000000000000";
// Both in the bundled list: USDT at 6 decimals, WETH at 18.
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";

const REFUSAL = "Unknown (not named in the calldata)";

// The figure whose asset is in question. At 18 decimals it renders as
// 0.000000000001; in base units it renders as itself.
const MIN_OUT = 1234567n;
const HALF_ETH = 500000000000000000n;

const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_SWAP_EXACT_IN = 0x07;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;

const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
    "function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);

function execute(commands, inputs) {
    return routerIface.encodeFunctionData("execute", [
        commands,
        inputs,
        9999999999n,
    ]);
}

function v4Input(actions, params) {
    return coder.encode(
        ["bytes", "bytes[]"],
        [new Uint8Array(actions), params],
    );
}

// IV4Router.ExactInputParams as the decoder reads it:
// (Currency currencyIn, PathKey[] path, uint128 amountIn, uint128 minOut).
// An empty path names no output currency at all.
function exactInParams(currencyIn, path, amountIn, amountOutMin) {
    return coder.encode(
        [
            "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
        ],
        [[currencyIn, path, amountIn, amountOutMin]],
    );
}

// IV4Router.ExactInputSingleParams: (PoolKey, bool zeroForOne, uint128
// amountIn, uint128 minOut, bytes hookData).
function exactInSingleParams(currency0, currency1, zeroForOne, min) {
    return coder.encode(
        [
            "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
        ],
        [
            [
                [currency0, currency1, 500, 10, ZERO],
                zeroForOne,
                1000000n,
                min,
                "0x",
            ],
        ],
    );
}

// V3_SWAP_EXACT_IN (command 0x00) input: path is token(20) fee(3) token(20).
function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) {
    const path =
        "0x" +
        tokenIn.slice(2).toLowerCase() +
        "000bb8" +
        tokenOut.slice(2).toLowerCase();
    return coder.encode(
        ["address", "uint256", "uint256", "bytes", "bool"],
        [USER, amountIn, amountOutMin, path, true],
    );
}

function detail(data, label) {
    const decoded = uniswap.decode(data, ROUTER, {});
    expect(decoded).not.toBeNull();
    return decoded.details.find((d) => d.label === label);
}

describe("a V4 step that states a Min. received but no output currency", () => {
    // SWAP_EXACT_IN with an empty path: amountOutMin decodes, no currency out
    // does, and there is no TAKE to supply one.
    const data = () =>
        execute("0x10", [
            v4Input(
                [V4_SWAP_EXACT_IN],
                [exactInParams(USDT, [], 5000000n, MIN_OUT)],
            ),
        ]);

    test("says the output token is unknown instead of naming ETH", () => {
        expect(detail(data(), "Token Out").value).toBe(REFUSAL);
    });

    test("keeps a Token Out line, so the figure is never unattributed", () => {
        const out = detail(data(), "Token Out");
        expect(out).toBeDefined();
        // A refusal is not a token: nothing to link to an explorer.
        expect(out.address).toBeUndefined();
        expect(out.isToken).toBeUndefined();
    });

    test("refuses to scale Min. received rather than assuming 18", () => {
        expect(detail(data(), "Min. received").value).toBe(
            unknownDecimalsAmount(MIN_OUT),
        );
    });

    test("does not name ETH in the swap title either", () => {
        const decoded = uniswap.decode(data(), ROUTER, {});
        expect(decoded.name).toBe("Uniswap Swap");
    });
});

describe("a V4 step whose Min. received supersedes an earlier step's", () => {
    // V3 USDT -> WETH, then a V4 step that carries the final Min. received but
    // names no currency. The figure belongs to the V4 step, so it must not be
    // read against the token the V3 step named.
    const data = () =>
        execute(solidityPacked(["uint8", "uint8"], [0x00, 0x10]), [
            v3ExactIn(USDT, WETH, 5000000n, HALF_ETH),
            v4Input(
                [V4_SWAP_EXACT_IN],
                [exactInParams(WETH, [], HALF_ETH, MIN_OUT)],
            ),
        ]);

    test("does not keep naming the earlier step's output token", () => {
        expect(detail(data(), "Token Out").value).toBe(REFUSAL);
    });

    test("refuses to scale the superseding figure", () => {
        expect(detail(data(), "Min. received").value).toBe(
            unknownDecimalsAmount(MIN_OUT),
        );
    });
});

describe("native ETH out, which V4 spells as the zero address", () => {
    // The pin on the other half of the determination. PoolKey currencies are
    // ordered, so native ETH is currency0; zeroForOne false swaps USDT in for
    // ETH out. TAKE names the same zero-address currency.
    const data = () =>
        execute("0x10", [
            v4Input(
                [V4_SETTLE, V4_SWAP_EXACT_IN_SINGLE, V4_TAKE],
                [
                    coder.encode(
                        ["address", "uint256", "bool"],
                        [USDT, 5000000n, true],
                    ),
                    exactInSingleParams(ZERO, USDT, false, HALF_ETH),
                    coder.encode(
                        ["address", "address", "uint256"],
                        [ZERO, USER, 0n],
                    ),
                ],
            ),
        ]);

    test("a Currency of address(0) decodes to a truthy address string", () => {
        // The fact the whole determination rests on: an absent output currency
        // and a native-ETH one are distinguishable here, because the ABI
        // decoder never yields null for an address word.
        const [currency] = coder.decode(
            ["address", "address", "uint256"],
            coder.encode(["address", "address", "uint256"], [ZERO, USER, 0n]),
        );
        expect(currency).toBe(ZERO);
        expect(Boolean(currency)).toBe(true);
    });

    test("is still named ETH and still scaled at 18 decimals", () => {
        expect(detail(data(), "Token Out").value).toBe("ETH");
        expect(detail(data(), "Min. received").value).toBe("0.5000 ETH");
        expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Swap USDT → ETH");
    });
});
