Search code examples
unit-testingtestingsolidity

Asserting attribute change after transaction, using Foundry test network


Context

I am trying to test whether a function receiveInvestment() external payable { that receives currency from a wallet address private _investorWallet; changes the uint256 private _someStoredValue; value of a Solidity contract _dim. However, I can either change the _someStoredValue; with a function call (and assert its side-effects successfully), OR send the the transfer (and assert its side-effects successfully).

Issue

I cannot both send funds from wallet address private _investorWallet, and have the receiveInvestment() function that receives those funds change the _someStoredValue attribute in the _dim contract and assert both side-effects successfully. The first side-effect can be asserted by verifying the _investorWallet funds are reduced by the investmentAmount, and the second side-effect can be asserted by calling the getSomeStoredValue() function on the _dim contract.

MWE

Below is the minimal working example (MWE) that demonstrates the issue, it consists of a _dim contract with Soldity code:

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.23; // Specifies the Solidity compiler version.

import { console2 } from "forge-std/src/console2.sol";

contract DecentralisedInvestmentManager {
  event PaymentReceived(address from, uint256 amount);
  event InvestmentReceived(address from, uint256 amount);

  uint256 private _projectLeadFracNumerator;
  uint256 private _projectLeadFracDenominator;
  address private _projectLead;

  uint256 private _someStoredValue;

  /**
   * Constructor for creating a Tier instance. The values cannot be changed
   * after creation.
   *
   */
  constructor(uint256 projectLeadFracNumerator, uint256 projectLeadFracDenominator, address projectLead) {
    // Store incoming arguments in contract.
    _projectLeadFracNumerator = projectLeadFracNumerator;
    _projectLeadFracDenominator = projectLeadFracDenominator;
    _projectLead = projectLead;
  }

  function receiveInvestment() external payable {
    require(msg.value > 0, "The amount invested was not larger than 0.");

    require(msg.sender == 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, "The sender was unexpected.");

    _someStoredValue = 15;

    emit InvestmentReceived(msg.sender, msg.value);
  }

  function getSomeStoredValue() public view returns (uint256) {
    return _someStoredValue;
  }
}

And the test file:

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.23 <0.9.0;
import { console2 } from "forge-std/src/console2.sol";

// Used to run the tests
import { PRBTest } from "@prb/test/src/PRBTest.sol";
import { StdCheats } from "forge-std/src/StdCheats.sol";

// Import the main contract that is being tested.
import { DecentralisedInvestmentManager } from "../src/DecentralisedInvestmentManager.sol";

/// @dev If this is your first time with Forge, read this tutorial in the Foundry Book:
/// https://book.getfoundry.sh/forge/writing-tests
contract SimplifiedTest is PRBTest, StdCheats {
  address internal projectLeadAddress;
  address private _investorWallet;
  address private _userWallet;
  DecentralisedInvestmentManager private _dim;

  /// @dev A function invoked before each test case is run.
  function setUp() public virtual {
    // Initialise the main contract that is being tested.
    projectLeadAddress = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
    uint256 projectLeadFracNumerator = 4;
    uint256 projectLeadFracDenominator = 10;
    _dim = new DecentralisedInvestmentManager(projectLeadFracNumerator, projectLeadFracDenominator, projectLeadAddress);

    // Initialsie the investor and user wallets used to interact with the main contract.
    _investorWallet = address(uint160(uint256(keccak256(bytes("1")))));
    deal(_investorWallet, 80000 wei);
    _userWallet = address(uint160(uint256(keccak256(bytes("2")))));
    deal(_userWallet, 100002 wei);

    // Print the addresses to console.
    console2.log("projectLeadAddress=    ", projectLeadAddress);
    console2.log("_investorWallet=       ", _investorWallet);
    console2.log("_userWallet=           ", _investorWallet, "\n");
  }

  /**
  Test whether the _someStoredValue attribute in the _dim contract is saved
  after its value is set in the receiveInvestment function.*/
  function testDimAttributeIsSaved() public {
    uint256 startBalance = _investorWallet.balance;
    uint256 investmentAmount = 200_000 wei;

    // Send investment directly from the investor wallet.
    (bool investmentSuccess, bytes memory investmentResult) = _investorWallet.call{ value: investmentAmount }(
      abi.encodeWithSelector(_dim.receiveInvestment.selector)
    );
    // (bool success, ) = address(_dim).call{value: investmentAmount}(abi.encodeWithSignature("receiveInvestment()"));

    // Assert that investor balance decreased by the investment amount.
    uint256 endBalance = _investorWallet.balance;
    assertEq(endBalance - startBalance, investmentAmount);

    // Assert investment data is stored in contract object _dim.
    // FAILS:
    assertEq(_dim.getSomeStoredValue(), 15);
  }
}

the problematic line is in the test file:

(bool investmentSuccess, bytes memory investmentResult) = _investorWallet.call{ value: investmentAmount }(
      abi.encodeWithSelector(_dim.receiveInvestment.selector)
    );

That transaction is registered in the balance of the _investorWallet, but the _someStoredValue that is returned by the getSomeStoredValue() is 0 instead of 15, meaning the attribute change performed by the receiveInvestment function is not stored/witnessable in the _dim contract in the test file.

For your convenience you can also checkout this commit and run :

forge test -vvv --match-test testDimAttributeIsSaved

to reproduce the error.

Output

Running the MWE above yields output:

[⠊] Compiling...
[⠆] Compiling 2 files with 0.8.23
[⠰] Solc 0.8.23 finished in 223.55ms
Compiler run successful with warnings:
Warning (2072): Unused local variable.
  --> test/simplify.t.sol:48:6:
   |
48 |     (bool investmentSuccess, bytes memory investmentResult) = _investorWallet.call{ value: investmentAmount }(
   |      ^^^^^^^^^^^^^^^^^^^^^^

Warning (2072): Unused local variable.
  --> test/simplify.t.sol:48:30:
   |
48 |     (bool investmentSuccess, bytes memory investmentResult) = _investorWallet.call{ value: investmentAmount }(
   |                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^


Ran 1 test for test/simplify.t.sol:SimplifiedTest
[FAIL. Reason: assertion failed] testDimAttributeIsSaved() (gas: 51284)
Logs:
  projectLeadAddress=     0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
  _investorWallet=        0x82Df0950F5A951637E0307CdCB4c672F298B8Bc6
  _userWallet=            0x82Df0950F5A951637E0307CdCB4c672F298B8Bc6 


Traces:
  [51284] SimplifiedTest::testDimAttributeIsSaved()
    ├─ [0] 0x82Df0950F5A951637E0307CdCB4c672F298B8Bc6::receiveInvestment{value: 200000}()
    │   └─ ← [Stop] 
    ├─ [2268] DecentralisedInvestmentManager::getSomeStoredValue() [staticcall]
    │   └─ ← [Return] 0
    ├─ emit Log(err: "Error: a == b not satisfied [uint256]")
    ├─ emit LogNamedUint256(key: "   Left", value: 0)
    ├─ emit LogNamedUint256(key: "  Right", value: 15)
    ├─ [0] VM::store(VM: [0x7109709ECfa91a80626fF3989D68f67F5b1DD12D], 0x6661696c65640000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000001)
    │   └─ ← [Return] 
    └─ ← [Stop] 

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 286.63µs (38.54µs CPU time)

Ran 1 test suite in 531.22ms (286.63µs CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/simplify.t.sol:SimplifiedTest
[FAIL. Reason: assertion failed] testDimAttributeIsSaved() (gas: 51284)

Encountered a total of 1 failing tests, 0 tests succeeded

Which implies the _someStoredValue is 0 instead of 15, even though one would expect the receiveInvestment function has set it to 15.

Question

How can one perform a transaction from a wallet _investorWallet into the receiveInvestment and assert the _dim._someStoredValue attribute has been changed inside that function call?


Solution

  • Mistakes

    I do not exactly know why the above solution does not work. However I made multiple mistakes.

    • I tried to send more eth than the investorWallet contained.
    • I (incorrectly) assumed the transaction was sent from a wallet other than the investorWallet when it did not work.
    • I (incorrectly) assumed the _dim instance in the transfer was another _dim instance than the one I used in the assert about the attribute.

    Guess on problem

    I think the line:

     // Send investment directly from the investor wallet.
        (bool investmentSuccess, bytes memory investmentResult) = _investorWallet.call{ value: investmentAmount }(
          abi.encodeWithSelector(_dim.receiveInvestment.selector)
        );
    

    Tries to call the receiveInvestment function from the investorWallet, instead of from the _dim contract. However, this seems contradictory to the observation about the investorWallet, which loses the investmentAmount, which seems to imply the transaction is successfull, even though in Ethereum transactions are rejected if they are directed to a function or constructor that is non-payable. I do not know where that transaction ends up, but I would assume it is unlikely that that is a payable function (even though I think evidence contradicts that, unless the investmentAmount is spent on gas.).

    Solution

    The solution below first sets the message sender to the investorWallet address and then performs the transfer into the _dim.receiveInvestment function. The attribute is succesfully changed after this transaction.

    /**
      Test whether the _someStoredValue attribute in the _dim contract is saved
      after its value is set in the receiveInvestment function.*/
      function testPaymentTriggersAttributeChange() public {
        uint256 startBalance = _investorWallet.balance;
        uint256 investmentAmount = 200_000 wei;
    
        console2.log("_dim balance before=", address(_dim).balance);
        vm.deal(address(_investorWallet), startBalance);
        vm.prank(address(_investorWallet));
        _dim.receiveInvestment{ value: investmentAmount }();
    
        // require(success, "Send ether failed");
    
        // Call receiveInvestment directly on _dim
        // _dim.receiveInvestment{value: investmentAmount}();
        // bytes memory callData = abi.encodeWithSelector(_dim.receiveInvestment.selector, investmentAmount);
    
        // Assert that investor balance decreased by the investment amount
        uint256 endBalance = _investorWallet.balance;
    
        console2.log("startBalance=", startBalance);
        console2.log("endBalance=", endBalance);
        console2.log("investmentAmount=", investmentAmount);
        console2.log("_dim balance after=", address(_dim).balance);
    
        assertEq(startBalance - endBalance, investmentAmount);
    
        // Assert balance in dim contract equals the investmentAmount.
        assertEq(investmentAmount, address(_dim).balance);
    
        // Assert investment data is stored in contract object _dim.
        assertEq(_dim.getSomeStoredValue(), 15);
      }
    

    With the base contract that receives the payment:

    // SPDX-License-Identifier: UNLICENSED
    pragma solidity >=0.8.23; // Specifies the Solidity compiler version.
    
    import { console2 } from "forge-std/src/console2.sol";
    
    contract DecentralisedInvestmentManager {
      event PaymentReceived(address from, uint256 amount);
      event InvestmentReceived(address from, uint256 amount);
    
      uint256 private _projectLeadFracNumerator;
      uint256 private _projectLeadFracDenominator;
      address private _projectLead;
    
      uint256 public _someStoredValue;
    
      /**
       * Constructor for creating a Tier instance. The values cannot be changed
       * after creation.
       *
       */
      constructor(uint256 projectLeadFracNumerator, uint256 projectLeadFracDenominator, address projectLead) {
        // Store incoming arguments in contract.
        _projectLeadFracNumerator = projectLeadFracNumerator;
        _projectLeadFracDenominator = projectLeadFracDenominator;
        _projectLead = projectLead;
      }
    
      function receiveInvestment() external payable {
    
        require(msg.value > 0, "The amount invested was not larger than 0.");
    
        require(msg.sender == 0x82Df0950F5A951637E0307CdCB4c672F298B8Bc6, "The sender was unexpected.");
    
        _someStoredValue = 15;
        console2.log("Set _someStoredValue to:", _someStoredValue);
    
        emit InvestmentReceived(msg.sender, msg.value);
      }
    
      function getSomeStoredValue() public view returns (uint256) {
        return _someStoredValue;
      }
    }