const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic');

const { GovernorHelper } = require('../helpers/governance');
const { OperationState } = require('../helpers/enums');
const time = require('../helpers/time');

const { shouldSupportInterfaces } = require('../utils/introspection/SupportsInterface.behavior');

const salt = '0x025e7b0be353a74631ad648c667493c0e1cd31caa4cc2d3520fdc171ea0cc726'; // a random value

const MINDELAY = time.duration.days(1);
const DEFAULT_ADMIN_ROLE = ethers.ZeroHash;
const PROPOSER_ROLE = ethers.id('PROPOSER_ROLE');
const EXECUTOR_ROLE = ethers.id('EXECUTOR_ROLE');
const CANCELLER_ROLE = ethers.id('CANCELLER_ROLE');

const getAddress = obj => obj.address ?? obj.target ?? obj;

function genOperation(target, value, data, predecessor, salt) {
  const id = ethers.keccak256(
    ethers.AbiCoder.defaultAbiCoder().encode(
      ['address', 'uint256', 'bytes', 'uint256', 'bytes32'],
      [getAddress(target), value, data, predecessor, salt],
    ),
  );
  return { id, target, value, data, predecessor, salt };
}

function genOperationBatch(targets, values, payloads, predecessor, salt) {
  const id = ethers.keccak256(
    ethers.AbiCoder.defaultAbiCoder().encode(
      ['address[]', 'uint256[]', 'bytes[]', 'uint256', 'bytes32'],
      [targets.map(getAddress), values, payloads, predecessor, salt],
    ),
  );
  return { id, targets, values, payloads, predecessor, salt };
}

async function fixture() {
  const [admin, proposer, canceller, executor, other] = await ethers.getSigners();

  const mock = await ethers.deployContract('TimelockController', [MINDELAY, [proposer], [executor], admin]);
  const callreceivermock = await ethers.deployContract('CallReceiverMock');
  const implementation2 = await ethers.deployContract('Implementation2');

  expect(await mock.hasRole(CANCELLER_ROLE, proposer)).to.be.true;
  await mock.connect(admin).revokeRole(CANCELLER_ROLE, proposer);
  await mock.connect(admin).grantRole(CANCELLER_ROLE, canceller);

  return {
    admin,
    proposer,
    canceller,
    executor,
    other,
    mock,
    callreceivermock,
    implementation2,
  };
}

describe('TimelockController', function () {
  beforeEach(async function () {
    Object.assign(this, await loadFixture(fixture));
  });

  shouldSupportInterfaces(['ERC1155Receiver']);

  it('initial state', async function () {
    expect(await this.mock.getMinDelay()).to.equal(MINDELAY);

    expect(await this.mock.DEFAULT_ADMIN_ROLE()).to.equal(DEFAULT_ADMIN_ROLE);
    expect(await this.mock.PROPOSER_ROLE()).to.equal(PROPOSER_ROLE);
    expect(await this.mock.EXECUTOR_ROLE()).to.equal(EXECUTOR_ROLE);
    expect(await this.mock.CANCELLER_ROLE()).to.equal(CANCELLER_ROLE);

    expect(
      await Promise.all(
        [PROPOSER_ROLE, CANCELLER_ROLE, EXECUTOR_ROLE].map(role => this.mock.hasRole(role, this.proposer)),
      ),
    ).to.deep.equal([true, false, false]);

    expect(
      await Promise.all(
        [PROPOSER_ROLE, CANCELLER_ROLE, EXECUTOR_ROLE].map(role => this.mock.hasRole(role, this.canceller)),
      ),
    ).to.deep.equal([false, true, false]);

    expect(
      await Promise.all(
        [PROPOSER_ROLE, CANCELLER_ROLE, EXECUTOR_ROLE].map(role => this.mock.hasRole(role, this.executor)),
      ),
    ).to.deep.equal([false, false, true]);
  });

  it('optional admin', async function () {
    const mock = await ethers.deployContract('TimelockController', [
      MINDELAY,
      [this.proposer],
      [this.executor],
      ethers.ZeroAddress,
    ]);
    expect(await mock.hasRole(DEFAULT_ADMIN_ROLE, this.admin)).to.be.false;
    expect(await mock.hasRole(DEFAULT_ADMIN_ROLE, mock.target)).to.be.true;
  });

  describe('methods', function () {
    describe('operation hashing', function () {
      it('hashOperation', async function () {
        this.operation = genOperation(
          '0x29cebefe301c6ce1bb36b58654fea275e1cacc83',
          '0xf94fdd6e21da21d2',
          '0xa3bc5104',
          '0xba41db3be0a9929145cfe480bd0f1f003689104d275ae912099f925df424ef94',
          '0x60d9109846ab510ed75c15f979ae366a8a2ace11d34ba9788c13ac296db50e6e',
        );
        expect(
          await this.mock.hashOperation(
            this.operation.target,
            this.operation.value,
            this.operation.data,
            this.operation.predecessor,
            this.operation.salt,
          ),
        ).to.equal(this.operation.id);
      });

      it('hashOperationBatch', async function () {
        this.operation = genOperationBatch(
          Array(8).fill('0x2d5f21620e56531c1d59c2df9b8e95d129571f71'),
          Array(8).fill('0x2b993cfce932ccee'),
          Array(8).fill('0xcf51966b'),
          '0xce8f45069cc71d25f71ba05062de1a3974f9849b004de64a70998bca9d29c2e7',
          '0x8952d74c110f72bfe5accdf828c74d53a7dfb71235dfa8a1e8c75d8576b372ff',
        );
        expect(
          await this.mock.hashOperationBatch(
            this.operation.targets,
            this.operation.values,
            this.operation.payloads,
            this.operation.predecessor,
            this.operation.salt,
          ),
        ).to.equal(this.operation.id);
      });
    });
    describe('simple', function () {
      describe('schedule', function () {
        beforeEach(async function () {
          this.operation = genOperation(
            '0x31754f590B97fD975Eb86938f18Cc304E264D2F2',
            0n,
            '0x3bf92ccc',
            ethers.ZeroHash,
            salt,
          );
        });

        it('proposer can schedule', async function () {
          const tx = await this.mock
            .connect(this.proposer)
            .schedule(
              this.operation.target,
              this.operation.value,
              this.operation.data,
              this.operation.predecessor,
              this.operation.salt,
              MINDELAY,
            );

          await expect(tx)
            .to.emit(this.mock, 'CallScheduled')
            .withArgs(
              this.operation.id,
              0n,
              this.operation.target,
              this.operation.value,
              this.operation.data,
              this.operation.predecessor,
              MINDELAY,
            )
            .to.emit(this.mock, 'CallSalt')
            .withArgs(this.operation.id, this.operation.salt);

          expect(await this.mock.getTimestamp(this.operation.id)).to.equal(
            (await time.clockFromReceipt.timestamp(tx)) + MINDELAY,
          );
        });

        it('prevent overwriting active operation', async function () {
          await this.mock
            .connect(this.proposer)
            .schedule(
              this.operation.target,
              this.operation.value,
              this.operation.data,
              this.operation.predecessor,
              this.operation.salt,
              MINDELAY,
            );

          await expect(
            this.mock
              .connect(this.proposer)
              .schedule(
                this.operation.target,
                this.operation.value,
                this.operation.data,
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
            .withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Unset));
        });

        it('prevent non-proposer from committing', async function () {
          await expect(
            this.mock
              .connect(this.other)
              .schedule(
                this.operation.target,
                this.operation.value,
                this.operation.data,
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
            .withArgs(this.other, PROPOSER_ROLE);
        });

        it('enforce minimum delay', async function () {
          await expect(
            this.mock
              .connect(this.proposer)
              .schedule(
                this.operation.target,
                this.operation.value,
                this.operation.data,
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY - 1n,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'TimelockInsufficientDelay')
            .withArgs(MINDELAY - 1n, MINDELAY);
        });

        it('schedule operation with salt zero', async function () {
          await expect(
            this.mock
              .connect(this.proposer)
              .schedule(
                this.operation.target,
                this.operation.value,
                this.operation.data,
                this.operation.predecessor,
                ethers.ZeroHash,
                MINDELAY,
              ),
          ).to.not.emit(this.mock, 'CallSalt');
        });
      });

      describe('execute', function () {
        beforeEach(async function () {
          this.operation = genOperation(
            '0xAe22104DCD970750610E6FE15E623468A98b15f7',
            0n,
            '0x13e414de',
            ethers.ZeroHash,
            '0xc1059ed2dc130227aa1d1d539ac94c641306905c020436c636e19e3fab56fc7f',
          );
        });

        it('revert if operation is not scheduled', async function () {
          await expect(
            this.mock
              .connect(this.executor)
              .execute(
                this.operation.target,
                this.operation.value,
                this.operation.data,
                this.operation.predecessor,
                this.operation.salt,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
            .withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
        });

        describe('with scheduled operation', function () {
          beforeEach(async function () {
            await this.mock
              .connect(this.proposer)
              .schedule(
                this.operation.target,
                this.operation.value,
                this.operation.data,
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY,
              );
          });

          it('revert if execution comes too early 1/2', async function () {
            await expect(
              this.mock
                .connect(this.executor)
                .execute(
                  this.operation.target,
                  this.operation.value,
                  this.operation.data,
                  this.operation.predecessor,
                  this.operation.salt,
                ),
            )
              .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
              .withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
          });

          it('revert if execution comes too early 2/2', async function () {
            // -1 is too tight, test sometime fails
            await this.mock.getTimestamp(this.operation.id).then(clock => time.increaseTo.timestamp(clock - 5n));

            await expect(
              this.mock
                .connect(this.executor)
                .execute(
                  this.operation.target,
                  this.operation.value,
                  this.operation.data,
                  this.operation.predecessor,
                  this.operation.salt,
                ),
            )
              .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
              .withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
          });

          describe('on time', function () {
            beforeEach(async function () {
              await this.mock.getTimestamp(this.operation.id).then(time.increaseTo.timestamp);
            });

            it('executor can reveal', async function () {
              await expect(
                this.mock
                  .connect(this.executor)
                  .execute(
                    this.operation.target,
                    this.operation.value,
                    this.operation.data,
                    this.operation.predecessor,
                    this.operation.salt,
                  ),
              )
                .to.emit(this.mock, 'CallExecuted')
                .withArgs(this.operation.id, 0n, this.operation.target, this.operation.value, this.operation.data);
            });

            it('prevent non-executor from revealing', async function () {
              await expect(
                this.mock
                  .connect(this.other)
                  .execute(
                    this.operation.target,
                    this.operation.value,
                    this.operation.data,
                    this.operation.predecessor,
                    this.operation.salt,
                  ),
              )
                .to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
                .withArgs(this.other, EXECUTOR_ROLE);
            });

            it('prevents reentrancy execution', async function () {
              // Create operation
              const reentrant = await ethers.deployContract('$TimelockReentrant');
              const reentrantOperation = genOperation(
                reentrant,
                0n,
                reentrant.interface.encodeFunctionData('reenter'),
                ethers.ZeroHash,
                salt,
              );

              // Schedule so it can be executed
              await this.mock
                .connect(this.proposer)
                .schedule(
                  reentrantOperation.target,
                  reentrantOperation.value,
                  reentrantOperation.data,
                  reentrantOperation.predecessor,
                  reentrantOperation.salt,
                  MINDELAY,
                );

              // Advance on time to make the operation executable
              await this.mock.getTimestamp(reentrantOperation.id).then(time.increaseTo.timestamp);

              // Grant executor role to the reentrant contract
              await this.mock.connect(this.admin).grantRole(EXECUTOR_ROLE, reentrant);

              // Prepare reenter
              const data = this.mock.interface.encodeFunctionData('execute', [
                getAddress(reentrantOperation.target),
                reentrantOperation.value,
                reentrantOperation.data,
                reentrantOperation.predecessor,
                reentrantOperation.salt,
              ]);
              await reentrant.enableRentrancy(this.mock, data);

              // Expect to fail
              await expect(
                this.mock
                  .connect(this.executor)
                  .execute(
                    reentrantOperation.target,
                    reentrantOperation.value,
                    reentrantOperation.data,
                    reentrantOperation.predecessor,
                    reentrantOperation.salt,
                  ),
              )
                .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
                .withArgs(reentrantOperation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));

              // Disable reentrancy
              await reentrant.disableReentrancy();
              const nonReentrantOperation = reentrantOperation; // Not anymore

              // Try again successfully
              await expect(
                this.mock
                  .connect(this.executor)
                  .execute(
                    nonReentrantOperation.target,
                    nonReentrantOperation.value,
                    nonReentrantOperation.data,
                    nonReentrantOperation.predecessor,
                    nonReentrantOperation.salt,
                  ),
              )
                .to.emit(this.mock, 'CallExecuted')
                .withArgs(
                  nonReentrantOperation.id,
                  0n,
                  getAddress(nonReentrantOperation),
                  nonReentrantOperation.value,
                  nonReentrantOperation.data,
                );
            });
          });
        });
      });
    });

    describe('batch', function () {
      describe('schedule', function () {
        beforeEach(async function () {
          this.operation = genOperationBatch(
            Array(8).fill('0xEd912250835c812D4516BBD80BdaEA1bB63a293C'),
            Array(8).fill(0n),
            Array(8).fill('0x2fcb7a88'),
            ethers.ZeroHash,
            '0x6cf9d042ade5de78bed9ffd075eb4b2a4f6b1736932c2dc8af517d6e066f51f5',
          );
        });

        it('proposer can schedule', async function () {
          const tx = this.mock
            .connect(this.proposer)
            .scheduleBatch(
              this.operation.targets,
              this.operation.values,
              this.operation.payloads,
              this.operation.predecessor,
              this.operation.salt,
              MINDELAY,
            );
          for (const i in this.operation.targets) {
            await expect(tx)
              .to.emit(this.mock, 'CallScheduled')
              .withArgs(
                this.operation.id,
                i,
                getAddress(this.operation.targets[i]),
                this.operation.values[i],
                this.operation.payloads[i],
                this.operation.predecessor,
                MINDELAY,
              )
              .to.emit(this.mock, 'CallSalt')
              .withArgs(this.operation.id, this.operation.salt);
          }

          expect(await this.mock.getTimestamp(this.operation.id)).to.equal(
            (await time.clockFromReceipt.timestamp(tx)) + MINDELAY,
          );
        });

        it('prevent overwriting active operation', async function () {
          await this.mock
            .connect(this.proposer)
            .scheduleBatch(
              this.operation.targets,
              this.operation.values,
              this.operation.payloads,
              this.operation.predecessor,
              this.operation.salt,
              MINDELAY,
            );

          await expect(
            this.mock
              .connect(this.proposer)
              .scheduleBatch(
                this.operation.targets,
                this.operation.values,
                this.operation.payloads,
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
            .withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Unset));
        });

        it('length of batch parameter must match #1', async function () {
          await expect(
            this.mock
              .connect(this.proposer)
              .scheduleBatch(
                this.operation.targets,
                [],
                this.operation.payloads,
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
            .withArgs(this.operation.targets.length, this.operation.payloads.length, 0n);
        });

        it('length of batch parameter must match #1', async function () {
          await expect(
            this.mock
              .connect(this.proposer)
              .scheduleBatch(
                this.operation.targets,
                this.operation.values,
                [],
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
            .withArgs(this.operation.targets.length, 0n, this.operation.payloads.length);
        });

        it('prevent non-proposer from committing', async function () {
          await expect(
            this.mock
              .connect(this.other)
              .scheduleBatch(
                this.operation.targets,
                this.operation.values,
                this.operation.payloads,
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
            .withArgs(this.other, PROPOSER_ROLE);
        });

        it('enforce minimum delay', async function () {
          await expect(
            this.mock
              .connect(this.proposer)
              .scheduleBatch(
                this.operation.targets,
                this.operation.values,
                this.operation.payloads,
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY - 1n,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'TimelockInsufficientDelay')
            .withArgs(MINDELAY - 1n, MINDELAY);
        });
      });

      describe('execute', function () {
        beforeEach(async function () {
          this.operation = genOperationBatch(
            Array(8).fill('0x76E53CcEb05131Ef5248553bEBDb8F70536830b1'),
            Array(8).fill(0n),
            Array(8).fill('0x58a60f63'),
            ethers.ZeroHash,
            '0x9545eeabc7a7586689191f78a5532443698538e54211b5bd4d7dc0fc0102b5c7',
          );
        });

        it('revert if operation is not scheduled', async function () {
          await expect(
            this.mock
              .connect(this.executor)
              .executeBatch(
                this.operation.targets,
                this.operation.values,
                this.operation.payloads,
                this.operation.predecessor,
                this.operation.salt,
              ),
          )
            .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
            .withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
        });

        describe('with scheduled operation', function () {
          beforeEach(async function () {
            await this.mock
              .connect(this.proposer)
              .scheduleBatch(
                this.operation.targets,
                this.operation.values,
                this.operation.payloads,
                this.operation.predecessor,
                this.operation.salt,
                MINDELAY,
              );
          });

          it('revert if execution comes too early 1/2', async function () {
            await expect(
              this.mock
                .connect(this.executor)
                .executeBatch(
                  this.operation.targets,
                  this.operation.values,
                  this.operation.payloads,
                  this.operation.predecessor,
                  this.operation.salt,
                ),
            )
              .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
              .withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
          });

          it('revert if execution comes too early 2/2', async function () {
            // -1 is to tight, test sometime fails
            await this.mock.getTimestamp(this.operation.id).then(clock => time.increaseTo.timestamp(clock - 5n));

            await expect(
              this.mock
                .connect(this.executor)
                .executeBatch(
                  this.operation.targets,
                  this.operation.values,
                  this.operation.payloads,
                  this.operation.predecessor,
                  this.operation.salt,
                ),
            )
              .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
              .withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
          });

          describe('on time', function () {
            beforeEach(async function () {
              await this.mock.getTimestamp(this.operation.id).then(time.increaseTo.timestamp);
            });

            it('executor can reveal', async function () {
              const tx = this.mock
                .connect(this.executor)
                .executeBatch(
                  this.operation.targets,
                  this.operation.values,
                  this.operation.payloads,
                  this.operation.predecessor,
                  this.operation.salt,
                );
              for (const i in this.operation.targets) {
                await expect(tx)
                  .to.emit(this.mock, 'CallExecuted')
                  .withArgs(
                    this.operation.id,
                    i,
                    this.operation.targets[i],
                    this.operation.values[i],
                    this.operation.payloads[i],
                  );
              }
            });

            it('prevent non-executor from revealing', async function () {
              await expect(
                this.mock
                  .connect(this.other)
                  .executeBatch(
                    this.operation.targets,
                    this.operation.values,
                    this.operation.payloads,
                    this.operation.predecessor,
                    this.operation.salt,
                  ),
              )
                .to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
                .withArgs(this.other, EXECUTOR_ROLE);
            });

            it('length mismatch #1', async function () {
              await expect(
                this.mock
                  .connect(this.executor)
                  .executeBatch(
                    [],
                    this.operation.values,
                    this.operation.payloads,
                    this.operation.predecessor,
                    this.operation.salt,
                  ),
              )
                .to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
                .withArgs(0, this.operation.payloads.length, this.operation.values.length);
            });

            it('length mismatch #2', async function () {
              await expect(
                this.mock
                  .connect(this.executor)
                  .executeBatch(
                    this.operation.targets,
                    [],
                    this.operation.payloads,
                    this.operation.predecessor,
                    this.operation.salt,
                  ),
              )
                .to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
                .withArgs(this.operation.targets.length, this.operation.payloads.length, 0n);
            });

            it('length mismatch #3', async function () {
              await expect(
                this.mock
                  .connect(this.executor)
                  .executeBatch(
                    this.operation.targets,
                    this.operation.values,
                    [],
                    this.operation.predecessor,
                    this.operation.salt,
                  ),
              )
                .to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
                .withArgs(this.operation.targets.length, 0n, this.operation.values.length);
            });

            it('prevents reentrancy execution', async function () {
              // Create operation
              const reentrant = await ethers.deployContract('$TimelockReentrant');
              const reentrantBatchOperation = genOperationBatch(
                [reentrant],
                [0n],
                [reentrant.interface.encodeFunctionData('reenter')],
                ethers.ZeroHash,
                salt,
              );

              // Schedule so it can be executed
              await this.mock
                .connect(this.proposer)
                .scheduleBatch(
                  reentrantBatchOperation.targets,
                  reentrantBatchOperation.values,
                  reentrantBatchOperation.payloads,
                  reentrantBatchOperation.predecessor,
                  reentrantBatchOperation.salt,
                  MINDELAY,
                );

              // Advance on time to make the operation executable
              await this.mock.getTimestamp(reentrantBatchOperation.id).then(time.increaseTo.timestamp);

              // Grant executor role to the reentrant contract
              await this.mock.connect(this.admin).grantRole(EXECUTOR_ROLE, reentrant);

              // Prepare reenter
              const data = this.mock.interface.encodeFunctionData('executeBatch', [
                reentrantBatchOperation.targets.map(getAddress),
                reentrantBatchOperation.values,
                reentrantBatchOperation.payloads,
                reentrantBatchOperation.predecessor,
                reentrantBatchOperation.salt,
              ]);
              await reentrant.enableRentrancy(this.mock, data);

              // Expect to fail
              await expect(
                this.mock
                  .connect(this.executor)
                  .executeBatch(
                    reentrantBatchOperation.targets,
                    reentrantBatchOperation.values,
                    reentrantBatchOperation.payloads,
                    reentrantBatchOperation.predecessor,
                    reentrantBatchOperation.salt,
                  ),
              )
                .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
                .withArgs(reentrantBatchOperation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));

              // Disable reentrancy
              await reentrant.disableReentrancy();
              const nonReentrantBatchOperation = reentrantBatchOperation; // Not anymore

              // Try again successfully
              const tx = this.mock
                .connect(this.executor)
                .executeBatch(
                  nonReentrantBatchOperation.targets,
                  nonReentrantBatchOperation.values,
                  nonReentrantBatchOperation.payloads,
                  nonReentrantBatchOperation.predecessor,
                  nonReentrantBatchOperation.salt,
                );
              for (const i in nonReentrantBatchOperation.targets) {
                await expect(tx)
                  .to.emit(this.mock, 'CallExecuted')
                  .withArgs(
                    nonReentrantBatchOperation.id,
                    i,
                    nonReentrantBatchOperation.targets[i],
                    nonReentrantBatchOperation.values[i],
                    nonReentrantBatchOperation.payloads[i],
                  );
              }
            });
          });
        });

        it('partial execution', async function () {
          const operation = genOperationBatch(
            [this.callreceivermock, this.callreceivermock, this.callreceivermock],
            [0n, 0n, 0n],
            [
              this.callreceivermock.interface.encodeFunctionData('mockFunction'),
              this.callreceivermock.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
              this.callreceivermock.interface.encodeFunctionData('mockFunction'),
            ],
            ethers.ZeroHash,
            '0x8ac04aa0d6d66b8812fb41d39638d37af0a9ab11da507afd65c509f8ed079d3e',
          );

          await this.mock
            .connect(this.proposer)
            .scheduleBatch(
              operation.targets,
              operation.values,
              operation.payloads,
              operation.predecessor,
              operation.salt,
              MINDELAY,
            );

          await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);

          await expect(
            this.mock
              .connect(this.executor)
              .executeBatch(
                operation.targets,
                operation.values,
                operation.payloads,
                operation.predecessor,
                operation.salt,
              ),
          ).to.be.revertedWithCustomError(this.mock, 'FailedCall');
        });
      });
    });

    describe('cancel', function () {
      beforeEach(async function () {
        this.operation = genOperation(
          '0xC6837c44AA376dbe1d2709F13879E040CAb653ca',
          0n,
          '0x296e58dd',
          ethers.ZeroHash,
          '0xa2485763600634800df9fc9646fb2c112cf98649c55f63dd1d9c7d13a64399d9',
        );
        await this.mock
          .connect(this.proposer)
          .schedule(
            this.operation.target,
            this.operation.value,
            this.operation.data,
            this.operation.predecessor,
            this.operation.salt,
            MINDELAY,
          );
      });

      it('canceller can cancel', async function () {
        await expect(this.mock.connect(this.canceller).cancel(this.operation.id))
          .to.emit(this.mock, 'Cancelled')
          .withArgs(this.operation.id);
      });

      it('cannot cancel invalid operation', async function () {
        await expect(this.mock.connect(this.canceller).cancel(ethers.ZeroHash))
          .to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
          .withArgs(
            ethers.ZeroHash,
            GovernorHelper.proposalStatesToBitMap([OperationState.Waiting, OperationState.Ready]),
          );
      });

      it('prevent non-canceller from canceling', async function () {
        await expect(this.mock.connect(this.other).cancel(this.operation.id))
          .to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
          .withArgs(this.other, CANCELLER_ROLE);
      });
    });
  });

  describe('maintenance', function () {
    it('prevent unauthorized maintenance', async function () {
      await expect(this.mock.connect(this.other).updateDelay(0n))
        .to.be.revertedWithCustomError(this.mock, 'TimelockUnauthorizedCaller')
        .withArgs(this.other);
    });

    it('timelock scheduled maintenance', async function () {
      const newDelay = time.duration.hours(6);
      const operation = genOperation(
        this.mock,
        0n,
        this.mock.interface.encodeFunctionData('updateDelay', [newDelay]),
        ethers.ZeroHash,
        '0xf8e775b2c5f4d66fb5c7fa800f35ef518c262b6014b3c0aee6ea21bff157f108',
      );

      await this.mock
        .connect(this.proposer)
        .schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);

      await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);

      await expect(
        this.mock
          .connect(this.executor)
          .execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
      )
        .to.emit(this.mock, 'MinDelayChange')
        .withArgs(MINDELAY, newDelay);

      expect(await this.mock.getMinDelay()).to.equal(newDelay);
    });
  });

  describe('dependency', function () {
    beforeEach(async function () {
      this.operation1 = genOperation(
        '0xdE66bD4c97304200A95aE0AadA32d6d01A867E39',
        0n,
        '0x01dc731a',
        ethers.ZeroHash,
        '0x64e932133c7677402ead2926f86205e2ca4686aebecf5a8077627092b9bb2feb',
      );
      this.operation2 = genOperation(
        '0x3c7944a3F1ee7fc8c5A5134ba7c79D11c3A1FCa3',
        0n,
        '0x8f531849',
        this.operation1.id,
        '0x036e1311cac523f9548e6461e29fb1f8f9196b91910a41711ea22f5de48df07d',
      );
      await this.mock
        .connect(this.proposer)
        .schedule(
          this.operation1.target,
          this.operation1.value,
          this.operation1.data,
          this.operation1.predecessor,
          this.operation1.salt,
          MINDELAY,
        );
      await this.mock
        .connect(this.proposer)
        .schedule(
          this.operation2.target,
          this.operation2.value,
          this.operation2.data,
          this.operation2.predecessor,
          this.operation2.salt,
          MINDELAY,
        );

      await this.mock.getTimestamp(this.operation2.id).then(time.increaseTo.timestamp);
    });

    it('cannot execute before dependency', async function () {
      await expect(
        this.mock
          .connect(this.executor)
          .execute(
            this.operation2.target,
            this.operation2.value,
            this.operation2.data,
            this.operation2.predecessor,
            this.operation2.salt,
          ),
      )
        .to.be.revertedWithCustomError(this.mock, 'TimelockUnexecutedPredecessor')
        .withArgs(this.operation1.id);
    });

    it('can execute after dependency', async function () {
      await this.mock
        .connect(this.executor)
        .execute(
          this.operation1.target,
          this.operation1.value,
          this.operation1.data,
          this.operation1.predecessor,
          this.operation1.salt,
        );
      await this.mock
        .connect(this.executor)
        .execute(
          this.operation2.target,
          this.operation2.value,
          this.operation2.data,
          this.operation2.predecessor,
          this.operation2.salt,
        );
    });
  });

  describe('usage scenario', function () {
    this.timeout(10000);

    it('call', async function () {
      const operation = genOperation(
        this.implementation2,
        0n,
        this.implementation2.interface.encodeFunctionData('setValue', [42n]),
        ethers.ZeroHash,
        '0x8043596363daefc89977b25f9d9b4d06c3910959ef0c4d213557a903e1b555e2',
      );

      await this.mock
        .connect(this.proposer)
        .schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);

      await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);

      await this.mock
        .connect(this.executor)
        .execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt);

      expect(await this.implementation2.getValue()).to.equal(42n);
    });

    it('call reverting', async function () {
      const operation = genOperation(
        this.callreceivermock,
        0n,
        this.callreceivermock.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
        ethers.ZeroHash,
        '0xb1b1b276fdf1a28d1e00537ea73b04d56639128b08063c1a2f70a52e38cba693',
      );

      await this.mock
        .connect(this.proposer)
        .schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);

      await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);

      await expect(
        this.mock
          .connect(this.executor)
          .execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
      ).to.be.revertedWithCustomError(this.mock, 'FailedCall');
    });

    it('call throw', async function () {
      const operation = genOperation(
        this.callreceivermock,
        0n,
        this.callreceivermock.interface.encodeFunctionData('mockFunctionThrows'),
        ethers.ZeroHash,
        '0xe5ca79f295fc8327ee8a765fe19afb58f4a0cbc5053642bfdd7e73bc68e0fc67',
      );

      await this.mock
        .connect(this.proposer)
        .schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);

      await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);

      // Targeted function reverts with a panic code (0x1) + the timelock bubble the panic code
      await expect(
        this.mock
          .connect(this.executor)
          .execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
      ).to.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR);
    });

    it('call out of gas', async function () {
      const operation = genOperation(
        this.callreceivermock,
        0n,
        this.callreceivermock.interface.encodeFunctionData('mockFunctionOutOfGas'),
        ethers.ZeroHash,
        '0xf3274ce7c394c5b629d5215723563a744b817e1730cca5587c567099a14578fd',
      );

      await this.mock
        .connect(this.proposer)
        .schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);

      await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);

      await expect(
        this.mock
          .connect(this.executor)
          .execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, {
            gasLimit: '100000',
          }),
      ).to.be.revertedWithCustomError(this.mock, 'FailedCall');
    });

    it('call payable with eth', async function () {
      const operation = genOperation(
        this.callreceivermock,
        1,
        this.callreceivermock.interface.encodeFunctionData('mockFunction'),
        ethers.ZeroHash,
        '0x5ab73cd33477dcd36c1e05e28362719d0ed59a7b9ff14939de63a43073dc1f44',
      );

      await this.mock
        .connect(this.proposer)
        .schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);

      await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);

      expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
      expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);

      await this.mock
        .connect(this.executor)
        .execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, {
          value: 1,
        });

      expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
      expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(1n);
    });

    it('call nonpayable with eth', async function () {
      const operation = genOperation(
        this.callreceivermock,
        1,
        this.callreceivermock.interface.encodeFunctionData('mockFunctionNonPayable'),
        ethers.ZeroHash,
        '0xb78edbd920c7867f187e5aa6294ae5a656cfbf0dea1ccdca3751b740d0f2bdf8',
      );

      await this.mock
        .connect(this.proposer)
        .schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);

      await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);

      expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
      expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);

      await expect(
        this.mock
          .connect(this.executor)
          .execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
      ).to.be.revertedWithCustomError(this.mock, 'FailedCall');

      expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
      expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);
    });

    it('call reverting with eth', async function () {
      const operation = genOperation(
        this.callreceivermock,
        1,
        this.callreceivermock.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
        ethers.ZeroHash,
        '0xdedb4563ef0095db01d81d3f2decf57cf83e4a72aa792af14c43a792b56f4de6',
      );

      await this.mock
        .connect(this.proposer)
        .schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);

      await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);

      expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
      expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);

      await expect(
        this.mock
          .connect(this.executor)
          .execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
      ).to.be.revertedWithCustomError(this.mock, 'FailedCall');

      expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
      expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);
    });
  });

  describe('safe receive', function () {
    describe('ERC721', function () {
      const tokenId = 1n;

      beforeEach(async function () {
        this.token = await ethers.deployContract('$ERC721', ['Non Fungible Token', 'NFT']);
        await this.token.$_mint(this.other, tokenId);
      });

      it('can receive an ERC721 safeTransfer', async function () {
        await this.token.connect(this.other).safeTransferFrom(this.other, this.mock, tokenId);
      });
    });

    describe('ERC1155', function () {
      const tokenIds = {
        1: 1000n,
        2: 2000n,
        3: 3000n,
      };

      beforeEach(async function () {
        this.token = await ethers.deployContract('$ERC1155', ['https://token-cdn-domain/{id}.json']);
        await this.token.$_mintBatch(this.other, Object.keys(tokenIds), Object.values(tokenIds), '0x');
      });

      it('can receive ERC1155 safeTransfer', async function () {
        await this.token.connect(this.other).safeTransferFrom(
          this.other,
          this.mock,
          ...Object.entries(tokenIds)[0n], // id + amount
          '0x',
        );
      });

      it('can receive ERC1155 safeBatchTransfer', async function () {
        await this.token
          .connect(this.other)
          .safeBatchTransferFrom(this.other, this.mock, Object.keys(tokenIds), Object.values(tokenIds), '0x');
      });
    });
  });
});
