Skip to content

Expand x402 tracking with audit fields and ordered writes#39

Merged
ponderingdemocritus merged 2 commits intomainfrom
ponderingdemocritus/norawsigneddata
Feb 10, 2026
Merged

Expand x402 tracking with audit fields and ordered writes#39
ponderingdemocritus merged 2 commits intomainfrom
ponderingdemocritus/norawsigneddata

Conversation

@ponderingdemocritus
Copy link
Contributor

@ponderingdemocritus ponderingdemocritus commented Feb 10, 2026

This PR expands resource tracking to persist additional x402 audit columns and hashes across core and facilitator-server schemas.
It threads the new audit extraction through verify/settle flows and tracking middleware.
It also adds per-record tracking operation ordering, clears stale verification errors on successful verification, and surfaces missing-row updates in the Postgres store.
Unit and server tests were added/updated for helper hashing, write ordering, Postgres updates, schema columns, and undefined-body handling.

Summary by CodeRabbit

  • New Features
    • Expanded audit tracking: six new x402-related metadata fields and indexes added to records; improved per-record operation ordering for reliable verification/settle flows.
  • Documentation
    • Added a detailed PRD describing goals, requirements, rollout, testing, and acceptance criteria for expanded x402 tracking.
  • Tests
    • Added unit and integration tests covering audit extraction, hashing, DB persistence, ordering, and error paths.

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

📝 Walkthrough

Walkthrough

This PR adds x402 audit fields across schema, types, helpers, store, middleware, and app wiring; implements canonical JSON hashing and extractX402AuditFields; introduces per-record operation queuing in the tracking module; and includes schema/migration files and tests to persist and verify the new audit data.

Changes

Cohort / File(s) Summary
Documentation
docs/prd-expanded-x402-tracking.md
New PRD describing expanded x402 tracking persistence, requirements, rollout, tests, and open questions.
DB schema & snapshots
examples/facilitator-server/drizzle/0000_cloudy_sugar_man.sql, examples/facilitator-server/drizzle/meta/0000_snapshot.json, examples/facilitator-server/src/schema/tracking.ts
Added six new columns to resource_call_records (x402_version, payment_nonce, payment_valid_before, payload_hash, requirements_hash, payment_signature_hash) and four new btree indexes.
App wiring
examples/facilitator-server/src/app.ts
Imported and passed extracted x402 audit fields into tracking calls in verify/settle flows; safely parse request body to extract payload/requirements where needed.
Middleware
packages/core/src/middleware/core.ts
Compute x402Audit via extractX402AuditFields and pass it into resourceTracking.recordVerification calls in pre-handle and payment-verified paths.
Tracking helpers & exports
packages/core/src/tracking/helpers.ts, packages/core/src/tracking/lib.ts
Added canonical JSON utilities and hashCanonicalJson; implemented extractX402AuditFields (x402Version, paymentNonce, paymentValidBefore, payloadHash, requirementsHash, paymentSignatureHash); exported helpers.
Types
packages/core/src/tracking/types.ts
Added six optional fields to ResourceCallRecord for x402 audit metadata.
Tracking module
packages/core/src/tracking/module.ts
Extended recordVerification signature to accept x402Audit; added per-record operation queues (enqueueTrackingOperation) to serialize updates and preserve per-record ordering.
Postgres store
packages/core/src/tracking/postgres-store.ts
Expanded POSTGRES_SCHEMA, INSERT, UPDATE, and row-to-record mapping to include the six new x402 audit columns; added index creation and RETURNING/absent-row handling in updates.
Examples & tests
examples/facilitator-server/tests/*, packages/core/tests/unit/*
Added/updated tests: app behavior on verify errors, schema expectations, postgres store SQL assertions, resource tracking ordering test, and x402 helper/hash tests.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant App as Facilitator App
  participant Module as ResourceTrackingModule
  participant Store as PostgresStore

  Client->>App: request (payment payload + requirements)
  App->>Module: startTracking(request)
  Module->>Store: create(record)
  Store-->>Module: created(id)
  App->>Module: recordVerification(id, success, paymentDetails, undefined, x402Audit)
  Module->>Module: enqueue per-record op (serialize)
  Module->>Store: update(record with verification + x402Audit)
  Store-->>Module: update result
  App->>Module: finalizeTracking(id)
  Module->>Module: enqueue per-record op (serialize)
  Module->>Store: update(finalize)
  Store-->>Module: update result
  Module-->>App: done
  App-->>Client: response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐇 I nibble logs and hash each JSON sight,
I queue each write to keep the order right,
Nonces and signatures hop into rows,
Audit trails bloom where the payment river flows,
A little rabbit cheers — persistence grows! 🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Expand x402 tracking with audit fields and ordered writes' directly and specifically summarizes the main changes: adding x402 audit fields to tracking and implementing ordered write semantics for per-record operations.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ponderingdemocritus/norawsigneddata

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
packages/core/src/tracking/helpers.ts (3)

161-162: isRecord declared below its first use in extractPayer.

isRecord is referenced at line 98 inside extractPayer but defined at line 161. It works at runtime because the function isn't called at module load, but hoisting the helper above extractPayer would improve readability and avoid a potential TDZ surprise if someone later adds a top-level call.


164-188: canonicalizeJson produces identical output for bigint and its string equivalent.

BigInt(123)JSON.stringify("123")'"123"', which is the same as canonicalizeJson("123"). If a field could plausibly hold either type across versions of the payload schema, two semantically different values would hash identically. For the current x402 audit use case this is unlikely to matter, but worth a brief comment in the code so future consumers know about the collision.


195-204: normalizeOptionalNumber silently drops floats — verify this is intentional.

Number.isInteger rejects values like 1.5. If an x402Version or similar field were ever a non-integer number, it would silently become undefined rather than throwing. If only integers are valid, a brief doc-comment noting the intent would help future readers.

packages/core/tests/unit/postgresTrackingStore.test.ts (1)

56-65: Consider asserting param count or positional values for stronger guarantees.

Currently the create test spot-checks two values with toContain. If the INSERT column/param ordering drifts, you'd still pass. A positional assertion (e.g., params[indexOfX402Version] === 2) or at least expect(params.length).toBe(N) would catch silent regressions more reliably.

examples/facilitator-server/src/app.ts (1)

219-256: Repeated body-parsing and conditional-extraction pattern across verify/settle catch blocks.

The four catch paths (verify, settle, settle-aborted, settle-generic) each repeat the (body ?? {}) destructuring and conditional extractPaymentDetails / extractX402AuditFields logic. Consider extracting a small helper (e.g., tryExtractBodyFields(body)) to DRY this up and reduce the chance of future divergence when the extraction logic changes.

Sketch
function tryExtractBodyFields(body: unknown) {
  const parsed = (body ?? {}) as {
    paymentPayload?: PaymentPayload;
    paymentRequirements?: PaymentRequirements;
  };
  const { paymentPayload, paymentRequirements } = parsed;
  if (!paymentPayload || !paymentRequirements) {
    return { paymentPayload, paymentRequirements, details: undefined, audit: undefined };
  }
  return {
    paymentPayload,
    paymentRequirements,
    details: extractPaymentDetails(paymentPayload, paymentRequirements),
    audit: extractX402AuditFields(paymentPayload, paymentRequirements),
  };
}

Also applies to: 332-414


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

@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.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@examples/facilitator-server/src/app.ts`:
- Around line 226-239: The verify-error tracking call to safeTrack currently
passes undefined as the 3rd argument to module.recordVerification, which drops
payment details; update the safeTrack invocation that calls
module.recordVerification (the call with trackingId, false, undefined, ...) to
pass extractPaymentDetails(paymentPayload, paymentRequirements) instead of
undefined when both paymentPayload and paymentRequirements are present (i.e.,
replace the 3rd arg with a conditional that calls
extractPaymentDetails(paymentPayload, paymentRequirements) only when those two
variables are defined), keeping the existing error message and audit fields
logic intact.

In `@packages/core/src/tracking/helpers.ts`:
- Around line 164-186: canonicalizeJson currently serializes object properties
whose value is undefined as "key":null, which differs from JSON.stringify (it
omits such keys); update canonicalizeJson in
packages/core/src/tracking/helpers.ts to skip object keys whose raw value is ===
undefined when building the serialized array so objects with omitted-undefined
properties match JSON.stringify; specifically, inside canonicalizeJson where you
iterate Object.keys(value).sort(), check typeof value[key] !== "undefined" (or
value.hasOwnProperty(key) && value[key] !== undefined) before pushing the
`${keyPart}:${valuePart}` entry, leaving other logic (arrays, dates, primitives,
and isRecord checks) unchanged.

In `@packages/core/tests/unit/postgresTrackingStore.test.ts`:
- Line 5: The describe block name is incorrect: locate the describe call
currently using the string "PostgresResourceTrackingStore.update" in
packages/core/tests/unit/postgresTrackingStore.test.ts and change it to a more
accurate label such as "PostgresResourceTrackingStore" (or split into two
describes like "PostgresResourceTrackingStore.create" and
"PostgresResourceTrackingStore.update" so tests that exercise create vs update
are grouped correctly); update the describe title around the tests (the test
titled "persists expanded x402 audit columns during create" references create)
so the describe string matches the operations being tested.
🧹 Nitpick comments (6)
examples/facilitator-server/src/schema/tracking.ts (1)

56-59: Indexes are selective — verify payment_valid_before and payment_signature_hash are intentionally excluded.

Four of the six new columns are indexed, but payment_valid_before and payment_signature_hash are not. This aligns with the PRD's guidance to "add only query-justified indexes," but worth confirming these two won't be used in filter/lookup queries. If payment_valid_before is ever used for range queries (e.g., finding expired authorizations), an index would be beneficial.

packages/core/src/middleware/core.ts (1)

265-274: Consider extracting partial audit fields on payment errors for forensic purposes.

In the error path, extractX402AuditFields is not called. While this is reasonable (the payload may be malformed), the PRD mentions forensic investigation as a goal. If the payload is parseable but verification fails (e.g., expired nonce, invalid signature), the audit fields could still be valuable for debugging. This is a low-priority consideration for a future iteration.

packages/core/tests/unit/resourceTrackingModule.test.ts (1)

90-95: Sleep-based assertions are timing-sensitive.

The sleep(5) and sleep(10) delays work in practice but are inherently fragile in slow CI environments. If these ever flake, consider exposing a flush/drain mechanism on the module (or awaiting the internal queue directly) for deterministic assertions. Not blocking, just noting.

examples/facilitator-server/drizzle/meta/0000_snapshot.json (1)

205-264: Consider whether payment_signature_hash should also be indexed.

Four of the six new columns have indexes (x402_version, payment_nonce, payload_hash, requirements_hash), but payment_valid_before and payment_signature_hash do not. If you anticipate querying by signature hash (e.g., for duplicate payment detection or forensic lookups), an index would be useful. Otherwise, the omission is fine to keep write overhead low.

packages/core/src/tracking/helpers.ts (1)

161-162: Move isRecord above its first usage in extractPayer for clarity.

isRecord is a const arrow function defined at line 161 but referenced at line 98 inside extractPayer. This works at runtime (the function is called after module initialization), but placing the helper before its callers improves readability and avoids confusion about temporal dead zones.

packages/core/src/tracking/postgres-store.ts (1)

334-340: Document the throwing behavior of update() in JSDoc.

The RETURNING id check now throws when a tracking record is not found (line 338-340), which is a behavioral change from the previous silent no-op. This is intentional and properly tested (see postgresTrackingStore.test.ts), and all production callers in module.ts already wrap calls with safeTrack(), which catches and logs exceptions without crashing. However, since the method's throwing behavior is now a contract, add JSDoc to document it explicitly for any future direct callers of the store.

@ponderingdemocritus ponderingdemocritus merged commit 3304318 into main Feb 10, 2026
4 checks passed
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