feat: add tx sponsor service and propagate sponsor balance in txpool#141
feat: add tx sponsor service and propagate sponsor balance in txpool#141
Conversation
Introduce a Fastify-based sponsor service that signs EvNode transactions on behalf of users, validating chain ID, gas limits, fee caps and executor signatures before sponsoring. Includes a JSON-RPC proxy that transparently sponsors unsigned 0x76 transactions. Update txpool validation to return the sponsor's balance for sponsored transactions, so the pool uses it for pending/queued ordering.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review infoConfiguration used: defaults Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds a Sponsor Service (JSON-RPC proxy that validates, signs, and sponsors 0x76/EvNode transactions) with config, routes, services, tests, and docs; updates Rust txpool validation to return an optional sponsor-balance override for EvNode transactions. Changes
Sequence Diagram(s)sequenceDiagram
rect rgba(240,248,255,0.5)
actor Client
end
rect rgba(224,255,255,0.5)
participant SponsorService
participant PolicyEngine
participant SponsorSigner
participant RpcClient
participant Node
end
Client->>SponsorService: POST / (JSON-RPC eth_sendRawTransaction with 0x76 tx)
SponsorService->>SponsorService: parse & detect EvNode tx
alt Pre-signed by sponsor
SponsorService->>RpcClient: sendRawTransaction(rawTx)
RpcClient->>Node: eth_sendRawTransaction
Node-->>RpcClient: txHash
RpcClient-->>SponsorService: txHash
SponsorService-->>Client: JSON-RPC ok(txHash)
else Needs sponsor
SponsorService->>PolicyEngine: validate(intent)
PolicyEngine-->>SponsorService: validation result / errors
alt validation passes
SponsorService->>RpcClient: getBalance(sponsorAddress)
RpcClient->>Node: eth_getBalance
Node-->>RpcClient: balance
RpcClient-->>SponsorService: balance
alt balance sufficient
SponsorService->>SponsorSigner: signAsSponsor(intent)
SponsorSigner-->>SponsorService: sponsorSignature
SponsorService->>RpcClient: sendRawTransaction(signedTx)
RpcClient->>Node: eth_sendRawTransaction
Node-->>RpcClient: txHash
RpcClient-->>SponsorService: txHash
SponsorService-->>Client: JSON-RPC ok(txHash)
else balance low
SponsorService-->>Client: JSON-RPC error (SPONSOR_BALANCE_LOW)
end
else validation fails
SponsorService-->>Client: JSON-RPC error (policy)
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Claude finished @randygrok's task —— View job Code Review: Transaction Sponsor Service & Txpool ChangesI've completed a comprehensive code review of this PR. Here's my assessment: Review Checklist:
🎯 Overall AssessmentThis is a well-architected PR that introduces gas abstraction for EvNode transactions. The sponsor service acts as a transparent JSON-RPC proxy, and the txpool changes properly handle dual-payer accounting. However, there are several important issues that should be addressed. 🔴 Critical Issues1. Sponsor Service: Missing Graceful Shutdown (
|
Remove rate limiting config, error codes, and endpoint from sponsor-service README since the feature is not implemented. Remove outdated BUG comment from txpool test that actually passes.
There was a problem hiding this comment.
Actionable comments posted: 16
🧹 Nitpick comments (7)
crates/node/src/txpool.rs (2)
630-826: No test coverage for the sponsored EvNode path returningSome(sponsor_balance).The core change in this PR is propagating the sponsor's balance for sponsored transactions, but all tests exercise only non-sponsored EvNode transactions. Consider adding a test that constructs a sponsored EvNode transaction (with a valid
fee_payer_signature), sets up the mock state provider with a known sponsor balance, and asserts thatvalidate_evnodereturnsOk(Some(expected_sponsor_balance)).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/node/src/txpool.rs` around lines 630 - 826, Add a test that covers the sponsored EvNode path: create a helper analogous to create_non_sponsored_evnode_tx (e.g., create_sponsored_evnode_tx) that sets fee_payer_signature to Some(valid_signature) and returns EvNodeSignedTx, use create_pooled_tx to wrap it, configure the mock state/provider or AccountInfoReader to return a known sponsor balance for the fee payer address, call EvTransactionValidator::validate_evnode(&pooled, sender_balance, &mut state) and assert it returns Ok(Some(expected_sponsor_balance)); ensure the test references create_pooled_tx, validate_evnode, and the new create_sponsored_evnode_tx helper so it exercises the branch that propagates sponsor_balance.
460-474: Sponsored transactions intentionally allow non-zero value transfers with sponsor coverage.The code at lines 460-474 validates sponsored EvNode transactions by checking the sponsor's balance against gas cost only, while skipping the executor's balance check. However, this is intentional design: the sponsor is expected to cover both gas and call values. The test at
crates/tests/src/e2e_tests.rsconfirms sponsored transactions with non-zero value (0.001 ETH transfer) execute successfully.Line 512 overrides the executor's balance with the sponsor's balance in the pool ordering logic. If
pooled.cost()includes both gas and value (standard for EthPooledTransaction), then pool comparisons would use the sponsor balance against a cost that may include the executor's value portion. This could lead to misranking if the cost computation doesn't align with the intended balance model. Consider:
- Documenting whether cost includes call values for EvNode transactions
- If it does, documenting that the sponsor is expected to cover both gas and value portions in sponsored transactions
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/node/src/txpool.rs` around lines 460 - 474, The pool currently validates only sponsor gas coverage in validate_sponsor_balance (invoked when tx.fee_payer_signature is Some and sponsor is recovered via tx.recover_sponsor) while check_sender_overdraft is skipped for executors; update documentation and/or code so the pool ordering logic is consistent: either (A) document in tx/txpool types that pooled.cost() includes both gas and call value for EvNode/EvPooledTransaction and explicitly state sponsors must cover both gas and value, or (B) adjust cost computation or validation so pooled.cost() only reflects gas for sponsored transactions and executor value is handled separately; ensure references to validate_sponsor_balance, check_sender_overdraft, pooled.cost(), and the fee_payer_signature/recover_sponsor flow are updated accordingly.bin/sponsor-service/tests/unit/policy-engine.test.ts (1)
6-45: Consider adding a test for theINVALID_INTENTpath (validateEvNodeTxfailure).The
PolicyEngine.validatefunction callsvalidateEvNodeTxfirst, but there is no unit test exercising that branch. Adding a test with a structurally invalid transaction (e.g., emptycallsarray or a malformed field) would close this gap.Would you like me to draft an additional test case for the
INVALID_INTENTpath?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@bin/sponsor-service/tests/unit/policy-engine.test.ts` around lines 6 - 45, Add a unit test that exercises the INVALID_INTENT branch by creating an intent whose underlying evnode tx is structurally invalid (e.g., use makeTx({ calls: [] }) or otherwise malformed fields) and passing it to PolicyEngine.validate so that validateEvNodeTx fails; in the new test (similar to others) call await expect(engine.validate(invalidIntent)).rejects.toThrow('INVALID_INTENT') (or the exact error string thrown by validateEvNodeTx) to assert the proper rejection; reference PolicyEngine.validate, validateEvNodeTx, makeIntent and makeTx when locating where to add the test.bin/sponsor-service/src/index.ts (1)
7-13: No graceful shutdown handler — containers will be killed hard on SIGTERM.Without a
SIGTERM/SIGINThandler callingapp.close(), Kubernetes pod termination (anddocker stop) will forcefully kill the process, potentially dropping in-flight requests.🛡️ Proposed addition after line 13
app.listen({ port: config.port, host: '0.0.0.0' }, (err, address) => { if (err) { app.log.error(err); process.exit(1); } app.log.info(`Sponsor service listening on ${address}`); }); + +const shutdown = async (signal: string) => { + app.log.info(`Received ${signal}, shutting down gracefully`); + await app.close(); + process.exit(0); +}; +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT'));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@bin/sponsor-service/src/index.ts` around lines 7 - 13, Add a graceful shutdown handler so the Fastify server closes on SIGTERM/SIGINT instead of being killed; in the file that calls app.listen, register process.on('SIGTERM') and process.on('SIGINT') handlers that call app.close(), log shutdown start/complete (using app.log.info/error), and then exit the process after close (or on close error). Reference the existing app.listen callback and use app.close() to stop accepting new requests and finish in-flight work before calling process.exit.bin/sponsor-service/package.json (1)
18-18: Unpin@types/bunfor reproducible installs.
"latest"is a non-deterministic version specifier; it can silently pull in type-breaking changes between CI runs. Pin to a specific semver range.♻️ Proposed fix
- "@types/bun": "latest", + "@types/bun": "^1.2.0",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@bin/sponsor-service/package.json` at line 18, The dependency "@types/bun" in package.json uses the non-deterministic "latest" tag; replace "latest" with a pinned semver range (for example ^X.Y.Z or an exact version) to ensure reproducible installs, update the lockfile by reinstalling (npm/yarn/pnpm install) and commit the updated package.json and lockfile; target the "@types/bun" entry when making this change.bin/sponsor-service/src/services/policy-engine.ts (1)
15-41: Cheap policy checks run after expensive async crypto recovery.
recoverExecutorperforms an EC signature recovery, butgasLimitandmaxFeePerGaschecks are pure bigint comparisons. Moving them beforerecoverExecutorlets callers fail fast on policy violations without paying the crypto cost.✅ Proposed refactor
async validate(intent: SponsorableIntent): Promise<void> { try { validateEvNodeTx(intent.tx); } catch (e) { throw INVALID_INTENT((e as Error).message); } if (intent.tx.chainId !== this.config.chainId) { throw CHAIN_ID_MISMATCH(this.config.chainId, intent.tx.chainId); } + if (intent.tx.gasLimit > this.config.maxGasLimitPerTx) { + throw GAS_LIMIT_EXCEEDED(this.config.maxGasLimitPerTx, intent.tx.gasLimit); + } + + if (intent.tx.maxFeePerGas > this.config.maxFeePerGasLimit) { + throw FEE_TOO_HIGH(this.config.maxFeePerGasLimit, intent.tx.maxFeePerGas); + } + const recoveredExecutor = await recoverExecutor({ transaction: intent.tx, executorSignature: intent.executorSignature, }); if (recoveredExecutor.toLowerCase() !== intent.executorAddress.toLowerCase()) { throw INVALID_EXECUTOR_SIGNATURE(); } - - if (intent.tx.gasLimit > this.config.maxGasLimitPerTx) { - throw GAS_LIMIT_EXCEEDED(this.config.maxGasLimitPerTx, intent.tx.gasLimit); - } - - if (intent.tx.maxFeePerGas > this.config.maxFeePerGasLimit) { - throw FEE_TOO_HIGH(this.config.maxFeePerGasLimit, intent.tx.maxFeePerGas); - } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@bin/sponsor-service/src/services/policy-engine.ts` around lines 15 - 41, In validate() the pure-policy checks (tx.gasLimit and tx.maxFeePerGas) are performed after the expensive async recoverExecutor call; move the cheap bigint comparisons for intent.tx.gasLimit and intent.tx.maxFeePerGas to run immediately after the chainId check and before calling recoverExecutor so we fail fast on GAS_LIMIT_EXCEEDED and FEE_TOO_HIGH without doing signature recovery; update the order in the validate function (referencing validate, recoverExecutor, gasLimit, maxFeePerGas, and intent.tx) accordingly.bin/sponsor-service/src/routes/jsonrpc.ts (1)
79-80: Silentcatch {}on proxy path discards diagnostic information.The error is swallowed without logging, making upstream connection failures invisible in traces. Consider logging the error at warn/error level.
✅ Proposed fix
- } catch { - return reply.send(jsonRpcError(body.id, -32003, 'Unable to connect to upstream node')); + } catch (e) { + request.log.warn({ err: e }, 'Upstream proxy request failed'); + return reply.send(jsonRpcError(body.id, -32003, 'Unable to connect to upstream node'));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@bin/sponsor-service/src/routes/jsonrpc.ts` around lines 79 - 80, The catch block that returns reply.send(jsonRpcError(...)) swallows the thrown error; update the catch in the JSON-RPC proxy handler to capture the exception (e.g. catch (err)) and log it (use existing logger or reply.log) before returning jsonRpcError so upstream connection failures are visible; reference the jsonRpcError call and reply.send invocation in the proxy handler to add the logging and include err.message and stack (or err) in the log entry.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@bin/sponsor-service/package.json`:
- Line 13: Update the Fastify dependency declaration in package.json: replace
the current "fastify": "^5.2.0" entry with a range that enforces the
security-patched minimum (e.g., "fastify": ">=5.7.3" or "fastify": "^5.7.3") so
the floor version is at least 5.7.3; ensure the change is applied to the
"fastify" dependency line in package.json and commit the updated file.
In `@bin/sponsor-service/README.md`:
- Around line 57-61: The README shows inconsistent CHAIN_ID examples (e.g.,
CHAIN_ID=1337 in the "Running" snippet and CHAIN_ID=1234 in the E2E testing
snippet); update all CHAIN_ID occurrences in the README (both the
RPC_URL/CHAIN_ID env examples and the E2E testing env block) to match the actual
dev/test chain ID used by the ev-node docker-compose setup so examples are
consistent and accurate.
- Around line 7-24: The markdown fenced ASCII-art diagram in the README lacks a
language tag causing MD040; update the opening fence for that diagram (the
triple-backtick that starts the ASCII-art block) to include a plaintext tag
(e.g., change ``` to ```text) so the block is treated as plain text; locate the
ASCII-art diagram in README.md and modify only the opening fence to add the
language specifier.
In `@bin/sponsor-service/src/config.ts`:
- Line 31: The port assignment uses Number(envOrDefault('PORT','3000')) which
yields NaN for non-numeric values; update the config to parse and validate the
port: call envOrDefault('PORT','3000'), parse it with parseInt(...,10) (or
Number(...) then Number.isFinite), check that the result is a valid positive
integer, and if not throw a clear error (or fallback to the default) so
Fastify.listen() doesn't fail with a confusing error; change the 'port' property
in the exported config object accordingly and ensure the validation occurs where
'port' is set.
- Line 27: The config currently asserts sponsorPrivateKey:
requireEnv('SPONSOR_PRIVATE_KEY') as Hex without runtime validation; update
loadConfig to eagerly validate the string returned by
requireEnv('SPONSOR_PRIVATE_KEY') before casting or passing to
privateKeyToAccount: ensure it starts with "0x" (or add it), contains only hex
characters, and is the correct length for a 32-byte private key (0x + 64 hex
chars / 66 chars total), and throw a clear, descriptive error referencing
SPONSOR_PRIVATE_KEY if invalid; adjust the code paths that use sponsorPrivateKey
(e.g., privateKeyToAccount) to rely on this validated value.
In `@bin/sponsor-service/src/routes/health.ts`:
- Around line 4-16: The health route (registerHealthRoute -> GET '/v1/health')
always returns 200 even when status === 'degraded'; change the response code to
reflect health: if status === 'healthy' return 200, otherwise set
reply.code(503) (or reply.status) before sending the body. Locate the logic that
computes status (sponsorBalance, connected, status) and branch the reply to call
reply.code(503).send(...) for degraded and reply.send(...) (or
reply.code(200).send(...)) for healthy.
In `@bin/sponsor-service/src/routes/jsonrpc.ts`:
- Around line 22-25: EVNODE_TX_TYPE_HEX may be missing a leading zero making
isEvNodeTx brittle; change how EVNODE_TX_TYPE_HEX is derived so it always has
two hex chars (zero-pad to length 2 and normalize case) and then compare that
normalized value against rawTx.slice(2,4).toLowerCase() inside isEvNodeTx
(referencing EVNODE_TX_TYPE, EVNODE_TX_TYPE_HEX, and isEvNodeTx and the
rawTx.slice(2, 4) comparison).
- Around line 55-58: The route handler in registerJsonRpcRoute reads
request.body as JsonRpcRequest and directly accesses body.method, which will
throw if Fastify sets body to null for empty JSON; add a guard right after const
body = request.body as JsonRpcRequest to verify body is a non-null object and
that body.method is present/typed (e.g., typeof body === 'object' && body !==
null && typeof body.method === 'string'); if the check fails, respond with a
proper JSON-RPC error object (invalid request) using the same reply flow instead
of proceeding to call service methods.
In `@bin/sponsor-service/src/services/rpc-client.ts`:
- Around line 9-15: The fetchRpc method can hang because fetch has no timeout;
update the fetchRpc function to use an AbortController: create an
AbortController, start a timer (e.g., configurable RPC_TIMEOUT_MS) that calls
controller.abort() after the timeout, pass signal: controller.signal into fetch,
and clear the timer after fetch completes or throws to avoid leaks; ensure the
method throws or returns a clear error when aborted so callers (e.g., any RPC
client class using fetchRpc) can handle timeout failures.
- Around line 43-46: The proxy method currently calls response.json() directly
which will throw a SyntaxError on non-JSON upstream bodies (e.g., HTML 502), so
update proxy to catch JSON parsing errors from response.json() (after obtaining
the Response from fetchRpc) and handle them consistently: if response.json()
fails, fall back to response.text() and return or throw a standardized
NODE_ERROR payload (preserving response.status and body/text) instead of letting
the SyntaxError escape; keep the call to fetchRpc unchanged and use the proxy
function name to locate the change.
In `@bin/sponsor-service/src/services/sponsor-service.ts`:
- Around line 46-52: The sponsorIntent flow has a TOCTOU race where concurrent
calls each call this.rpc.getBalance(this.sponsorSigner.address) and proceed if
balance > this.minBalance, potentially draining funds; fix by serializing the
critical section (balance check + sign/submit) using a mutex or promise queue
around sponsorIntent, or alternatively perform an optimistic re-check after
signing and before broadcasting and throw SPONSOR_BALANCE_LOW() if the balance
is now below this.minBalance; update references in sponsorIntent,
this.rpc.getBalance, this.sponsorSigner.address, this.minBalance and ensure any
added mutex/queue is documented and used for all paths that submit sponsored
transactions.
- Line 2: Replace the deprecated viem API: change the import from hexToSignature
to parseSignature and update all usages of hexToSignature(...) to
parseSignature(...); adjust callers expecting a v value to use the returned
object fields { r, s, yParity } (map yParity to v if your code expects 0/1 vs
27/28) and ensure any downstream code that previously used the old return shape
is updated to use r/s and yParity from parseSignature.
In `@bin/sponsor-service/tests/helpers/fixtures.ts`:
- Around line 7-8: Add inline gitleaks suppression comments above the test
private key constants to avoid CI false-positives: place a gitleaks:allow
comment (e.g., // gitleaks:allow or /* gitleaks:allow */) immediately above the
exports for TEST_EXECUTOR_KEY and TEST_SPONSOR_KEY so the well-known
Hardhat/Anvil test keys are whitelisted while leaving the rest of the code
unchanged.
In `@bin/sponsor-service/tests/integration/api.test.ts`:
- Around line 27-34: The GET /v1/policy integration test ("test('GET /v1/policy
returns config'") is missing an assertion for the maxFeePerGas field returned by
registerPolicyRoute; update that test to assert the field is present (e.g., add
expect(body).toHaveProperty('maxFeePerGas')) or assert its expected value so
changes to maxFeePerGas are detected.
- Around line 72-74: Update the assertion in the test where
expect(body.error.message).toMatch(...) is used: remove the overly broad "fetch"
alternative and replace it with the specific known error identifiers or messages
(e.g., match "Node error", "balance", and the exact constants like "NODE_ERROR"
or "SPONSOR_BALANCE_LOW") so the regex only accepts expected failure modes;
update the toMatch pattern in bin/sponsor-service/tests/integration/api.test.ts
accordingly.
In `@crates/node/src/txpool.rs`:
- Around line 410-412: Update the doc comment that currently reads "Validates an
EvNode transaction..." to wrap the type name EvNode in backticks (i.e.,
`EvNode`) so the doc comment references the type correctly and satisfies the
clippy lint; modify the triple-slash comment immediately above the validation
function that mentions EvNode (the comment starting "Validates an EvNode
transaction...") to use `EvNode` instead.
---
Nitpick comments:
In `@bin/sponsor-service/package.json`:
- Line 18: The dependency "@types/bun" in package.json uses the
non-deterministic "latest" tag; replace "latest" with a pinned semver range (for
example ^X.Y.Z or an exact version) to ensure reproducible installs, update the
lockfile by reinstalling (npm/yarn/pnpm install) and commit the updated
package.json and lockfile; target the "@types/bun" entry when making this
change.
In `@bin/sponsor-service/src/index.ts`:
- Around line 7-13: Add a graceful shutdown handler so the Fastify server closes
on SIGTERM/SIGINT instead of being killed; in the file that calls app.listen,
register process.on('SIGTERM') and process.on('SIGINT') handlers that call
app.close(), log shutdown start/complete (using app.log.info/error), and then
exit the process after close (or on close error). Reference the existing
app.listen callback and use app.close() to stop accepting new requests and
finish in-flight work before calling process.exit.
In `@bin/sponsor-service/src/routes/jsonrpc.ts`:
- Around line 79-80: The catch block that returns reply.send(jsonRpcError(...))
swallows the thrown error; update the catch in the JSON-RPC proxy handler to
capture the exception (e.g. catch (err)) and log it (use existing logger or
reply.log) before returning jsonRpcError so upstream connection failures are
visible; reference the jsonRpcError call and reply.send invocation in the proxy
handler to add the logging and include err.message and stack (or err) in the log
entry.
In `@bin/sponsor-service/src/services/policy-engine.ts`:
- Around line 15-41: In validate() the pure-policy checks (tx.gasLimit and
tx.maxFeePerGas) are performed after the expensive async recoverExecutor call;
move the cheap bigint comparisons for intent.tx.gasLimit and
intent.tx.maxFeePerGas to run immediately after the chainId check and before
calling recoverExecutor so we fail fast on GAS_LIMIT_EXCEEDED and FEE_TOO_HIGH
without doing signature recovery; update the order in the validate function
(referencing validate, recoverExecutor, gasLimit, maxFeePerGas, and intent.tx)
accordingly.
In `@bin/sponsor-service/tests/unit/policy-engine.test.ts`:
- Around line 6-45: Add a unit test that exercises the INVALID_INTENT branch by
creating an intent whose underlying evnode tx is structurally invalid (e.g., use
makeTx({ calls: [] }) or otherwise malformed fields) and passing it to
PolicyEngine.validate so that validateEvNodeTx fails; in the new test (similar
to others) call await
expect(engine.validate(invalidIntent)).rejects.toThrow('INVALID_INTENT') (or the
exact error string thrown by validateEvNodeTx) to assert the proper rejection;
reference PolicyEngine.validate, validateEvNodeTx, makeIntent and makeTx when
locating where to add the test.
In `@crates/node/src/txpool.rs`:
- Around line 630-826: Add a test that covers the sponsored EvNode path: create
a helper analogous to create_non_sponsored_evnode_tx (e.g.,
create_sponsored_evnode_tx) that sets fee_payer_signature to
Some(valid_signature) and returns EvNodeSignedTx, use create_pooled_tx to wrap
it, configure the mock state/provider or AccountInfoReader to return a known
sponsor balance for the fee payer address, call
EvTransactionValidator::validate_evnode(&pooled, sender_balance, &mut state) and
assert it returns Ok(Some(expected_sponsor_balance)); ensure the test references
create_pooled_tx, validate_evnode, and the new create_sponsored_evnode_tx helper
so it exercises the branch that propagates sponsor_balance.
- Around line 460-474: The pool currently validates only sponsor gas coverage in
validate_sponsor_balance (invoked when tx.fee_payer_signature is Some and
sponsor is recovered via tx.recover_sponsor) while check_sender_overdraft is
skipped for executors; update documentation and/or code so the pool ordering
logic is consistent: either (A) document in tx/txpool types that pooled.cost()
includes both gas and call value for EvNode/EvPooledTransaction and explicitly
state sponsors must cover both gas and value, or (B) adjust cost computation or
validation so pooled.cost() only reflects gas for sponsored transactions and
executor value is handled separately; ensure references to
validate_sponsor_balance, check_sender_overdraft, pooled.cost(), and the
fee_payer_signature/recover_sponsor flow are updated accordingly.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
bin/sponsor-service/bun.lockis excluded by!**/*.lock
📒 Files selected for processing (17)
bin/sponsor-service/README.mdbin/sponsor-service/package.jsonbin/sponsor-service/src/config.tsbin/sponsor-service/src/errors.tsbin/sponsor-service/src/index.tsbin/sponsor-service/src/routes/health.tsbin/sponsor-service/src/routes/jsonrpc.tsbin/sponsor-service/src/routes/policy.tsbin/sponsor-service/src/server.tsbin/sponsor-service/src/services/policy-engine.tsbin/sponsor-service/src/services/rpc-client.tsbin/sponsor-service/src/services/sponsor-service.tsbin/sponsor-service/tests/helpers/fixtures.tsbin/sponsor-service/tests/integration/api.test.tsbin/sponsor-service/tests/unit/policy-engine.test.tsbin/sponsor-service/tsconfig.jsoncrates/node/src/txpool.rs
bin/sponsor-service/README.md
Outdated
| ``` | ||
| Client (evnode-viem) Sponsor Service ev-reth | ||
| | | | | ||
| | eth_sendRawTransaction(0x76) | | | ||
| |----------------------------->| | | ||
| | | 1. Decode 0x76 tx | | ||
| | | 2. No feePayerSignature? | | ||
| | | 3. Validate policy | | ||
| | | 4. Sign as sponsor | | ||
| | | 5. Re-encode & forward --->| | ||
| | | | | ||
| | { txHash } |<---------------------------| | ||
| |<-----------------------------| | | ||
| | | | | ||
| | eth_chainId / eth_getBalance | | | ||
| |----------------------------->| proxy ---------------------->| | ||
| |<-----------------------------|<----------------------------| | ||
| ``` |
There was a problem hiding this comment.
Add a language specifier to the fenced code block.
The ASCII-art diagram fence has no language tag, causing a markdownlint MD040 warning. Use text or plaintext.
📝 Proposed fix
-```
+```text
Client (evnode-viem) Sponsor Service ev-reth📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ``` | |
| Client (evnode-viem) Sponsor Service ev-reth | |
| | | | | |
| | eth_sendRawTransaction(0x76) | | | |
| |----------------------------->| | | |
| | | 1. Decode 0x76 tx | | |
| | | 2. No feePayerSignature? | | |
| | | 3. Validate policy | | |
| | | 4. Sign as sponsor | | |
| | | 5. Re-encode & forward --->| | |
| | | | | |
| | { txHash } |<---------------------------| | |
| |<-----------------------------| | | |
| | | | | |
| | eth_chainId / eth_getBalance | | | |
| |----------------------------->| proxy ---------------------->| | |
| |<-----------------------------|<----------------------------| | |
| ``` |
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)
[warning] 7-7: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@bin/sponsor-service/README.md` around lines 7 - 24, The markdown fenced
ASCII-art diagram in the README lacks a language tag causing MD040; update the
opening fence for that diagram (the triple-backtick that starts the ASCII-art
block) to include a plaintext tag (e.g., change ``` to ```text) so the block is
treated as plain text; locate the ASCII-art diagram in README.md and modify only
the opening fence to add the language specifier.
| async sponsorIntent(intent: SponsorableIntent): Promise<SponsorResult> { | ||
| await this.policyEngine.validate(intent); | ||
|
|
||
| const balance = await this.rpc.getBalance(this.sponsorSigner.address); | ||
| if (balance < this.minBalance) { | ||
| throw SPONSOR_BALANCE_LOW(); | ||
| } |
There was a problem hiding this comment.
TOCTOU race: concurrent sponsorIntent calls all pass the balance check simultaneously.
Multiple in-flight requests can each read the sponsor balance, all find it above minSponsorBalance, and all proceed to sign and submit transactions. This can exhaust the actual on-chain balance well below the configured minimum, especially under load.
Consider serializing sponsorship submissions (e.g., a promise queue or mutex) or re-checking the balance optimistically after signing but before broadcasting. At minimum, document the known race.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@bin/sponsor-service/src/services/sponsor-service.ts` around lines 46 - 52,
The sponsorIntent flow has a TOCTOU race where concurrent calls each call
this.rpc.getBalance(this.sponsorSigner.address) and proceed if balance >
this.minBalance, potentially draining funds; fix by serializing the critical
section (balance check + sign/submit) using a mutex or promise queue around
sponsorIntent, or alternatively perform an optimistic re-check after signing and
before broadcasting and throw SPONSOR_BALANCE_LOW() if the balance is now below
this.minBalance; update references in sponsorIntent, this.rpc.getBalance,
this.sponsorSigner.address, this.minBalance and ensure any added mutex/queue is
documented and used for all paths that submit sponsored transactions.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/node/src/txpool.rs`:
- Around line 460-470: The sponsor-only check currently computes only gas_cost
and calls validate_sponsor_balance, but for sponsored EvNode transactions you
must also validate the executor's balance covers the total cost (gas + call
values) similar to non-sponsored paths: after recovering sponsor
(tx.recover_sponsor) and computing gas_cost, compute the total transaction cost
(use EvNodeTransaction::value() or pooled.cost() which includes call values +
gas) and call the same executor balance check used for non-sponsored txs (e.g.,
call check_sender_overdraft or the existing method that validates the executor)
against pooled.transaction().signer(); if insufficient, return the same
InvalidPoolTransactionError used for sender overdrafts so sponsored txs cannot
be accepted when the executor lacks funds.
- Validate executor balance for call values in sponsored EvNode txs - Health endpoint returns 503 when node unreachable (3-state model) - Add fetch timeout (10s) to RPC client to prevent indefinite hangs - Handle non-JSON upstream responses in proxy method - Validate SPONSOR_PRIVATE_KEY format and PORT at startup - Guard against null JSON-RPC request body - Replace deprecated hexToSignature with parseSignature (viem 2.x) - Bump fastify floor to ^5.7.3 - Migrate README diagram to mermaid, fix install path, unify CHAIN_ID - Add gitleaks:allow for well-known test keys - Improve test assertions (maxFeePerGas, tighter error regex)
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
crates/node/src/txpool.rs (1)
812-859: Add a sponsored + non-zero call-value test.The new coverage is great for non-sponsored transactions, but this PR’s new behavior hinges on sponsored balance propagation. A targeted sponsored test here would prevent regressions in whichever policy you adopt for dual-payer accounting.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/node/src/txpool.rs` around lines 812 - 859, Add a new test mirroring non_sponsored_evnode_rejects_when_balance_covers_gas_but_not_value but for a sponsored transaction: construct an EvNodeTransaction with a non-zero Call.value and set fee_payer_signature to Some(...) (use sample_signature()), create the signed/pooled tx via Signed::new_unhashed and create_pooled_tx, then call validate_evnode(&pooled, sender_balance, &mut state) (and if needed simulate sponsor balance via the same call or state mock) and assert the expected outcome for sponsored dual-payer accounting (e.g., reject when sponsor only covers gas but not call value); reference EvNodeTransaction.fee_payer_signature, validate_evnode, Signed::new_unhashed, create_pooled_tx, sample_signature(), and create_test_validator to locate where to add the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@bin/sponsor-service/README.md`:
- Around line 35-39: The README currently says "Build the client library first
(from repo root)" but the command uses "cd ../../clients" which is inconsistent
with the following "Install dependencies (from bin/sponsor-service)"
instructions; update the text or commands so they match: either change the first
comment to "Build the client library first (from bin/sponsor-service)" or change
the command to run from repo root (e.g., use "cd clients && bun install && bun
run build"), and ensure the second step explicitly runs "bun install" from
bin/sponsor-service as shown; verify and update the two lines containing "cd
../../clients && bun install && bun run build" and "bun install" so copy/paste
will run in the intended directories.
In `@bin/sponsor-service/src/config.ts`:
- Around line 37-40: The PORT config validator (the port: (() => { ... }) block)
only checks for positive integers but not an upper bound, so values like 70000
slip through; update the inline validator that parses envOrDefault('PORT',
'3000') into p (Number(envOrDefault(...))) to also assert p <= 65535 and throw a
clear Error (e.g., "PORT must be an integer between 1 and 65535") when the check
fails so invalid ports fail fast during configuration.
In `@bin/sponsor-service/src/routes/jsonrpc.ts`:
- Around line 69-74: In the catch block handling errors for the JSON-RPC route,
avoid returning raw exception messages to clients: log the full error
server-side (e.g., using request.log.error(e) or a logger) and change the
reply.send calls that use (e as Error).message or e.message to return a generic
message like "Internal server error"; keep sponsorErrorToRpcCode(body.id)
mapping for SponsorError but still return a non-sensitive message (or a safe
public message property if SponsorError exposes one) via jsonRpcError(body.id,
sponsorErrorToRpcCode(e), "Internal server error") and for other errors use
jsonRpcError(body.id, -32000, "Internal server error") after logging the
original error.
In `@bin/sponsor-service/src/services/rpc-client.ts`:
- Around line 20-23: In call(), avoid letting response.json() throw a raw parse
error by mirroring proxy() behavior: attempt to parse JSON but catch any parsing
or non-JSON responses, read response.text() as a fallback, and throw a
normalized NODE_ERROR containing useful context (HTTP status, response text or
parse error message) instead of propagating the raw exception; update the logic
around fetchRpc / response.json() in the call() implementation so it returns
data.result on success or throws NODE_ERROR on parse/non-JSON responses just
like proxy().
In `@crates/node/src/txpool.rs`:
- Around line 520-522: The current replacement of balance with sponsor_balance
when constructing TransactionValidationOutcome::Valid (using
override_balance.unwrap_or(balance)) incorrectly merges sponsor and recipient
balances for sponsored transactions carrying a non-zero call value; update
txpool validation so sponsored transactions with value > 0 are explicitly
handled: either (preferred quick fix) reject/flag sponsored txs that carry value
> 0 in the validation path (return Invalid or a new outcome) until split
accounting is implemented, or (full fix) implement true dual-accounting in the
validation logic by tracking sponsor_balance for gas/fees and the
executor/recipient balance for call value and change
TransactionValidationOutcome (or add fields) to represent both balances and use
those for pool ordering/overdraft checks; locate and modify the code paths
around TransactionValidationOutcome::Valid, override_balance, sponsor_balance
and any callers in txpool.rs to enforce this policy.
---
Nitpick comments:
In `@crates/node/src/txpool.rs`:
- Around line 812-859: Add a new test mirroring
non_sponsored_evnode_rejects_when_balance_covers_gas_but_not_value but for a
sponsored transaction: construct an EvNodeTransaction with a non-zero Call.value
and set fee_payer_signature to Some(...) (use sample_signature()), create the
signed/pooled tx via Signed::new_unhashed and create_pooled_tx, then call
validate_evnode(&pooled, sender_balance, &mut state) (and if needed simulate
sponsor balance via the same call or state mock) and assert the expected outcome
for sponsored dual-payer accounting (e.g., reject when sponsor only covers gas
but not call value); reference EvNodeTransaction.fee_payer_signature,
validate_evnode, Signed::new_unhashed, create_pooled_tx, sample_signature(), and
create_test_validator to locate where to add the test.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (10)
bin/sponsor-service/README.mdbin/sponsor-service/package.jsonbin/sponsor-service/src/config.tsbin/sponsor-service/src/routes/health.tsbin/sponsor-service/src/routes/jsonrpc.tsbin/sponsor-service/src/services/rpc-client.tsbin/sponsor-service/src/services/sponsor-service.tsbin/sponsor-service/tests/helpers/fixtures.tsbin/sponsor-service/tests/integration/api.test.tscrates/node/src/txpool.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- bin/sponsor-service/tests/integration/api.test.ts
- bin/sponsor-service/tests/helpers/fixtures.ts
| # Build the client library first (from repo root) | ||
| cd ../../clients && bun install && bun run build | ||
|
|
||
| # Install dependencies (from bin/sponsor-service) | ||
| bun install |
There was a problem hiding this comment.
Fix the setup directory context to avoid broken copy/paste instructions.
The command path and the comment disagree, so following this verbatim can run installs in the wrong directory.
📝 Proposed README fix
-# Build the client library first (from repo root)
-cd ../../clients && bun install && bun run build
-
-# Install dependencies (from bin/sponsor-service)
-bun install
+# From bin/sponsor-service, build the client library first
+cd ../../clients && bun install && bun run build
+
+# Return to sponsor-service and install dependencies
+cd ../bin/sponsor-service && bun install🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@bin/sponsor-service/README.md` around lines 35 - 39, The README currently
says "Build the client library first (from repo root)" but the command uses "cd
../../clients" which is inconsistent with the following "Install dependencies
(from bin/sponsor-service)" instructions; update the text or commands so they
match: either change the first comment to "Build the client library first (from
bin/sponsor-service)" or change the command to run from repo root (e.g., use "cd
clients && bun install && bun run build"), and ensure the second step explicitly
runs "bun install" from bin/sponsor-service as shown; verify and update the two
lines containing "cd ../../clients && bun install && bun run build" and "bun
install" so copy/paste will run in the intended directories.
| port: (() => { | ||
| const p = Number(envOrDefault('PORT', '3000')); | ||
| if (!Number.isInteger(p) || p <= 0) throw new Error('PORT must be a positive integer'); | ||
| return p; |
There was a problem hiding this comment.
Add an upper-bound check for PORT to fail fast on invalid values.
PORT=70000 currently passes config validation and fails later during listen.
✅ Proposed fix
port: (() => {
const p = Number(envOrDefault('PORT', '3000'));
- if (!Number.isInteger(p) || p <= 0) throw new Error('PORT must be a positive integer');
+ if (!Number.isInteger(p) || p <= 0 || p > 65535) {
+ throw new Error('PORT must be an integer between 1 and 65535');
+ }
return p;
})(),📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| port: (() => { | |
| const p = Number(envOrDefault('PORT', '3000')); | |
| if (!Number.isInteger(p) || p <= 0) throw new Error('PORT must be a positive integer'); | |
| return p; | |
| port: (() => { | |
| const p = Number(envOrDefault('PORT', '3000')); | |
| if (!Number.isInteger(p) || p <= 0 || p > 65535) { | |
| throw new Error('PORT must be an integer between 1 and 65535'); | |
| } | |
| return p; | |
| })(), |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@bin/sponsor-service/src/config.ts` around lines 37 - 40, The PORT config
validator (the port: (() => { ... }) block) only checks for positive integers
but not an upper bound, so values like 70000 slip through; update the inline
validator that parses envOrDefault('PORT', '3000') into p
(Number(envOrDefault(...))) to also assert p <= 65535 and throw a clear Error
(e.g., "PORT must be an integer between 1 and 65535") when the check fails so
invalid ports fail fast during configuration.
| } catch (e) { | ||
| if (e instanceof SponsorError) { | ||
| return reply.send(jsonRpcError(body.id, sponsorErrorToRpcCode(e), e.message)); | ||
| } | ||
| return reply.send(jsonRpcError(body.id, -32000, (e as Error).message)); | ||
| } |
There was a problem hiding this comment.
Avoid leaking internal error messages in JSON-RPC responses.
Returning raw exception messages to clients can expose internals; log details server-side and return a generic error message.
🔒 Proposed fix
} catch (e) {
if (e instanceof SponsorError) {
return reply.send(jsonRpcError(body.id, sponsorErrorToRpcCode(e), e.message));
}
- return reply.send(jsonRpcError(body.id, -32000, (e as Error).message));
+ request.log.error({ err: e }, 'Unhandled error in JSON-RPC handler');
+ return reply.send(jsonRpcError(body.id, -32000, 'Internal error'));
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch (e) { | |
| if (e instanceof SponsorError) { | |
| return reply.send(jsonRpcError(body.id, sponsorErrorToRpcCode(e), e.message)); | |
| } | |
| return reply.send(jsonRpcError(body.id, -32000, (e as Error).message)); | |
| } | |
| } catch (e) { | |
| if (e instanceof SponsorError) { | |
| return reply.send(jsonRpcError(body.id, sponsorErrorToRpcCode(e), e.message)); | |
| } | |
| request.log.error({ err: e }, 'Unhandled error in JSON-RPC handler'); | |
| return reply.send(jsonRpcError(body.id, -32000, 'Internal error')); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@bin/sponsor-service/src/routes/jsonrpc.ts` around lines 69 - 74, In the catch
block handling errors for the JSON-RPC route, avoid returning raw exception
messages to clients: log the full error server-side (e.g., using
request.log.error(e) or a logger) and change the reply.send calls that use (e as
Error).message or e.message to return a generic message like "Internal server
error"; keep sponsorErrorToRpcCode(body.id) mapping for SponsorError but still
return a non-sensitive message (or a safe public message property if
SponsorError exposes one) via jsonRpcError(body.id, sponsorErrorToRpcCode(e),
"Internal server error") and for other errors use jsonRpcError(body.id, -32000,
"Internal server error") after logging the original error.
| const response = await this.fetchRpc({ jsonrpc: '2.0', id, method, params }); | ||
| const data = (await response.json()) as { result?: any; error?: { message: string } }; | ||
| if (data.error) throw NODE_ERROR(data.error.message); | ||
| return data.result; |
There was a problem hiding this comment.
Handle non-JSON upstream responses in call() the same way as proxy().
Right now response.json() can throw a raw parse error instead of returning a normalized NODE_ERROR.
🛠️ Proposed fix
async call(method: string, params: unknown[] = []): Promise<any> {
const id = this.nextId++;
const response = await this.fetchRpc({ jsonrpc: '2.0', id, method, params });
- const data = (await response.json()) as { result?: any; error?: { message: string } };
+ let data: { result?: any; error?: { message: string } };
+ try {
+ data = (await response.json()) as { result?: any; error?: { message: string } };
+ } catch {
+ throw NODE_ERROR(`upstream returned non-JSON response (status ${response.status})`);
+ }
if (data.error) throw NODE_ERROR(data.error.message);
return data.result;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const response = await this.fetchRpc({ jsonrpc: '2.0', id, method, params }); | |
| const data = (await response.json()) as { result?: any; error?: { message: string } }; | |
| if (data.error) throw NODE_ERROR(data.error.message); | |
| return data.result; | |
| const response = await this.fetchRpc({ jsonrpc: '2.0', id, method, params }); | |
| let data: { result?: any; error?: { message: string } }; | |
| try { | |
| data = (await response.json()) as { result?: any; error?: { message: string } }; | |
| } catch { | |
| throw NODE_ERROR(`upstream returned non-JSON response (status ${response.status})`); | |
| } | |
| if (data.error) throw NODE_ERROR(data.error.message); | |
| return data.result; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@bin/sponsor-service/src/services/rpc-client.ts` around lines 20 - 23, In
call(), avoid letting response.json() throw a raw parse error by mirroring
proxy() behavior: attempt to parse JSON but catch any parsing or non-JSON
responses, read response.text() as a fallback, and throw a normalized NODE_ERROR
containing useful context (HTTP status, response text or parse error message)
instead of propagating the raw exception; update the logic around fetchRpc /
response.json() in the call() implementation so it returns data.result on
success or throws NODE_ERROR on parse/non-JSON responses just like proxy().
| Ok(override_balance) => TransactionValidationOutcome::Valid { | ||
| balance: override_balance.unwrap_or(balance), | ||
| state_nonce, |
There was a problem hiding this comment.
balance override is unsafe for sponsored txs that carry non-zero call value.
At Line 521, replacing balance with sponsor_balance for all sponsored txs creates a single-balance model for a dual-payer transaction. Pool ordering/overdraft logic uses transaction cost (gas + value), so this can mis-account call value against sponsor balance and misclassify tx readiness.
Please add an explicit policy for sponsored value > 0 transactions (e.g., reject in pool until split accounting is implemented, or implement true sponsor-gas/executor-value accounting).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/node/src/txpool.rs` around lines 520 - 522, The current replacement of
balance with sponsor_balance when constructing
TransactionValidationOutcome::Valid (using override_balance.unwrap_or(balance))
incorrectly merges sponsor and recipient balances for sponsored transactions
carrying a non-zero call value; update txpool validation so sponsored
transactions with value > 0 are explicitly handled: either (preferred quick fix)
reject/flag sponsored txs that carry value > 0 in the validation path (return
Invalid or a new outcome) until split accounting is implemented, or (full fix)
implement true dual-accounting in the validation logic by tracking
sponsor_balance for gas/fees and the executor/recipient balance for call value
and change TransactionValidationOutcome (or add fields) to represent both
balances and use those for pool ordering/overdraft checks; locate and modify the
code paths around TransactionValidationOutcome::Valid, override_balance,
sponsor_balance and any callers in txpool.rs to enforce this policy.
Summary
This PR introduces gas abstraction for EvNode transactions via a new Sponsor Service and the Rust-side plumbing to support it in the transaction pool.
Problem
EvNode transactions (type
0x76) require the executor to have ETH to pay gas, creating friction for onboarding new users or apps that want to abstract away gas costs.Additionally, sponsored transactions from executors with zero balance were being rejected by the txpool. The pool checked the executor's balance against the transaction cost and flagged it as an overdraft, even though the sponsor is the one paying for gas. This caused valid sponsored transactions to be discarded before they could be included in a block.
Solution
Sponsor Service (
bin/sponsor-service/) — A transparent JSON-RPC proxy (Bun + Fastify) that sits between the client and ev-reth. Clients just change their RPC URL to point to the sponsor service; zero code changes needed.Flow:
eth_sendRawTransactionwith a0x76tx signed only by the executor (no sponsor signature)Txpool fix (
crates/node/src/txpool.rs) — When a0x76transaction has a valid sponsor signature, the pool now uses the sponsor's balance for the overdraft check and pending/queued ordering, instead of the executor's. This fixes the edge case where a fully sponsored transaction from an executor with 0 ETH was incorrectly rejected as an overdraft.What's included
validate_sponsor_balancenow returns the sponsor's balance, whichvalidate_evnodepropagates as an override so the pool orders by the account that actually pays for gasclients/): minor version bumpSummary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests
Closes #138