-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2688 from NomicFoundation/francovictorio/hh-498/c…
…hange-token-balance Add support for changeTokenBalance and changeTokenBalances matchers
- Loading branch information
Showing
7 changed files
with
830 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
184 changes: 184 additions & 0 deletions
184
packages/hardhat-chai-matchers/src/changeTokenBalance.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
import { BigNumber, BigNumberish, Contract, providers } from "ethers"; | ||
import { ensure } from "./calledOnContract/utils"; | ||
import { Account, getAddressOf } from "./misc/account"; | ||
|
||
type TransactionResponse = providers.TransactionResponse; | ||
|
||
interface Token extends Contract { | ||
balanceOf(address: string, overrides?: any): Promise<BigNumber>; | ||
} | ||
|
||
export function supportChangeTokenBalance(Assertion: Chai.AssertionStatic) { | ||
Assertion.addMethod( | ||
"changeTokenBalance", | ||
function ( | ||
this: any, | ||
token: Token, | ||
account: Account | string, | ||
balanceChange: BigNumberish | ||
) { | ||
const subject = this._obj; | ||
|
||
checkToken(token, "changeTokenBalance"); | ||
|
||
const derivedPromise = Promise.all([ | ||
getBalanceChange(subject, token, account), | ||
getAddressOf(account), | ||
getTokenDescription(token), | ||
]).then(([actualChange, address, tokenDescription]) => { | ||
this.assert( | ||
actualChange.eq(BigNumber.from(balanceChange)), | ||
`Expected "${address}" to change its balance of ${tokenDescription} by ${balanceChange.toString()}, ` + | ||
`but it has changed by ${actualChange.toString()}`, | ||
`Expected "${address}" to not change its balance of ${tokenDescription} by ${balanceChange.toString()}, but it did`, | ||
balanceChange, | ||
actualChange | ||
); | ||
}); | ||
|
||
this.then = derivedPromise.then.bind(derivedPromise); | ||
this.catch = derivedPromise.catch.bind(derivedPromise); | ||
|
||
return this; | ||
} | ||
); | ||
|
||
Assertion.addMethod( | ||
"changeTokenBalances", | ||
function ( | ||
this: any, | ||
token: Token, | ||
accounts: Array<Account | string>, | ||
balanceChanges: BigNumberish[] | ||
) { | ||
const subject = this._obj; | ||
|
||
checkToken(token, "changeTokenBalances"); | ||
|
||
if (accounts.length !== balanceChanges.length) { | ||
throw new Error( | ||
`The number of accounts (${accounts.length}) is different than the number of expected balance changes (${balanceChanges.length})` | ||
); | ||
} | ||
|
||
const balanceChangesPromise = Promise.all( | ||
accounts.map((account) => getBalanceChange(subject, token, account)) | ||
); | ||
const addressesPromise = Promise.all(accounts.map(getAddressOf)); | ||
|
||
const derivedPromise = Promise.all([ | ||
balanceChangesPromise, | ||
addressesPromise, | ||
getTokenDescription(token), | ||
]).then(([actualChanges, addresses, tokenDescription]) => { | ||
this.assert( | ||
actualChanges.every((change, ind) => | ||
change.eq(BigNumber.from(balanceChanges[ind])) | ||
), | ||
`Expected ${ | ||
addresses as any | ||
} to change their balance of ${tokenDescription} by ${ | ||
balanceChanges as any | ||
}, ` + `but it has changed by ${actualChanges as any}`, | ||
`Expected ${ | ||
addresses as any | ||
} to not change their balance of ${tokenDescription} by ${ | ||
balanceChanges as any | ||
}, but they did`, | ||
balanceChanges.map((balanceChange) => balanceChange.toString()), | ||
actualChanges.map((actualChange) => actualChange.toString()) | ||
); | ||
}); | ||
|
||
this.then = derivedPromise.then.bind(derivedPromise); | ||
this.catch = derivedPromise.catch.bind(derivedPromise); | ||
|
||
return this; | ||
} | ||
); | ||
} | ||
|
||
function checkToken(token: unknown, method: string) { | ||
if (typeof token !== "object" || token === null || !("functions" in token)) { | ||
throw new Error( | ||
`The first argument of ${method} must be the contract instance of the token` | ||
); | ||
} else if ((token as any).functions.balanceOf === undefined) { | ||
throw new Error("The given contract instance is not an ERC20 token"); | ||
} | ||
} | ||
|
||
export async function getBalanceChange( | ||
transaction: | ||
| TransactionResponse | ||
| Promise<TransactionResponse> | ||
| (() => TransactionResponse) | ||
| (() => Promise<TransactionResponse>), | ||
token: Token, | ||
account: Account | string | ||
) { | ||
const hre = await import("hardhat"); | ||
const provider = hre.network.provider; | ||
|
||
let txResponse: TransactionResponse; | ||
|
||
if (typeof transaction === "function") { | ||
txResponse = await transaction(); | ||
} else { | ||
txResponse = await transaction; | ||
} | ||
|
||
const txReceipt = await txResponse.wait(); | ||
const txBlockNumber = txReceipt.blockNumber; | ||
|
||
const block = await provider.send("eth_getBlockByHash", [ | ||
txReceipt.blockHash, | ||
false, | ||
]); | ||
|
||
ensure( | ||
block.transactions.length === 1, | ||
Error, | ||
"Multiple transactions found in block" | ||
); | ||
|
||
const address = await getAddressOf(account); | ||
|
||
const balanceAfter = await token.balanceOf(address, { | ||
blockTag: txBlockNumber, | ||
}); | ||
|
||
const balanceBefore = await token.balanceOf(address, { | ||
blockTag: txBlockNumber - 1, | ||
}); | ||
|
||
return BigNumber.from(balanceAfter).sub(balanceBefore); | ||
} | ||
|
||
let tokenDescriptionsCache: Record<string, string> = {}; | ||
/** | ||
* Get a description for the given token. Use the symbol of the token if | ||
* possible; if it doesn't exist, the name is used; if the name doesn't | ||
* exist, the address of the token is used. | ||
*/ | ||
async function getTokenDescription(token: Token): Promise<string> { | ||
if (tokenDescriptionsCache[token.address] === undefined) { | ||
let tokenDescription = `<token at ${token.address}>`; | ||
try { | ||
tokenDescription = await token.symbol(); | ||
} catch (e) { | ||
try { | ||
tokenDescription = await token.name(); | ||
} catch (e2) {} | ||
} | ||
|
||
tokenDescriptionsCache[token.address] = tokenDescription; | ||
} | ||
|
||
return tokenDescriptionsCache[token.address]; | ||
} | ||
|
||
// only used by tests | ||
export function clearTokenDescriptionsCache() { | ||
tokenDescriptionsCache = {}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.