mirror of
https://github.com/logos-messaging/logos-messaging-nim.git
synced 2026-01-02 14:03:06 +00:00
* Bump commit for vendor wakurlnv2contract * Update RLN registration proc for contract updates * add option to runAnvil for state dump or load with optional contract deployment on setup * Code clean up * Upodate rln relay tests to use cached anvil state * Minor updates to utils and new test for anvil state dump * stopAnvil needs to wait for graceful shutdown * configure runAnvil to use load state in other tests * reduce ci timeout * Allow for RunAnvil load state file to be compressed * Fix linting * Change return type of sendMintCall to Futre[void] * Update naming of ci path for interop tests
759 lines
29 KiB
Nim
759 lines
29 KiB
Nim
{.used.}
|
|
|
|
{.push raises: [].}
|
|
|
|
import
|
|
std/[options, os, osproc, streams, strutils, strformat],
|
|
results,
|
|
stew/byteutils,
|
|
testutils/unittests,
|
|
chronos,
|
|
chronicles,
|
|
stint,
|
|
web3,
|
|
web3/conversions,
|
|
web3/eth_api_types,
|
|
json_rpc/rpcclient,
|
|
libp2p/crypto/crypto,
|
|
eth/keys,
|
|
results
|
|
|
|
import
|
|
waku/[
|
|
waku_rln_relay,
|
|
waku_rln_relay/protocol_types,
|
|
waku_rln_relay/constants,
|
|
waku_rln_relay/rln,
|
|
],
|
|
../testlib/common
|
|
|
|
const CHAIN_ID* = 1234'u256
|
|
|
|
# Path to the file which Anvil loads at startup to initialize the chain with pre-deployed contracts, an account funded with tokens and approved for spending
|
|
const DEFAULT_ANVIL_STATE_PATH* =
|
|
"tests/waku_rln_relay/anvil_state/state-deployed-contracts-mint-and-approved.json.gz"
|
|
# The contract address of the TestStableToken used for the RLN Membership registration fee
|
|
const TOKEN_ADDRESS* = "0x5FbDB2315678afecb367f032d93F642f64180aa3"
|
|
# The contract address used ti interact with the WakuRLNV2 contract via the proxy
|
|
const WAKU_RLNV2_PROXY_ADDRESS* = "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707"
|
|
|
|
proc generateCredentials*(): IdentityCredential =
|
|
let credRes = membershipKeyGen()
|
|
return credRes.get()
|
|
|
|
proc getRateCommitment*(
|
|
idCredential: IdentityCredential, userMessageLimit: UserMessageLimit
|
|
): RlnRelayResult[RawRateCommitment] =
|
|
return RateCommitment(
|
|
idCommitment: idCredential.idCommitment, userMessageLimit: userMessageLimit
|
|
).toLeaf()
|
|
|
|
proc generateCredentials*(n: int): seq[IdentityCredential] =
|
|
var credentials: seq[IdentityCredential]
|
|
for i in 0 ..< n:
|
|
credentials.add(generateCredentials())
|
|
return credentials
|
|
|
|
proc getContractAddressFromDeployScriptOutput(output: string): Result[string, string] =
|
|
const searchStr = "Return ==\n0: address "
|
|
const addressLength = 42 # Length of an Ethereum address in hex format
|
|
let idx = output.find(searchStr)
|
|
if idx >= 0:
|
|
let startPos = idx + searchStr.len
|
|
let endPos = output.find('\n', startPos)
|
|
if (endPos - startPos) >= addressLength:
|
|
let address = output[startPos ..< endPos]
|
|
return ok(address)
|
|
return err("Unable to find contract address in deploy script output")
|
|
|
|
proc getForgePath(): string =
|
|
var forgePath = ""
|
|
if existsEnv("XDG_CONFIG_HOME"):
|
|
forgePath = joinPath(forgePath, os.getEnv("XDG_CONFIG_HOME", ""))
|
|
else:
|
|
forgePath = joinPath(forgePath, os.getEnv("HOME", ""))
|
|
forgePath = joinPath(forgePath, ".foundry/bin/forge")
|
|
return $forgePath
|
|
|
|
template execForge(cmd: string): tuple[output: string, exitCode: int] =
|
|
# unset env vars that affect e.g. "forge script" before running forge
|
|
execCmdEx("unset ETH_FROM ETH_PASSWORD && " & cmd)
|
|
|
|
contract(ERC20Token):
|
|
proc allowance(owner: Address, spender: Address): UInt256 {.view.}
|
|
proc balanceOf(account: Address): UInt256 {.view.}
|
|
|
|
proc getTokenBalance(
|
|
web3: Web3, tokenAddress: Address, account: Address
|
|
): Future[UInt256] {.async.} =
|
|
let token = web3.contractSender(ERC20Token, tokenAddress)
|
|
return await token.balanceOf(account).call()
|
|
|
|
proc ethToWei(eth: UInt256): UInt256 =
|
|
eth * 1000000000000000000.u256
|
|
|
|
proc sendMintCall(
|
|
web3: Web3,
|
|
accountFrom: Address,
|
|
tokenAddress: Address,
|
|
recipientAddress: Address,
|
|
amountTokens: UInt256,
|
|
recipientBalanceBeforeExpectedTokens: Option[UInt256] = none(UInt256),
|
|
): Future[void] {.async.} =
|
|
let doBalanceAssert = recipientBalanceBeforeExpectedTokens.isSome()
|
|
|
|
if doBalanceAssert:
|
|
let balanceBeforeMint = await getTokenBalance(web3, tokenAddress, recipientAddress)
|
|
let balanceBeforeExpectedTokens = recipientBalanceBeforeExpectedTokens.get()
|
|
assert balanceBeforeMint == balanceBeforeExpectedTokens,
|
|
fmt"Balance is {balanceBeforeMint} before minting but expected {balanceBeforeExpectedTokens}"
|
|
|
|
# Create mint transaction
|
|
# Method ID for mint(address,uint256) is 0x40c10f19 which is part of the openzeppelin ERC20 standard
|
|
# The method ID for a deployed test token can be viewed here https://sepolia.lineascan.build/address/0x185A0015aC462a0aECb81beCc0497b649a64B9ea#writeContract
|
|
let mintSelector = "0x40c10f19"
|
|
let addressHex = recipientAddress.toHex()
|
|
# Pad the address and amount to 32 bytes each
|
|
let paddedAddress = addressHex.align(64, '0')
|
|
|
|
let amountHex = amountTokens.toHex()
|
|
let amountWithout0x =
|
|
if amountHex.toLower().startsWith("0x"):
|
|
amountHex[2 .. ^1]
|
|
else:
|
|
amountHex
|
|
let paddedAmount = amountWithout0x.align(64, '0')
|
|
let mintCallData = mintSelector & paddedAddress & paddedAmount
|
|
let gasPrice = int(await web3.provider.eth_gasPrice())
|
|
|
|
# Create the transaction
|
|
var tx: TransactionArgs
|
|
tx.`from` = Opt.some(accountFrom)
|
|
tx.to = Opt.some(tokenAddress)
|
|
tx.value = Opt.some(0.u256) # No ETH is sent for token operations
|
|
tx.gasPrice = Opt.some(Quantity(gasPrice))
|
|
tx.data = Opt.some(byteutils.hexToSeqByte(mintCallData))
|
|
|
|
trace "Sending mint call"
|
|
discard await web3.send(tx)
|
|
|
|
let balanceOfSelector = "0x70a08231"
|
|
let balanceCallData = balanceOfSelector & paddedAddress
|
|
|
|
# Wait a bit for transaction to be mined
|
|
await sleepAsync(500.milliseconds)
|
|
|
|
if doBalanceAssert:
|
|
let balanceAfterMint = await getTokenBalance(web3, tokenAddress, recipientAddress)
|
|
let balanceAfterExpectedTokens =
|
|
recipientBalanceBeforeExpectedTokens.get() + amountTokens
|
|
assert balanceAfterMint == balanceAfterExpectedTokens,
|
|
fmt"Balance is {balanceAfterMint} after transfer but expected {balanceAfterExpectedTokens}"
|
|
|
|
# Check how many tokens a spender (the RLN contract) is allowed to spend on behalf of the owner (account which wishes to register a membership)
|
|
proc checkTokenAllowance(
|
|
web3: Web3, tokenAddress: Address, owner: Address, spender: Address
|
|
): Future[UInt256] {.async.} =
|
|
let token = web3.contractSender(ERC20Token, tokenAddress)
|
|
let allowance = await token.allowance(owner, spender).call()
|
|
trace "Current allowance", owner = owner, spender = spender, allowance = allowance
|
|
return allowance
|
|
|
|
proc setupContractDeployment(
|
|
forgePath: string, submodulePath: string
|
|
): Result[void, string] =
|
|
trace "Contract deployer paths", forgePath = forgePath, submodulePath = submodulePath
|
|
# Build the Foundry project
|
|
try:
|
|
let (forgeCleanOutput, forgeCleanExitCode) =
|
|
execCmdEx(fmt"""cd {submodulePath} && {forgePath} clean""")
|
|
trace "Executed forge clean command", output = forgeCleanOutput
|
|
if forgeCleanExitCode != 0:
|
|
return err("forge clean command failed")
|
|
|
|
let (forgeInstallOutput, forgeInstallExitCode) =
|
|
execCmdEx(fmt"""cd {submodulePath} && {forgePath} install""")
|
|
trace "Executed forge install command", output = forgeInstallOutput
|
|
if forgeInstallExitCode != 0:
|
|
return err("forge install command failed")
|
|
|
|
let (pnpmInstallOutput, pnpmInstallExitCode) =
|
|
execCmdEx(fmt"""cd {submodulePath} && pnpm install""")
|
|
trace "Executed pnpm install command", output = pnpmInstallOutput
|
|
if pnpmInstallExitCode != 0:
|
|
return err("pnpm install command failed" & pnpmInstallOutput)
|
|
|
|
let (forgeBuildOutput, forgeBuildExitCode) =
|
|
execCmdEx(fmt"""cd {submodulePath} && {forgePath} build""")
|
|
trace "Executed forge build command", output = forgeBuildOutput
|
|
if forgeBuildExitCode != 0:
|
|
return err("forge build command failed")
|
|
|
|
# Set the environment variable API keys to anything for local testnet deployment
|
|
putEnv("API_KEY_CARDONA", "123")
|
|
putEnv("API_KEY_LINEASCAN", "123")
|
|
putEnv("API_KEY_ETHERSCAN", "123")
|
|
except OSError, IOError:
|
|
return err("Command execution failed: " & getCurrentExceptionMsg())
|
|
return ok()
|
|
|
|
proc deployTestToken*(
|
|
pk: keys.PrivateKey, acc: Address, web3: Web3
|
|
): Future[Result[Address, string]] {.async.} =
|
|
## Executes a Foundry forge script that deploys the a token contract (ERC-20) used for testing. This is a prerequisite to enable the contract deployment and this token contract address needs to be minted and approved for the accounts that need to register memberships with the contract
|
|
## submodulePath: path to the submodule containing contract deploy scripts
|
|
|
|
# All RLN related tests should be run from the root directory of the project
|
|
let submodulePath = absolutePath("./vendor/waku-rlnv2-contract")
|
|
|
|
# Verify submodule path exists
|
|
if not dirExists(submodulePath):
|
|
error "Submodule path does not exist", submodulePath = submodulePath
|
|
return err("Submodule path does not exist: " & submodulePath)
|
|
|
|
let forgePath = getForgePath()
|
|
|
|
setupContractDeployment(forgePath, submodulePath).isOkOr:
|
|
error "Failed to setup contract deployment", error = $error
|
|
return err("Failed to setup contract deployment: " & $error)
|
|
|
|
# Deploy TestToken contract
|
|
let forgeCmdTestToken =
|
|
fmt"""cd {submodulePath} && {forgePath} script test/TestToken.sol --broadcast -vvv --rpc-url http://localhost:8540 --tc TestTokenFactory --private-key {pk} && rm -rf broadcast/*/*/run-1*.json && rm -rf cache/*/*/run-1*.json"""
|
|
let (outputDeployTestToken, exitCodeDeployTestToken) = execForge(forgeCmdTestToken)
|
|
trace "Executed forge command to deploy TestToken contract",
|
|
output = outputDeployTestToken
|
|
if exitCodeDeployTestToken != 0:
|
|
error "Forge command to deploy TestToken contract failed",
|
|
error = outputDeployTestToken
|
|
return
|
|
err("Forge command to deploy TestToken contract failed: " & outputDeployTestToken)
|
|
|
|
# Parse the command output to find contract address
|
|
let testTokenAddress = getContractAddressFromDeployScriptOutput(outputDeployTestToken).valueOr:
|
|
error "Failed to get TestToken contract address from deploy script output",
|
|
error = $error
|
|
return err(
|
|
"Failed to get TestToken contract address from deploy script output: " & $error
|
|
)
|
|
info "Address of the TestToken contract", testTokenAddress
|
|
|
|
let testTokenAddressBytes = hexToByteArray[20](testTokenAddress)
|
|
let testTokenAddressAddress = Address(testTokenAddressBytes)
|
|
putEnv("TOKEN_ADDRESS", testTokenAddressAddress.toHex())
|
|
|
|
return ok(testTokenAddressAddress)
|
|
|
|
# Sends an ERC20 token approval call to allow a spender to spend a certain amount of tokens on behalf of the owner
|
|
proc approveTokenAllowanceAndVerify*(
|
|
web3: Web3,
|
|
accountFrom: Address,
|
|
privateKey: keys.PrivateKey,
|
|
tokenAddress: Address,
|
|
spender: Address,
|
|
amountWei: UInt256,
|
|
expectedAllowanceBefore: Option[UInt256] = none(UInt256),
|
|
): Future[Result[TxHash, string]] {.async.} =
|
|
var allowanceBefore: UInt256
|
|
if expectedAllowanceBefore.isSome():
|
|
allowanceBefore =
|
|
await checkTokenAllowance(web3, tokenAddress, accountFrom, spender)
|
|
let expected = expectedAllowanceBefore.get()
|
|
if allowanceBefore != expected:
|
|
return
|
|
err(fmt"Allowance is {allowanceBefore} before approval but expected {expected}")
|
|
|
|
# Temporarily set the private key
|
|
let oldPrivateKey = web3.privateKey
|
|
web3.privateKey = Opt.some(privateKey)
|
|
web3.lastKnownNonce = Opt.none(Quantity)
|
|
|
|
try:
|
|
# ERC20 approve function signature: approve(address spender, uint256 amount)
|
|
# Method ID for approve(address,uint256) is 0x095ea7b3
|
|
const APPROVE_SELECTOR = "0x095ea7b3"
|
|
let addressHex = spender.toHex().align(64, '0')
|
|
let amountHex = amountWei.toHex().align(64, '0')
|
|
let approveCallData = APPROVE_SELECTOR & addressHex & amountHex
|
|
|
|
let gasPrice = await web3.provider.eth_gasPrice()
|
|
|
|
var tx: TransactionArgs
|
|
tx.`from` = Opt.some(accountFrom)
|
|
tx.to = Opt.some(tokenAddress)
|
|
tx.value = Opt.some(0.u256)
|
|
tx.gasPrice = Opt.some(gasPrice)
|
|
tx.gas = Opt.some(Quantity(100000))
|
|
tx.data = Opt.some(byteutils.hexToSeqByte(approveCallData))
|
|
tx.chainId = Opt.some(CHAIN_ID)
|
|
|
|
trace "Sending approve call", tx = tx
|
|
let txHash = await web3.send(tx)
|
|
let receipt = await web3.getMinedTransactionReceipt(txHash)
|
|
|
|
if receipt.status.isNone():
|
|
return err("Approval transaction failed receipt is none")
|
|
if receipt.status.get() != 1.Quantity:
|
|
return err("Approval transaction failed status quantity not 1")
|
|
|
|
# Single verification check after mining (no extra sleep needed)
|
|
let allowanceAfter =
|
|
await checkTokenAllowance(web3, tokenAddress, accountFrom, spender)
|
|
let expectedAfter =
|
|
if expectedAllowanceBefore.isSome():
|
|
expectedAllowanceBefore.get() + amountWei
|
|
else:
|
|
amountWei
|
|
|
|
if allowanceAfter < expectedAfter:
|
|
return err(
|
|
fmt"Allowance is {allowanceAfter} after approval but expected at least {expectedAfter}"
|
|
)
|
|
|
|
return ok(txHash)
|
|
except CatchableError as e:
|
|
return err(fmt"Failed to send approve transaction: {e.msg}")
|
|
finally:
|
|
# Restore the old private key
|
|
web3.privateKey = oldPrivateKey
|
|
|
|
proc executeForgeContractDeployScripts*(
|
|
privateKey: keys.PrivateKey, acc: Address, web3: Web3
|
|
): Future[Result[Address, string]] {.async, gcsafe.} =
|
|
## Executes a set of foundry forge scripts required to deploy the RLN contract and returns the deployed proxy contract address
|
|
## submodulePath: path to the submodule containing contract deploy scripts
|
|
|
|
# All RLN related tests should be run from the root directory of the project
|
|
let submodulePath = "./vendor/waku-rlnv2-contract"
|
|
|
|
# Verify submodule path exists
|
|
if not dirExists(submodulePath):
|
|
error "Submodule path does not exist", submodulePath = submodulePath
|
|
return err("Submodule path does not exist: " & submodulePath)
|
|
|
|
let forgePath = getForgePath()
|
|
info "Forge path", forgePath
|
|
|
|
# Verify forge executable exists
|
|
if not fileExists(forgePath):
|
|
error "Forge executable not found", forgePath = forgePath
|
|
return err("Forge executable not found: " & forgePath)
|
|
|
|
trace "contract deployer account details", account = acc, privateKey = privateKey
|
|
let setupContractEnv = setupContractDeployment(forgePath, submodulePath)
|
|
if setupContractEnv.isErr():
|
|
error "Failed to setup contract deployment"
|
|
return err("Failed to setup contract deployment")
|
|
|
|
# Deploy LinearPriceCalculator contract
|
|
let forgeCmdPriceCalculator =
|
|
fmt"""cd {submodulePath} && {forgePath} script script/Deploy.s.sol --broadcast -vvvv --rpc-url http://localhost:8540 --tc DeployPriceCalculator --private-key {privateKey} && rm -rf broadcast/*/*/run-1*.json && rm -rf cache/*/*/run-1*.json"""
|
|
let (outputDeployPriceCalculator, exitCodeDeployPriceCalculator) =
|
|
execForge(forgeCmdPriceCalculator)
|
|
trace "Executed forge command to deploy LinearPriceCalculator contract",
|
|
output = outputDeployPriceCalculator
|
|
if exitCodeDeployPriceCalculator != 0:
|
|
return error("Forge command to deploy LinearPriceCalculator contract failed")
|
|
|
|
# Parse the output to find contract address
|
|
let priceCalculatorAddressRes =
|
|
getContractAddressFromDeployScriptOutput(outputDeployPriceCalculator)
|
|
if priceCalculatorAddressRes.isErr():
|
|
error "Failed to get LinearPriceCalculator contract address from deploy script output"
|
|
let priceCalculatorAddress = priceCalculatorAddressRes.get()
|
|
info "Address of the LinearPriceCalculator contract", priceCalculatorAddress
|
|
putEnv("PRICE_CALCULATOR_ADDRESS", priceCalculatorAddress)
|
|
|
|
let forgeCmdWakuRln =
|
|
fmt"""cd {submodulePath} && {forgePath} script script/Deploy.s.sol --broadcast -vvvv --rpc-url http://localhost:8540 --tc DeployWakuRlnV2 --private-key {privateKey} && rm -rf broadcast/*/*/run-1*.json && rm -rf cache/*/*/run-1*.json"""
|
|
let (outputDeployWakuRln, exitCodeDeployWakuRln) = execForge(forgeCmdWakuRln)
|
|
trace "Executed forge command to deploy WakuRlnV2 contract",
|
|
output = outputDeployWakuRln
|
|
if exitCodeDeployWakuRln != 0:
|
|
error "Forge command to deploy WakuRlnV2 contract failed",
|
|
output = outputDeployWakuRln
|
|
|
|
# Parse the output to find contract address
|
|
let wakuRlnV2AddressRes =
|
|
getContractAddressFromDeployScriptOutput(outputDeployWakuRln)
|
|
if wakuRlnV2AddressRes.isErr():
|
|
error "Failed to get WakuRlnV2 contract address from deploy script output"
|
|
##TODO: raise exception here?
|
|
let wakuRlnV2Address = wakuRlnV2AddressRes.get()
|
|
info "Address of the WakuRlnV2 contract", wakuRlnV2Address
|
|
putEnv("WAKURLNV2_ADDRESS", wakuRlnV2Address)
|
|
|
|
# Deploy Proxy contract
|
|
let forgeCmdProxy =
|
|
fmt"""cd {submodulePath} && {forgePath} script script/Deploy.s.sol --broadcast -vvvv --rpc-url http://localhost:8540 --tc DeployProxy --private-key {privateKey} && rm -rf broadcast/*/*/run-1*.json && rm -rf cache/*/*/run-1*.json"""
|
|
let (outputDeployProxy, exitCodeDeployProxy) = execForge(forgeCmdProxy)
|
|
trace "Executed forge command to deploy proxy contract", output = outputDeployProxy
|
|
if exitCodeDeployProxy != 0:
|
|
error "Forge command to deploy Proxy failed", error = outputDeployProxy
|
|
return err("Forge command to deploy Proxy failed")
|
|
|
|
let proxyAddress = getContractAddressFromDeployScriptOutput(outputDeployProxy)
|
|
let proxyAddressBytes = hexToByteArray[20](proxyAddress.get())
|
|
let proxyAddressAddress = Address(proxyAddressBytes)
|
|
|
|
info "Address of the Proxy contract", proxyAddressAddress
|
|
|
|
await web3.close()
|
|
return ok(proxyAddressAddress)
|
|
|
|
proc sendEthTransfer*(
|
|
web3: Web3,
|
|
accountFrom: Address,
|
|
accountTo: Address,
|
|
amountWei: UInt256,
|
|
accountToBalanceBeforeExpectedWei: Option[UInt256] = none(UInt256),
|
|
): Future[TxHash] {.async.} =
|
|
let doBalanceAssert = accountToBalanceBeforeExpectedWei.isSome()
|
|
|
|
if doBalanceAssert:
|
|
let balanceBeforeWei = await web3.provider.eth_getBalance(accountTo, "latest")
|
|
let balanceBeforeExpectedWei = accountToBalanceBeforeExpectedWei.get()
|
|
assert balanceBeforeWei == balanceBeforeExpectedWei,
|
|
fmt"Balance is {balanceBeforeWei} before transfer but expected {balanceBeforeExpectedWei}"
|
|
|
|
let gasPrice = int(await web3.provider.eth_gasPrice())
|
|
|
|
var tx: TransactionArgs
|
|
tx.`from` = Opt.some(accountFrom)
|
|
tx.to = Opt.some(accountTo)
|
|
tx.value = Opt.some(amountWei)
|
|
tx.gasPrice = Opt.some(Quantity(gasPrice))
|
|
|
|
# TODO: handle the error if sending fails
|
|
let txHash = await web3.send(tx)
|
|
|
|
# Wait a bit for transaction to be mined
|
|
await sleepAsync(200.milliseconds)
|
|
|
|
if doBalanceAssert:
|
|
let balanceAfterWei = await web3.provider.eth_getBalance(accountTo, "latest")
|
|
let balanceAfterExpectedWei = accountToBalanceBeforeExpectedWei.get() + amountWei
|
|
assert balanceAfterWei == balanceAfterExpectedWei,
|
|
fmt"Balance is {balanceAfterWei} after transfer but expected {balanceAfterExpectedWei}"
|
|
|
|
return txHash
|
|
|
|
proc createEthAccount*(
|
|
ethAmount: UInt256 = 1000.u256
|
|
): Future[(keys.PrivateKey, Address)] {.async.} =
|
|
let web3 = await newWeb3(EthClient)
|
|
let accounts = await web3.provider.eth_accounts()
|
|
let gasPrice = Quantity(await web3.provider.eth_gasPrice())
|
|
web3.defaultAccount = accounts[0]
|
|
|
|
let pk = keys.PrivateKey.random(rng[])
|
|
let acc = Address(toCanonicalAddress(pk.toPublicKey()))
|
|
|
|
var tx: TransactionArgs
|
|
tx.`from` = Opt.some(accounts[0])
|
|
tx.value = Opt.some(ethToWei(ethAmount))
|
|
tx.to = Opt.some(acc)
|
|
tx.gasPrice = Opt.some(Quantity(gasPrice))
|
|
|
|
# Send ethAmount to acc
|
|
discard await web3.send(tx)
|
|
let balance = await web3.provider.eth_getBalance(acc, "latest")
|
|
assert balance == ethToWei(ethAmount),
|
|
fmt"Balance is {balance} but expected {ethToWei(ethAmount)}"
|
|
|
|
return (pk, acc)
|
|
|
|
proc createEthAccount*(web3: Web3): (keys.PrivateKey, Address) =
|
|
let pk = keys.PrivateKey.random(rng[])
|
|
let acc = Address(toCanonicalAddress(pk.toPublicKey()))
|
|
|
|
return (pk, acc)
|
|
|
|
proc getAnvilPath*(): string =
|
|
var anvilPath = ""
|
|
if existsEnv("XDG_CONFIG_HOME"):
|
|
anvilPath = joinPath(anvilPath, os.getEnv("XDG_CONFIG_HOME", ""))
|
|
else:
|
|
anvilPath = joinPath(anvilPath, os.getEnv("HOME", ""))
|
|
anvilPath = joinPath(anvilPath, ".foundry/bin/anvil")
|
|
return $anvilPath
|
|
|
|
proc decompressGzipFile*(
|
|
compressedPath: string, targetPath: string
|
|
): Result[void, string] =
|
|
## Decompress a gzipped file using the gunzip command-line utility
|
|
let cmd = fmt"gunzip -c {compressedPath} > {targetPath}"
|
|
|
|
try:
|
|
let (output, exitCode) = execCmdEx(cmd)
|
|
if exitCode != 0:
|
|
return err(
|
|
"Failed to decompress '" & compressedPath & "' to '" & targetPath & "': " &
|
|
output
|
|
)
|
|
except OSError as e:
|
|
return err("Failed to execute gunzip command: " & e.msg)
|
|
except IOError as e:
|
|
return err("Failed to execute gunzip command: " & e.msg)
|
|
|
|
ok()
|
|
|
|
proc compressGzipFile*(sourcePath: string, targetPath: string): Result[void, string] =
|
|
## Compress a file with gzip using the gzip command-line utility
|
|
let cmd = fmt"gzip -c {sourcePath} > {targetPath}"
|
|
|
|
try:
|
|
let (output, exitCode) = execCmdEx(cmd)
|
|
if exitCode != 0:
|
|
return err(
|
|
"Failed to compress '" & sourcePath & "' to '" & targetPath & "': " & output
|
|
)
|
|
except OSError as e:
|
|
return err("Failed to execute gzip command: " & e.msg)
|
|
except IOError as e:
|
|
return err("Failed to execute gzip command: " & e.msg)
|
|
|
|
ok()
|
|
|
|
# Runs Anvil daemon
|
|
proc runAnvil*(
|
|
port: int = 8540,
|
|
chainId: string = "1234",
|
|
stateFile: Option[string] = none(string),
|
|
dumpStateOnExit: bool = false,
|
|
): Process =
|
|
# Passed options are
|
|
# --port Port to listen on.
|
|
# --gas-limit Sets the block gas limit in WEI.
|
|
# --balance The default account balance, specified in ether.
|
|
# --chain-id Chain ID of the network.
|
|
# --load-state Initialize the chain from a previously saved state snapshot (read-only)
|
|
# --dump-state Dump the state on exit to the given file (write-only)
|
|
# See anvil documentation https://book.getfoundry.sh/reference/anvil/ for more details
|
|
try:
|
|
let anvilPath = getAnvilPath()
|
|
info "Anvil path", anvilPath
|
|
|
|
var args =
|
|
@[
|
|
"--port",
|
|
$port,
|
|
"--gas-limit",
|
|
"300000000000000",
|
|
"--balance",
|
|
"1000000000",
|
|
"--chain-id",
|
|
$chainId,
|
|
]
|
|
|
|
# Add state file argument if provided
|
|
if stateFile.isSome():
|
|
var statePath = stateFile.get()
|
|
info "State file parameter provided",
|
|
statePath = statePath,
|
|
dumpStateOnExit = dumpStateOnExit,
|
|
absolutePath = absolutePath(statePath)
|
|
|
|
# Check if the file is gzip compressed and handle decompression
|
|
if statePath.endsWith(".gz"):
|
|
let decompressedPath = statePath[0 .. ^4] # Remove .gz extension
|
|
debug "Gzip compressed state file detected",
|
|
compressedPath = statePath, decompressedPath = decompressedPath
|
|
|
|
if not fileExists(decompressedPath):
|
|
decompressGzipFile(statePath, decompressedPath).isOkOr:
|
|
error "Failed to decompress state file", error = error
|
|
return nil
|
|
|
|
statePath = decompressedPath
|
|
|
|
if dumpStateOnExit:
|
|
# Ensure the directory exists
|
|
let stateDir = parentDir(statePath)
|
|
if not dirExists(stateDir):
|
|
createDir(stateDir)
|
|
# Fresh deployment: start clean and dump state on exit
|
|
args.add("--dump-state")
|
|
args.add(statePath)
|
|
debug "Anvil configured to dump state on exit", path = statePath
|
|
else:
|
|
# Using cache: only load state, don't overwrite it (preserves clean cached state)
|
|
if fileExists(statePath):
|
|
args.add("--load-state")
|
|
args.add(statePath)
|
|
debug "Anvil configured to load state file (read-only)", path = statePath
|
|
else:
|
|
warn "State file does not exist, anvil will start fresh",
|
|
path = statePath, absolutePath = absolutePath(statePath)
|
|
else:
|
|
info "No state file provided, anvil will start fresh without state persistence"
|
|
|
|
info "Starting anvil with arguments", args = args.join(" ")
|
|
|
|
let runAnvil =
|
|
startProcess(anvilPath, args = args, options = {poUsePath, poStdErrToStdOut})
|
|
let anvilPID = runAnvil.processID
|
|
|
|
# We read stdout from Anvil to see when daemon is ready
|
|
var anvilStartLog: string
|
|
var cmdline: string
|
|
while true:
|
|
try:
|
|
if runAnvil.outputstream.readLine(cmdline):
|
|
anvilStartLog.add(cmdline)
|
|
if cmdline.contains("Listening on 127.0.0.1:" & $port):
|
|
break
|
|
else:
|
|
error "Anvil daemon exited (closed output)",
|
|
pid = anvilPID, startLog = anvilStartLog
|
|
return
|
|
except Exception, CatchableError:
|
|
warn "Anvil daemon stdout reading error; assuming it started OK",
|
|
pid = anvilPID, startLog = anvilStartLog, err = getCurrentExceptionMsg()
|
|
break
|
|
info "Anvil daemon is running and ready", pid = anvilPID, startLog = anvilStartLog
|
|
return runAnvil
|
|
except: # TODO: Fix "BareExcept" warning
|
|
error "Anvil daemon run failed", err = getCurrentExceptionMsg()
|
|
|
|
# Stops Anvil daemon
|
|
proc stopAnvil*(runAnvil: Process) {.used.} =
|
|
if runAnvil.isNil:
|
|
info "stopAnvil called with nil Process"
|
|
return
|
|
|
|
let anvilPID = runAnvil.processID
|
|
info "Stopping Anvil daemon", anvilPID = anvilPID
|
|
|
|
try:
|
|
# Send termination signals
|
|
when not defined(windows):
|
|
discard execCmdEx(fmt"kill -TERM {anvilPID}")
|
|
# Wait for graceful shutdown to allow state dumping
|
|
sleep(200)
|
|
# Only force kill if process is still running
|
|
let checkResult = execCmdEx(fmt"kill -0 {anvilPID} 2>/dev/null")
|
|
if checkResult.exitCode == 0:
|
|
info "Anvil process still running after TERM signal, sending KILL",
|
|
anvilPID = anvilPID
|
|
discard execCmdEx(fmt"kill -9 {anvilPID}")
|
|
else:
|
|
discard execCmdEx(fmt"taskkill /F /PID {anvilPID}")
|
|
|
|
# Close Process object to release resources
|
|
close(runAnvil)
|
|
info "Anvil daemon stopped", anvilPID = anvilPID
|
|
except Exception as e:
|
|
info "Error stopping Anvil daemon", anvilPID = anvilPID, error = e.msg
|
|
|
|
proc setupOnchainGroupManager*(
|
|
ethClientUrl: string = EthClient,
|
|
amountEth: UInt256 = 10.u256,
|
|
deployContracts: bool = true,
|
|
): Future[OnchainGroupManager] {.async.} =
|
|
## Setup an onchain group manager for testing
|
|
## If deployContracts is false, it will assume that the Anvil testnet already has the required contracts deployed, this significantly speeds up test runs.
|
|
## To run Anvil with a cached state file containing pre-deployed contracts, see runAnvil documentation.
|
|
##
|
|
## To generate/update the cached state file:
|
|
## 1. Call runAnvil with stateFile and dumpStateOnExit=true
|
|
## 2. Run setupOnchainGroupManager with deployContracts=true to deploy contracts
|
|
## 3. The state will be saved to the specified file when anvil exits
|
|
## 4. Commit this file to git
|
|
##
|
|
## To use cached state:
|
|
## 1. Call runAnvil with stateFile and dumpStateOnExit=false
|
|
## 2. Anvil loads state in read-only mode (won't overwrite the cached file)
|
|
## 3. Call setupOnchainGroupManager with deployContracts=false
|
|
## 4. Tests run fast using pre-deployed contracts
|
|
let rlnInstanceRes = createRlnInstance()
|
|
check:
|
|
rlnInstanceRes.isOk()
|
|
|
|
let rlnInstance = rlnInstanceRes.get()
|
|
|
|
let web3 = await newWeb3(ethClientUrl)
|
|
let accounts = await web3.provider.eth_accounts()
|
|
web3.defaultAccount = accounts[1]
|
|
|
|
var privateKey: keys.PrivateKey
|
|
var acc: Address
|
|
var testTokenAddress: Address
|
|
var contractAddress: Address
|
|
|
|
if not deployContracts:
|
|
info "Using contract addresses from constants"
|
|
|
|
testTokenAddress = Address(hexToByteArray[20](TOKEN_ADDRESS))
|
|
contractAddress = Address(hexToByteArray[20](WAKU_RLNV2_PROXY_ADDRESS))
|
|
|
|
(privateKey, acc) = createEthAccount(web3)
|
|
|
|
# Fund the test account
|
|
discard await sendEthTransfer(web3, web3.defaultAccount, acc, ethToWei(1000.u256))
|
|
|
|
# Mint tokens to the test account
|
|
await sendMintCall(
|
|
web3, web3.defaultAccount, testTokenAddress, acc, ethToWei(1000.u256)
|
|
)
|
|
|
|
# Approve the contract to spend tokens
|
|
let tokenApprovalResult = await approveTokenAllowanceAndVerify(
|
|
web3, acc, privateKey, testTokenAddress, contractAddress, ethToWei(200.u256)
|
|
)
|
|
assert tokenApprovalResult.isOk(), tokenApprovalResult.error
|
|
else:
|
|
info "Performing Token and RLN contracts deployment"
|
|
(privateKey, acc) = createEthAccount(web3)
|
|
|
|
# fund the default account
|
|
discard await sendEthTransfer(
|
|
web3, web3.defaultAccount, acc, ethToWei(1000.u256), some(0.u256)
|
|
)
|
|
|
|
testTokenAddress = (await deployTestToken(privateKey, acc, web3)).valueOr:
|
|
assert false, "Failed to deploy test token contract: " & $error
|
|
return
|
|
|
|
# mint the token from the generated account
|
|
await sendMintCall(
|
|
web3,
|
|
web3.defaultAccount,
|
|
testTokenAddress,
|
|
acc,
|
|
ethToWei(1000.u256),
|
|
some(0.u256),
|
|
)
|
|
|
|
contractAddress = (await executeForgeContractDeployScripts(privateKey, acc, web3)).valueOr:
|
|
assert false, "Failed to deploy RLN contract: " & $error
|
|
return
|
|
|
|
# If the generated account wishes to register a membership, it needs to approve the contract to spend its tokens
|
|
let tokenApprovalResult = await approveTokenAllowanceAndVerify(
|
|
web3,
|
|
acc,
|
|
privateKey,
|
|
testTokenAddress,
|
|
contractAddress,
|
|
ethToWei(200.u256),
|
|
some(0.u256),
|
|
)
|
|
|
|
assert tokenApprovalResult.isOk(), tokenApprovalResult.error
|
|
|
|
let manager = OnchainGroupManager(
|
|
ethClientUrls: @[ethClientUrl],
|
|
ethContractAddress: $contractAddress,
|
|
chainId: CHAIN_ID,
|
|
ethPrivateKey: some($privateKey),
|
|
rlnInstance: rlnInstance,
|
|
onFatalErrorAction: proc(errStr: string) =
|
|
raiseAssert errStr
|
|
,
|
|
)
|
|
|
|
return manager
|
|
|
|
{.pop.}
|