Skip to content

feat: add tx sponsor service and propagate sponsor balance in txpool#141

Merged
randygrok merged 7 commits intomainfrom
randygrok/tx-sponsor-service
Feb 26, 2026
Merged

feat: add tx sponsor service and propagate sponsor balance in txpool#141
randygrok merged 7 commits intomainfrom
randygrok/tx-sponsor-service

Conversation

@randygrok
Copy link
Contributor

@randygrok randygrok commented Feb 24, 2026

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:

  1. Client sends eth_sendRawTransaction with a 0x76 tx signed only by the executor (no sponsor signature)
  2. Sponsor service intercepts it, validates against configurable policies (chain ID, gas cap, fee cap), signs as sponsor, and forwards to ev-reth
  3. All other RPC calls are proxied transparently

Txpool fix (crates/node/src/txpool.rs) — When a 0x76 transaction 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

  • Sponsor Service: config, policy engine, RPC proxy, health/policy REST endpoints, unit + integration tests
  • Txpool fix: validate_sponsor_balance now returns the sponsor's balance, which validate_evnode propagates as an override so the pool orders by the account that actually pays for gas
  • Client lib (clients/): minor version bump

Summary by CodeRabbit

  • New Features

    • Sponsor Service: transparent JSON‑RPC proxy that signs and sponsors transactions with zero-code client changes.
    • Added /v1/health and /v1/policy endpoints for service status and sponsorship rules.
  • Bug Fixes

    • Txpool: sponsored transactions now use sponsor balance for pending/queued ordering and validate executor balance for value transfers.
  • Documentation

    • Comprehensive setup, usage, and configuration guide for Sponsor Service.
  • Tests

    • Added unit and integration tests covering PolicyEngine, API and JSON‑RPC flows.

Closes #138

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.
@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d671a4b and 99ac99d.

📒 Files selected for processing (1)
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Service docs & packaging
bin/sponsor-service/README.md, bin/sponsor-service/package.json, bin/sponsor-service/tsconfig.json
New README, package manifest, and TS config to document and build/run the Sponsor Service.
Bootstrap & server wiring
bin/sponsor-service/src/index.ts, bin/sponsor-service/src/server.ts, bin/sponsor-service/src/config.ts
App entry, server factory, and environment-backed SponsorConfig loader with validations and defaults.
Routing
bin/sponsor-service/src/routes/jsonrpc.ts, bin/sponsor-service/src/routes/health.ts, bin/sponsor-service/src/routes/policy.ts
JSON-RPC root proxy handling eth_sendRawTransaction (EvNode detection, sponsor flow, proxy fallback) and REST endpoints for /v1/health and /v1/policy.
Core services & clients
bin/sponsor-service/src/services/sponsor-service.ts, bin/sponsor-service/src/services/policy-engine.ts, bin/sponsor-service/src/services/rpc-client.ts
SponsorService signs/submits intents, PolicyEngine enforces policy checks, RpcClient performs JSON-RPC calls (balance, sendRawTransaction, proxy).
Errors and types
bin/sponsor-service/src/errors.ts
SponsorError class and factory helpers for domain error codes, messages, and status mapping.
Tests & fixtures
bin/sponsor-service/tests/helpers/fixtures.ts, bin/sponsor-service/tests/integration/api.test.ts, bin/sponsor-service/tests/unit/policy-engine.test.ts
Test fixtures, unit tests for PolicyEngine, and integration tests covering health/policy endpoints and JSON-RPC sponsor/proxy flows.
Rust txpool update
crates/node/src/txpool.rs
Txpool validator: added check_sender_overdraft, validate_sponsor_balance now returns sponsor balance, validate_evnode returns Option (optional sponsor-balance override); callers updated and tests adjusted.
Miscellaneous
bin/sponsor-service/...
Multiple new files composing the Sponsor Service implementation and tests (see cohorts above).

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐇 I nibble bytes and hop through RPC springs,
I sign the fees and tuck them under wings.
Policy checked, balance weighed with cheer,
EvNode hops out, the path becomes clear. ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description is comprehensive, covering the problem, solution, and what's included. However, the required template sections (Type of Change, Checklist, Testing) are not filled out. Complete the missing template sections: mark the Type of Change (New feature), verify the Checklist items, and document the testing performed.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: introducing a sponsor service for gas abstraction and updating txpool balance handling for sponsored transactions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch randygrok/tx-sponsor-service

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link

claude bot commented Feb 24, 2026

Claude finished @randygrok's task —— View job


Code Review: Transaction Sponsor Service & Txpool Changes

I've completed a comprehensive code review of this PR. Here's my assessment:

Review Checklist:

  • Analyze sponsor service architecture and implementation
  • Review txpool validation changes
  • Security analysis (key handling, validation, input sanitization)
  • Performance considerations
  • Test coverage assessment
  • Code quality and best practices
  • Provide detailed feedback and recommendations

🎯 Overall Assessment

This 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 Issues

1. Sponsor Service: Missing Graceful Shutdown (bin/sponsor-service/src/index.ts:7-13)

The service has no SIGTERM/SIGINT handlers, so container shutdowns will kill it forcefully, potentially dropping in-flight requests.

Fix: Add graceful shutdown handlers:

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'));

2. Txpool: Incomplete Sponsored Transaction Validation (crates/node/src/txpool.rs:470-477)

The code validates executor balance against call values for sponsored transactions, which is correct. However, there's a critical issue in how the pool uses the sponsor balance override.

At line 521, override_balance.unwrap_or(balance) replaces the executor's balance with the sponsor's for all pool operations, including cost comparisons that include both gas + call values. This creates an accounting mismatch where the sponsor's balance is validated against costs that should be split between sponsor (gas) and executor (call values).

Recommendation: Either:

  • Add explicit validation to reject sponsored transactions with non-zero call values until proper dual-accounting is implemented
  • Implement proper dual-balance tracking in TransactionValidationOutcome

3. RPC Client: No Timeout on fetch() (bin/sponsor-service/src/services/rpc-client.ts:9-15)

Fixed - The code now properly uses AbortSignal.timeout(timeoutMs).


🟡 Security Concerns

1. Private Key Validation (bin/sponsor-service/src/config.ts:27-33)

Fixed - The code now validates the private key format with a proper regex.

2. Error Message Leakage (bin/sponsor-service/src/routes/jsonrpc.ts:73)

Raw exception messages are returned to clients, potentially exposing internals. Should log errors server-side and return generic messages.

3. Test Private Keys (bin/sponsor-service/tests/helpers/fixtures.ts:7-8)

Fixed - gitleaks:allow comments have been added.

4. TOCTOU Race Condition (bin/sponsor-service/src/services/sponsor-service.ts:46-57)

The sponsor balance check has a race condition where concurrent requests can all pass the balance check and drain funds below the minimum. The comment acknowledges this, but it's still a real concern under load.


🔵 Performance & Quality Issues

1. Policy Validation Order (bin/sponsor-service/src/services/policy-engine.ts:26-40)

Expensive crypto operations (signature recovery) run before cheap policy checks (gas limits). Should validate gas/fee limits first for fail-fast behavior.

2. Non-JSON Response Handling (bin/sponsor-service/src/services/rpc-client.ts:21)

The call() method doesn't handle non-JSON responses like proxy() does. Should catch JSON parse errors consistently.

3. Health Endpoint Status Codes (bin/sponsor-service/src/routes/health.ts:25)

Fixed - Health endpoint now returns 503 for degraded status.


📝 Code Quality Observations

Strengths:

  • Clean Architecture: Well-separated concerns with policy engine, RPC client, and sponsor service
  • Good Error Handling: Structured error types with proper HTTP status mapping
  • Comprehensive Validation: Thorough policy checks for gas limits, fees, chain ID, and signatures
  • Documentation: Good inline comments explaining complex logic (e.g., TOCTOU race)
  • Type Safety: Strong TypeScript typing throughout

Areas for Improvement:

  • Test Coverage: Missing sponsored transaction tests in txpool (only non-sponsored tests exist)
  • Input Validation: JSON-RPC handler should validate request body structure
  • Logging: Silent catch blocks swallow diagnostic information

🧪 Test Coverage Analysis

Good Coverage:

  • Policy engine validation (chain ID, gas limits, signature recovery)
  • Integration tests for JSON-RPC endpoints
  • Non-sponsored EvNode transaction validation

Missing Coverage:

  • Critical: No txpool tests for sponsored transactions with fee_payer_signature
  • Concurrent sponsor request handling
  • Health endpoint degraded status responses
  • Error boundary testing for malformed JSON-RPC requests

Recommended Test: Add a txpool test that creates a sponsored EvNode transaction and validates the sponsor balance propagation logic.


🔧 Minor Issues

  1. README: Inconsistent chain ID values (1337 vs 1234) across examples
  2. Package.json: Non-deterministic "@types/bun": "latest" dependency
  3. Config Validation: PORT validation missing upper bound check (should be ≤ 65535)

🏆 Final Recommendation

This is a solid implementation of gas abstraction with good architecture and security considerations. The critical txpool accounting issue should be resolved before merge, and the graceful shutdown handler is important for production deployment.

Priority Order:

  1. High: Fix txpool sponsor balance override logic
  2. High: Add graceful shutdown handling
  3. Medium: Address security concerns (error message leakage, TOCTOU race)
  4. Low: Code quality improvements and missing test coverage

The sponsor service successfully abstracts gas payments while maintaining the existing JSON-RPC interface - exactly what was intended. With the critical issues addressed, this will be a valuable addition to the codebase.


randygrok and others added 3 commits February 24, 2026 21:51
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.
@randygrok randygrok marked this pull request as ready for review February 25, 2026 08:52
@randygrok randygrok requested a review from a team as a code owner February 25, 2026 08:52
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🧹 Nitpick comments (7)
crates/node/src/txpool.rs (2)

630-826: No test coverage for the sponsored EvNode path returning Some(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 that validate_evnode returns Ok(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.rs confirms 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 the INVALID_INTENT path (validateEvNodeTx failure).

The PolicyEngine.validate function calls validateEvNodeTx first, but there is no unit test exercising that branch. Adding a test with a structurally invalid transaction (e.g., empty calls array or a malformed field) would close this gap.

Would you like me to draft an additional test case for the INVALID_INTENT path?

🤖 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/SIGINT handler calling app.close(), Kubernetes pod termination (and docker 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/bun for 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.

recoverExecutor performs an EC signature recovery, but gasLimit and maxFeePerGas checks are pure bigint comparisons. Moving them before recoverExecutor lets 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: Silent catch {} 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

📥 Commits

Reviewing files that changed from the base of the PR and between ecfefef and 8ccaea6.

⛔ Files ignored due to path filters (1)
  • bin/sponsor-service/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • bin/sponsor-service/README.md
  • bin/sponsor-service/package.json
  • bin/sponsor-service/src/config.ts
  • bin/sponsor-service/src/errors.ts
  • bin/sponsor-service/src/index.ts
  • bin/sponsor-service/src/routes/health.ts
  • bin/sponsor-service/src/routes/jsonrpc.ts
  • bin/sponsor-service/src/routes/policy.ts
  • bin/sponsor-service/src/server.ts
  • bin/sponsor-service/src/services/policy-engine.ts
  • bin/sponsor-service/src/services/rpc-client.ts
  • bin/sponsor-service/src/services/sponsor-service.ts
  • bin/sponsor-service/tests/helpers/fixtures.ts
  • bin/sponsor-service/tests/integration/api.test.ts
  • bin/sponsor-service/tests/unit/policy-engine.test.ts
  • bin/sponsor-service/tsconfig.json
  • crates/node/src/txpool.rs

Comment on lines +7 to +24
```
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 ---------------------->|
|<-----------------------------|<----------------------------|
```
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
```
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.

Comment on lines +46 to +52
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();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8ccaea6 and e0fe1ee.

📒 Files selected for processing (1)
  • crates/node/src/txpool.rs

- 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)
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between e0fe1ee and d671a4b.

📒 Files selected for processing (10)
  • bin/sponsor-service/README.md
  • bin/sponsor-service/package.json
  • bin/sponsor-service/src/config.ts
  • bin/sponsor-service/src/routes/health.ts
  • bin/sponsor-service/src/routes/jsonrpc.ts
  • bin/sponsor-service/src/services/rpc-client.ts
  • bin/sponsor-service/src/services/sponsor-service.ts
  • bin/sponsor-service/tests/helpers/fixtures.ts
  • bin/sponsor-service/tests/integration/api.test.ts
  • crates/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

Comment on lines +35 to +39
# Build the client library first (from repo root)
cd ../../clients && bun install && bun run build

# Install dependencies (from bin/sponsor-service)
bun install
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +37 to +40
port: (() => {
const p = Number(envOrDefault('PORT', '3000'));
if (!Number.isInteger(p) || p <= 0) throw new Error('PORT must be a positive integer');
return p;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +69 to +74
} 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));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
} 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.

Comment on lines +20 to +23
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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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().

Comment on lines +520 to 522
Ok(override_balance) => TransactionValidationOutcome::Valid {
balance: override_balance.unwrap_or(balance),
state_nonce,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

@randygrok randygrok merged commit c34841b into main Feb 26, 2026
17 checks passed
@randygrok randygrok deleted the randygrok/tx-sponsor-service branch February 26, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Create an example service for sponsorship feature

2 participants