// Preparation of the transaction an approval screen displays.
//
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
// The nonce, the gas limit and the fees have to be filled in from the network
// before anything can be signed, and whoever fills them in decides what the
// user is shown. That work used to happen in the popup, after the user had
// already approved: the numbers on the approval screen came from the popup and
// were compared against nothing, so a compromised popup could display one fee
// and sign another, and the ceilings in approvalVerify.js were all that stood
// between the user and a fee that hands the validator the balance.
//
// So it happens here instead, in the background, before the approval window is
// opened. The background populates the transaction, shows that object, and
// verifies the signed artifact against that same object — the popup is handed
// a finished transaction and signs it as given. Every field the user reads is
// then a field that is compared.
//
// The cost is an RPC round trip before the approval window exists. Nothing is
// displayed while it is in flight, and a failure — an unreachable node, a
// reverting gas estimate, a transaction type this wallet does not sign, a fee
// past the ceilings — means no approval and no window at all: the error goes
// back to the requesting page, which is where the user's click came from. That
// is deliberate. The alternative, opening the window first and populating
// behind a spinner, needs a pending approval that exists before it can be
// displayed or signed, and a half-initialised approval is exactly the state
// the settle interlock in the background exists to keep out of that record.
// The failure also lands earlier than it used to rather than later: the same
// estimate previously failed after the user had typed their password.

const {
    VoidSigner,
    accessListify,
    getAddress,
    getBytes,
    hexlify,
    toQuantity,
} = require("ethers");
const {
    ALLOWED_TX_TYPES,
    SERIALIZED_FIELDS,
    assertWithinCeilings,
} = require("./approvalVerify");

// How long the population may take before the request is failed back to the
// page. Without a bound a hung RPC endpoint leaves the dApp's promise pending
// forever with nothing on screen to explain it; ethers' own request timeout is
// minutes long, which is not a wait anyone will sit through.
const POPULATE_TIMEOUT_MS = 20000;

// The request fields taken from the page. Anything else is dropped rather than
// passed to ethers: the object is page-controlled, and a future ethers that
// learns to carry a new transaction field must not start picking one up out of
// it without this module knowing.
const REQUEST_FIELDS = [
    "to",
    "value",
    "data",
    "nonce",
    "gasLimit",
    "gasPrice",
    "maxFeePerGas",
    "maxPriorityFeePerGas",
    "chainId",
    "accessList",
    "type",
];

class ApprovalPrepareError extends Error {
    constructor(message) {
        super(message);
        this.name = "ApprovalPrepareError";
    }
}

function fail(message) {
    return new ApprovalPrepareError(message);
}

function present(v) {
    return v !== null && v !== undefined && v !== "";
}

// These strings reach the user through the requesting page, so they are full
// sentences even when the tail of one came from ethers or from the node.
function sentence(text) {
    return /[.!?]$/.test(text) ? text : text + ".";
}

// Reject a promise that has taken too long, and never leave the timer behind.
async function withTimeout(promise, ms, message) {
    let timer = null;
    try {
        return await Promise.race([
            promise,
            new Promise((_resolve, reject) => {
                timer = setTimeout(() => reject(fail(message)), ms);
            }),
        ]);
    } finally {
        if (timer !== null) clearTimeout(timer);
    }
}

// The page's request, reduced to the fields this wallet acts on.
function requestFrom(txParams, from) {
    const request = { from: getAddress(from) };
    for (const key of REQUEST_FIELDS) {
        if (present(txParams[key])) request[key] = txParams[key];
    }
    if (
        present(request.type) &&
        !ALLOWED_TX_TYPES.includes(Number(request.type))
    ) {
        throw fail(
            "The site asked for a transaction of a type this wallet does not sign.",
        );
    }
    return request;
}

// Turn a populated transaction into the object that crosses to the popup, is
// displayed, and is compared with the signed artifact. It carries exactly the
// fields its type serializes, plus the address it is to be signed by, and
// every quantity as a hex string: extension messaging is JSON, which has no
// bigint, and a field that did not survive the trip would be a field the user
// was shown and nothing compared.
function serializeApprovedTx(populated, from) {
    const type = Number(populated.type);
    if (!ALLOWED_TX_TYPES.includes(type)) {
        throw fail(
            "This transaction would have to be sent as a type this wallet does not sign.",
        );
    }
    const approved = { type, from: getAddress(from) };
    for (const key of SERIALIZED_FIELDS[type]) {
        if (key === "to") {
            approved.to = present(populated.to)
                ? getAddress(populated.to)
                : null;
        } else if (key === "data") {
            approved.data = present(populated.data)
                ? hexlify(getBytes(populated.data))
                : "0x";
        } else if (key === "accessList") {
            approved.accessList = accessListify(populated.accessList || []);
        } else if (key === "value") {
            approved.value = toQuantity(populated.value || 0);
        } else if (!present(populated[key])) {
            // Unreachable while populateTransaction() fills every quantity of
            // the type it produced. If it ever does not, the approval must not
            // be raised: an unfixed quantity is one the artifact cannot be
            // checked against.
            throw fail(
                "The transaction could not be prepared: the network did not supply a " +
                    key +
                    ".",
            );
        } else {
            approved[key] = toQuantity(populated[key]);
        }
    }
    return approved;
}

// Populate the transaction a site asked for, as the address it will be signed
// by, and return the object to display, sign and verify against. Throws with a
// full sentence when no approval can be raised.
async function prepareApprovalTx(provider, from, txParams) {
    if (!present(from)) {
        throw fail("There is no active address to send this transaction from.");
    }
    const request = requestFrom(txParams || {}, from);

    let populated;
    try {
        // The sequence ethers' own sendTransaction() runs internally, so the
        // nonce, gas, fee and chain id are populated exactly as they were when
        // the popup did this. VoidSigner cannot sign, which is the point: the
        // background prepares, the popup signs.
        populated = await withTimeout(
            new VoidSigner(getAddress(from), provider).populateTransaction(
                request,
            ),
            POPULATE_TIMEOUT_MS,
            "The transaction could not be prepared: the network did not answer in time.",
        );
    } catch (e) {
        if (e instanceof ApprovalPrepareError) throw e;
        throw fail(
            sentence(
                "The transaction could not be prepared: " +
                    (e.shortMessage ||
                        e.message ||
                        "the network did not answer"),
            ),
        );
    }

    const approved = serializeApprovedTx(populated, from);
    // The backstop, applied before the user is shown anything rather than
    // after they have approved it: what is displayed here is what gets signed,
    // so an RPC node reporting an absurd fee has to be refused here.
    assertWithinCeilings(approved);
    return approved;
}

module.exports = {
    prepareApprovalTx,
    serializeApprovedTx,
    ApprovalPrepareError,
    POPULATE_TIMEOUT_MS,
    REQUEST_FIELDS,
};
