feat: balance pre-checks for L2PS transactions (rebased onto stabilisation)#937
feat: balance pre-checks for L2PS transactions (rebased onto stabilisation)#937Shitikyan wants to merge 3 commits into
Conversation
…ient funds before processing
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 reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
WalkthroughThis 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. ChangesL2PS Balance Validation
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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
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. Comment |
Greptile SummaryThis PR adds balance pre-checks for L2PS transactions at two layers (
Confidence Score: 2/5The 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
|
| 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
Reviews (1): Last reviewed commit: "fix(balance-check): align with stabilisa..." | Re-trigger Greptile
| if (senderLocks.get(sender) === previous.then(() => next)) { | ||
| senderLocks.delete(sender) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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) | |
| } | |
| } |
| 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"}` | ||
| } |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| 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` |
There was a problem hiding this comment.
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}`) |
There was a problem hiding this comment.
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.
| 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)}`) |
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
src/libs/blockchain/routines/validateTransaction.tssrc/libs/l2ps/L2PSTransactionExecutor.tssrc/libs/network/manageExecution.tssrc/libs/network/routines/transactions/handleL2PS.ts
| 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` | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
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).
| // 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| // `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)) { |
There was a problem hiding this comment.
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.
| // `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.
Rebased follow-up to #680 (which was opened in Feb against the old
testnetbase and has been sitting cold for 4 months). The two original feat + review commits are cherry-picked onto currentstabilisation; a third commit re-aligns the typing where stabilisation moved on (tx.content.amountwidened tonumber | string,GCR.getGCRNativeBalancerenamed togetAccountBalance).Closes #680.
What this brings up
confirmTransaction—tx.content.amount > 0paths now reject senders without sufficient funds before signingValidityData. Adjusted to BigInt comparisons for post-fork OS magnitudes.confirmTransaction— decrypts the inner tx, verifies sender balance, fee added only onnative/sendper executor parity. Fail-closed: any "cannot verify" outcome (missing l2ps_uid, L2PS not loaded, decryption error) returns a precise error instead of silently passing.checkSenderBalanceinhandleL2PS— same fee-scoping; serialised per-sender via in-processwithSenderLockto close the TOCTOU window between balance read and executor debit.manageExecution—JSON.stringifyofreturnValue.extracould throw on circular refs / BigInts and abort the client response. Replaced with a guardedsafeStringifyExtrathat survives both.Review history baked in
This PR ships #680's content with the bot-review fixes already applied:
L2PS_TX_FEEonly added when the inner tx isnative/send.[BALANCE ERROR]string instead ofnull.JSON.stringifyreplaced withsafeStringifyExtrathat handles circular refs and BigInt.Type-check delta
origin/stabilisationTest plan
BALANCE ERRORbefore signingL2PS network not loadedand submit l2psEncryptedTx viaconfirmTransaction→ expect explicitBALANCE ERROR(fail-closed)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes