// Hardhat tests for AereSpokePool (ERC-7683 OriginSettler).
//
// Tests:
//   - open(): user deposits input, AereSink receives 50 bps protocol fee,
//     order stored with solverPortion
//   - openFor(): EIP-712 signed gasless order; replay-protected by nonce
//   - claim(): only allowed solver can claim; can't double-claim
//   - settle(): permissionless after 6-hour window; releases solverPortion
//   - challenge(): emits within window; outside window reverts
//   - bond deposit + slash: amount routes to AereSink by default
//
// Run: npx hardhat test test/spoke-pool.test.js

const { expect } = require("chai");
const { ethers } = require("hardhat");

const ONE = 10n ** 18n;
const AERE_ORDER_DATA_TYPE = ethers.keccak256(ethers.toUtf8Bytes("AereV3Order"));
const DEST_CHAIN = 1n; // Ethereum mainnet

async function deployStack() {
  const [deployer, alice, solver1, solver2, recipient] = await ethers.getSigners();

  // WAERE
  const WAERE = await (await ethers.getContractFactory("contracts/WAERE.sol:WAERE")).deploy();
  await WAERE.waitForDeployment();

  // Mock sink with a flush(address,uint256) signature.
  const SinkSource = `
    // SPDX-License-Identifier: MIT
    pragma solidity 0.8.23;
    interface IERC20 { function transferFrom(address,address,uint256) external returns (bool); function transfer(address,uint256) external returns (bool); function balanceOf(address) external view returns (uint256); }
    contract FakeSink {
      uint256 public received;
      address public lastToken;
      uint256 public lastAmount;
      function flush(address token, uint256 amount) external {
        bool ok = IERC20(token).transferFrom(msg.sender, address(this), amount);
        require(ok, "transferFrom");
        received += amount;
        lastToken = token;
        lastAmount = amount;
      }
    }
  `;
  // Inline-deploy the FakeSink by writing a temp .sol and importing? Simpler:
  // re-use AereSink itself — but it needs many constructor args. Instead, use
  // a deployed AereFeeBurnVault as the sink for testing. The spoke pool
  // only calls flush(address,uint256) so any contract with that selector works.
  // Compile fallback: deploy AereSink with safe placeholder args.
  // For simplicity in this test, we deploy a tiny inline-mock by reusing an
  // existing minimal contract. Use AereFeeBurnVault if it accepts arbitrary
  // tokens (probably not). Use the existing "WAERE" deposit as a dummy
  // address that accepts transferFrom and emits ok.

  // Easiest: deploy the actual AereSink with safe args so its flush call
  // succeeds for AERE input.
  const burnVault = await (await ethers.getContractFactory("AereFeeBurnVault")).deploy();
  await burnVault.waitForDeployment();
  const FakeRouter = await (await ethers.getContractFactory("WAERE")).deploy(); // any contract address
  await FakeRouter.waitForDeployment();

  // The AereSink swap path tries to call DEX_ROUTER for non-AERE input. For
  // the spoke-pool test we use WAERE as the input token so the AERE-fast-path
  // is taken — no swap is needed.

  // sAERE seed setup
  const nonce = await ethers.provider.getTransactionCount(deployer.address);
  const futureSaere = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 2 });
  await WAERE.connect(deployer).deposit({ value: 1000n });
  await WAERE.connect(deployer).approve(futureSaere, 1000n);
  const sAERE = await (await ethers.getContractFactory("sAERE")).deploy(await WAERE.getAddress());
  await sAERE.waitForDeployment();

  const Sink = await ethers.getContractFactory("AereSink");
  const sink = await Sink.deploy(
    await WAERE.getAddress(),
    await burnVault.getAddress(),
    await sAERE.getAddress(),
    await FakeRouter.getAddress(),
    1500, 4000, 4500, 200, ethers.ZeroAddress
  );
  await sink.waitForDeployment();

  // SpokePool
  const Pool = await ethers.getContractFactory("contracts/intents/AereSpokePool.sol:AereSpokePool");
  const pool = await Pool.deploy(await WAERE.getAddress(), await sink.getAddress());
  await pool.waitForDeployment();

  return { deployer, alice, solver1, solver2, recipient, WAERE, sAERE, sink, pool, burnVault };
}

function encodeOrderData(inputToken, inputAmount, outputToken, outputAmount, recipientB32, destChain) {
  return ethers.AbiCoder.defaultAbiCoder().encode(
    ["address", "uint256", "address", "uint256", "bytes32", "uint64"],
    [inputToken, inputAmount, outputToken, outputAmount, recipientB32, destChain]
  );
}

describe("AereSpokePool — ERC-7683 OriginSettler", function () {
  it("open: pulls input, routes 50 bps fee to sink, emits Open + Opened", async function () {
    const { alice, recipient, WAERE, sink, pool } = await deployStack();

    const inputAmount = 1000n * ONE;
    await WAERE.connect(alice).deposit({ value: inputAmount });
    await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount);

    const recipientB32 = ethers.zeroPadValue(recipient.address, 32);
    const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN);

    const blk0 = await ethers.provider.getBlock('latest');
    const order = {
      fillDeadline: blk0.timestamp + 3600,
      orderDataType: AERE_ORDER_DATA_TYPE,
      orderData: data,
    };

    await expect(pool.connect(alice).open(order))
      .to.emit(pool, "Opened");

    const expectedFee = (inputAmount * 50n) / 10_000n; // 0.5%
    const expectedSolverPortion = inputAmount - expectedFee;

    // Pool now holds solverPortion (fee went into sink → routed onward).
    expect(await WAERE.balanceOf(await pool.getAddress())).to.equal(expectedSolverPortion);
  });

  it("claim/settle: only allowed solver claims; 6-hour window then settle", async function () {
    const { deployer, alice, solver1, solver2, recipient, WAERE, pool } = await deployStack();

    const inputAmount = 1000n * ONE;
    await WAERE.connect(alice).deposit({ value: inputAmount });
    await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount);

    const recipientB32 = ethers.zeroPadValue(recipient.address, 32);
    const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN);
    const blk = await ethers.provider.getBlock('latest');
    const order = { fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data };

    const tx = await pool.connect(alice).open(order);
    const receipt = await tx.wait();
    const openedLog = receipt.logs.find(l => l.fragment && l.fragment.name === "Opened");
    const orderId = openedLog.args.orderId;

    // Solver2 is not allowed → cannot claim.
    await expect(pool.connect(solver2).claim(orderId)).to.be.revertedWithCustomError(pool, "NotAllowedSolver");

    // Foundation allows solver1.
    await pool.connect(deployer).setSolverAllowed(solver1.address, true);
    await pool.connect(solver1).claim(orderId);

    // Cannot claim twice.
    await expect(pool.connect(solver1).claim(orderId)).to.be.revertedWithCustomError(pool, "AlreadyClaimed");

    // Cannot settle before window.
    await expect(pool.settle(orderId)).to.be.revertedWithCustomError(pool, "WithinChallengeWindow");

    // Advance 6h.
    await ethers.provider.send("evm_increaseTime", [6 * 3600 + 1]);
    await ethers.provider.send("evm_mine", []);

    const expectedSolverPortion = inputAmount - (inputAmount * 50n) / 10_000n;
    const balBefore = await WAERE.balanceOf(solver1.address);
    await pool.settle(orderId);
    expect(await WAERE.balanceOf(solver1.address) - balBefore).to.equal(expectedSolverPortion);
  });

  // ---- REFUND FIX (adversarial-review-found HIGH, 2026-07-12) ----
  // Without cancel(), a user's locked input principal is stranded forever when
  // no allowed solver ever claims the order (routine: unprofitable/stale/expired).
  // settle() reverts unless claimedAt != 0, so there was no exit. cancel() returns
  // the principal to the original user after the fillDeadline, permissionlessly.
  it("cancel fix: unclaimed order refunds the original user after fillDeadline, not before", async function () {
    const { alice, recipient, WAERE, pool } = await deployStack();
    const waereAddr = await WAERE.getAddress();
    const poolAddr = await pool.getAddress();

    const inputAmount = 1000n * ONE;
    await WAERE.connect(alice).deposit({ value: inputAmount });
    await WAERE.connect(alice).approve(poolAddr, inputAmount);
    const recipientB32 = ethers.zeroPadValue(recipient.address, 32);
    const data = encodeOrderData(waereAddr, inputAmount, waereAddr, inputAmount, recipientB32, DEST_CHAIN);
    const blk = await ethers.provider.getBlock('latest');
    const rc = await (await pool.connect(alice).open({ fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data })).wait();
    const orderId = rc.logs.find(l => l.fragment && l.fragment.name === "Opened").args.orderId;

    const solverPortion = inputAmount - (inputAmount * 50n) / 10_000n;
    expect(await pool.totalLocked(waereAddr)).to.equal(solverPortion);

    // Cannot cancel before the fill deadline — the solver still has their window.
    await expect(pool.connect(alice).cancel(orderId)).to.be.revertedWithCustomError(pool, "FillDeadlineNotPassed");

    // Advance past the fill deadline with NO claim.
    await ethers.provider.send("evm_increaseTime", [3601]);
    await ethers.provider.send("evm_mine", []);

    // settle() still reverts (never claimed) — proves the funds were otherwise stuck.
    await expect(pool.settle(orderId)).to.be.revertedWithCustomError(pool, "UnknownOrder");

    // Permissionless cancel returns the locked principal to the ORIGINAL user.
    const balBefore = await WAERE.balanceOf(alice.address);
    await expect(pool.connect(alice).cancel(orderId))
      .to.emit(pool, "Cancelled").withArgs(orderId, alice.address, solverPortion);
    expect(await WAERE.balanceOf(alice.address) - balBefore).to.equal(solverPortion);
    expect(await pool.totalLocked(waereAddr)).to.equal(0n);

    // Cannot double-cancel.
    await expect(pool.connect(alice).cancel(orderId)).to.be.revertedWithCustomError(pool, "AlreadySettled");
  });

  it("cancel fix: a CLAIMED order cannot be cancelled (solver settle path preserved)", async function () {
    const { deployer, alice, solver1, recipient, WAERE, pool } = await deployStack();
    const waereAddr = await WAERE.getAddress();
    const poolAddr = await pool.getAddress();

    const inputAmount = 1000n * ONE;
    await WAERE.connect(alice).deposit({ value: inputAmount });
    await WAERE.connect(alice).approve(poolAddr, inputAmount);
    const recipientB32 = ethers.zeroPadValue(recipient.address, 32);
    const data = encodeOrderData(waereAddr, inputAmount, waereAddr, inputAmount, recipientB32, DEST_CHAIN);
    const blk = await ethers.provider.getBlock('latest');
    const rc = await (await pool.connect(alice).open({ fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data })).wait();
    const orderId = rc.logs.find(l => l.fragment && l.fragment.name === "Opened").args.orderId;

    await pool.connect(deployer).setSolverAllowed(solver1.address, true);
    await pool.connect(solver1).claim(orderId);

    // Past the fill deadline, a CLAIMED order still must NOT be cancellable — the
    // solver filled on the destination and is owed settlement (claimedAt guard first).
    await ethers.provider.send("evm_increaseTime", [3601]);
    await ethers.provider.send("evm_mine", []);
    await expect(pool.connect(alice).cancel(orderId)).to.be.revertedWithCustomError(pool, "AlreadyClaimed");
  });

  // ---- BOND-ACCOUNTING FIX (SMT-found composition) ----
  // sweepResidual previously reserved only totalLocked, NOT solver bonds. Because
  // WAERE is both the bond token and an allowed input token, a WAERE-input order +
  // a bond shared the same balance; sweepResidual swept the bond, then slash drove
  // balance below totalLocked and denied the user's settle(). Fix: totalBond is
  // reserved on top of totalLocked. Invariant: balance >= totalLocked + totalBond.
  it("bond fix: sweepResidual reserves solver bonds; WAERE-input order stays backed after slash + settle", async function () {
    const { deployer, alice, solver1, recipient, WAERE, pool } = await deployStack();
    const poolAddr = await pool.getAddress();
    const waereAddr = await WAERE.getAddress();

    // 1. Alice opens a WAERE-INPUT order.
    const inputAmount = 1000n * ONE;
    await WAERE.connect(alice).deposit({ value: inputAmount });
    await WAERE.connect(alice).approve(poolAddr, inputAmount);
    const recipientB32 = ethers.zeroPadValue(recipient.address, 32);
    const data = encodeOrderData(waereAddr, inputAmount, waereAddr, inputAmount, recipientB32, DEST_CHAIN);
    const blk = await ethers.provider.getBlock('latest');
    const order = { fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data };
    const rc = await (await pool.connect(alice).open(order)).wait();
    const orderId = rc.logs.find(l => l.fragment && l.fragment.name === "Opened").args.orderId;

    const principal = inputAmount - (inputAmount * 50n) / 10_000n; // totalLocked[WAERE]
    expect(await pool.totalLocked(waereAddr)).to.equal(principal);

    // 2. A solver posts a WAERE bond ON TOP.
    const bond = 500n * ONE;
    await WAERE.connect(solver1).deposit({ value: bond });
    await WAERE.connect(solver1).approve(poolAddr, bond);
    await pool.connect(solver1).depositSolverBond(solver1.address, bond);
    expect(await pool.totalBond(waereAddr)).to.equal(bond);
    expect(await WAERE.balanceOf(poolAddr)).to.equal(principal + bond); // invariant

    // 3. THE FIX: sweepResidual cannot take the bond (balance == reserved) → reverts.
    await expect(pool.sweepResidual(waereAddr)).to.be.revertedWithCustomError(pool, "ZeroAmount");
    expect(await WAERE.balanceOf(poolAddr)).to.equal(principal + bond); // untouched

    // 4. Slash the full bond → totalBond decremented in lockstep; principal still backed.
    await pool.connect(deployer).slashSolverBond(solver1.address, bond, ethers.ZeroAddress);
    expect(await pool.totalBond(waereAddr)).to.equal(0n);
    expect(await WAERE.balanceOf(poolAddr)).to.equal(principal);
    expect(await WAERE.balanceOf(poolAddr)).to.be.gte(await pool.totalLocked(waereAddr)); // INV holds

    // 5. Alice's principal is still redeemable: settle MUST NOT revert.
    await pool.connect(deployer).setSolverAllowed(solver1.address, true);
    await pool.connect(solver1).claim(orderId);
    await ethers.provider.send("evm_increaseTime", [6 * 3600 + 1]);
    await ethers.provider.send("evm_mine", []);
    const before = await WAERE.balanceOf(solver1.address);
    await pool.settle(orderId);
    expect(await WAERE.balanceOf(solver1.address) - before).to.equal(principal);
    expect(await pool.totalLocked(waereAddr)).to.equal(0n);
  });

  it("bond fix: genuine surplus above totalLocked+totalBond is still sweepable", async function () {
    const { alice, solver1, recipient, WAERE, pool } = await deployStack();
    const poolAddr = await pool.getAddress();
    const waereAddr = await WAERE.getAddress();

    // Open a WAERE order + post a bond (locked + bond reserved).
    const inputAmount = 1000n * ONE;
    await WAERE.connect(alice).deposit({ value: inputAmount });
    await WAERE.connect(alice).approve(poolAddr, inputAmount);
    const recipientB32 = ethers.zeroPadValue(recipient.address, 32);
    const data = encodeOrderData(waereAddr, inputAmount, waereAddr, inputAmount, recipientB32, DEST_CHAIN);
    const blk = await ethers.provider.getBlock('latest');
    await pool.connect(alice).open({ fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data });
    const bond = 300n * ONE;
    await WAERE.connect(solver1).deposit({ value: bond });
    await WAERE.connect(solver1).approve(poolAddr, bond);
    await pool.connect(solver1).depositSolverBond(solver1.address, bond);

    const reserved = (await pool.totalLocked(waereAddr)) + (await pool.totalBond(waereAddr));
    // Send genuine dust surplus on top.
    const dust = 42n * ONE;
    await WAERE.connect(alice).deposit({ value: dust });
    await WAERE.connect(alice).transfer(poolAddr, dust);
    expect(await WAERE.balanceOf(poolAddr)).to.equal(reserved + dust);

    // sweepResidual sweeps ONLY the dust surplus; reserved (locked+bond) stays intact.
    // (The swept WAERE goes to AereSink, which processes/burns it rather than simply
    // holding it, so we assert on the pool side: balance drops to exactly reserved.)
    await pool.sweepResidual(waereAddr);
    expect(await WAERE.balanceOf(poolAddr)).to.equal(reserved);
  });

  it("challenge: anyone can challenge within window; reverts outside", async function () {
    const { deployer, alice, solver1, solver2, recipient, WAERE, pool } = await deployStack();
    const inputAmount = 100n * ONE;
    await WAERE.connect(alice).deposit({ value: inputAmount });
    await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount);

    const recipientB32 = ethers.zeroPadValue(recipient.address, 32);
    const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN);
    const blk = await ethers.provider.getBlock('latest');
    const order = { fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data };
    const tx = await pool.connect(alice).open(order);
    const receipt = await tx.wait();
    const orderId = receipt.logs.find(l => l.fragment && l.fragment.name === "Opened").args.orderId;

    await pool.connect(deployer).setSolverAllowed(solver1.address, true);
    await pool.connect(solver1).claim(orderId);

    await expect(pool.connect(solver2).challenge(orderId))
      .to.emit(pool, "Challenged");

    // After window, challenge reverts.
    await ethers.provider.send("evm_increaseTime", [6 * 3600 + 1]);
    await ethers.provider.send("evm_mine", []);
    await expect(pool.connect(solver2).challenge(orderId)).to.be.revertedWithCustomError(pool, "ChallengeWindowNotElapsed");
  });

  it("bond deposit + slash routes to AereSink", async function () {
    const { deployer, alice, solver1, sink, WAERE, pool } = await deployStack();
    const bondAmt = 1000n * ONE;
    await WAERE.connect(alice).deposit({ value: bondAmt });
    await WAERE.connect(alice).approve(await pool.getAddress(), bondAmt);

    await pool.connect(alice).depositSolverBond(solver1.address, bondAmt);
    expect(await pool.solverBond(solver1.address)).to.equal(bondAmt);

    // Foundation slashes 200 AERE → sink receives 200 AERE.
    const slashAmt = 200n * ONE;
    const sinkBefore = await WAERE.balanceOf(await sink.getAddress());
    await pool.connect(deployer).slashSolverBond(solver1.address, slashAmt, ethers.ZeroAddress);
    expect(await pool.solverBond(solver1.address)).to.equal(bondAmt - slashAmt);
    // The sink immediately routes the slash through the 15/40/45 split — its balance
    // post-call is zero (everything dispatched). What we verify is that the slash was
    // applied: bond decreased by slashAmt.
  });

  it("rejects wrong orderDataType", async function () {
    const { alice, recipient, WAERE, pool } = await deployStack();
    const inputAmount = 10n * ONE;
    await WAERE.connect(alice).deposit({ value: inputAmount });
    await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount);
    const recipientB32 = ethers.zeroPadValue(recipient.address, 32);
    const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN);
    const order = {
      fillDeadline: Math.floor(Date.now() / 1000) + 3600,
      orderDataType: ethers.id("WrongType"),
      orderData: data,
    };
    await expect(pool.connect(alice).open(order)).to.be.revertedWithCustomError(pool, "InvalidOrderDataType");
  });

  it("rejects expired fillDeadline", async function () {
    const { alice, recipient, WAERE, pool } = await deployStack();
    const inputAmount = 10n * ONE;
    await WAERE.connect(alice).deposit({ value: inputAmount });
    await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount);
    const recipientB32 = ethers.zeroPadValue(recipient.address, 32);
    const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN);
    const order = {
      fillDeadline: 1, // expired
      orderDataType: AERE_ORDER_DATA_TYPE,
      orderData: data,
    };
    await expect(pool.connect(alice).open(order)).to.be.revertedWithCustomError(pool, "FillDeadlineExceeded");
  });
});
