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).
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.
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.
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.
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?
I do not exactly know why the above solution does not work. However I made multiple mistakes.
_dim
instance in the transfer was another _dim
instance than the one I used in the assert about the attribute.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.).
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;
}
}