Skip to content

feat: balance pre-checks for L2PS transactions (rebased onto stabilisation)#937

Open
Shitikyan wants to merge 3 commits into
stabilisationfrom
fix-l2ps-balance-check-on-stabilisation
Open

feat: balance pre-checks for L2PS transactions (rebased onto stabilisation)#937
Shitikyan wants to merge 3 commits into
stabilisationfrom
fix-l2ps-balance-check-on-stabilisation

Conversation

@Shitikyan

@Shitikyan Shitikyan commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Rebased follow-up to #680 (which was opened in Feb against the old testnet base and has been sitting cold for 4 months). The two original feat + review commits are cherry-picked onto current stabilisation; a third commit re-aligns the typing where stabilisation moved on (tx.content.amount widened to number | string, GCR.getGCRNativeBalance renamed to getAccountBalance).

Closes #680.

What this brings up

  • Native-amount balance check in confirmTransactiontx.content.amount > 0 paths now reject senders without sufficient funds before signing ValidityData. Adjusted to BigInt comparisons for post-fork OS magnitudes.
  • L2PS encrypted-tx balance check in confirmTransaction — decrypts the inner tx, verifies sender balance, fee added only on native/send per executor parity. Fail-closed: any "cannot verify" outcome (missing l2ps_uid, L2PS not loaded, decryption error) returns a precise error instead of silently passing.
  • checkSenderBalance in handleL2PS — same fee-scoping; serialised per-sender via in-process withSenderLock to close the TOCTOU window between balance read and executor debit.
  • Safe failure-log in manageExecutionJSON.stringify of returnValue.extra could throw on circular refs / BigInts and abort the client response. Replaced with a guarded safeStringifyExtra that survives both.

Review history baked in

This PR ships #680's content with the bot-review fixes already applied:

  • Fee scoping (qodo bug Splitting transactions from blocks #2, CodeRabbit handleL2PS.ts:105 / validateTransaction.ts:180) — L2PS_TX_FEE only added when the inner tx is native/send.
  • Fail-closed validate path (CodeRabbit validateTransaction.ts:164) — every "cannot verify" branch now returns a precise [BALANCE ERROR] string instead of null.
  • TOCTOU lock (CodeRabbit handleL2PS.ts:151 Critical) — per-sender in-process serialisation around the (check + insert + execute) sequence.
  • Safe-stringify failure log (qodo bug Fees development #1, CodeRabbit manageExecution.ts:84) — JSON.stringify replaced with safeStringifyExtra that handles circular refs and BigInt.

Type-check delta

errors
baseline origin/stabilisation 20 pre-existing
this branch 20
net new 0

Test plan

  • Run native send with insufficient balance → expect BALANCE ERROR before signing
  • Run L2PS native/send with insufficient balance → expect same gate
  • Run L2PS non-send tx (web2 / crosschain / gcr_edits-only) → expect fee gate NOT applied
  • Run two concurrent L2PS sends from same sender that together exceed balance → expect second to be rejected at the gate (sender lock)
  • Force L2PS network not loaded and submit l2psEncryptedTx via confirmTransaction → expect explicit BALANCE ERROR (fail-closed)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added enhanced balance validation to verify senders have sufficient funds before transactions are processed.
    • Implemented sequential transaction processing per sender for L2PS transactions to prevent race conditions.
  • Bug Fixes

    • Improved error logging to safely handle complex data types without crashes.

Shitikyan and others added 3 commits June 11, 2026 21:08
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 <[email protected]>
After rebasing onto stabilisation, two type drifts surfaced in the
native-amount balance branch:

- `tx.content.amount` was widened from `number` to `number | string` by
  the v4 bigint-widening work; the literal `> 0` and `<` comparisons no
  longer type-check, and a plain `Number()` cast would round any
  post-fork OS magnitude. Coerce through `BigInt(... ?? 0)` so the
  comparison stays precise for the full balance range.
- `GCR.getGCRNativeBalance` was renamed to `getAccountBalance` on
  stabilisation and now returns `bigint` directly. Match the new
  signature.

Behaviour preserved: any tx with positive `amount` whose sender lacks
the required balance still gets rejected with the same
`[BALANCE ERROR]` message, only with bigint-precise numbers.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@qodo-code-review

Copy link
Copy Markdown
Contributor

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR implements pre-signing balance validation for Layer 2 Protocol Service (L2PS) encrypted transactions and standard transactions. The L2PS fee constant is exported, transaction validation gains GCR balance checks and L2PS-specific decryption+balance verification, and the L2PS handler introduces concurrent per-sender processing with upfront balance validation to prevent insufficient-fund errors.

Changes

L2PS Balance Validation

Layer / File(s) Summary
L2PS fee constant export
src/libs/l2ps/L2PSTransactionExecutor.ts
L2PS_TX_FEE is exported to allow balance-check logic in other modules to compute required sender funds accurately.
Safe error logging infrastructure
src/libs/network/manageExecution.ts
Added safeStringifyExtra utility that serializes values for logging without throwing on bigint or circular references; integrated into broadcastTx error paths to safely log failed transaction details.
Pre-signing transaction balance validation
src/libs/blockchain/routines/validateTransaction.ts
Extended confirmTransaction to verify sender balance after signature verification: standard transactions use GCR.getAccountBalance, and l2psEncryptedTx use a new checkL2PSBalance helper that decrypts the inner transaction, determines fee-bearing status, and validates the decrypted sender's balance against required funds (amount + optional L2PS fee).
L2PS handler concurrency and balance pre-check
src/libs/network/routines/transactions/handleL2PS.ts
Integrated per-sender serialization via withSenderLock and balance pre-validation before processing: added isL2PSFeeBearing (fee applies only to native/send) and checkSenderBalance helpers; modified main flow to run balance check and subsequent processing within the sender lock, returning 400 on balance failure.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • kynesyslabs/node#926: Both PRs touch L2PSTransactionExecutor's native-send flow and the L2PS_TX_FEE value/units used in sender balance-check arithmetic (main PR exports the constant and adds balance validation using it; retrieved PR canonicalizes amount + L2PS_TX_FEE across the osDenomination fork for the executor's balance checks).

  • kynesyslabs/node#886: Both PRs modify src/libs/blockchain/routines/validateTransaction.ts's confirmTransaction (and related nonce/validation flow)—main PR adds pre-signing balance checks around the existing nonce verification, while the retrieved PR changes confirmTransaction/assignNonce to populate expectedPrior for nonce enforcement—so they're directly connected at the transaction-validation code level.

Poem

🐰 A rabbit hops through balance-check lands,
With L2PS fees held firmly in hands,
No empty wallets shall pass the gate,
Per-sender locks ensure they arrive on-time freight!
Safe logging blooms, bigints no longer sting—
A protocol refined for each bouncing thing. 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: implementing balance pre-checks for L2PS transactions with a rebase context.
Linked Issues check ✅ Passed The PR fully implements all coding objectives from #680: balance pre-checks for L2PS transactions, fail-closed error handling, TOCTOU prevention via sender locks, and logging reliability improvements.
Out of Scope Changes check ✅ Passed All changes directly support the balance pre-check objectives; no unrelated modifications detected in the file summaries.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-l2ps-balance-check-on-stabilisation

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


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.

@greptile-apps

greptile-apps Bot commented Jun 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds balance pre-checks for L2PS transactions at two layers (confirmTransaction and handleL2PS) and introduces a per-sender in-process lock to close the TOCTOU window between the balance read and executor debit. It also hardens the manageExecution failure-log path against JSON.stringify throwing on circular refs or BigInt values.

  • Balance pre-checks (validateTransaction.ts, handleL2PS.ts): both the native-tx check and the L2PS inner-tx check compare a DEM-unit required amount against an OS-unit balance returned by L2PSTransactionExecutor.getBalance post-fork, making the gate a no-op for any real post-fork balance. Additionally, BigInt(tx.content.amount) in confirmTransaction throws uncaught on fractional/non-integer wire values instead of returning a signed ValidityData with valid: false.
  • TOCTOU sender lock (handleL2PS.ts): withSenderLock stores previous.then(() => next) in the map, then checks identity against a second call to previous.then(() => next) — two distinct Promise objects — so the senderLocks.delete cleanup is permanently unreachable, leaking one map entry per sender indefinitely.
  • Safe-stringify log (manageExecution.ts): result.response?.extra in the same log line is still interpolated as a raw template literal rather than through safeStringifyExtra, partially defeating the fix.

Confidence Score: 2/5

The balance pre-checks that are the core purpose of this PR are not functionally enforced post-fork due to a unit mismatch, the sender lock leaks memory on every call, and the safe-stringify fix is incomplete.

The two central balance-check functions both compare a DEM-unit required amount against an OS-unit balance, meaning any sender with any balance passes the gate on a post-fork node. The withSenderLock map entry is never cleaned up because the identity comparison is between two freshly allocated Promise objects — the longer the node runs, the more memory is consumed. The safeStringifyExtra fix also leaves result.response?.extra unguarded in the same log line, so the crash risk it was meant to eliminate is still present on that field. Together these issues mean the primary deliverable of the PR does not work correctly in the deployed configuration.

validateTransaction.ts and handleL2PS.ts both need the amount-canonicalization fix; handleL2PS.ts also needs the withSenderLock promise-identity fix; manageExecution.ts needs the safeStringifyExtra guard extended to result.response?.extra.

Security Review

  • Balance gate bypass post-fork (handleL2PS.ts, validateTransaction.ts): the DEM-vs-OS unit mismatch means the balance pre-checks compare ~1amount (DEM) against a balance of ~10⁹× larger OS-unit values. Any sender with any positive balance trivially passes the gate regardless of the actual amount being transferred, making balance enforcement for L2PS transactions effectively non-functional post-fork.

Important Files Changed

Filename Overview
src/libs/blockchain/routines/validateTransaction.ts Adds native balance pre-check and L2PS encrypted tx balance check; BigInt(tx.content.amount) can throw on fractional values, and the L2PS fee/amount comparison uses DEM units against an OS-unit balance post-fork, making the gate ineffective.
src/libs/network/routines/transactions/handleL2PS.ts Adds per-sender TOCTOU lock and balance pre-check; the lock's cleanup condition (previous.then(() => next) identity check) is always false so senderLocks leaks on every call, and the balance comparison uses DEM units against OS-unit balances post-fork.
src/libs/network/manageExecution.ts Adds safeStringifyExtra for BigInt/circular-ref safety in the failure log, but result.response?.extra in the same log line is still interpolated unsafely via template literal.
src/libs/l2ps/L2PSTransactionExecutor.ts Exports L2PS_TX_FEE constant — minimal, low-risk change to enable fee-scoping in callers.

Sequence Diagram

sequenceDiagram
    participant Client
    participant confirmTransaction
    participant handleL2PS
    participant withSenderLock
    participant checkSenderBalance
    participant L2PSExecutor

    Client->>confirmTransaction: broadcast tx (l2psEncryptedTx)
    confirmTransaction->>confirmTransaction: BigInt(tx.content.amount) check
    Note over confirmTransaction: throws on fractional amount
    confirmTransaction->>confirmTransaction: checkL2PSBalance(tx)
    confirmTransaction->>L2PSExecutor: getBalance(sender) → OS-unit bigint
    Note over confirmTransaction: compared vs DEM-unit totalRequired (wrong post-fork)
    confirmTransaction-->>Client: ValidityData (signed)

    Client->>handleL2PS: L2PS tx broadcast
    handleL2PS->>handleL2PS: decryptAndValidate
    handleL2PS->>withSenderLock: lock(sender, fn)
    Note over withSenderLock: senderLocks.delete() never fires
    withSenderLock->>checkSenderBalance: check balance
    checkSenderBalance->>L2PSExecutor: getBalance(sender) → OS-unit bigint
    Note over checkSenderBalance: compared vs DEM-unit totalRequired (wrong post-fork)
    checkSenderBalance-->>withSenderLock: null (passes)
    withSenderLock->>L2PSExecutor: execute tx (canonicalizes to OS units)
    L2PSExecutor-->>handleL2PS: result
    handleL2PS-->>Client: RPCResponse
Loading

Reviews (1): Last reviewed commit: "fix(balance-check): align with stabilisa..." | Re-trigger Greptile

Comment on lines +107 to +110
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 withSenderLock cleanup condition always evaluates to false — map grows unboundedly

Every call to previous.then(() => next) allocates a fresh Promise object; the value stored in the map (line 99) and the value produced by the identical expression in the if condition (line 107) are two different objects. The strict-equality check can never be true, so senderLocks.delete(sender) is never reached. Every sender who submits an L2PS tx adds an entry to the map and that entry is never removed — exactly the leak the comment claims to prevent.

Fix: save the chained promise into a const and reuse it in both places.

Suggested change
if (senderLocks.get(sender) === previous.then(() => next)) {
senderLocks.delete(sender)
}
}
const previous = senderLocks.get(sender) ?? Promise.resolve()
let release: () => void = () => undefined
const next = new Promise<void>(res => {
release = res
})
const myEntry = previous.then(() => next)
senderLocks.set(sender, myEntry)
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) === myEntry) {
senderLocks.delete(sender)
}
}

Comment on lines +152 to +163
const fee = feeBearing ? L2PS_TX_FEE : 0
const totalRequired = amount + fee
if (totalRequired === 0) return null

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

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 security BigInt(totalRequired) compares DEM-unit required against OS-unit balance post-fork

L2PS_TX_FEE is 1 in DEM. Post-fork the executor calls canonicalizeAmountToOs(L2PS_TX_FEE, forkActive) which yields ~10⁹ OS units. But checkSenderBalance adds L2PS_TX_FEE as-is (fee = 1) so totalRequired and BigInt(totalRequired) are in DEM units, while L2PSTransactionExecutor.getBalance() returns BigInt(account.balance) in OS units post-fork. A sender with 0.5 DEM (≈ 5×10⁸ OS) passes the pre-check (5×10⁸ >= 1) but fails at execution, making this gate a no-op for insufficient-balance detection post-fork.

Suggested change
const fee = feeBearing ? L2PS_TX_FEE : 0
const totalRequired = amount + fee
if (totalRequired === 0) return null
try {
const balance = await L2PSTransactionExecutor.getBalance(sender)
if (balance < BigInt(totalRequired)) {
return `Insufficient balance: need ${totalRequired} (${amount} + ${fee} fee) but have ${balance}`
}
} catch (error) {
return `Balance check failed: ${error instanceof Error ? error.message : "Unknown error"}`
}
const referenceHeight = getSharedState.lastBlockNumber ?? 0
const forkActive = isForkActive("osDenomination", referenceHeight)
const feeCanonical = feeBearing ? canonicalizeAmountToOs(L2PS_TX_FEE, forkActive) : 0n
const amountCanonical = feeBearing ? canonicalizeAmountToOs(amount, forkActive) : 0n
const totalRequired = amountCanonical + feeCanonical
if (totalRequired === 0n) return null
try {
const balance = await L2PSTransactionExecutor.getBalance(sender)
if (balance < totalRequired) {
return `Insufficient balance: need ${totalRequired} (${amountCanonical} + ${feeCanonical} fee) but have ${balance}`
}
} catch (error) {
return `Balance check failed: ${error instanceof Error ? error.message : "Unknown error"}`
}

// by the v4 bigint-widening work; coerce through BigInt so the
// comparison is precise for post-fork OS magnitudes (which a plain
// `Number` cast would round).
const txAmount = BigInt(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.

P1 BigInt(tx.content.amount) throws uncaught on non-integer values

tx.content.amount is typed number | string. BigInt(1.5) throws RangeError and BigInt("1.5") throws SyntaxError. Any malformed transaction carrying a fractional amount (possible with a misbehaving or adversarial client) will crash confirmTransaction with an unhandled exception rather than returning a signed ValidityData with valid: false. Wrapping this in a try/catch and returning an error validityData is safer.

Comment on lines +276 to +303
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`

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 security Same DEM-vs-OS unit mismatch in checkL2PSBalance (mirrors the handleL2PS.ts issue)

checkL2PSBalance computes const fee = feeBearing ? L2PS_TX_FEE : 0 (DEM integer) and totalRequired = amount + fee in DEM, then compares balance < BigInt(totalRequired) where balance is the OS-magnitude value from L2PSTransactionExecutor.getBalance. Post-fork this comparison is effectively a no-op for any real balance because the stored OS value is ~10⁹× larger than the DEM-unit required amount. The same canonicalizeAmountToOs canonicalization that handleNativeTransaction applies must be used here too.

// circular reference or a BigInt would otherwise make
// `JSON.stringify` throw, abort the error-handling
// branch, and leave the client with no response.
log.error(`[SERVER] broadcastTx FAILED — returning to client: result=${returnValue.result}, extra=${safeStringifyExtra(returnValue.extra)}, response.extra=${result.response?.extra}`)

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 result.response?.extra accessed without safeStringifyExtra — can still throw

The new log line passes result.response?.extra via template-literal interpolation (implicit toString()). If result.response?.extra is a BigInt or an object with a circular reference, coercing it to string throws a TypeError/RangeError and undoes the safe-stringify protection that was the stated goal of this change. Wrap it with safeStringifyExtra too.

Suggested change
log.error(`[SERVER] broadcastTx FAILED — returning to client: result=${returnValue.result}, extra=${safeStringifyExtra(returnValue.extra)}, response.extra=${result.response?.extra}`)
log.error(`[SERVER] broadcastTx FAILED — returning to client: result=${returnValue.result}, extra=${safeStringifyExtra(returnValue.extra)}, response.extra=${safeStringifyExtra(result.response?.extra)}`)

@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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/libs/blockchain/routines/validateTransaction.ts`:
- Around line 276-304: The balance-check logic duplicates validation and wrongly
assumes sendAmount is a number; extract a shared helper (e.g.,
L2PSTransactionExecutor.parseAmountOrThrow or a util parseL2PSAmount) that
accepts number|string, converts to BigInt safely (for numbers ensure
integer/non-negative, for strings allow large integers via BigInt), rejects
non-integer/negative values with a clear error, and returns BigInt; update
validateTransaction's check (currently in the block that reads
nativePayload.args and computes amount/fee) and handleL2PS.checkSenderBalance to
call this helper and compare BigInt(totalRequired) with the BigInt balance from
L2PSTransactionExecutor.getBalance to avoid type-mismatch and duplicated logic.
- Around line 276-304: The native send amount check currently rejects string
amounts; update the block that reads nativePayload.args so sendAmount can be
number|string and is converted using the same canonicalizer used in
L2PSTransactionExecutor.handleNativeTransaction (call
canonicalizeAmountToOs(sendAmount)) before validation and assignment to amount;
keep the checks for numeric/finite/non-negative after canonicalization, compute
fee/totalRequired as before, and then compare balance using
L2PSTransactionExecutor.getBalance(sender) as currently implemented (ensure
canonicalizeAmountToOs is imported/available).

In `@src/libs/network/routines/transactions/handleL2PS.ts`:
- Around line 141-158: handleL2PS.ts incorrectly assumes the inner tx amount is
a number (casts args to [string, number] and checks typeof sendAmount ===
"number"), which rejects valid post-fork string amounts and duplicates logic
from validateTransaction.ts; update the parsing in the fee-bearing branch of
handleL2PS (where feeBearing, sendAmount, amount are used) to accept string or
number (change the args typing to [string, string|number] or treat sendAmount as
string|number), normalize/parse string amounts into a numeric value (or BigInt
if required by L2PSTransactionExecutor.getBalance comparisons) with proper
validation (finite, non-negative), and factor this normalization into a shared
helper used by both handleL2PS and checkL2PSBalance in validateTransaction.ts to
avoid duplication and ensure consistent behavior.
- Around line 105-110: The cleanup check always fails because previous.then(()
=> next) creates a fresh Promise object so strict equality with the stored map
value never matches; fix by creating and storing the exact Promise instance you
later compare against: when you set senderLocks.set(sender, <promise>) (the
promise created from previous.then(() => next)), capture that promise in a local
variable (e.g., const expected = previous.then(() => next)) and use
senderLocks.set(sender, expected) and then compare senderLocks.get(sender) ===
expected before calling senderLocks.delete(sender); update the code around the
senderLocks set/get logic (symbols: senderLocks, sender, previous, next) so the
same Promise object is used for both storage and comparison.
🪄 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: 620b154f-f66b-4f45-8010-eb7fdeb76c99

📥 Commits

Reviewing files that changed from the base of the PR and between 4e8d616 and 0caa50a.

📒 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 on lines +276 to +304
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`
}

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.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Duplicated balance-check logic with same type mismatch bug.

Both checkL2PSBalance in validateTransaction.ts and checkSenderBalance in handleL2PS.ts extract and validate sendAmount with the assumption it's always a number, but post-fork OS magnitudes may be strings. Both will incorrectly reject valid large-amount transactions.

Consider extracting a shared helper (e.g., in L2PSTransactionExecutor or a dedicated utility) that properly handles number | string amounts using BigInt coercion, ensuring both validation paths remain consistent and correctly handle all amount formats.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/libs/blockchain/routines/validateTransaction.ts` around lines 276 - 304,
The balance-check logic duplicates validation and wrongly assumes sendAmount is
a number; extract a shared helper (e.g.,
L2PSTransactionExecutor.parseAmountOrThrow or a util parseL2PSAmount) that
accepts number|string, converts to BigInt safely (for numbers ensure
integer/non-negative, for strings allow large integers via BigInt), rejects
non-integer/negative values with a clear error, and returns BigInt; update
validateTransaction's check (currently in the block that reads
nativePayload.args and computes amount/fee) and handleL2PS.checkSenderBalance to
call this helper and compare BigInt(totalRequired) with the BigInt balance from
L2PSTransactionExecutor.getBalance to avoid type-mismatch and duplicated logic.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Type mismatch: sendAmount may be string post-fork, causing false rejections.

The code extracts sendAmount from nativePayload.args[1] and validates it with typeof sendAmount !== "number" (line 287), but the PR summary states tx.content.amount was widened to number | string for post-fork OS magnitudes. If the SDK sends string amounts for large values, this check incorrectly rejects them as invalid.

Compare with L2PSTransactionExecutor.handleNativeTransaction (line 240-243 in the executor), which handles rawAmount as number | string and uses canonicalizeAmountToOs for proper conversion.

🐛 Proposed fix to handle string amounts
-        let amount = 0
+        let amount = 0n
         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]
+                const [, sendAmount] = nativePayload.args as [string, number | string]
+                // Coerce through BigInt to handle both number and string (post-fork OS magnitude)
+                let sendAmountBigInt: bigint
+                try {
+                    sendAmountBigInt = BigInt(sendAmount)
+                } catch {
+                    return `[Tx Validation] [BALANCE ERROR] Invalid native send amount: ${String(sendAmount)}\n`
+                }
-                if (
-                    typeof sendAmount !== "number" ||
-                    !Number.isFinite(sendAmount) ||
-                    sendAmount < 0
-                ) {
+                if (sendAmountBigInt < 0n) {
                     return `[Tx Validation] [BALANCE ERROR] Invalid native send amount: ${String(sendAmount)}\n`
                 }
-                amount = sendAmount
+                amount = sendAmountBigInt
             }
         }
 
-        const fee = feeBearing ? L2PS_TX_FEE : 0
-        const totalRequired = amount + fee
-        if (totalRequired === 0) return null
+        const fee = feeBearing ? BigInt(L2PS_TX_FEE) : 0n
+        const totalRequired = amount + fee
+        if (totalRequired === 0n) return null
 
         const balance = await L2PSTransactionExecutor.getBalance(sender)
-        if (balance < BigInt(totalRequired)) {
+        if (balance < totalRequired) {
             return `[Tx Validation] [BALANCE ERROR] Insufficient balance: need ${totalRequired} but have ${balance}\n`
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/libs/blockchain/routines/validateTransaction.ts` around lines 276 - 304,
The native send amount check currently rejects string amounts; update the block
that reads nativePayload.args so sendAmount can be number|string and is
converted using the same canonicalizer used in
L2PSTransactionExecutor.handleNativeTransaction (call
canonicalizeAmountToOs(sendAmount)) before validation and assignment to amount;
keep the checks for numeric/finite/non-negative after canonicalization, compute
fee/totalRequired as before, and then compare balance using
L2PSTransactionExecutor.getBalance(sender) as currently implemented (ensure
canonicalizeAmountToOs is imported/available).

Comment on lines +105 to +110
// 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
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Memory leak: promise equality check always fails, entries never cleaned.

The cleanup check at line 107 compares senderLocks.get(sender) against previous.then(() => next), but .then() creates a new Promise object each invocation. The stored value (line 99) and this comparison target are different Promise instances, so === always returns false and senderLocks.delete(sender) never executes.

🐛 Proposed fix to properly clean up sender locks
 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))
+    const chain = previous.then(() => next)
+    senderLocks.set(sender, chain)
     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)) {
+        if (senderLocks.get(sender) === chain) {
             senderLocks.delete(sender)
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/libs/network/routines/transactions/handleL2PS.ts` around lines 105 - 110,
The cleanup check always fails because previous.then(() => next) creates a fresh
Promise object so strict equality with the stored map value never matches; fix
by creating and storing the exact Promise instance you later compare against:
when you set senderLocks.set(sender, <promise>) (the promise created from
previous.then(() => next)), capture that promise in a local variable (e.g.,
const expected = previous.then(() => next)) and use senderLocks.set(sender,
expected) and then compare senderLocks.get(sender) === expected before calling
senderLocks.delete(sender); update the code around the senderLocks set/get logic
(symbols: senderLocks, sender, previous, next) so the same Promise object is
used for both storage and comparison.

Comment on lines +141 to +158
// `amount` is only meaningful when the inner tx is a native send.
let amount = 0
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
}

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

try {
const balance = await L2PSTransactionExecutor.getBalance(sender)
if (balance < BigInt(totalRequired)) {

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Same type mismatch: sendAmount may be string post-fork.

This has the same issue as checkL2PSBalance in validateTransaction.ts: the code casts args to [string, number] (line 144-145) and validates with typeof sendAmount !== "number" (line 146), but post-fork amounts may be strings. This will incorrectly reject valid transactions with large amounts.

Additionally, this logic duplicates checkL2PSBalance in validateTransaction.ts. Consider extracting a shared helper to ensure both paths handle amounts consistently.

🐛 Proposed fix to handle string amounts
     // `amount` is only meaningful when the inner tx is a native send.
-    let amount = 0
+    let amount = 0n
     if (feeBearing) {
-        const [, sendAmount] = (decryptedTx.content.data as any[])[1]
-            .args as [string, number]
-        if (typeof sendAmount !== "number" || !Number.isFinite(sendAmount) || sendAmount < 0) {
+        const [, sendAmount] = (decryptedTx.content.data as any[])[1]
+            .args as [string, number | string]
+        let sendAmountBigInt: bigint
+        try {
+            sendAmountBigInt = BigInt(sendAmount)
+        } catch {
+            return `Invalid native send amount: ${String(sendAmount)}`
+        }
+        if (sendAmountBigInt < 0n) {
             return `Invalid native send amount: ${String(sendAmount)}`
         }
-        amount = sendAmount
+        amount = sendAmountBigInt
     }
 
-    const fee = feeBearing ? L2PS_TX_FEE : 0
+    const fee = feeBearing ? BigInt(L2PS_TX_FEE) : 0n
     const totalRequired = amount + fee
-    if (totalRequired === 0) return null
+    if (totalRequired === 0n) return null
 
     try {
         const balance = await L2PSTransactionExecutor.getBalance(sender)
-        if (balance < BigInt(totalRequired)) {
+        if (balance < totalRequired) {
             return `Insufficient balance: need ${totalRequired} (${amount} + ${fee} fee) but have ${balance}`
         }
📝 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
// `amount` is only meaningful when the inner tx is a native send.
let amount = 0
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
}
const fee = feeBearing ? L2PS_TX_FEE : 0
const totalRequired = amount + fee
if (totalRequired === 0) return null
try {
const balance = await L2PSTransactionExecutor.getBalance(sender)
if (balance < BigInt(totalRequired)) {
// `amount` is only meaningful when the inner tx is a native send.
let amount = 0n
if (feeBearing) {
const [, sendAmount] = (decryptedTx.content.data as any[])[1]
.args as [string, number | string]
let sendAmountBigInt: bigint
try {
sendAmountBigInt = BigInt(sendAmount)
} catch {
return `Invalid native send amount: ${String(sendAmount)}`
}
if (sendAmountBigInt < 0n) {
return `Invalid native send amount: ${String(sendAmount)}`
}
amount = sendAmountBigInt
}
const fee = feeBearing ? BigInt(L2PS_TX_FEE) : 0n
const totalRequired = amount + fee
if (totalRequired === 0n) return null
try {
const balance = await L2PSTransactionExecutor.getBalance(sender)
if (balance < totalRequired) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/libs/network/routines/transactions/handleL2PS.ts` around lines 141 - 158,
handleL2PS.ts incorrectly assumes the inner tx amount is a number (casts args to
[string, number] and checks typeof sendAmount === "number"), which rejects valid
post-fork string amounts and duplicates logic from validateTransaction.ts;
update the parsing in the fee-bearing branch of handleL2PS (where feeBearing,
sendAmount, amount are used) to accept string or number (change the args typing
to [string, string|number] or treat sendAmount as string|number),
normalize/parse string amounts into a numeric value (or BigInt if required by
L2PSTransactionExecutor.getBalance comparisons) with proper validation (finite,
non-negative), and factor this normalization into a shared helper used by both
handleL2PS and checkL2PSBalance in validateTransaction.ts to avoid duplication
and ensure consistent behavior.

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