Skip to content

fix(micropayment-engine): correct EIP-55 checksum on verifyingContract (Story 15.1)#37

Merged
RBKunnela merged 1 commit into
mainfrom
fix/micropayment-checksum-bug
May 22, 2026
Merged

fix(micropayment-engine): correct EIP-55 checksum on verifyingContract (Story 15.1)#37
RBKunnela merged 1 commit into
mainfrom
fix/micropayment-checksum-bug

Conversation

@RBKunnela
Copy link
Copy Markdown
Owner

@RBKunnela RBKunnela commented May 22, 2026

Summary

MicropaymentEngine.signBatchSettlement() was failing on every call under viem 2.49+ because of two related bugs in src/micropayment-engine.ts:

  1. Primary (the named bug): verifyingContract literal at line 232 had a malformed EIP-55 checksum (4 letter case-swaps). viem 2.49+ rejects non-canonical checksums with InvalidAddressError.
  2. Secondary (found by Task 15.1.2 investigation): generateNonce() returned an un-prefixed 64-char hex string, then the call site cast it as `0x${string}` — a type-system lie. Even with the checksum fixed, viem threw BytesSizeMismatchError: expected bytes32, got bytes64 at hashTypedData.

Both fixes are required to unblock Story 15 Track B (5 failing tests: B-3, B-6, B-7, B-9, B-15).

Root cause analysis

Why no one caught it before Story 15: the only call path that exercises this code is batchPayments()signBatchSettlement(), and the file had 0% test coverage prior to Story 15. The malformed checksum and lying nonce cast were silently broken for the entire lifetime of the module.

The 4 character case-swaps (primary):

- 0x50b0f7224fFc5f4f7685DbcE1B8b7E7B8B8A4A23  (broken)
+ 0x50b0f7224fFC5F4F7685DbcE1B8b7e7B8b8A4A23  (canonical)

Canonical checksum was derived programmatically, not hand-typed:

node -e "import('viem').then(v => console.log(v.getAddress('0x50b0f7224ffc5f4f7685dbce1b8b7e7b8b8a4a23')))"
# 0x50b0f7224fFC5F4F7685DbcE1B8b7e7B8b8A4A23

The lying type cast (secondary):

- private generateNonce(): string {
+ private generateNonce(): `0x${string}` {
    const buf = new Uint8Array(32);
    webcrypto.getRandomValues(buf);
-   return Array.from(buf)
+   const hex = Array.from(buf)
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
+   return `0x${hex}`;
  }

The redundant as \0x${string}`` cast at line 235 is harmless after this change and was left untouched to keep the diff minimal.

Audit pass (Task 15.1.3)

Grepped src/micropayment-engine.ts for as \0x${string}` casts and hardcoded addresses (0x[A-Fa-f0-9]{40}`):

Line Pattern Verdict Action
56 config.walletPrivateKey as \0x${string}`` OK — runtime-validated by startsWith('0x') check at line 52 immediately before the cast None
232 Hardcoded address verifyingContract FIXED in this PR — canonical EIP-55 verified via getAddress() Done
235 this.generateNonce() as \0x${string}`` Was lying. FIXED in this PR at the source: generateNonce() now genuinely returns 0x-prefixed and the return type is honest. Cast kept (now redundant but harmless) to minimize diff Done
240 p.recipient as \0x${string}`` Defensive re-cast of an already-typed Address field. No new runtime validation here, but recipients enter via queuePayment(recipient: Address, ...) which is the type-system gate. Per AC 3 (Out-of-Scope), not fixed in this PR Backlog — track separately

Only one hardcoded address in the file. Recommend a follow-up story to add runtime getAddress() validation at queuePayment entry, but that is out of scope here.

Secondary suspect outcome

Status: FIXED (not benign).

Local verification with the primary fix applied but generateNonce unchanged produced:

FAIL — BytesSizeMismatchError: Expected bytes32, got bytes64.
   at validateData (viem/utils/typedData.ts:110:17)
   at validateTypedData (viem/utils/typedData.ts:132:29)
   at hashTypedData (viem/utils/signature/hashTypedData.ts:64:3)

After fixing generateNonce to return 0x-prefixed hex with an honest return type:

SUCCESS — signature length: 132 (65 bytes — correct EIP-712)
SUCCESS — nonce length: 66 (32 bytes — correct bytes32)
SUCCESS — nonce starts with 0x: true

Verification method

  1. Canonical checksum derivation: viem.getAddress() on the lowercase form → confirms the new literal byte-for-byte.
  2. Smoke script (ad-hoc, not committed): instantiated MicropaymentEngine with a deterministic key, queued a payment, called batchPayments(). The signing path now produces a valid 65-byte signature and 32-byte nonce. Script deleted before commit.
  3. npm run type-check — PASS (no output).
  4. npm run lint — PASS (no output).
  5. Regression coverage: Story 15's tests/micropayment-engine.test.ts (B-3, B-6, B-7, B-9, B-15) will exercise this path properly once that branch is rebased on this fix. Per Story 15.1 scope, test file is NOT included in this PR — owned by Story 15.

Files changed

  • src/micropayment-engine.ts — 4 insertions, 3 deletions. Two fixes, one file.

SINKRA chain

  • @sm (River) drafted Story 15.1 — docs/stories/15.1.checksum-fix.md on this paybot-sdk
  • @po (Pax) validated 10/10 + 5/5. Canonical checksum live-verified via viem getAddress().
  • @dev (Dex) — this PR
  • @qa — pending (will exercise the 12-check matrix + 2-pass discipline)
  • @devops (Gage) — pending merge after @qa PASS

Out of scope (deferred)

  • Adding runtime getAddress() validation at queuePayment entry (line 240 cast).
  • Removing the now-redundant as \0x${string}`` cast at line 235.
  • Story 15 Track B test commits.
  • Other address literals across the codebase (only verified addresses in this single file per scope).

Closes Task #18

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Updated batch settlement signing validation to ensure proper contract verification and consistency in hexadecimal formatting.

Review Change Stack

…ory 15.1)

Two related blockers preventing the signing path from working with
viem 2.49+ strict validation:

1. verifyingContract literal had wrong EIP-55 checksum (4 letter case-swaps).
   Canonical address (verified via viem getAddress()):
   0x50b0f7224fFC5F4F7685DbcE1B8b7e7B8b8A4A23

2. generateNonce() returned un-prefixed 64-char hex but the call site cast
   it as `0x${string}` — a type-system lie. viem rejected at hashTypedData
   with BytesSizeMismatchError ("expected bytes32, got bytes64"). Fixed
   at the source: nonce is now genuinely 0x-prefixed and the return type
   is honest.

Verified locally with an ad-hoc smoke script (not committed) — signTypedData
returns a 65-byte (132-char hex) signature and a 32-byte (66-char hex)
nonce after these two fixes. Test file in Story 15 (tests/micropayment-engine.test.ts)
will exercise this path properly post-merge.

Audit pass (Task 15.1.3) found no other hardcoded addresses in this file
and the remaining `as` casts on hex types are pre-existing patterns out of
scope for this hotfix.

Closes Task #18

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

Walkthrough

The batch settlement signing mechanism receives two updates: the EIP-712 domain's verifyingContract address is corrected for hex capitalization consistency, and the generateNonce() helper now returns 0x-prefixed nonces with explicit 0x${string} type annotation, maintaining the 32-byte random value generation.

Changes

Batch Settlement EIP-712 Signing

Layer / File(s) Summary
EIP-712 domain and nonce generation
src/micropayment-engine.ts
verifyingContract address capitalization corrected in the domain configuration. generateNonce() now returns 0x-prefixed hex strings typed as 0x${string}, replacing unprefixed hex output while preserving 32-byte randomness.

🎯 2 (Simple) | ⏱️ ~8 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary fix: correcting the EIP-55 checksum on verifyingContract in the micropayment engine. It directly matches the main change documented in the PR objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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/micropayment-checksum-bug

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.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request updates the verifyingContract address to its checksummed version and modifies the generateNonce method to return a 0x-prefixed hex string with a more specific template literal type. A review comment suggests optimizing the hex conversion in generateNonce by using Buffer or an existing utility function to improve performance and reduce code duplication.

Comment on lines +344 to +347
const hex = Array.from(buf)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return `0x${hex}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The current method of converting the Uint8Array to a hex string is inefficient as it involves multiple array allocations and iterations. Since this is a Node.js environment, using Buffer is significantly more performant and concise. Alternatively, you could consider importing and using the existing generateEIP3009Nonce utility from src/crypto.ts to avoid code duplication.

    const hex = Buffer.from(buf).toString('hex');
    return `0x${hex}`;

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/micropayment-engine.ts (1)

228-233: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use canonical network domain config instead of a hardcoded verifyingContract.

Line 232 still hardcodes the domain address and force-casts it with as Address. That can drift from canonical network config and sign against the wrong EIP-712 domain after address changes. Pull verifyingContract from the shared network/domain constants instead.

Proposed fix
+import { EIP712_DOMAINS } from './networks.js';
...
     const domain = {
       name: 'PayBot',
       version: '1',
       chainId: 8453,
-      verifyingContract: '0x50b0f7224fFC5F4F7685DbcE1B8b7e7B8b8A4A23' as Address,
+      verifyingContract: EIP712_DOMAINS['eip155:8453'].verifyingContract,
     };

As per coding guidelines, "No hardcoded private keys, secrets, or wallet addresses."

🤖 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/micropayment-engine.ts` around lines 228 - 233, The domain object
currently hardcodes verifyingContract and force-casts it, which can drift from
the canonical network config; replace the literal '0x50b0f...' value in the
domain declaration with the shared/canonical domain/network constant (e.g., the
exported verifyingContract or domain value from your network/domain constants),
remove the unsafe 'as Address' cast, and ensure the domain uses that imported
constant (keeping name: domain) so EIP‑712 signing always uses the canonical
verifyingContract.
🤖 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.

Outside diff comments:
In `@src/micropayment-engine.ts`:
- Around line 228-233: The domain object currently hardcodes verifyingContract
and force-casts it, which can drift from the canonical network config; replace
the literal '0x50b0f...' value in the domain declaration with the
shared/canonical domain/network constant (e.g., the exported verifyingContract
or domain value from your network/domain constants), remove the unsafe 'as
Address' cast, and ensure the domain uses that imported constant (keeping name:
domain) so EIP‑712 signing always uses the canonical verifyingContract.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: eae67023-7c1e-4529-bad1-59373d17b3b6

📥 Commits

Reviewing files that changed from the base of the PR and between 7930691 and b15fc72.

📒 Files selected for processing (1)
  • src/micropayment-engine.ts

@RBKunnela
Copy link
Copy Markdown
Owner Author

Merge authorization per .claude/rules/automated-pr-merge-authority.md (7th application):

  • @sm: Drafted Story 15.1 (block-lifter for Story 15 Track B)
  • @po: PASS 10/10 + 5/5 (canonical checksum live-verified via viem getAddress)
  • @dev: Implemented 2 fixes
    • Primary: EIP-55 checksum corrected on micropayment-engine.ts:232 (4 case-swaps)
    • Secondary: generateNonce now returns 0x-prefixed hex (lines 341-347)
    • 1 file, 4+/3- diff
    • End-to-end signing verified: 132-char signature + 66-char 0x-prefixed nonce
  • @qa: PASS — 2 QA passes, 4/4 required CI green, 8/8 AC met
    • Negative smoke: OLD broken checksum still throws InvalidAddressError (confirms fix is necessary)
    • mergeStateStatus UNSTABLE only from non-required Gemini perf advisory (not gating)
  • @aiox-master (Orion): Chain routing approved
  • @devops: Executing merge

Closes Task #18. Unblocks Story 15 Track B (5 previously-failing tests should pass once @dev resumes on rebased branch).

Pattern note: 3rd product bug discovered today by AIOX agents driving forward on coverage/test work:

  1. Task chore(deps): bump esbuild, @vitest/coverage-v8 and vitest #14 (dual-mode dead code) — fixed in PR feat(x402-v2): fix dual-mode dead-code bug via Option C refactor (Story 14) #36 (7930691)
  2. Task ci(deps): bump github/codeql-action from 3 to 4 #18 (EIP-55 checksum + generateNonce 0x prefix) — fixed in THIS PR
  3. Both surfaced via SVG-1 plausible-but-wrong pattern in untested code

Non-blocking observations from @qa (backlog candidates):

  • Gemini perf suggestion: Buffer.from(buf).toString('hex') faster than map/join (line 347)
  • Inline comment at line 232 noting "EIP-55 canonical — derived via viem.getAddress(), do not hand-edit"
  • Line 240 (p.recipient re-cast) deferred per AC 3 — backlog for runtime getAddress() validation at queuePayment entry
  • Optional unit test for generateNonce shape (Story 15 Track B will lock it)

@RBKunnela RBKunnela merged commit 23694dd into main May 22, 2026
8 checks passed
@RBKunnela RBKunnela deleted the fix/micropayment-checksum-bug branch May 22, 2026 22:28
RBKunnela added a commit that referenced this pull request May 22, 2026
…ory 15) (#38)

Closes Task #15. Builds on Story 15.1 (PR #37 @ 23694dd) which unblocked
this work by fixing the EIP-55 checksum + 0x-prefix issues in
src/micropayment-engine.ts.

Track A — extend tests/x402-v2.test.ts (tests #14-#26)
  - on402Response: happy / non-402 status / missing+malformed header
  - submitPayment: 200 OK / non-2xx / network error rewrap
  - verifyReceipt: 200+verified / non-2xx / network error (no throw)
  - createPaymentIntentHeader: happy / optional fields undef
  - negotiatePaymentIntent: happy / TODO branch locked
  - src/x402-v2.ts: 63.74% → 97.32% line coverage

Track B — create tests/micropayment-engine.test.ts (B-1 through B-18)
  - constructor (3): 0x-guard, defaults, custom thresholds
  - queuePayment (3): paymentId shape, usdToBaseUnits errors, auto-settle
  - batchPayments (3): EIP-712 BatchSettlement sign / missing IDs / skipGas
  - getGasEstimate (2): 6-decimal USD, inverse scaling
  - setBatchWindow (2): s→ms conversion, window-boundary observable
  - getQueueStatistics (2): empty zeroes, multi-window aggregation
  - clearOldPayments (2): nothing-to-clear, settled+old removal
  - getPaymentStatus (1): find-or-undefined cross-window
  - src/micropayment-engine.ts: 0% → 100% line coverage

Track C — .github/workflows/ci.yml
  - Add `Coverage gate` step (npm run coverage) between Run tests + Build
  - Add `Upload coverage report` step (actions/upload-artifact@ea165f8 v4)
    with if: always(), 14-day retention, per matrix Node version

Test count: 115 baseline + 13 Track A + 18 Track B = 146 tests, all PASS
Coverage: global 97.52% lines / 86.75% branches / 100% funcs / 97.52% stmts
  (exceeds vitest.config.ts thresholds 80/80/70/80 → npm run coverage exit 0)

Determinism: all tests use vi.useFakeTimers + vi.setSystemTime + frozen
Math.random; fetch is stubbed via vi.stubGlobal; zero real network I/O.

Verbatim source preservation: 0 changes to src/ (verified git diff --stat).
Zero skipped tests, zero .todo/xit/xdescribe/.only.

Story chain: full SINKRA — @sm draft (v0.1) → @po GO-conditional → @sm patch
(v0.2) → @dev attempt 1 → bug escalation → @aios-master roundtable → Story
15.1 source fix (PR #37) → @dev resume (this PR) → @qa pending → @devops
pending merge.
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