Skip to content

feat(x402-v2): fix dual-mode dead-code bug via Option C refactor (Story 14)#36

Merged
RBKunnela merged 1 commit into
mainfrom
feat/story-14-dual-mode-refactor
May 22, 2026
Merged

feat(x402-v2): fix dual-mode dead-code bug via Option C refactor (Story 14)#36
RBKunnela merged 1 commit into
mainfrom
feat/story-14-dual-mode-refactor

Conversation

@RBKunnela
Copy link
Copy Markdown
Owner

Summary

Story 14 — Option C refactor. Fixes the dead-code bug in X402Handler.signPayment
where the else if (protocol === 'dual') arm (introduced by rebase artifact
dbe4c89 on 2026-05-20) was unreachable because the preceding
if (protocol === 'x402' || protocol === 'dual') already matched dual.
Net effect of the bug: dual-mode silently fell through to x402-only signing
and attached MPP fields as inert metadata — no real MPP signature.

Roundtable verdict (2026-05-22): 3 specialists (@architect, @dev,
@qa-equivalent) agreed FIX over rebuild. Operator selected Option C
(extract-and-dispatch).

Customer impact: ZERO at present. paybot-sdk 0.3.0 is unreleased;
paybot-mcp + paybot-core pin to ^0.2.0. Fix lands before 0.3.0 ships.

What changed

  • Extracted signX402(account, requirements) private helper — verbatim body
    from the pre-refactor x402 branch (current lines 165-205).
  • Extracted signMPP(account, requirements, intentId) private helper —
    verbatim body from the pre-refactor mpp branch (current lines 208-250).
  • Rewrote signPayment as a switch dispatcher:
    • x402signX402 only
    • mppsignMPP only
    • dual → BOTH signX402 and signMPP; signedData = { x402, mpp };
      top-level signature is the x402 signature (primary, legacy-compatible)
    • default → throws PayBotApiError with code UNSUPPORTED_PROTOCOL (402)
  • Deleted the unreachable dead-code branch.
  • Added JSDoc to all three methods (what / @param / @returns / @throws / @example).
  • Inline // WHY comment on the dual case explaining the design choice.

Acceptance criteria (8/8 met with tests)

AC Mechanism Test
1. signX402 byte-for-byte equivalent verbatim body migration T1, T2, T3, T13
2. signMPP byte-for-byte equivalent verbatim body migration T4, T5, T6
3. signPayment(x402) dispatch switch case 'x402' T7
4. signPayment(mpp) dispatch switch case 'mpp' T8
5. signPayment(dual) calls both, packs { x402, mpp } switch case 'dual' T9, T12
6. Unsupported protocol throws switch default T10
7. Public type stability (signedData: Record<string, unknown>) no src/types.ts change typecheck PASS
8. No regressions (102 existing tests) run full suite 102/102

Tests added (13 new in tests/x402-v2.test.ts)

tests/x402-v2.test.ts is NEW — zero tests existed for the x402-v2 module.

  1. [UNIT] signX402 — should produce a verifiable EIP-3009 TransferWithAuthorization signature with typical inputs
  2. [UNIT] signX402 — should throw UNSUPPORTED_NETWORK PayBotApiError when network has no EIP-712 domain
  3. [UNIT] signX402 — should handle max-uint256 amount without overflow and produce a valid signature
  4. [UNIT] signMPP — should produce a valid PaymentAuthorization signature with typical inputs and intentId
  5. [UNIT] signMPP — should handle missing intentId by defaulting paymentIntent field to 'unknown'
  6. [UNIT] signMPP — should produce a different signature than signX402 for identical inputs (proves it uses different typed-data)
  7. [UNIT] signPayment — should dispatch to signX402 only when protocol is 'x402' and return identical shape to legacy behavior
  8. [UNIT] signPayment — should dispatch to signMPP only when protocol is 'mpp' and return identical shape to legacy behavior
  9. [UNIT] signPayment — should call BOTH signX402 and signMPP when protocol is 'dual' and pack signedData as { x402, mpp } with x402 signature as primary
  10. [UNIT] signPayment — should throw PayBotApiError with code UNSUPPORTED_PROTOCOL and status 402 for unknown protocol values
  11. [UNIT] signPayment — should throw PayBotApiError with code MISSING_WALLET_KEY when walletPrivateKey is not configured
  12. [UNIT] signPayment — dual-mode mpp signature should be a real cryptographic signature, not metadata (proves dead-code bug is fixed)CANONICAL REGRESSION TEST for the original bug. Runs recoverTypedDataAddress against signedData.mpp.signature with the MPP typed-data structure and asserts the recovered address matches the signer. If the signature were inert metadata, recovery would fail or yield a wrong address.
  13. [UNIT] signPayment — dual-mode x402 signature byte-for-byte equals x402-only signature for the same inputs (proves refactor is non-breaking) — verbatim-migration regression shield.

Determinism: vi.useFakeTimers() + vi.setSystemTime('2026-05-22T12:00:00Z') +
mocked generateEIP3009Nonce returns fixed bytes32. Signatures byte-identical
across runs.

Verbatim migration verified

Test 13 asserts dual.signedData.x402 is toEqual to the standalone
x402-only signedData object, and that dual.signature equals
x402Only.signature. Same inputs (same fixed nonce, same fixed timestamp,
same private key) produce byte-for-byte identical x402 signatures regardless
of whether the signX402 helper was reached via the x402 case or the
dual case.

Risk

Medium — crypto-signature code. Mitigations:

  1. Deterministic test fixtures (fake timers + mocked nonce).
  2. Verbatim body migration (no logic change inside helpers).
  3. Test 13 byte-for-byte regression catches any drift.
  4. Test 12 EIP-712 recovery catches any silent "metadata not signature" regression.
  5. 102 pre-existing tests still green (no integration-test regression).

Quality gates (local — CI will rerun)

  • npm test115/115 PASS (102 existing + 13 new)
  • npm run lintPASS (clean)
  • npm run type-checkPASS (clean)
  • Coverage on new code (signX402 / signMPP / signPayment): all lines hit
  • Global coverage: 69.93% (improved from pre-existing baseline 54.25%; the
    global 80% threshold failure is pre-existing and out of scope per
    Story 14 — Task chore(deps): bump vite, @vitest/coverage-v8 and vitest #15 covers project-wide coverage backfill)

Out of scope (Story 14 explicit exclusions)

Chain governance

Full SINKRA chain per automated-pr-merge-authority.md:

  • @sm (River) — drafted Story 14 (2026-05-22)
  • @po (Pax) — validated 12/12 checks (2026-05-22)
  • @dev (Dex) — implementing (this PR)
  • @qa (Smith) — pending (will run 12-check matrix, 2-pass minimum)
  • @devops (Gage) — pending merge after @qa PASS verdict

Do not auto-merge. Per automated-pr-merge-authority.md only @devops
clicks the merge button, and only after the full chain signs off.

References

  • Story: D:\1.GITHUB\AIOX-Enterprise\docs\stories\14.dual-mode-refactor.md
  • Bug origin: commit dbe4c89 (2026-05-20)
  • Roundtable verdict: 2026-05-22 (3 specialists, FIX > rebuild, Option C)
  • Quality Foundation Protocol: .claude/rules/quality-foundation.md

🤖 Generated with Claude Code

…g dual-mode (Story 14)

Bug: pre-existing if/else-if chain in `signPayment` (src/x402-v2.ts line 251+)
included an unreachable `else if (protocol === 'dual')` arm — line 163 already
matched `dual` because of `protocol === 'x402' || protocol === 'dual'`. The
dead branch was meant to produce both an x402 EIP-3009 signature AND an MPP
PaymentAuthorization signature; instead, dual-mode silently fell into the
x402-only branch and attached MPP fields as inert metadata under
`signedData.mppFormat` — no real MPP signature was ever produced.

Bug origin commit: dbe4c89 (2026-05-20 rebase artifact).
Customer impact: ZERO at present (paybot-sdk 0.3.0 unreleased; paybot-mcp
and paybot-core pin to ^0.2.0). Fix lands before 0.3.0 ships.

Fix (Option C per 2026-05-22 roundtable verdict):
- Extracted `signX402(account, requirements)` private helper, verbatim body
  from the pre-refactor x402 branch.
- Extracted `signMPP(account, requirements, intentId)` private helper,
  verbatim body from the pre-refactor mpp branch.
- Rewrote `signPayment` as a `switch` dispatcher with explicit `x402` / `mpp`
  / `dual` cases and a `default` arm throwing PayBotApiError with code
  `UNSUPPORTED_PROTOCOL` (status 402).
- Dual-mode now calls BOTH helpers; `signedData = { x402, mpp }` packs both
  signed payloads; top-level `signature` mirrors the x402 signature for
  legacy compatibility.
- Deleted the unreachable dead-code branch (was lines 251-311). No silent
  behavior change for `x402` or `mpp` consumers — proven by Test 13
  (byte-for-byte regression).

Tests: new tests/x402-v2.test.ts (was zero) — 13 unit tests covering
signX402 happy/error/edge, signMPP happy/error/edge, dispatcher x402/mpp/
dual/unsupported/missing-key paths, and two regression tests including the
canonical Test 12 that runs EIP-712 signature recovery against the
dual-mode MPP signature and confirms it recovers the signer address (i.e.,
proves the signature is real cryptography, not metadata). Determinism:
vi.useFakeTimers + vi.setSystemTime + mocked generateEIP3009Nonce.

Quality: 115/115 tests pass (102 existing + 13 new); lint clean; typecheck
clean; all new code lines (signX402 / signMPP / signPayment) hit. JSDoc
added to all three methods with @param / @returns / @throws / @example.

Out of scope per Story 14: discriminated union for signedData, broader
coverage backfill on other untested public methods (Task #15), 0.3.0
release. SignedPayment.signedData stays Record<string, unknown>; no
breaking type change.

Refs: docs/stories/14.dual-mode-refactor.md (AIOX-Enterprise)
SDC chain: @sm draft → @po validated 12/12 → @dev (this PR) → @qa pending → @devops merge.

🤖 Generated with Claude Code

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

coderabbitai Bot commented May 22, 2026

Warning

Rate limit exceeded

@RBKunnela has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 22 minutes and 27 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, 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 have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5fe3579c-f613-4538-bf5f-60824d5d9ac9

📥 Commits

Reviewing files that changed from the base of the PR and between b4de4e7 and eab9d53.

📒 Files selected for processing (2)
  • src/x402-v2.ts
  • tests/x402-v2.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/story-14-dual-mode-refactor

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 refactors the signPayment method in X402Handler by extracting protocol-specific logic into signX402 and signMPP private methods and implementing a switch dispatcher. This change ensures that the dual protocol mode correctly generates both x402 and MPP signatures. Additionally, a new test suite is introduced to provide regression testing and verify protocol support. Review feedback identified a critical bug in the signMPP implementation where the expiration timestamp and payment intent ID in the metadata did not match the values used for the signature, which would lead to verification failures.

Comment thread src/x402-v2.ts
Comment on lines +247 to +281
const nonce = generateEIP3009Nonce();
const nowSeconds = BigInt(Math.floor(Date.now() / 1000));

const signature = await account.signTypedData({
domain,
types: {
PaymentAuthorization: [
{ name: 'payer', type: 'address' },
{ name: 'recipient', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' },
{ name: 'expires', type: 'uint256' },
{ name: 'paymentIntent', type: 'string' },
],
},
primaryType: 'PaymentAuthorization',
message: {
payer: account.address,
recipient: requirements.payTo as `0x${string}`,
amount: BigInt(requirements.amount),
nonce,
expires: nowSeconds + BigInt(3600),
paymentIntent: intentId || 'unknown',
},
});

const signedData: Record<string, unknown> = {
payer: account.address,
recipient: requirements.payTo,
amount: requirements.amount,
nonce,
expires: nowSeconds.toString(),
paymentIntent: intentId,
signature,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The signMPP method contains logic discrepancies between the signed message and the returned signedData, which will cause verification failures for recipients.

  1. Expiration Mismatch: The signature is generated using nowSeconds + BigInt(3600), but signedData.expires is set to nowSeconds.toString(). This makes the payload appear expired immediately to any verifier, as the signed expiration time is 1 hour in the future while the metadata claims it expires now.
  2. Intent ID Mismatch: The signature uses intentId || 'unknown', but signedData.paymentIntent uses the raw intentId. If intentId is falsy (e.g., undefined or ''), the verifier will receive a value that doesn't match the one used for the signature recovery.

Extracting these to local variables ensures consistency between the cryptographic signature and the returned metadata.

    const nonce = generateEIP3009Nonce();
    const nowSeconds = BigInt(Math.floor(Date.now() / 1000));
    const expires = nowSeconds + BigInt(3600);
    const paymentIntent = intentId || 'unknown';

    const signature = await account.signTypedData({
      domain,
      types: {
        PaymentAuthorization: [
          { name: 'payer', type: 'address' },
          { name: 'recipient', type: 'address' },
          { name: 'amount', type: 'uint256' },
          { name: 'nonce', type: 'bytes32' },
          { name: 'expires', type: 'uint256' },
          { name: 'paymentIntent', type: 'string' },
        ],
      },
      primaryType: 'PaymentAuthorization',
      message: {
        payer: account.address,
        recipient: requirements.payTo as `0x${string}`,
        amount: BigInt(requirements.amount),
        nonce,
        expires,
        paymentIntent,
      },
    });

    const signedData: Record<string, unknown> = {
      payer: account.address,
      recipient: requirements.payTo,
      amount: requirements.amount,
      nonce,
      expires: expires.toString(),
      paymentIntent,
      signature,
    };

@RBKunnela
Copy link
Copy Markdown
Owner Author

Merge authorization per .claude/rules/automated-pr-merge-authority.md (6th application — FIRST story-driven PR of the day):

  • @sm: Drafted Story 14 (D:\1.GITHUB\paybot-sdk\docs\stories\14.dual-mode-refactor.md)
  • @po: Validated 12/12 checks (GO verdict)
  • @dev: Implemented Option C refactor
    • 115/115 tests pass (102 existing preserved + 13 new)
    • Lint clean (dead-code bug eliminated by structural fix)
    • JSDoc on all 3 methods
    • Coverage on x402-v2.ts: 0% → 63.74% (100% on changed lines)
    • Verbatim migration: signX402 body == pre-refactor lines 165-205, signMPP body == pre-refactor lines 207-250
  • @qa: PASS — 2 passes, 8/8 AC met, verbatim migration verified by direct git diff
  • @aiox-master (Orion): Chain routing approved
  • @devops: Executing merge under operator pre-approval pattern

Closes Task #14. Fixes dual-mode dead-code bug discovered 2026-05-22:

  • Bug origin: commit dbe4c89 (2026-05-20, Tate Lyman rebase artifact)
  • Feature origin: commit 58f99b3 (2026-05-19, Renata)
  • Customer impact: ZERO (unreleased 0.3.0)
  • Approach: Option C (extract signX402 + signMPP helpers + switch dispatcher)
  • Roundtable verdict (3 specialists): FIX not REBUILD

Non-blocking observations from @qa:

Precedent chain (today, 2026-05-22):

This is the 8th merged PR in the paybot ecosystem in a single day.

@RBKunnela RBKunnela merged commit 7930691 into main May 22, 2026
8 checks passed
@RBKunnela RBKunnela deleted the feat/story-14-dual-mode-refactor branch May 22, 2026 20:08
RBKunnela added a commit that referenced this pull request May 22, 2026
Add `npm run lint` as a CI step in the build matrix job, positioned
between `npm ci` (install) and `npm run type-check` so lint failures
fast-fail before downstream type-check/test/coverage/build steps.

Now possible because Task #14 (dual-mode dead-code fix in PR #36)
eliminated the no-dupe-else-if blocker on `src/x402-v2.ts:251`.
Lint passes clean on main (verified locally pre-commit).

Why lint gate matters: paybot-sdk auto-publishes to npm on main push.
Without a CI lint gate, lint regressions could ship to the registry.

Note on Task #16 (coverage/ in .gitignore): already present on line 12
of `.gitignore`. No-op; not included in this PR.

Closes #17
Refs #16 (no-op, pre-existing)
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