diff --git a/apps/benchmarks/benchmarks.nim b/apps/benchmarks/benchmarks.nim index 826e66302..705beeacf 100644 --- a/apps/benchmarks/benchmarks.nim +++ b/apps/benchmarks/benchmarks.nim @@ -19,10 +19,8 @@ proc benchmark( var start_time = getTime() for i in 0 .. registerCount - 1: - try: - await manager.register(idCredentials[i], UserMessageLimit(messageLimit + 1)) - except Exception, CatchableError: - assert false, "exception raised: " & getCurrentExceptionMsg() + (await manager.register(idCredentials[i], UserMessageLimit(messageLimit + 1))).isOkOr: + assert false, "register failed: " & error info "registration finished", iter = i, elapsed_ms = (getTime() - start_time).inMilliseconds @@ -47,14 +45,21 @@ proc benchmark( proofGenTimes.add(getTime() - generate_time) let verify_time = getTime() - let ok = manager.verifyProof(data, proof).valueOr: + discard manager.verifyProof(data, proof).valueOr: raiseAssert $error proofVerTimes.add(getTime() - verify_time) info "iteration finished", iter = i, elapsed_ms = (getTime() - start_time).inMilliseconds - echo "Proof generation times: ", sum(proofGenTimes) div len(proofGenTimes) - echo "Proof verification times: ", sum(proofVerTimes) div len(proofVerTimes) + proc fmtMs(d: times.Duration): string = + formatFloat(d.inNanoseconds.float / 1_000_000.0, ffDecimal, 3) & " ms" + + let avgGen = sum(proofGenTimes) div len(proofGenTimes) + let avgVer = sum(proofVerTimes) div len(proofVerTimes) + echo "Proof generation (avg/min/max): ", + fmtMs(avgGen), " / ", fmtMs(min(proofGenTimes)), " / ", fmtMs(max(proofGenTimes)) + echo "Proof verification (avg/min/max): ", + fmtMs(avgVer), " / ", fmtMs(min(proofVerTimes)), " / ", fmtMs(max(proofVerTimes)) proc main() = # Start a local Ethereum JSON-RPC (Anvil) so that the group-manager setup can connect. diff --git a/logos_delivery/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim b/logos_delivery/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim index 258e9d133..66edcf93d 100644 --- a/logos_delivery/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim +++ b/logos_delivery/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim @@ -215,7 +215,7 @@ proc updateRecentRoots*(g: OnchainGroupManager): Future[bool] {.async.} = # Append new roots to the tail; trim happens below if we exceed the window. for root in toAdd: g.validRoots.addLast(root) - debug "appended recent roots to list of valid roots", count = toAdd.len, roots = toAdd + trace "appended recent roots to list of valid roots", count = toAdd.len, roots = toAdd while g.validRoots.len > AcceptableRootWindowSize: discard g.validRoots.popFirst() @@ -339,15 +339,13 @@ method register*( return high(int) else: let calculatedGasPrice = int(fetchedGasPrice) * 2 - debug "Gas price calculated", + trace "Gas price calculated", fetchedGasPrice = fetchedGasPrice, gasPrice = calculatedGasPrice return calculatedGasPrice, ) ).valueOr: return err("Failed to get gas price: " & error) - let idCommitmentHex = identityCredential.idCommitment.inHex() - debug "identityCredential idCommitmentHex", idCommitment = idCommitmentHex let idCommitment = identityCredential.idCommitment.toUInt256() let idCommitmentsToErase: seq[UInt256] = @[] info "registering the member", @@ -366,20 +364,22 @@ method register*( ).valueOr: return err("Failed to register member: " & error) - # wait for the transaction to be mined + # wait for the transaction to be mined and get the receipt let tsReceipt = ( await retryWrapper( RetryStrategy.new(), "Failed to get the transaction receipt", proc(): Future[ReceiptObject] {.async.} = - return await ethRpc.getMinedTransactionReceipt(txHash), + let r = await ethRpc.provider.eth_getTransactionReceipt(txHash) + if r.isNil(): + raise newException(CatchableError, "transaction not yet mined") + return r, ) ).valueOr: return err("Failed to get transaction receipt: " & error) - debug "registration transaction mined", txHash = txHash g.registrationTxHash = some(txHash) # the receipt topic holds the hash of signature of the raised events - debug "ts receipt", receipt = tsReceipt[] + trace "registration receipt", receipt = tsReceipt[] if tsReceipt.status.isNone(): return err("Transaction failed: status is None") @@ -409,7 +409,6 @@ method register*( ## Extract membership index from transaction log data (big endian) membershipIndex = UInt256.fromBytesBE(arguments[64 .. 95]) - trace "parsed membershipIndex", membershipIndex g.userMessageLimit = some(userMessageLimit) g.membershipIndex = some(membershipIndex.toMembershipIndex()) g.idCredentials = some(identityCredential) @@ -645,17 +644,10 @@ method init*(g: OnchainGroupManager): Future[GroupManagerResult[void]] {.async.} g.membershipIndex = some(keystoreCred.treeIndex) g.userMessageLimit = some(keystoreCred.userMessageLimit) - # now we check on the contract if the commitment actually has a membership - let idCommitmentBytes = keystoreCred.identityCredential.idCommitment - let idCommitmentUInt256 = keystoreCred.identityCredential.idCommitment.toUInt256() - let idCommitmentHex = idCommitmentBytes.inHex() - info "Keystore idCommitment in bytes", idCommitmentBytes = idCommitmentBytes - info "Keystore idCommitment in UInt256 ", idCommitmentUInt256 = idCommitmentUInt256 - info "Keystore idCommitment in hex ", idCommitmentHex = idCommitmentHex + let idCommitment = keystoreCred.identityCredential.idCommitment let membershipExists = (await g.fetchMembershipStatus(idCommitment)).valueOr: return err("the commitment does not have a membership: " & error) - info "membershipExists", membershipExists = membershipExists g.idCredentials = some(keystoreCred.identityCredential) diff --git a/tests/waku_rln_relay/test_rln_group_manager_onchain.nim b/tests/waku_rln_relay/test_rln_group_manager_onchain.nim index 199629bd8..fa406eda3 100644 --- a/tests/waku_rln_relay/test_rln_group_manager_onchain.nim +++ b/tests/waku_rln_relay/test_rln_group_manager_onchain.nim @@ -3,7 +3,7 @@ {.push raises: [].} import - std/[options, sequtils, deques, random, locks, osproc, algorithm], + std/[options, sequtils, deques, random, locks, osproc, algorithm, exitprocs], results, stew/byteutils, testutils/unittests, @@ -29,16 +29,37 @@ import ../testlib/wakucore, ./utils_onchain +# Anvil is started once for the whole suite. The first test runs the full +# `setupOnchainGroupManager` flow (fund a fresh account + mint tokens + approve +# allowance) and then takes a baseline snapshot capturing that post-setup chain +# state. Subsequent tests revert to the baseline (restoring the funded account) +# and build a bare manager pointing at the same key. evm_revert consumes the snapshot +# ID, so we re-snapshot after every revert. Cleanup is registered via +# addExitProc so anvil is terminated when the test binary exits. +var sharedAnvilProc: Process +var anvilStarted: bool = false +var baselineSnapshotId: string +var fundedPrivateKey: string + suite "Onchain group manager": - var anvilProc {.threadVar.}: Process var manager {.threadVar.}: OnchainGroupManager setup: - anvilProc = runAnvil(stateFile = some(DEFAULT_ANVIL_STATE_PATH)) - manager = waitFor setupOnchainGroupManager(deployContracts = false) - - teardown: - stopAnvil(anvilProc) + if not anvilStarted: + sharedAnvilProc = runAnvil(stateFile = some(DEFAULT_ANVIL_STATE_PATH)) + anvilStarted = true + addExitProc( + proc() = + if not sharedAnvilProc.isNil: + stopAnvil(sharedAnvilProc) + ) + manager = waitFor setupOnchainGroupManager(deployContracts = false) + fundedPrivateKey = manager.ethPrivateKey.get() + baselineSnapshotId = waitFor takeEvmSnapshot() + else: + discard waitFor revertEvmSnapshot(baselineSnapshotId) + baselineSnapshotId = waitFor takeEvmSnapshot() + manager = buildOnchainGroupManager(fundedPrivateKey) test "should initialize successfully": (waitFor manager.init()).isOkOr: @@ -83,7 +104,7 @@ suite "Onchain group manager": raiseAssert "updateMemberCount failed (initial): " & error check waku_rln_number_registered_memberships.value() == 0.0 - const credentialCount = 3 + const credentialCount = 1 let credentials = generateCredentials(credentialCount) for i in 0 ..< credentials.len: (waitFor manager.register(credentials[i], UserMessageLimit(20))).isOkOr: @@ -95,7 +116,7 @@ suite "Onchain group manager": test "updateRoots: appends new on-chain root to validRoots after registration": # basic check for the soon to be deprecated root contract function, is replaced by getRecentRoots() - const credentialCount = 6 + const credentialCount = 2 let credentials = generateCredentials(credentialCount) (waitFor manager.init()).isOkOr: raiseAssert $error @@ -148,41 +169,6 @@ suite "Onchain group manager": manager.validRoots.len == credentialCount manager.validRoots.items().toSeq().allIt(it != default(MerkleNode)) - test "updateRecentRoots: oldest roots are evicted once the window is exceeded": - const - initialCount = AcceptableRootWindowSize - RlnContractRootCacheSize - additionalCount = RlnContractRootCacheSize + 1 - # one more than the cache size to ensure eviction occurs - let credentials = generateCredentials(initialCount + additionalCount) - (waitFor manager.init()).isOkOr: - raiseAssert $error - - # Register the first credentials and snapshot the 3 oldest roots. - for i in 0 ..< initialCount: - (waitFor manager.register(credentials[i], UserMessageLimit(20))).isOkOr: - assert false, "Failed to register credential " & $i & ": " & error - discard waitFor manager.updateRecentRoots() - - check manager.validRoots.len >= 3 - let firstThreeBefore = - @[manager.validRoots[0], manager.validRoots[1], manager.validRoots[2]] - - # Register the remaining credentials, pushing the deque past AcceptableRootWindowSize. - for i in initialCount ..< credentials.len: - (waitFor manager.register(credentials[i], UserMessageLimit(20))).isOkOr: - assert false, "Failed to register credential " & $i & ": " & error - discard waitFor manager.updateRecentRoots() - - let rootsAfter = manager.validRoots.items().toSeq() - - # AcceptableRootWindowSize + 1 registrations evicts exactly the single oldest root, - # so only the first of the original three is gone; the other two remain. - check: - manager.validRoots.len == AcceptableRootWindowSize - firstThreeBefore[0] notin rootsAfter - firstThreeBefore[1] in rootsAfter - firstThreeBefore[2] in rootsAfter - test "register: should guard against uninitialized state": let dummyCommitment = default(IDCommitment) @@ -406,6 +392,49 @@ suite "Onchain group manager": check: manager.lastRootsRefreshMoment == firstRefreshTs + test "validateRoot: concurrent misses coalesce onto a single refresh future": + (waitFor manager.init()).isOkOr: + raiseAssert $error + + let credentials = generateCredentials() + (waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr: + assert false, "register failed: " & error + + # Clean gates: the throttle window must not short-circuit the first call, + # and the in-flight slot must start empty so we can observe whether + # subsequent callers install a second future. + manager.lastRootsRefreshMoment = default(Moment) + manager.rootsRefreshInFlightFut = nil + + var badRoot: MerkleNode + badRoot[0] = 0x77 + + # The first validateRoot call runs synchronously down to the suspended + # RPC inside doRefresh, installing rootsRefreshInFlightFut along the way. + # Calls 2 and 3 enter while that future is still pending, so they must + # observe the same reference and await it rather than start a second + # doRefresh. + let f1 = manager.validateRoot(badRoot) + let inFlight = manager.rootsRefreshInFlightFut + let f2 = manager.validateRoot(badRoot) + let afterSecond = manager.rootsRefreshInFlightFut + let f3 = manager.validateRoot(badRoot) + let afterThird = manager.rootsRefreshInFlightFut + + check: + inFlight != nil + not inFlight.finished() + # No new doRefresh future was created by the coalescing callers. + afterSecond == inFlight + afterThird == inFlight + + waitFor allFutures(f1, f2, f3) + + check: + # The same in-flight future served all three callers and was never + # replaced by a competing refresh. + manager.rootsRefreshInFlightFut == inFlight + test "generateProof: fast-paths without refresh inside throttle window": (waitFor manager.init()).isOkOr: raiseAssert $error @@ -498,7 +527,7 @@ suite "Onchain group manager": (waitFor manager.init()).isOkOr: raiseAssert $error - const credentialCount = 4 + const credentialCount = 2 let credentials = generateCredentials(credentialCount) for i in 0 ..< credentials.len: (waitFor manager.register(credentials[i], UserMessageLimit(20))).isOkOr: @@ -517,6 +546,49 @@ suite "Onchain group manager": manager.merkleProofCache.len > 0 waku_rln_number_registered_memberships.value() == float64(credentialCount) + test "ensureFreshMerkleProofPath: concurrent calls coalesce onto a single refresh future": + (waitFor manager.init()).isOkOr: + raiseAssert $error + + let credentials = generateCredentials() + (waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr: + assert false, "register failed: " & error + + # Empty cache + epoch-zero check timestamp guarantees the first caller + # will fall through to fetchMerkleProofElements; the followers should + # observe the resulting in-flight future and await it rather than start + # their own refresh. + manager.merkleProofCache = @[] + manager.lastMerklePathCheckMoment = default(Moment) + manager.proofPathRefreshInFlightFut = nil + + let f1 = manager.ensureFreshMerkleProofPath() + let inFlight = manager.proofPathRefreshInFlightFut + let f2 = manager.ensureFreshMerkleProofPath() + let afterSecond = manager.proofPathRefreshInFlightFut + let f3 = manager.ensureFreshMerkleProofPath() + let afterThird = manager.proofPathRefreshInFlightFut + + check: + inFlight != nil + not inFlight.finished() + afterSecond == inFlight + afterThird == inFlight + + waitFor allFutures(f1, f2, f3) + let r1 = f1.read() + let r2 = f2.read() + let r3 = f3.read() + + check: + r1.isOk() + r2.isOk() + r3.isOk() + # Same future served all callers; field was not replaced by a second + # refresh while the first was still in flight. + manager.proofPathRefreshInFlightFut == inFlight + manager.merkleProofCache.len > 0 + test "verifyProof: should verify valid proof": let credentials = generateCredentials() (waitFor manager.init()).isOkOr: @@ -608,43 +680,6 @@ suite "Onchain group manager": check: verified == false - test "root queue should be updated correctly": - const credentialCount = 9 - let credentials = generateCredentials(credentialCount) - (waitFor manager.init()).isOkOr: - raiseAssert $error - - type TestBackfillFuts = array[0 .. credentialCount - 1, Future[void]] - var futures: TestBackfillFuts - for i in 0 ..< futures.len: - futures[i] = newFuture[void]() - - proc generateCallback( - futs: TestBackfillFuts, credentials: seq[IdentityCredential] - ): OnRegisterCallback = - var futureIndex = 0 - proc callback(registrations: seq[Membership]): Future[void] {.async.} = - if registrations.len == 1 and - registrations[0].rateCommitment == - getRateCommitment(credentials[futureIndex], UserMessageLimit(20)).get() and - registrations[0].index == MembershipIndex(futureIndex): - futs[futureIndex].complete() - futureIndex += 1 - - return callback - - manager.onRegister(generateCallback(futures, credentials)) - - for i in 0 ..< credentials.len: - (waitFor manager.register(credentials[i], UserMessageLimit(20))).isOkOr: - assert false, "Failed to register credential " & $i & ": " & error - discard waitFor manager.updateRecentRoots() - - waitFor allFutures(futures) - - check: - manager.validRoots.len == credentialCount - test "isReady should return false if ethRpc is none": (waitFor manager.init()).isOkOr: raiseAssert $error diff --git a/tests/waku_rln_relay/utils_onchain.nim b/tests/waku_rln_relay/utils_onchain.nim index 79c850c93..828b273a2 100644 --- a/tests/waku_rln_relay/utils_onchain.nim +++ b/tests/waku_rln_relay/utils_onchain.nim @@ -4,7 +4,7 @@ import libp2p/crypto/rng {.push raises: [].} import - std/[options, os, osproc, streams, strutils, strformat], + std/[net, options, os, osproc, streams, strutils, strformat], results, stew/byteutils, testutils/unittests, @@ -30,12 +30,10 @@ import 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 +# Cached Anvil state with pre-deployed contracts and a pre-funded/approved account. 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 = @@ -84,6 +82,13 @@ contract(ERC20Token): proc allowance(owner: Address, spender: Address): UInt256 {.view.} proc balanceOf(account: Address): UInt256 {.view.} +# Custom Anvil/EVM JSON-RPC method bindings. +# evm_revert consumes its snapshot ID; callers must re-snapshot after each revert +# if they want to keep a baseline. +createRpcSigsFromNim(RpcClient): + proc evm_snapshot(): JsonString + proc evm_revert(snapshotId: JsonString): JsonString + proc getTokenBalance( web3: Web3, tokenAddress: Address, account: Address ): Future[UInt256] {.async.} = @@ -109,12 +114,9 @@ proc sendMintCall( 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 + # OpenZeppelin ERC20 mint(address,uint256) selector. 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() @@ -127,11 +129,10 @@ proc sendMintCall( 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.value = Opt.some(0.u256) tx.gasPrice = Opt.some(Quantity(gasPrice)) tx.data = Opt.some(byteutils.hexToSeqByte(mintCallData)) @@ -141,8 +142,7 @@ proc sendMintCall( let balanceOfSelector = "0x70a08231" let balanceCallData = balanceOfSelector & paddedAddress - # Wait a bit for transaction to be mined - await sleepAsync(500.milliseconds) + await sleepAsync(200.milliseconds) if doBalanceAssert: let balanceAfterMint = await getTokenBalance(web3, tokenAddress, recipientAddress) @@ -151,7 +151,6 @@ proc sendMintCall( 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.} = @@ -164,33 +163,28 @@ 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 + # Forge requires these env vars to be set; values are unused on local testnet. putEnv("API_KEY_CARDONA", "123") putEnv("API_KEY_LINEASCAN", "123") putEnv("API_KEY_ETHERSCAN", "123") @@ -201,13 +195,11 @@ proc setupContractDeployment( 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 + ## Deploys the ERC-20 test token used to pay the RLN membership registration fee. - # All RLN related tests should be run from the root directory of the project + # Path is relative; RLN tests must be run from the project root. 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) @@ -218,26 +210,22 @@ proc deployTestToken*( 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 + debug "Address of the TestToken contract", testTokenAddress let testTokenAddressBytes = hexToByteArray[20](testTokenAddress) let testTokenAddressAddress = Address(testTokenAddressBytes) @@ -245,7 +233,6 @@ proc deployTestToken*( 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, @@ -264,14 +251,14 @@ proc approveTokenAllowanceAndVerify*( return err(fmt"Allowance is {allowanceBefore} before approval but expected {expected}") - # Temporarily set the private key + # Swap in the holder's key so the approve tx is signed as the token owner; + # restored in `finally`. 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 + # ERC20 approve(address,uint256) selector. const APPROVE_SELECTOR = "0x095ea7b3" let addressHex = spender.toHex().align(64, '0') let amountHex = amountWei.toHex().align(64, '0') @@ -297,7 +284,6 @@ proc approveTokenAllowanceAndVerify*( 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 = @@ -315,80 +301,64 @@ proc approveTokenAllowanceAndVerify*( 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 + ## Deploys the RLN contracts via forge scripts; returns the proxy address. - # All RLN related tests should be run from the root directory of the project + # Path is relative; RLN tests must be run from the project root. 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 + return err("Forge command to deploy WakuRlnV2 contract failed") - # 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? + return err("Failed to get WakuRlnV2 contract address from deploy script output") 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") @@ -397,7 +367,7 @@ proc executeForgeContractDeployScripts*( let proxyAddressBytes = hexToByteArray[20](proxyAddress.get()) let proxyAddressAddress = Address(proxyAddressBytes) - info "Address of the Proxy contract", proxyAddressAddress + debug "Address of the Proxy contract", proxyAddressAddress await web3.close() return ok(proxyAddressAddress) @@ -428,7 +398,6 @@ proc sendEthTransfer*( # 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: @@ -456,7 +425,6 @@ proc createEthAccount*( 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), @@ -516,25 +484,17 @@ proc compressGzipFile*(sourcePath: string, targetPath: string): Result[void, str 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) - # Values used are representative of Linea Sepolia testnet - # See anvil documentation https://book.getfoundry.sh/reference/anvil/ for more details + # Gas/fee values mirror Linea Sepolia testnet. + # See https://book.getfoundry.sh/reference/anvil/ for option details. try: let anvilPath = getAnvilPath() - info "Anvil path", anvilPath + debug "Anvil path", anvilPath var args = @[ "--port", @@ -550,21 +510,18 @@ proc runAnvil*( "--chain-id", $chainId, "--disable-min-priority-fee", + "--silent", ] - # Add state file argument if provided if stateFile.isSome(): var statePath = stateFile.get() - info "State file parameter provided", + debug "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 + let decompressedPath = statePath[0 .. ^4] if not fileExists(decompressedPath): decompressGzipFile(statePath, decompressedPath).isOkOr: @@ -574,16 +531,15 @@ proc runAnvil*( 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 + # 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) + # Load-only so we don't clobber the committed cached state file. if fileExists(statePath): args.add("--load-state") args.add(statePath) @@ -592,65 +548,101 @@ proc runAnvil*( 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" + debug "No state file provided, anvil will start fresh without state persistence" - info "Starting anvil with arguments", args = args.join(" ") + debug "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: + # Poll the JSON-RPC port to detect Anvil process readiness. + const startupTimeoutMs = 10_000 + const pollIntervalMs = 100 + var elapsed = 0 + var ready = false + while elapsed < startupTimeoutMs: + if not runAnvil.running: + error "Anvil daemon exited before becoming ready", pid = anvilPID + return 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 + let sock = newSocket() + try: + sock.connect("127.0.0.1", Port(port), timeout = 500) + ready = true + finally: + close(sock) + if ready: + break + except CatchableError: + discard + sleep(pollIntervalMs) + elapsed += pollIntervalMs + + if not ready: + error "Anvil daemon did not become ready within timeout", + pid = anvilPID, timeoutMs = startupTimeoutMs + return + + debug "Anvil daemon is running and ready", pid = anvilPID return runAnvil except: # TODO: Fix "BareExcept" warning error "Anvil daemon run failed", err = getCurrentExceptionMsg() -# Stops Anvil daemon +proc takeEvmSnapshot*(ethClientUrl: string = EthClient): Future[string] {.async.} = + ## Captures Anvil chain state and returns the snapshot ID as a JSON-encoded + ## hex string (e.g. "\"0x1\""). The ID is consumed by revertEvmSnapshot, so + ## re-snapshot after revert if you need to roll back to the same baseline again. + let web3 = await newWeb3(ethClientUrl) + try: + let raw = await web3.provider.evm_snapshot() + return string(raw) + finally: + try: + await web3.close() + except CatchableError: + discard + +proc revertEvmSnapshot*( + snapshotId: string, ethClientUrl: string = EthClient +): Future[bool] {.async.} = + ## Rolls the chain back to the given snapshot. The snapshot ID is consumed by + ## this call; take a new snapshot if you intend to revert to this state again. + let web3 = await newWeb3(ethClientUrl) + try: + let raw = await web3.provider.evm_revert(JsonString(snapshotId)) + return string(raw) == "true" + finally: + try: + await web3.close() + except CatchableError: + discard + proc stopAnvil*(runAnvil: Process) {.used.} = if runAnvil.isNil: - info "stopAnvil called with nil Process" + error "stopAnvil called with nil Process" return let anvilPID = runAnvil.processID - info "Stopping Anvil daemon", anvilPID = anvilPID + debug "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 + # Give Anvil time to dump state on graceful shutdown before escalating to KILL. 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", + warn "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 + debug "Anvil daemon stopped", anvilPID = anvilPID except Exception as e: - info "Error stopping Anvil daemon", anvilPID = anvilPID, error = e.msg + error "Error stopping Anvil daemon", anvilPID = anvilPID, error = e.msg proc setupOnchainGroupManager*( ethClientUrl: string = EthClient, @@ -678,7 +670,7 @@ proc setupOnchainGroupManager*( let rlnInstance = rlnInstanceRes.get() - let web3 = await newWeb3(ethClientUrl) + var web3 = await newWeb3(ethClientUrl) let accounts = await web3.provider.eth_accounts() web3.defaultAccount = accounts[1] @@ -688,31 +680,27 @@ proc setupOnchainGroupManager*( var contractAddress: Address if not deployContracts: - info "Using contract addresses from constants" + debug "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) + web3, acc, privateKey, testTokenAddress, contractAddress, ethToWei(2000.u256) ) assert tokenApprovalResult.isOk(), tokenApprovalResult.error else: - info "Performing Token and RLN contracts deployment" + debug "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) ) @@ -721,7 +709,6 @@ proc setupOnchainGroupManager*( assert false, "Failed to deploy test token contract: " & $error return - # mint the token from the generated account await sendMintCall( web3, web3.defaultAccount, @@ -735,14 +722,24 @@ proc setupOnchainGroupManager*( 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 + # `executeForgeContractDeployScripts` shells out to `forge` via blocking + # `execCmdEx` calls (many seconds). While those run the chronos event loop + # is frozen and the existing web3 HTTP connection to Anvil rots; the next + # eth_call fails with "Not connected". Reconnect before continuing. + try: + await web3.close() + except CatchableError: + discard + web3 = await newWeb3(ethClientUrl) + web3.defaultAccount = accounts[1] + let tokenApprovalResult = await approveTokenAllowanceAndVerify( web3, acc, privateKey, testTokenAddress, contractAddress, - ethToWei(200.u256), + ethToWei(2000.u256), some(0.u256), ) @@ -761,4 +758,26 @@ proc setupOnchainGroupManager*( return manager +proc buildOnchainGroupManager*( + privateKey: string, ethClientUrl: string = EthClient +): OnchainGroupManager = + ## Constructs an OnchainGroupManager pointing at the cached RLN proxy contract + ## using the supplied private key. No on-chain work happens here — the caller + ## is expected to have an Anvil snapshot where this key already owns a funded, + ## token-approved account (e.g. via a prior `setupOnchainGroupManager` followed + ## by `takeEvmSnapshot`). Each call returns a fresh RLN instance. + let rlnInstanceRes = createRlnInstance() + check: + rlnInstanceRes.isOk() + return OnchainGroupManager( + ethClientUrls: @[ethClientUrl], + ethContractAddress: WAKU_RLNV2_PROXY_ADDRESS, + chainId: CHAIN_ID, + ethPrivateKey: some(privateKey), + rlnInstance: rlnInstanceRes.get(), + onFatalErrorAction: proc(errStr: string) = + raiseAssert errStr + , + ) + {.pop.}