Skip to content

test(devnet): cross-RPC nonce-replay e2e test for nonceEnforcement (batch E)#899

Open
tcsenpai wants to merge 1 commit into
stabilisationfrom
feat/audit-sweep-batch-e-nonce-e2e-test-2026-06-01
Open

test(devnet): cross-RPC nonce-replay e2e test for nonceEnforcement (batch E)#899
tcsenpai wants to merge 1 commit into
stabilisationfrom
feat/audit-sweep-batch-e-nonce-e2e-test-2026-06-01

Conversation

@tcsenpai
Copy link
Copy Markdown
Contributor

@tcsenpai tcsenpai commented Jun 1, 2026

Plain-English summary

What your code does

The `nonceEnforcement` fork landed across PRs #884-#887 to close cross-RPC replay attacks. The protection is supposed to work like this:

  1. Sign a transaction once. It has a specific nonce (sequence number).
  2. Try to broadcast that same signed transaction through two different nodes simultaneously.
  3. Both nodes may validate it independently and pass it forward.
  4. At block-application time, only ONE survives — the second sees `accountGCR.nonce !== expectedPrior` and is rejected.
  5. Receiver gets paid exactly once. Sender's nonce advances by exactly 1.

But until this PR, there was no automated test that exercised that scenario end-to-end. PRs #886 + #887's plain-English summaries described the protection, manual traces argued it works, but nothing actually ran the attack against running nodes.

What this PR adds

A devnet e2e test that simulates the attack and asserts the protection works:

  • New bash harness `test-double-broadcast-e2e.sh` boots the devnet fixture stack (with `nonceEnforcement.activationHeight: 0` active from block 0, set by PR feat(forks): register nonceEnforcement fork + ship assignNonce validation infra (batch C PR 1) #884's genesis fixture).
  • New bun script `double_broadcast_replay.mjs` signs one transaction, fires it at BOTH node-1 and node-2 in parallel via `DemosTransactions.confirm()` and `broadcast()`.
  • Polls for up to 60s, then asserts:
    • Receiver balance went up by exactly one transfer amount (not 2×).
    • Sender's nonce advanced by exactly 1 (not 2).
  • Exits 0 on OK, 1 with diagnostic dump on FAIL.

Why this matters

Three different layers of protection landed across four PRs (RPC validation, mempool advisory lock, consensus `expectedPrior` reject). Without an integration test exercising all three in concert against real running nodes, a future refactor could silently regress any one of them and the protection would degrade quietly — no unit test would catch it.

This test is the canary. Run it after any change to `assignNonce`, `Mempool.addTransaction`, `GCRNonceRoutines`, `endpointValidation` hash-strip, or `broadcastBlockHash` vote tally.


Technical detail

How to run

```bash
bash testing/devnet/scripts/test-double-broadcast-e2e.sh

or with --keep / --no-build flags (same as test-transfer-e2e.sh)

```

What it exercises

Layer Defence PR
RPC validation `assignNonce` checks `tx.content.nonce === account.nonce + 1 + pendingCount` #884 + #885
Mempool insert Advisory lock + in-lock re-check, fork-gated #887
Consensus apply `GCRNonceRoutines` rejects when `accountGCR.nonce !== expectedPrior` #886
BFT vote tally Vote race fixed, signature-count semantics removed #888

Failure modes the test catches

Symptom Root cause
`observed_receiver_delta > amount` Cross-RPC double-spend; both broadcasts applied at consensus
`observed_nonce_delta > 1` Same as above
`observed_receiver_delta < amount` after 60s Neither broadcast landed (network issue / safety net too eager / test infra broken)
Both confirms rejected Test cannot verify replay protection — fails with exit 1

Out of scope

  • Performance / load-test variants (1000 concurrent replays from one sender). Tracked separately.
  • Cross-RPC vote-relay scenarios (`broadcastBlockHash.ts` TODO). Separate consensus design.

Test plan

…atch E)

Closes the e2e-test follow-up flagged in PR #886's description.

What this verifies
  Builds + signs ONE transaction via the SDK against node-1, then
  submits the SAME signed tx to BOTH node-1 AND node-2 in parallel
  through `DemosTransactions.confirm()` + `DemosTransactions.
  broadcast()`. Asserts that after the chain advances:

    1. Receiver balance increased by EXACTLY one transfer amount
       (not 2× — no double-spend).
    2. Sender account nonce advanced by EXACTLY 1 (not 2 — both
       did not apply at consensus).

  Failure modes (any of these → FAIL with diagnostic dump):
    - `observed_receiver_delta > amount`: cross-RPC double-spend,
      both broadcasts applied (consensus-side `expectedPrior`
      reject in `GCRNonceRoutines` failed).
    - `observed_nonce_delta > 1`: same as above.
    - `observed_receiver_delta < amount`: neither broadcast
      landed (test couldn't verify the safety net engaged).

Files
  - `testing/devnet/scripts/double_broadcast_replay.mjs` —
    the dual-broadcast bun script. Reads NODE1_URL / NODE2_URL /
    IDENTITY_PATH / RECEIVER_PUBKEY / AMOUNT_OS from env so the
    bash harness can wire it to devnet without source edits.
  - `testing/devnet/scripts/test-double-broadcast-e2e.sh` —
    bash harness. Mirrors `test-transfer-e2e.sh` shape: boots
    devnet fixture stack, waits for both RPCs, runs the dual
    broadcast, asserts via the bun script's exit code.

How to run
    bash testing/devnet/scripts/test-double-broadcast-e2e.sh
    bash testing/devnet/scripts/test-double-broadcast-e2e.sh --keep --no-build

Exercises
  - PR #886's consensus-side `expectedPrior` apply-time reject in
    `GCRNonceRoutines`.
  - PR #887's same-node TOCTOU advisory lock in `Mempool.
    addTransaction` (the second broadcast hitting node-1 may be
    caught at the mempool boundary before reaching consensus,
    depending on race ordering).
  - PR #888's vote-race fix in `broadcastBlockHash` (both txs
    going through consensus correctly tally and the second one
    fails BFT once `expectedPrior` rejects the second nonce
    edit).

Out of scope
  - Performance / load test variants (1000 concurrent replays):
    tracked separately as the "mempool stress test" item.
  - Cross-RPC vote-relay scenarios (TODO at end of
    `broadcastBlockHash.ts`): separate consensus design follow-up.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 1, 2026

Warning

Review limit reached

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

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

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

⌛ 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: 4aa7d670-9c3f-403d-9908-a6ed520f3b20

📥 Commits

Reviewing files that changed from the base of the PR and between f62a609 and 410f35c.

📒 Files selected for processing (2)
  • testing/devnet/scripts/double_broadcast_replay.mjs
  • testing/devnet/scripts/test-double-broadcast-e2e.sh
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/audit-sweep-batch-e-nonce-e2e-test-2026-06-01

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.

@tcsenpai
Copy link
Copy Markdown
Contributor Author

tcsenpai commented Jun 1, 2026

@greptile review

@qodo-code-review
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Add cross-RPC nonce-replay e2e test for nonceEnforcement

🧪 Tests

Grey Divider

Walkthroughs

Description
• Add end-to-end test for cross-RPC nonce-replay protection
• Verifies nonceEnforcement fork prevents double-spend attacks
• Broadcasts same signed transaction through two nodes simultaneously
• Asserts receiver balance increases by exactly one transfer amount
• Validates sender nonce advances by exactly one (not two)
Diagram
flowchart LR
  A["Devnet Setup<br/>with nonceEnforcement"] --> B["Build + Sign<br/>Single TX"]
  B --> C["Submit to<br/>Node-1 & Node-2<br/>in Parallel"]
  C --> D["Broadcast<br/>ValidityData<br/>from Both"]
  D --> E["Poll Chain State<br/>for 60s"]
  E --> F["Assert:<br/>Receiver Delta = 1x<br/>Sender Nonce Delta = 1"]
  F --> G{Test Result}
  G -->|Pass| H["Exit 0: OK"]
  G -->|Fail| I["Exit 1: FAIL<br/>with Diagnostics"]

Loading

Grey Divider

File Changes

1. testing/devnet/scripts/test-double-broadcast-e2e.sh 🧪 Tests +159/-0

Bash harness for devnet double-broadcast e2e test

• Bash harness that boots devnet fixture stack with nonceEnforcement active from block 0
• Waits for both node-1 and node-2 RPCs to become available
• Verifies funded-genesis overlay applied to both nodes by checking sender balance
• Runs the dual-broadcast bun script with environment variables for node URLs, identity, receiver
 pubkey, and transfer amount
• Cleans up devnet on exit (with --keep flag option to leave running for manual inspection)

testing/devnet/scripts/test-double-broadcast-e2e.sh


2. testing/devnet/scripts/double_broadcast_replay.mjs 🧪 Tests +233/-0

Bun script for parallel cross-RPC transaction broadcast test

• Connects two Demos SDK clients to node-1 and node-2 with same sender identity
• Snapshots pre-state: sender/receiver balances and sender nonce
• Builds and signs one transaction via node-1 SDK (single nonce assignment)
• Submits same signed transaction to both nodes in parallel via DemosTransactions.confirm()
• Broadcasts resulting ValidityData from both nodes simultaneously
• Polls post-state for up to 60 seconds, checking receiver balance delta and sender nonce
 advancement
• Asserts receiver balance increased by exactly one transfer amount and sender nonce advanced by
 exactly one
• Exits 0 on success, 1 on double-spend or failure with diagnostic output

testing/devnet/scripts/double_broadcast_replay.mjs


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented Jun 1, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0)

Grey Divider


Action required

1. Missing await on getAddress 🐞 Bug ≡ Correctness
Description
double_broadcast_replay.mjs assigns sender = demos1.getAddress() without awaiting, but
getAddress() is used elsewhere in the repo as an async API. This makes sender a Promise and
breaks subsequent getAddressInfo(sender) calls, causing the e2e test to fail before it can
validate nonce-replay protection.
Code

testing/devnet/scripts/double_broadcast_replay.mjs[R69-76]

Evidence
The new script calls getAddress() without awaiting and then immediately uses the result as an
address; existing repo usage awaits getAddress(), showing the intended async contract.

testing/devnet/scripts/double_broadcast_replay.mjs[61-76]
scripts/send-l2-batch.ts[300-310]

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

### Issue description
`sender` is assigned from `demos1.getAddress()` without `await`, so it is likely a Promise rather than an address string. The script then passes `sender` into `getAddressInfo()`, which will fail or behave incorrectly.

### Issue Context
Another repo script uses `await demos.getAddress()` when retrieving the wallet address, indicating `getAddress()` is async.

### Fix Focus Areas
- testing/devnet/scripts/double_broadcast_replay.mjs[61-80]

### Suggested fix
- Change to `const sender = await demos1.getAddress()`.
- (Optional hardening) Validate `sender` matches expected hex address format before calling `getAddressInfo(sender)` and exit with a clear error if not.

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



Remediation recommended

2. Post-state polled from one node 🐞 Bug ☼ Reliability
Description
The poll loop claims it re-checks post-state via both RPCs but actually queries only node-1
(demos1) for receiver balance and sender nonce. This can produce false failures (e.g., reporting
"neither tx landed") if node-1 is temporarily stale while node-2 has the updated state.
Code

testing/devnet/scripts/double_broadcast_replay.mjs[R168-185]

Evidence
The code comment explicitly promises cross-RPC polling, but the implementation reads only from
demos1 in the loop, contradicting the intended behavior.

testing/devnet/scripts/double_broadcast_replay.mjs[167-185]

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

### Issue description
The poll loop says it checks both RPCs to avoid stale reads, but it only calls `demos1.getAddressInfo(...)`. This undermines the test's stated robustness goal.

### Issue Context
The test is explicitly exercising a cross-RPC scenario; post-state should be sampled from both nodes (or require convergence) to avoid relying on a single node's freshness.

### Fix Focus Areas
- testing/devnet/scripts/double_broadcast_replay.mjs[167-193]

### Suggested fix
- In each poll iteration, query BOTH `demos1` and `demos2` for receiver info and sender info (e.g., `Promise.all`).
- Compute deltas using either:
 - the max observed receiver balance / sender nonce across nodes, or
 - require both nodes to reflect the expected deltas before breaking and asserting.
- Update the log output to show both nodes’ observed deltas to aid debugging when nodes disagree.

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


3. Brittle balance JSON parsing 🐞 Bug ⚙ Maintainability
Description
test-double-broadcast-e2e.sh extracts balance using a regex over the raw JSON response, which is
fragile to response shape/format changes and can cause the script to exit early under `set -euo
pipefail`. This can break the test before it runs the double-broadcast scenario even when the RPC is
healthy.
Code

testing/devnet/scripts/test-double-broadcast-e2e.sh[R112-119]

Evidence
The harness uses regex parsing of the response body; the server-side response serialization is plain
JSON.stringify, so robust extraction should use JSON parsing to avoid brittle coupling to
formatting.

testing/devnet/scripts/test-double-broadcast-e2e.sh[112-119]
src/libs/network/bunServer.ts[288-303]

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

### Issue description
The script parses JSON with `grep|sed`, assuming an exact substring match for `"balance":"<digits>"`. Any small output variation (or different envelope nesting) can make this return empty and abort the harness.

### Issue Context
The RPC server returns responses via `JSON.stringify(...)`, so the balance is part of a structured JSON envelope; it should be extracted via JSON parsing rather than regex.

### Fix Focus Areas
- testing/devnet/scripts/test-double-broadcast-e2e.sh[112-119]

### Suggested fix
- Replace the `grep|sed` pipeline with a real JSON parse.
 - Since the harness already requires `bun`, a practical option is:
   - `curl ... | bun -e 'import { readFileSync } from "node:fs"; const j=JSON.parse(readFileSync(0,"utf8")); console.log(j?.response?.balance ?? j?.response?.response?.balance ?? "");'`
 - Alternatively use `jq -r` if you want an external dependency.
- Validate the parsed value is a non-empty digit string before proceeding.

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


Grey Divider

Qodo Logo

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 1, 2026

Greptile Summary

This PR adds an end-to-end test for the nonceEnforcement cross-RPC replay protection introduced across PRs #884#887: it signs one transaction, submits it to two nodes simultaneously, and asserts that exactly one transfer and one nonce increment land on-chain.

  • test-double-broadcast-e2e.sh: Bash harness that boots the devnet and invokes the bun script — broken as-is because it references docker-compose.fixture.yml, a file that does not exist anywhere in the repository.
  • double_broadcast_replay.mjs: Bun script implementing the replay attack; logic is mostly sound but does not assert that node-2's confirm actually succeeded, meaning the test silently degrades to a normal single-transfer check if node-2 rejects for any unrelated reason.

Confidence Score: 1/5

The test harness cannot run in its current form; the bash script will error out on the very first docker compose call.

Every docker compose call in the shell script passes -f docker-compose.fixture.yml, but that file is absent from the repository and has never been committed on any branch. The test fails before a single container starts, so the replay-protection assertion is never exercised. Additionally, the mjs script does not verify that node-2's confirm actually succeeded, so even after the missing file is supplied, a healthy-looking OK result could reflect a plain single-node transfer rather than a genuine replay deduplication.

testing/devnet/scripts/test-double-broadcast-e2e.sh needs the missing docker-compose.fixture.yml to be created and committed before any other fix makes sense. testing/devnet/scripts/double_broadcast_replay.mjs needs a guard to abort when confirmed2 is null.

Important Files Changed

Filename Overview
testing/devnet/scripts/test-double-broadcast-e2e.sh Bash harness that boots the devnet and runs the replay test — currently broken because it references docker-compose.fixture.yml which does not exist in the repository, causing every docker compose invocation to fail immediately.
testing/devnet/scripts/double_broadcast_replay.mjs Bun/Node script that signs one tx, confirms it on both nodes, broadcasts in parallel, then polls and asserts. Logic is sound but the test silently degrades to a normal single-transfer check if node-2's confirm fails, and the polling comment incorrectly claims both RPCs are queried.

Sequence Diagram

sequenceDiagram
    participant SH as test-double-broadcast-e2e.sh
    participant N1 as node-1 RPC
    participant N2 as node-2 RPC
    participant BUN as double_broadcast_replay.mjs
    participant C as Consensus

    SH->>N1: docker compose up (fixture)
    SH->>N2: docker compose up (fixture)
    SH->>N1: wait_for_rpc (health check)
    SH->>N2: wait_for_rpc (health check)
    SH->>BUN: bun double_broadcast_replay.mjs

    BUN->>N1: demos1.connect + connectWallet
    BUN->>N2: demos2.connect + connectWallet
    BUN->>N1: getAddressInfo (pre-state snapshot)
    BUN->>N1: demos1.pay() — build + sign ONE tx

    par Parallel confirm
        BUN->>N1: DemosTransactions.confirm(signedTx, demos1)
        BUN->>N2: DemosTransactions.confirm(signedTx, demos2)
    end

    par Parallel broadcast
        BUN->>N1: DemosTransactions.broadcast(confirmed1, demos1)
        BUN->>N2: DemosTransactions.broadcast(confirmed2, demos2)
    end

    N1-->>C: tx proposal (nonce N+1)
    N2-->>C: tx proposal (nonce N+1) replay
    C-->>C: nonceEnforcement: only first tx applied

    loop Poll every 2s up to 60s
        BUN->>N1: getAddressInfo(receiver)
        BUN->>N1: getAddressInfo(sender)
    end

    alt "delta == amount AND nonce_delta == 1"
        BUN-->>SH: exit 0 OK
    else "delta > amount OR nonce_delta > 1"
        BUN-->>SH: exit 1 DOUBLE-SPEND
    else "delta < amount after timeout"
        BUN-->>SH: exit 1 neither tx landed
    end
Loading

Comments Outside Diff (4)

  1. testing/devnet/scripts/test-double-broadcast-e2e.sh, line 27 (link)

    Missing docker-compose.fixture.yml — test cannot run

    COMPOSE_FILES references docker-compose.fixture.yml, but this file does not exist anywhere in the repository (confirmed via git log --all --follow). Every docker compose "${COMPOSE_FILES[@]}" call — including the down -v, up, and logs invocations — will immediately fail with a "no such file or directory" error, so the test harness is completely broken as written. The fixture overlay (funded genesis, nonceEnforcement.activationHeight: 0) that the PR description says was added in PR feat(forks): register nonceEnforcement fork + ship assignNonce validation infra (batch C PR 1) #884 is the missing piece.

  2. testing/devnet/scripts/double_broadcast_replay.mjs, line 130-145 (link)

    Replay scenario is never verified as having occurred

    If node-2's confirm() rejects for any reason unrelated to replay — sync lag, connection error, stale state — confirmed2 is null. The guard on line 132 only aborts when both confirms fail. With only confirmed1 succeeding, step 5 broadcasts a single tx and the rest of the test verifies an ordinary single-transfer, not replay protection. The test still exits 0 with OK, silently voiding the entire test's purpose.

    A minimal fix is to add an explicit check and early exit (or at least a prominent warning) when confirmed2 === null, because without a successful confirm on node-2 there is nothing to replay.

  3. testing/devnet/scripts/test-double-broadcast-e2e.sh, line 146-147 (link)

    SENDER_BAL_OS / 10 is integer division in bash; if the genesis balance is less than 10 OS units the result is 0. The mjs script's balance check (senderBalBefore < amountOs) passes for any balance when amountOs is 0n, and then asserts that receiver_delta === 0, which trivially holds — a zero-amount transfer is a false positive. Consider guarding against this.

  4. testing/devnet/scripts/double_broadcast_replay.mjs, line 168-170 (link)

    Comment says both RPCs are polled, but only demos1 is queried throughout the loop. The comment should be corrected to avoid misleading future readers.

    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!

Reviews (1): Last reviewed commit: "test(devnet): cross-RPC nonce-replay e2e..." | Re-trigger Greptile

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 1, 2026

Greptile Summary

This PR adds an end-to-end devnet test for the nonceEnforcement cross-RPC replay-protection: a bash harness boots the devnet, signs a single transaction, and fires it at both nodes simultaneously; a bun script then asserts the receiver balance increased by exactly one transfer amount and the sender nonce advanced by exactly one.

  • test-double-broadcast-e2e.sh: Orchestrates devnet startup, RPC readiness polling, and invokes the bun script; references docker-compose.fixture.yml which does not exist in the repository, meaning the test cannot run at all in its current state.
  • double_broadcast_replay.mjs: Implements the attack scenario and assertions; the post-state poll loop queries only node-1 despite the comment stating both RPCs are checked, and signedTx.content.nonce is accessed without a null guard immediately after an optional-chained .hash access on the same value.

Confidence Score: 2/5

The test cannot run as written — the devnet fixture compose file it depends on is not present in the repository.

The bash harness fails at docker compose up because docker-compose.fixture.yml is absent from the repository, so every invocation exits before any node starts and the test provides zero coverage. The poll loop in the bun script only queries node-1 despite the comment claiming both RPCs are cross-checked, risking silent false negatives. An unguarded property access on the pay() return value would surface as an unhandled TypeError rather than a clean diagnostic exit.

Both files need attention: test-double-broadcast-e2e.sh needs the missing fixture compose file resolved, and double_broadcast_replay.mjs needs the poll-loop RPC cross-check and the null guard corrected.

Important Files Changed

Filename Overview
testing/devnet/scripts/test-double-broadcast-e2e.sh New bash harness that boots the devnet fixture stack and orchestrates the double-broadcast test; fails immediately because it references a non-existent docker-compose.fixture.yml, and the AMOUNT_OS calculation can silently produce 0 for tiny devnet balances.
testing/devnet/scripts/double_broadcast_replay.mjs New bun script that signs one transaction, submits it to both nodes in parallel, and asserts the receiver balance incremented exactly once; has two correctness issues: an unguarded signedTx.content.nonce access that throws instead of exiting cleanly, and a poll loop that only queries node-1 despite the comment claiming it cross-checks both RPCs.

Sequence Diagram

sequenceDiagram
    participant SH as test-double-broadcast-e2e.sh
    participant DC as Docker Compose
    participant BUN as double_broadcast_replay.mjs
    participant N1 as node-1 RPC
    participant N2 as node-2 RPC
    participant Chain as Consensus / Chain

    SH->>DC: docker compose up node-1 node-2 postgres tlsnotary
    SH->>N1: poll GET / until Hello World
    SH->>N2: poll GET / until Hello World
    SH->>N1: getAddressInfo(sender)
    SH->>BUN: bun double_broadcast_replay.mjs

    BUN->>N1: connect + connectWallet(mnemonic)
    BUN->>N2: connect + connectWallet(mnemonic)
    BUN->>N1: getAddressInfo snapshot pre-state
    BUN->>N1: pay() build and sign ONE tx

    par Parallel confirm
        BUN->>N1: DemosTransactions.confirm(signedTx)
    and
        BUN->>N2: DemosTransactions.confirm(signedTx)
    end

    par Parallel broadcast
        BUN->>N1: DemosTransactions.broadcast(validityData1)
    and
        BUN->>N2: DemosTransactions.broadcast(validityData2)
    end

    Chain->>Chain: GCRNonceRoutines expectedPrior check rejects duplicate

    loop Poll up to 60s node-1 only
        BUN->>N1: getAddressInfo(receiver) and getAddressInfo(sender)
        BUN->>BUN: "check delta == expectedAmount and nonceDelta == 1"
    end

    BUN-->>SH: exit 0 OK or exit 1 FAIL
Loading

Reviews (1): Last reviewed commit: "test(devnet): cross-RPC nonce-replay e2e..." | Re-trigger Greptile

# Flags:
# --keep Leave the devnet running on exit (for manual poking)
# --no-build Skip --build on `docker compose up` (faster reruns)

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 Missing docker-compose.fixture.yml will immediately break the test run

COMPOSE_FILES references docker-compose.fixture.yml, but only docker-compose.yml exists in testing/devnet/. The first down call is silently masked by || true, so this failure goes unnoticed — but the up call on line 80 is not masked and runs under set -euo pipefail, so Docker Compose will exit non-zero ("no such file or directory") and abort the script before any node starts. The test will never execute.

Comment on lines +170 to +183
// stale node-1 state.
// -----------------------------------------------------------------------------
console.log("[double-broadcast] [4/4] polling post-state for up to 60s")

let observedDelta = 0n
let observedNonceDelta = 0
let polls = 0
const maxPolls = 30
const expectedDelta = amountOs

for (let i = 0; i < maxPolls; i++) {
polls = i + 1
await new Promise(r => setTimeout(r, 2000))
const recInfo = await demos1.getAddressInfo(RECEIVER_PUBKEY)
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 Post-state polling only queries node-1 despite comment claiming both RPCs are checked

The comment directly above the poll loop states: "Re-check via BOTH RPCs so we don't fool ourselves on stale node-1 state." However, both getAddressInfo calls (recInfo and sendInfo) target only demos1 (node-1). If the transaction committed on node-2 first and node-1 hasn't replicated it yet, all 30 polls will see stale state and the test exits with "neither tx landed" — a false negative that doesn't distinguish replay-protection failure from network lag.

Comment on lines +100 to +103
const signedTx = await demos1.pay(RECEIVER_PUBKEY, amountOs)
const localHash = signedTx?.hash
console.log(`[double-broadcast] local tx hash: ${localHash}`)
console.log(`[double-broadcast] tx.content.nonce: ${signedTx.content.nonce}`)
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 After pay() returns, signedTx?.hash safely handles a falsy return value, but the very next line signedTx.content.nonce accesses content without any guard. If pay() returns null or undefined, this throws an unhandled TypeError instead of the clean process.exit(1) the rest of the script uses.

Suggested change
const signedTx = await demos1.pay(RECEIVER_PUBKEY, amountOs)
const localHash = signedTx?.hash
console.log(`[double-broadcast] local tx hash: ${localHash}`)
console.log(`[double-broadcast] tx.content.nonce: ${signedTx.content.nonce}`)
const signedTx = await demos1.pay(RECEIVER_PUBKEY, amountOs)
if (!signedTx) {
console.error("[double-broadcast] FAIL: pay() returned null/undefined; cannot build signed tx")
process.exit(1)
}
const localHash = signedTx?.hash
console.log(`[double-broadcast] local tx hash: ${localHash}`)
console.log(`[double-broadcast] tx.content.nonce: ${signedTx.content.nonce}`)

Comment on lines +146 to +147
AMOUNT_OS="$((SENDER_BAL_OS / 10))"
echo "[double-broadcast-e2e] transfer amount: ${AMOUNT_OS} OS (10% of sender balance)"
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 If SENDER_BAL_OS is between 1 and 9 inclusive, integer division $((SENDER_BAL_OS / 10)) produces 0. The script passes AMOUNT_OS=0 to the bun script, which then checks senderBalBefore < amountOsN < 0n → false, so the test proceeds with a zero-value transfer. This doesn't exercise the double-spend path meaningfully.

Suggested change
AMOUNT_OS="$((SENDER_BAL_OS / 10))"
echo "[double-broadcast-e2e] transfer amount: ${AMOUNT_OS} OS (10% of sender balance)"
AMOUNT_OS="$((SENDER_BAL_OS / 10))"
if [[ "${AMOUNT_OS}" -eq 0 ]]; then
echo "[double-broadcast-e2e] ERROR: computed AMOUNT_OS=0 (sender balance ${SENDER_BAL_OS} OS is too small for 10% slice)."
exit 1
fi
echo "[double-broadcast-e2e] transfer amount: ${AMOUNT_OS} OS (10% of sender balance)"

Comment on lines +69 to +76
const sender = demos1.getAddress()
console.log(`[double-broadcast] SENDER=${sender}`)

// -----------------------------------------------------------------------------
// 2. Snapshot pre-state
// -----------------------------------------------------------------------------
const senderInfoBefore = await demos1.getAddressInfo(sender)
const receiverInfoBefore = await demos1.getAddressInfo(RECEIVER_PUBKEY)
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. Missing await on getaddress 🐞 Bug ≡ Correctness

double_broadcast_replay.mjs assigns sender = demos1.getAddress() without awaiting, but
getAddress() is used elsewhere in the repo as an async API. This makes sender a Promise and
breaks subsequent getAddressInfo(sender) calls, causing the e2e test to fail before it can
validate nonce-replay protection.
Agent Prompt
### Issue description
`sender` is assigned from `demos1.getAddress()` without `await`, so it is likely a Promise rather than an address string. The script then passes `sender` into `getAddressInfo()`, which will fail or behave incorrectly.

### Issue Context
Another repo script uses `await demos.getAddress()` when retrieving the wallet address, indicating `getAddress()` is async.

### Fix Focus Areas
- testing/devnet/scripts/double_broadcast_replay.mjs[61-80]

### Suggested fix
- Change to `const sender = await demos1.getAddress()`.
- (Optional hardening) Validate `sender` matches expected hex address format before calling `getAddressInfo(sender)` and exit with a clear error if not.

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

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