Skip to content

feat: Implement balance checks for L2PS transactions to ensure suffic…#680

Closed
Shitikyan wants to merge 3 commits into
testnetfrom
fix-l2ps-balance-check
Closed

feat: Implement balance checks for L2PS transactions to ensure suffic…#680
Shitikyan wants to merge 3 commits into
testnetfrom
fix-l2ps-balance-check

Conversation

@Shitikyan

@Shitikyan Shitikyan commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

…ient funds before processing

Summary by CodeRabbit

Release Notes

  • New Features

    • Transactions now include balance validation to verify sufficient funds before finalization
    • Added balance verification for Layer 2 protocol encrypted transactions
  • Bug Fixes

    • Improved error reporting for insufficient balance scenarios
    • Enhanced error logging for failed transaction broadcasts

@qodo-code-review

Copy link
Copy Markdown
Contributor
ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Review Summary by Qodo

Implement balance checks for L2PS and regular transactions
✨ Enhancement 🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Add balance validation for L2PS encrypted transactions before mempool insertion
• Implement sender balance checks for regular transactions to prevent insufficient funds
• Export L2PS_TX_FEE constant for reuse across modules
• Add error logging for failed broadcast transactions

Grey Divider

File Changes

1. src/libs/blockchain/routines/validateTransaction.ts ✨ Enhancement +76/-0

Add balance validation for transactions

• Added balance check for regular transactions before processing
• Implemented checkL2PSBalance() function to decrypt and validate L2PS transaction balances
• Added imports for L2PS-related modules and types
• Returns validation error if sender has insufficient funds

src/libs/blockchain/routines/validateTransaction.ts


2. src/libs/l2ps/L2PSTransactionExecutor.ts ✨ Enhancement +1/-1

Export L2PS transaction fee constant

• Exported L2PS_TX_FEE constant to make it accessible to other modules

src/libs/l2ps/L2PSTransactionExecutor.ts


3. src/libs/network/manageExecution.ts Error handling +3/-0

Add error logging for failed broadcasts

• Added error logging when broadcastTx fails to help with debugging
• Logs result code, extra data, and response details on failure

src/libs/network/manageExecution.ts


View more (1)
4. src/libs/network/routines/transactions/handleL2PS.ts ✨ Enhancement +41/-2

Add balance pre-check for L2PS transactions

• Added import for INativePayload type
• Imported L2PS_TX_FEE from L2PSTransactionExecutor
• Implemented checkSenderBalance() function to validate balance before mempool insertion
• Added pre-check balance validation call before processing valid L2PS transactions

src/libs/network/routines/transactions/handleL2PS.ts


Grey Divider

Qodo Logo

@sonarqubecloud

Copy link
Copy Markdown

@qodo-code-review

Copy link
Copy Markdown
Contributor
ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Logging can throw on failure 🐞 Bug ⛯ Reliability
Description
In broadcastTx failure handling, manageExecution JSON.stringifies returnValue.extra; if extra is
a non-serializable object (e.g., contains circular refs or BigInt), the log statement can throw and
break the error-handling path (client may not receive the intended error response).
Code

src/libs/network/manageExecution.ts[R82-84]

+                if (!result.success) {
+                    log.error(`[SERVER] broadcastTx FAILED — returning to client: result=${returnValue.result}, extra=${JSON.stringify(returnValue.extra)}, response.extra=${result.response?.extra}`)
+                }
Evidence
manageExecution performs JSON.stringify(returnValue.extra) in the failure branch.
returnValue.extra is assigned from result.extra, and handleExecuteTransaction sometimes sets
result.extra to an object, meaning it is not guaranteed to be safely JSON-serializable.

src/libs/network/manageExecution.ts[69-85]
src/libs/network/endpointHandlers.ts[419-443]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`manageExecution` logs `broadcastTx` failures using `JSON.stringify(returnValue.extra)`. Because `extra` can be an object (and potentially non-JSON-serializable), the logging line can throw and break the error path.

### Issue Context
`returnValue.extra` is populated from `result.extra`, and `handleExecuteTransaction` assigns objects to `result.extra` in multiple branches.

### Fix Focus Areas
- src/libs/network/manageExecution.ts[69-85]

### Suggested fix
- Wrap the stringify in a try/catch and fall back to a safe representation.
- Optionally implement a small `safeStringify` helper that:
 - converts `bigint` to string
 - handles circular references (or falls back to `util.inspect`)
 - truncates overly large outputs

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. L2PS fee/balance check inconsistent 🐞 Bug ✓ Correctness
Description
The new L2PS balance pre-checks always add L2PS_TX_FEE (even when the executor does not charge any
fee for that tx type), and they don’t validate sendAmount like the executor does. This can
incorrectly reject L2PS transactions that would otherwise execute successfully (e.g., non-send
native ops, or non-native txs with only gcr_edits) and can also produce misleading errors/behavior
when the amount is malformed.
Code

src/libs/network/routines/transactions/handleL2PS.ts[R80-102]

+async function checkSenderBalance(decryptedTx: Transaction): Promise<string | null> {
+    const sender = decryptedTx.content.from as string
+    if (!sender) return "Missing sender address in decrypted transaction"
+
+    // Extract amount from native payload
+    let amount = 0
+    if (decryptedTx.content.type === "native" && Array.isArray(decryptedTx.content.data)) {
+        const nativePayload = decryptedTx.content.data[1] as INativePayload
+        if (nativePayload?.nativeOperation === "send") {
+            const [, sendAmount] = nativePayload.args as [string, number]
+            amount = sendAmount || 0
+        }
+    }
+
+    const totalRequired = amount + L2PS_TX_FEE
+    try {
+        const balance = await L2PSTransactionExecutor.getBalance(sender)
+        if (balance < BigInt(totalRequired)) {
+            return `Insufficient balance: need ${totalRequired} (${amount} + ${L2PS_TX_FEE} fee) but have ${balance}`
+        }
+    } catch (error) {
+        return `Balance check failed: ${error instanceof Error ? error.message : "Unknown error"}`
+    }
Evidence
checkSenderBalance (and the similar checkL2PSBalance in confirmTransaction) always requires
amount + L2PS_TX_FEE even when the executor’s logic only burns/checks the fee for `nativeOperation
=== "send". The executor also validates amount` as a finite integer > 0, but the new pre-check
does not. Therefore the pre-check and executor can disagree: pre-check may reject while executor
would accept, or pre-check may crash/skip in edge cases.

src/libs/network/routines/transactions/handleL2PS.ts[80-102]
src/libs/blockchain/routines/validateTransaction.ts[163-176]
src/libs/l2ps/L2PSTransactionExecutor.ts[207-220]
src/libs/l2ps/L2PSTransactionExecutor.ts[158-189]
src/libs/l2ps/L2PSTransactionExecutor.ts[260-266]
src/libs/l2ps/L2PSTransactionExecutor.ts[435-438]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`checkSenderBalance` / `checkL2PSBalance` currently require `L2PS_TX_FEE` for all decrypted L2PS transactions, but the executor only checks/burns the fee for `nativeOperation === &quot;send&quot;` and may accept other tx types without fees. This mismatch can cause false rejections. Additionally, the pre-check doesn’t validate the extracted `sendAmount` like the executor does.

### Issue Context
- Executor fee+amount checks are implemented in `handleNativeTransaction` for `nativeOperation === &quot;send&quot;`.
- Other tx types (non-native with `gcr_edits`, or unknown native operations) do not burn/check a fee.

### Fix Focus Areas
- src/libs/network/routines/transactions/handleL2PS.ts[80-102]
- src/libs/blockchain/routines/validateTransaction.ts[145-176]
- src/libs/l2ps/L2PSTransactionExecutor.ts[158-220]

### Suggested fix
1. Decide on policy:
  - **Option A (match current executor):** Only add `L2PS_TX_FEE` to `totalRequired` for txs where the executor actually burns/charges the fee (currently native `send`).
  - **Option B (enforce fee for all L2PS txs):** Update `L2PSTransactionExecutor.generateGCREdits`/handlers to also burn/check the fee for other tx categories, then keep pre-check consistent.
2. Validate amount exactly as executor does before BigInt conversion:
  - `typeof sendAmount === &#x27;number&#x27; &amp;&amp; Number.isFinite(sendAmount) &amp;&amp; Number.isInteger(sendAmount) &amp;&amp; sendAmount &gt; 0`
3. Compute required balance using BigInt arithmetic:
  - `const required = BigInt(sendAmount) + BigInt(L2PS_TX_FEE)`
  - Avoid `BigInt(amount + fee)`.
4. Consider extracting a shared helper (e.g., in `L2PSTransactionExecutor`) to compute required balance so pre-check and executor cannot drift.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Double L2PS decrypt work 🐞 Bug ➹ Performance
Description
confirmTransaction now decrypts L2PS encrypted transactions for balance pre-check, but handleL2PS
decrypts again during processing. This duplicates expensive cryptographic work and config loading
for clients using confirmTx + broadcastTx flows, increasing latency and DoS surface.
Code

src/libs/blockchain/routines/validateTransaction.ts[R151-160]

+        const parallelNetworks = ParallelNetworks.getInstance()
+        let l2psInstance = await parallelNetworks.getL2PS(l2psUid)
+        if (!l2psInstance) {
+            l2psInstance = await parallelNetworks.loadL2PS(l2psUid)
+        }
+        if (!l2psInstance) return null // No L2PS config, let handleL2PS catch it
+
+        const decryptedTx = await l2psInstance.decryptTx(tx as any)
+        if (!decryptedTx?.content?.from) return null
+
Evidence
The newly-added checkL2PSBalance decrypts the transaction during confirmTransaction. The L2PS
handling path also decrypts the same encrypted transaction in decryptAndValidate, meaning the
system may decrypt twice for the same tx lifecycle.

src/libs/blockchain/routines/validateTransaction.ts[151-160]
src/libs/network/routines/transactions/handleL2PS.ts[47-55]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`confirmTransaction` decrypts L2PS encrypted transactions to perform a balance pre-check, but `handleL2PS` also decrypts the same payload later. This duplicates CPU/IO work and can be abused to amplify node load.

### Issue Context
- `checkL2PSBalance()` calls `l2psInstance.decryptTx(...)` during confirmTransaction.
- `decryptAndValidate()` calls `l2psInstance.decryptTx(...)` again during actual L2PS handling.

### Fix Focus Areas
- src/libs/blockchain/routines/validateTransaction.ts[141-182]
- src/libs/network/routines/transactions/handleL2PS.ts[44-70]

### Suggested fix
- Prefer doing the balance check in one place (typically `handleL2PS` right before mempool insertion/execution).
- If confirmTx must pre-check, consider:
 - caching the decrypted transaction temporarily keyed by encrypted tx hash, then reusing it in `handleL2PS`, or
 - returning a token/reference from confirmTx that allows reuse.
- At minimum, add a guard so confirmTransaction only decrypts when strictly necessary and the node has the L2PS config loaded.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment on lines +82 to +84
if (!result.success) {
log.error(`[SERVER] broadcastTx FAILED — returning to client: result=${returnValue.result}, extra=${JSON.stringify(returnValue.extra)}, response.extra=${result.response?.extra}`)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

1. Logging can throw on failure 🐞 Bug ⛯ Reliability

In broadcastTx failure handling, manageExecution JSON.stringifies returnValue.extra; if extra is
a non-serializable object (e.g., contains circular refs or BigInt), the log statement can throw and
break the error-handling path (client may not receive the intended error response).
Agent Prompt
### Issue description
`manageExecution` logs `broadcastTx` failures using `JSON.stringify(returnValue.extra)`. Because `extra` can be an object (and potentially non-JSON-serializable), the logging line can throw and break the error path.

### Issue Context
`returnValue.extra` is populated from `result.extra`, and `handleExecuteTransaction` assigns objects to `result.extra` in multiple branches.

### Fix Focus Areas
- src/libs/network/manageExecution.ts[69-85]

### Suggested fix
- Wrap the stringify in a try/catch and fall back to a safe representation.
- Optionally implement a small `safeStringify` helper that:
  - converts `bigint` to string
  - handles circular references (or falls back to `util.inspect`)
  - truncates overly large outputs

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +80 to +102
async function checkSenderBalance(decryptedTx: Transaction): Promise<string | null> {
const sender = decryptedTx.content.from as string
if (!sender) return "Missing sender address in decrypted transaction"

// Extract amount from native payload
let amount = 0
if (decryptedTx.content.type === "native" && Array.isArray(decryptedTx.content.data)) {
const nativePayload = decryptedTx.content.data[1] as INativePayload
if (nativePayload?.nativeOperation === "send") {
const [, sendAmount] = nativePayload.args as [string, number]
amount = sendAmount || 0
}
}

const totalRequired = amount + L2PS_TX_FEE
try {
const balance = await L2PSTransactionExecutor.getBalance(sender)
if (balance < BigInt(totalRequired)) {
return `Insufficient balance: need ${totalRequired} (${amount} + ${L2PS_TX_FEE} fee) but have ${balance}`
}
} catch (error) {
return `Balance check failed: ${error instanceof Error ? error.message : "Unknown error"}`
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

2. L2ps fee/balance check inconsistent 🐞 Bug ✓ Correctness

The new L2PS balance pre-checks always add L2PS_TX_FEE (even when the executor does not charge any
fee for that tx type), and they don’t validate sendAmount like the executor does. This can
incorrectly reject L2PS transactions that would otherwise execute successfully (e.g., non-send
native ops, or non-native txs with only gcr_edits) and can also produce misleading errors/behavior
when the amount is malformed.
Agent Prompt
### Issue description
`checkSenderBalance` / `checkL2PSBalance` currently require `L2PS_TX_FEE` for all decrypted L2PS transactions, but the executor only checks/burns the fee for `nativeOperation === "send"` and may accept other tx types without fees. This mismatch can cause false rejections. Additionally, the pre-check doesn’t validate the extracted `sendAmount` like the executor does.

### Issue Context
- Executor fee+amount checks are implemented in `handleNativeTransaction` for `nativeOperation === "send"`.
- Other tx types (non-native with `gcr_edits`, or unknown native operations) do not burn/check a fee.

### Fix Focus Areas
- src/libs/network/routines/transactions/handleL2PS.ts[80-102]
- src/libs/blockchain/routines/validateTransaction.ts[145-176]
- src/libs/l2ps/L2PSTransactionExecutor.ts[158-220]

### Suggested fix
1. Decide on policy:
   - **Option A (match current executor):** Only add `L2PS_TX_FEE` to `totalRequired` for txs where the executor actually burns/charges the fee (currently native `send`).
   - **Option B (enforce fee for all L2PS txs):** Update `L2PSTransactionExecutor.generateGCREdits`/handlers to also burn/check the fee for other tx categories, then keep pre-check consistent.
2. Validate amount exactly as executor does before BigInt conversion:
   - `typeof sendAmount === 'number' && Number.isFinite(sendAmount) && Number.isInteger(sendAmount) && sendAmount > 0`
3. Compute required balance using BigInt arithmetic:
   - `const required = BigInt(sendAmount) + BigInt(L2PS_TX_FEE)`
   - Avoid `BigInt(amount + fee)`.
4. Consider extracting a shared helper (e.g., in `L2PSTransactionExecutor`) to compute required balance so pre-check and executor cannot drift.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@coderabbitai

coderabbitai Bot commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@Shitikyan, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 42 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 763e2b85-1661-47b7-9598-2c0daa43b600

📥 Commits

Reviewing files that changed from the base of the PR and between 3188069 and c03d031.

📒 Files selected for processing (3)
  • src/libs/blockchain/routines/validateTransaction.ts
  • src/libs/network/manageExecution.ts
  • src/libs/network/routines/transactions/handleL2PS.ts

Walkthrough

The changes add sender balance validation checks at transaction processing layers. A constant is exported for use across modules, balance verification helpers are introduced in both blockchain and network layers, and error logging is enhanced for broadcast failures.

Changes

Cohort / File(s) Summary
Balance validation in blockchain layer
src/libs/blockchain/routines/validateTransaction.ts
Added checkL2PSBalance helper and balance pre-checks before transaction finalization. Validates native balance against transaction amount, with special decryption and fee computation for L2PS encrypted transactions. Returns early with BALANCE ERROR marking if insufficient funds detected.
L2PS fee constant export
src/libs/l2ps/L2PSTransactionExecutor.ts
Exported L2PS_TX_FEE constant to make the fee value accessible to other modules for balance calculations.
Balance validation in L2PS network handler
src/libs/network/routines/transactions/handleL2PS.ts
Added checkSenderBalance helper that validates sender has sufficient funds (amount + L2PS_TX_FEE) before mempool insertion. Inserted balance pre-check gate immediately after decrypted-hash verification, rejecting with HTTP 400-style error on insufficiency.
Error logging enhancement
src/libs/network/manageExecution.ts
Enhanced error logging for failed broadcastTx execution to include result, extra, and response details.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ValidateTransaction
    participant GCR
    participant L2PS as ParallelNetworks<br/>& L2PSExecutor
    participant Mempool

    rect rgba(100, 150, 200, 0.5)
    Note over ValidateTransaction,GCR: Regular Transaction Validation
    Client->>ValidateTransaction: confirmTransaction(tx)
    ValidateTransaction->>GCR: getGCRNativeBalance(sender)
    GCR-->>ValidateTransaction: balance
    alt balance < amount
        ValidateTransaction-->>Client: InvalidTransaction (BALANCE ERROR)
    else sufficient balance
        ValidateTransaction-->>Client: ValidTransaction
    end
    end

    rect rgba(200, 150, 100, 0.5)
    Note over ValidateTransaction,L2PS: L2PS Encrypted Transaction Validation
    Client->>ValidateTransaction: confirmTransaction(L2PSEncryptedTx)
    ValidateTransaction->>L2PS: checkL2PSBalance(tx)
    L2PS->>L2PS: decrypt inner transaction
    L2PS->>L2PS: extract nativeAmount from decrypted payload
    L2PS->>L2PSExecutor: getBalance(sender)
    L2PSExecutor-->>L2PS: L2PS balance
    alt (nativeAmount + L2PS_TX_FEE) > L2PS balance
        L2PS-->>ValidateTransaction: null
        ValidateTransaction-->>Client: InvalidTransaction (BALANCE ERROR)
    else sufficient L2PS balance
        L2PS-->>ValidateTransaction: success
        ValidateTransaction-->>Client: ValidTransaction
    end
    end

    rect rgba(150, 200, 100, 0.5)
    Note over Client,Mempool: L2PS Network Handler Pre-Check
    Client->>Mempool: submitL2PSTx(decryptedTx)
    Mempool->>Mempool: checkSenderBalance(decryptedTx)
    alt balance check fails
        Mempool-->>Client: HTTP 400 Error
    else balance sufficient
        Mempool->>Mempool: processValidL2PSTransaction()
        Mempool-->>Client: Accepted
    end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

Review effort 3/5


🐰 Balance checks now guard the gates,
Before transactions seal their fates,
L2PS and native, both assured,
No empty wallets get procured,
Smart validation hops along the way! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing balance checks for L2PS transactions to ensure sufficient funds, which is the primary objective across all modified files.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-l2ps-balance-check

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.

@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/libs/blockchain/routines/validateTransaction.ts`:
- Around line 154-164: The current guards in validateTransaction (checks around
l2psUid, ParallelNetworks.getInstance().getL2PS/loadL2PS, l2psInstance.decryptTx
and decryptedTx?.content?.from) return null on operational failures which
confirmTransaction treats as "no balance error" and allows validity to pass;
change these paths to surface failures instead of failing open: replace the null
returns with thrown errors or a distinct failure result (e.g., an explicit
BalanceVerificationFailure/throw) so the caller (confirmTransaction) can treat
inability to inspect as a verification failure; update both the block around
getL2PS/loadL2PS/decryptTx and the similar logic at lines 182-186 to propagate
errors (via throw or explicit error return) rather than returning null, and
ensure confirmTransaction handles that signal as a balance verification error.
- Around line 168-180: The balance check is charging L2PS_TX_FEE even for
non-native/send payloads because amount defaults to 0; change the logic to only
add L2PS_TX_FEE when the decryptedTx is a native send. Concretely, detect native
send (use the existing decryptedTx.content.type === "native" and
nativePayload?.nativeOperation === "send" check), set amount from
nativePayload.args only in that branch, compute totalRequired = amount +
(isNativeSend ? L2PS_TX_FEE : 0), then call
L2PSTransactionExecutor.getBalance(sender) and compare balance <
BigInt(totalRequired) as before so non-send payloads are not charged the fee.

In `@src/libs/network/manageExecution.ts`:
- Around line 82-84: The log line in manageExecution.ts that logs on broadcastTx
failure uses JSON.stringify on returnValue.extra and can throw for BigInt or
circular structures; change the logging to first produce a safe serializedExtra
by performing defensive serialization (e.g., JSON.stringify wrapped in a
try/catch or a serializer with a replacer that converts BigInt to string and
handles circular refs via a seen Set) and then use that serializedExtra in
log.error; update the failure branch around variables result, returnValue and
the log.error call so any serialization errors are caught and do not mask the
original failure.

In `@src/libs/network/routines/transactions/handleL2PS.ts`:
- Around line 80-105: checkSenderBalance currently always adds L2PS_TX_FEE and
queries getBalance even for non-native-send transactions; restrict the pre-check
so it only runs for decrypted native send payloads: detect
decryptedTx.content.type === "native" and nativePayload?.nativeOperation ===
"send" (as you already parse) and if not a native send, return null immediately;
otherwise compute amount, include L2PS_TX_FEE in totalRequired, call
L2PSTransactionExecutor.getBalance(sender) and compare as before. Ensure
references to L2PS_TX_FEE, checkSenderBalance, decryptedTx.content.type/data,
nativePayload.nativeOperation, and L2PSTransactionExecutor.getBalance are used
to locate and update the logic.
- Around line 146-151: The pre-check in handleL2PS uses checkSenderBalance but
is vulnerable to TOCTOU because concurrent sends can both pass before
L2PSTransactionExecutor.execute applies debits; fix by implementing
sender-scoped pending-debit tracking or locking: when handleL2PS accepts a tx,
reserve the sender's amount in the mempool (add per-sender pendingDebit field)
or acquire a sender-level lock (e.g., by sender ID) before running
checkSenderBalance and before calling L2PSTransactionExecutor.execute to
serialize processing; ensure L2PSTransactionExecutor.execute consults and
updates the same reservation (or requires the lock) and mempool updates remove
or adjust the pendingDebit on commit/rollback so concurrent requests correctly
see reserved amounts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fbb2f0b1-36aa-45cf-a92c-1479c5785343

📥 Commits

Reviewing files that changed from the base of the PR and between 50ab546 and 3188069.

📒 Files selected for processing (4)
  • src/libs/blockchain/routines/validateTransaction.ts
  • src/libs/l2ps/L2PSTransactionExecutor.ts
  • src/libs/network/manageExecution.ts
  • src/libs/network/routines/transactions/handleL2PS.ts

Comment thread src/libs/blockchain/routines/validateTransaction.ts Outdated
Comment thread src/libs/blockchain/routines/validateTransaction.ts
Comment thread src/libs/network/manageExecution.ts
Comment thread src/libs/network/routines/transactions/handleL2PS.ts
Comment thread src/libs/network/routines/transactions/handleL2PS.ts Outdated
Correctness — fee-bearing scope
- handleL2PS.ts (checkSenderBalance) and validateTransaction.ts
  (checkL2PSBalance) both unconditionally added `L2PS_TX_FEE` to the
  required-balance check, but the executor only burns that fee inside
  `L2PSTransactionExecutor.handleNativeTransaction()` for
  `nativeOperation === "send"`. The pre-checks therefore rejected
  every non-`native/send` L2PS payload (web2, crosschain, gcr_edits-
  only) with a false-negative balance error. Now the fee is added
  only when the inner tx is genuinely fee-bearing, and
  `sendAmount` is shape-validated up front instead of being silently
  coerced to 0 on a malformed payload.

Correctness — fail-closed validate path
- validateTransaction.ts previously returned `null` on every "cannot
  verify" outcome (no l2ps_uid, L2PS not loaded, decryption error).
  `confirmTransaction` reads `null` as "no balance error", which means
  the node signed and broadcast `ValidityData { valid: true }` for txs
  it never actually verified — a fail-open hole that turned every
  operational failure into a stamp of approval. Every previously-`null`
  failure path now returns a precise `[BALANCE ERROR]` string so
  `confirmTransaction` surfaces it instead of vouching for the tx.

Correctness — TOCTOU between balance check and executor debit
- handleL2PS.ts now funnels every `(check + insert + execute)`
  sequence through a per-sender in-process lock (`withSenderLock`).
  Without it, two concurrent broadcasts from the same wallet both
  read the same pre-debit balance and both pass the gate; the
  executor then debits twice from a wallet that had funds for one.
  In-process serialisation is sufficient because every L2PS tx for a
  given sender arrives through one node entry point; cross-node
  ordering is handled downstream by the mempool + consensus pipeline.
  The lock map drops entries once a sender's queue drains, so
  long-lived senders don't leak entries.

Reliability — safe failure-path logging
- manageExecution.ts:84 previously called `JSON.stringify(returnValue.extra)`
  in the broadcastTx failure-log branch. If `extra` carried a circular
  reference or a `BigInt`, stringify itself would throw, abort the
  error-handling path, and leave the client without any response.
  Replaced with a `safeStringifyExtra()` helper that serialises BigInts
  as `"123n"` and degrades to `<unserialisable: ...>` instead of
  throwing.

Net `tsc --noEmit --skipLibCheck` delta on this branch:
  baseline `origin/testnet`: 36 pre-existing errors
  this branch:               36 — 0 net new

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Shitikyan

Copy link
Copy Markdown
Contributor Author

Superseded by #937 — rebased onto current stabilisation with two extra commits: (a) my prior bot-review fixes (fee scoping, fail-closed validate, per-sender TOCTOU lock, safe-stringify failure log) and (b) a small type-drift alignment for the v4 bigint widening and the GCR API rename. Net tsc delta on stabilisation: 0 new errors.

@Shitikyan Shitikyan closed this Jun 11, 2026
@greptile-apps

greptile-apps Bot commented Jun 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds balance pre-checks for both native and L2PS encrypted transactions before they are admitted to the mempool, and exports L2PS_TX_FEE so it can be shared between the executor and the new validation paths. A per-sender in-process lock is introduced in handleL2PS to close the TOCTOU window between the balance read and the executor debit.

  • handleL2PS.ts: Adds withSenderLock (per-sender mutex) and checkSenderBalance; the lock cleanup condition is always false due to a Promise.then() identity bug causing a Map memory leak, and the args destructure for native/send sits outside the error-handling try/catch.
  • validateTransaction.ts: Adds a checkL2PSBalance helper called during confirmTransaction; this path has no sender lock (TOCTOU still open) and getBalance creates new L1 accounts as a side effect during what should be a read-only validation.
  • manageExecution.ts: Adds a safeStringifyExtra helper to guard the broadcastTx failure log against circular references and BigInt values — clean, low-risk change.

Confidence Score: 2/5

The new balance checks improve correctness but ship with a Map leak in the sender lock and an unguarded destructure that can throw outside the error handler — both on the hot path for every L2PS transaction.

The lock cleanup condition compares a freshly-created Promise against one stored in the Map; they will never be the same object, so the Map grows without bound. The args destructure in checkSenderBalance runs before the try/catch, meaning a malformed native/send tx throws an unhandled rejection through the lock. In validateTransaction.ts, getBalance silently creates new L1 accounts as a side effect during a read-only validation pass, and there is no sender lock, leaving a TOCTOU window open on that path.

Both handleL2PS.ts and validateTransaction.ts need attention — the lock implementation and unguarded destructure in the former, and the missing lock plus account-creation side effect in the latter.

Important Files Changed

Filename Overview
src/libs/network/routines/transactions/handleL2PS.ts Adds per-sender locking and a balance pre-check before mempool insertion; the lock cleanup condition is broken (always false) causing a Map memory leak, and the args destructuring for native/send runs outside the error-handling try/catch.
src/libs/blockchain/routines/validateTransaction.ts Adds a balance check for native txs and a new checkL2PSBalance helper for L2PS encrypted txs; missing a sender lock (TOCTOU window), getBalance has an unintended account-creation side effect during validation, and a duplicate log statement was added.
src/libs/l2ps/L2PSTransactionExecutor.ts Only change is exporting L2PS_TX_FEE constant so other modules can reference it — minimal, safe change.
src/libs/network/manageExecution.ts Adds a safeStringifyExtra helper and uses it in the broadcastTx failure log path to avoid JSON.stringify throwing on circular refs or BigInt values — clean, low-risk change.

Sequence Diagram

sequenceDiagram
    participant Client
    participant handleL2PS
    participant withSenderLock
    participant checkSenderBalance
    participant L2PSTransactionExecutor
    participant processValidL2PSTransaction

    Client->>handleL2PS: L2PSTransaction
    handleL2PS->>handleL2PS: validateL2PSStructure()
    handleL2PS->>handleL2PS: getL2PSInstance()
    handleL2PS->>handleL2PS: decryptAndValidate()
    handleL2PS->>withSenderLock: acquire per-sender lock
    withSenderLock->>checkSenderBalance: checkSenderBalance(decryptedTx)
    checkSenderBalance->>L2PSTransactionExecutor: getBalance(sender)
    L2PSTransactionExecutor-->>checkSenderBalance: BigInt balance
    alt Insufficient balance
        checkSenderBalance-->>withSenderLock: error string
        withSenderLock-->>handleL2PS: createErrorResponse(400)
        handleL2PS-->>Client: 400 error
    else Balance OK
        checkSenderBalance-->>withSenderLock: null
        withSenderLock->>processValidL2PSTransaction: process tx
        processValidL2PSTransaction->>L2PSTransactionExecutor: execute()
        L2PSTransactionExecutor-->>processValidL2PSTransaction: L2PSExecutionResult
        processValidL2PSTransaction-->>withSenderLock: RPCResponse 200
        withSenderLock-->>handleL2PS: release lock
        handleL2PS-->>Client: 200 success
    end
Loading

Reviews (1): Last reviewed commit: "review: address bot findings on PR #680" | Re-trigger Greptile

Comment on lines +89 to +111
const senderLocks = new Map<string, Promise<void>>()
async function withSenderLock<T>(
sender: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = senderLocks.get(sender) ?? Promise.resolve()
let release: () => void = () => undefined
const next = new Promise<void>(res => {
release = res
})
senderLocks.set(sender, previous.then(() => next))
try {
await previous
return await fn()
} finally {
release()
// Drop the map entry once the queue has drained so long-lived
// senders don't leak entries here.
if (senderLocks.get(sender) === previous.then(() => next)) {
senderLocks.delete(sender)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 The cleanup check always evaluates to false because Promise.prototype.then() returns a new Promise object on every invocation. The reference stored in senderLocks (call it Promise A) will never === the new Promise B created inline in the finally block. As a result senderLocks.delete(sender) is never called and the Map grows without bound — every sender address ever seen leaks an entry for the lifetime of the process.

Suggested change
const senderLocks = new Map<string, Promise<void>>()
async function withSenderLock<T>(
sender: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = senderLocks.get(sender) ?? Promise.resolve()
let release: () => void = () => undefined
const next = new Promise<void>(res => {
release = res
})
senderLocks.set(sender, previous.then(() => next))
try {
await previous
return await fn()
} finally {
release()
// Drop the map entry once the queue has drained so long-lived
// senders don't leak entries here.
if (senderLocks.get(sender) === previous.then(() => next)) {
senderLocks.delete(sender)
}
}
}
const senderLocks = new Map<string, Promise<void>>()
async function withSenderLock<T>(
sender: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = senderLocks.get(sender) ?? Promise.resolve()
let release: () => void = () => undefined
const next = new Promise<void>(res => {
release = res
})
const thisLock = previous.then(() => next)
senderLocks.set(sender, thisLock)
try {
await previous
return await fn()
} finally {
release()
// Drop the map entry once the queue has drained so long-lived
// senders don't leak entries here.
if (senderLocks.get(sender) === thisLock) {
senderLocks.delete(sender)
}
}
}

Comment on lines +143 to +150
if (feeBearing) {
const [, sendAmount] = (decryptedTx.content.data as any[])[1]
.args as [string, number]
if (typeof sendAmount !== "number" || !Number.isFinite(sendAmount) || sendAmount < 0) {
return `Invalid native send amount: ${String(sendAmount)}`
}
amount = sendAmount
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 The .args destructuring runs before the try/catch that only wraps getBalance. If a native/send tx arrives with args as undefined or a non-iterable, this line throws a TypeError that escapes checkSenderBalance and withSenderLock as an unhandled rejection. Wrapping the entire function body in a try/catch (matching the pattern already used in checkL2PSBalance in validateTransaction.ts) closes this gap.

Comment on lines +150 to +219
async function checkL2PSBalance(tx: Transaction): Promise<string | null> {
try {
const l2psPayload = (tx.content?.data as any)?.[1]
const l2psUid = l2psPayload?.l2ps_uid as string | undefined
if (!l2psUid) {
// Fail closed — `confirmTransaction` treats null as
// "balance OK" and would sign ValidityData claiming the tx
// is valid even though we never verified it.
return `[Tx Validation] [BALANCE ERROR] L2PS transaction missing l2ps_uid — cannot verify sender balance\n`
}

const parallelNetworks = ParallelNetworks.getInstance()
let l2psInstance = await parallelNetworks.getL2PS(l2psUid)
if (!l2psInstance) {
l2psInstance = await parallelNetworks.loadL2PS(l2psUid)
}
if (!l2psInstance) {
return `[Tx Validation] [BALANCE ERROR] L2PS network ${l2psUid} is not loaded on this node — cannot verify sender balance\n`
}

const decryptedTx = await l2psInstance.decryptTx(tx as any)
if (!decryptedTx?.content?.from) {
return `[Tx Validation] [BALANCE ERROR] L2PS payload decryption produced no sender — cannot verify balance\n`
}

const sender = decryptedTx.content.from as string

// L2PS_TX_FEE is only burned by the executor on `native` /
// `send` — see `L2PSTransactionExecutor.handleNativeTransaction()`.
// Charging the fee for other tx types here would reject valid
// non-send L2PS payloads (web2, crosschain, gcr_edits-only).
let amount = 0
let feeBearing = false
if (
decryptedTx.content.type === "native" &&
Array.isArray(decryptedTx.content.data)
) {
const nativePayload = decryptedTx.content.data[1] as INativePayload
if (nativePayload?.nativeOperation === "send") {
feeBearing = true
const [, sendAmount] = nativePayload.args as [string, number]
if (
typeof sendAmount !== "number" ||
!Number.isFinite(sendAmount) ||
sendAmount < 0
) {
return `[Tx Validation] [BALANCE ERROR] Invalid native send amount: ${String(sendAmount)}\n`
}
amount = sendAmount
}
}

const fee = feeBearing ? L2PS_TX_FEE : 0
const totalRequired = amount + fee
if (totalRequired === 0) return null

const balance = await L2PSTransactionExecutor.getBalance(sender)
if (balance < BigInt(totalRequired)) {
return `[Tx Validation] [BALANCE ERROR] Insufficient balance: need ${totalRequired} but have ${balance}\n`
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
log.error(`[confirmTransaction] L2PS balance pre-check error: ${message}`)
// Fail closed — a decryption / load throw means we cannot vouch
// for the tx; signing ValidityData claiming balance-OK here was
// the original fail-open hole.
return `[Tx Validation] [BALANCE ERROR] L2PS balance pre-check failed: ${message}\n`
}
return null
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 checkL2PSBalance is missing a sender lock and getBalance creates accounts as a side effect. Unlike handleL2PS, this code path has no per-sender mutex, so two concurrent confirmTransaction calls for the same L2PS sender can both pass the balance check concurrently. Additionally, L2PSTransactionExecutor.getBalance calls getOrCreateL1Account internally, which creates a new L1 account if one does not exist — running this during a read-only validation phase is an unintended write that may pollute the GCR with empty accounts from invalid or spam transactions.

Comment on lines +105 to +108
log.debug("[TX] confirmTransaction - Transaction validity verified, compiling ValidityData")

// Check sender balance covers the transfer amount
if (tx.content.amount > 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The same debug message is now logged twice in confirmTransaction: once immediately after the signature check (line 105) and again after the balance checks (lines 136–138), where it was already present before this PR. The second occurrence is the original, so the first should be removed.

Suggested change
log.debug("[TX] confirmTransaction - Transaction validity verified, compiling ValidityData")
// Check sender balance covers the transfer amount
if (tx.content.amount > 0) {
// Check sender balance covers the transfer amount
if (tx.content.amount > 0) {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +108 to +123
if (tx.content.amount > 0) {
const from = typeof tx.content.from === "string" ? tx.content.from : forgeToHex(tx.content.from)
let fromBalance = 0
try {
fromBalance = await GCR.getGCRNativeBalance(from)
} catch {
// Address not in GCR — balance is 0
}
if (fromBalance < tx.content.amount) {
validityData.data.message =
`[Tx Validation] [BALANCE ERROR] Insufficient balance: need ${tx.content.amount} but have ${fromBalance}\n`
validityData.data.valid = false
validityData = await signValidityData(validityData)
return validityData
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 For a native L1 transaction where tx.content.amount > 0, only the transfer amount is compared against fromBalance — no fee or gas cost is included. If the sender has exactly tx.content.amount tokens but execution also charges gas (handled by defineGas), this check will sign the transaction as valid even though it will revert at execution time.

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.

1 participant