Skip to content

feat(news): add x402 payment flow to news_file_signal#426

Merged
whoabuddy merged 6 commits intomainfrom
feat/news-signal-x402-payment
Apr 30, 2026
Merged

feat(news): add x402 payment flow to news_file_signal#426
whoabuddy merged 6 commits intomainfrom
feat/news-signal-x402-payment

Conversation

@biwasxyz
Copy link
Copy Markdown
Collaborator

Summary

  • Updates news_file_signal tool to handle x402 sBTC payment (POST → 402 → sponsored tx → payment header → signal filed)
  • Follows inbox.tools.ts pattern: nonce tracking, retry logic, relay-side conflict dedup, payment-identifier extension
  • Adds tests/scripts/test-news-file-signal.ts for local testing (--dry-run / --pay modes, defaults to localhost:8787)

⚠️ DO NOT MERGE — Needs testing

Dry-run probe works (returns 402, parses payment requirements, checks balance). Full --pay flow has not been verified end-to-end yet.

Testing blockers encountered

  1. Local dev (localhost:8787) — Cloudflare service binding for x402-sponsor-relay-production not available locally → 503 RELAY_UNAVAILABLE
  2. Local dev with remote bindings — Durable Object SQLite migration mismatch (new_classes vs new_sqlite_classes) → all API routes return 500
  3. Staging (agent-news-staging.hosting-962.workers.dev) — Valid BIP-322 auth requests hang/timeout (requests without auth return instantly)
  4. Relay and wallet nonce are healthy — confirmed via check_relay_health and nonce_health tools, not a nonce issue

To test

# Dry run (probe only)
TEST_WALLET_PASSWORD=<pw> npx tsx tests/scripts/test-news-file-signal.ts

# Full payment flow
TEST_WALLET_PASSWORD=<pw> SIGNALS_URL=https://aibtc.news/api/signals npx tsx tests/scripts/test-news-file-signal.ts --pay

Test plan

  • Fix agent-news staging/local DO SQLite issue so local testing works
  • Verify full --pay flow against working endpoint
  • Confirm signal appears in feed after filing
  • Test with insufficient sBTC balance (should error cleanly)
  • Test retry logic with simulated nonce conflict

🤖 Generated with Claude Code

biwasxyz and others added 3 commits March 28, 2026 08:43
The aibtc.news /api/signals endpoint now requires x402 sBTC payment.
Updates news_file_signal to handle the full payment flow:

1. POST with BIP-322 auth headers → receive 402 payment challenge
2. Parse payment requirements, check sBTC balance
3. Build sponsored sBTC transfer (relay pays gas)
4. Encode PaymentPayloadV2 with payment-identifier extension
5. POST with auth + payment headers → signal filed

Follows the inbox.tools.ts pattern with nonce tracking, retry logic,
and relay-side conflict deduplication.

DO NOT MERGE — needs end-to-end testing against live endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test script for verifying the news_file_signal x402 payment flow
against localhost:8787 (or any URL via SIGNALS_URL env var).

Modes:
  --dry-run (default): probes for 402, shows payment requirements
  --pay: executes full flow with sBTC payment

Usage:
  TEST_WALLET_PASSWORD=<pw> npx tsx tests/scripts/test-news-file-signal.ts [--dry-run|--pay]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documents all 7 news tools (read-only and authenticated), the x402
payment flow for news_file_signal, signal fields reference table,
agent behavior guidelines, and example user requests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

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

Adds x402 payment flow to news_file_signal — mirrors the same POST → 402 → sponsored tx → retry structure used for inbox messages, which is the right pattern.

What works well:

  • Clean separation between the probe step (step 1) and the payment step (steps 3–5)
  • Graceful fallback if the endpoint doesn't require payment (200/201 on initial POST)
  • Balance pre-check with InsufficientBalanceError prevents wasted tx builds
  • cachedTxHex / relay-side conflict handling correctly reuses the same tx on NONCE_CONFLICT, builds fresh for sender-side nonce mismatches

[blocking] classifyRetryableError diverges from the reference implementation

The news.tools.ts version is missing a guard that exists in inbox.tools.ts:

// inbox.tools.ts (lines ~217-222) — missing from news.tools.ts
if (status === 409) {
  const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
  if (/already exists|duplicate/i.test(bodyStr)) {
    return NOT_RETRYABLE;
  }
}

Without this, if the news API returns a 409 with { retryable: true } and "already exists" or "duplicate" anywhere in the body, news.tools.ts would retry — potentially re-paying for a signal that was already filed. The inbox version exits early for this case. Since the PR explicitly claims to follow the inbox.tools.ts pattern, this guard should be preserved.

[suggestion] Five helpers copied verbatim from inbox.tools.ts

getNextNonce, advanceNonceCache, sleep, classifyRetryableError, and buildSponsoredSbtcTransfer all exist in inbox.tools.ts already (I checked). Even buildSponsoredSbtcTransfer is identical except inbox.ts supports an optional memo arg (which news.ts doesn't need — noneCV() is fine).

Copy-paste creates divergence risk. The classifyRetryableError divergence above is exactly this problem landing on day one. Consider extracting these to src/utils/x402-payment.ts or src/services/sponsored-tx.ts — then both tools import from the same source and stay in sync automatically.

I realize this is a larger refactor, so if the preference is to ship this first and extract in a follow-up, that's reasonable — but the classifyRetryableError guard must be synced before merge.

[suggestion] Test script builds sponsored tx without post-conditions

The production buildSponsoredSbtcTransfer uses createFungiblePostCondition (enforces the exact transfer amount on-chain). The test script's makeContractCall omits it:

// tests/scripts/test-news-file-signal.ts ~line 200
const transaction = await makeContractCall({
  // ... no postConditions
  sponsored: true,
  fee: 0n,
});

If the amount or recipient is wrong, the on-chain enforcement is missing in the test. The test could pass even for a malformed transfer. Worth adding the post-condition to the test so it validates the same invariant the production code enforces.

[nit] Dead code after retry loop

throw new Error(
  `Signal filing failed after ${MAX_ATTEMPTS} attempts. Last error: ${lastError}`
);

This line is unreachable. On the last attempt (attempt === MAX_ATTEMPTS - 1), if retryable, the attempt < MAX_ATTEMPTS - 1 condition is false, so it falls through to the inner throw new Error(\Failed to file signal...`)`. The outer throw never fires. Safe to remove.

[nit] console.error for retry progress

console.error(\[news_file_signal] Retry attempt ${attempt}...`)— retrying is operational info, not an error.console.warnorconsole.log` would be more accurate.


Code quality notes:

The nonce duplication is the main structural concern. With nonce-tracker.ts already providing getTrackedNonce / reconcileWithChain / recordNonceUsed, and with the cross-process nonce-manager from skills-v0.36.0 installed to prevent nonce gaps, the right home for getNextNonce / advanceNonceCache is a shared utility rather than inline in two tool files.


Operational context:

We run ~6 signals/day via the ordinals beat. We've hit p-wallet-nonce-gap from concurrent dispatch cycles — skills-v0.36.0's nonce-manager was installed specifically to fix this. The retry + backoff pattern here is validated: we've seen relay CB auto-recover in under 30 minutes on transient conflicts, and the reuse-vs-rebuild logic for relay-side vs sender-side nonce conflicts is correct.

The testing blockers in the PR description (staging DO SQLite migration mismatch, BIP-322 auth hang) look like infrastructure issues independent of this code. Worth tracking those separately so they don't block this review cycle.

Copy link
Copy Markdown
Contributor

@tfireubs-ui tfireubs-ui left a comment

Choose a reason for hiding this comment

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

Solid implementation. Follows the send_inbox_message nonce-tracking + retry pattern correctly: 0x-prefixed txHex, sponsored tx, relay-side conflict detection, cached tx reuse across retries, fresh auth headers for the final POST (important — auth header timestamp must be fresh at submission time). LGTM.

@biwasxyz
Copy link
Copy Markdown
Collaborator Author

biwasxyz commented Apr 4, 2026

Code review

Found 3 blocking issues and 3 medium issues:

Blocking:

  1. classifyRetryableError missing duplicate-signal guard — re-payment risk — the inbox reference implementation has a 409 "already exists/duplicate" guard at the top of classifyRetryableError that short-circuits before retryable: true can promote a duplicate signal to retryable. This guard is entirely missing here. If the relay returns a 409 for an already-filed signal, the tool retries and re-pays. (Already flagged by @arc0btc's review, not yet addressed.)

  2. No recovery path when payment tx is broadcast but settlement fails — after MAX_ATTEMPTS, the error is a generic string with no txid. The inbox reference tracks seenRelayTxids and polls for on-chain confirmation as a safety net. Without this, if the relay accepts the payment broadcast but returns a 500 on settlement, the sBTC is gone and the user has no txid to investigate.

  3. seenRelayTxids tracking entirely absent — no stale dedup detection and no post-exhaustion recovery attempt using a confirmed txid. This is the mechanism that prevents payment loss in the inbox tool.

Medium:

  1. Dead/unreachable throw after retry loop — the post-loop throw is unreachable (every loop iteration either returns or throws). Should be deleted per CLAUDE.md "Delete Over Stub" principle.

  2. Test script omits post-conditions on sponsored tx — production buildSponsoredSbtcTransfer includes createFungiblePostCondition to enforce exact amount on-chain, but the test skips this. A malformed amount would pass the test but fail (or worse, succeed incorrectly) on-chain.

sponsored: true,
fee: 0n,
});
const txHex = "0x" + transaction.serialize();
console.log(" txHex length:", txHex.length, "prefix:", txHex.substring(0, 12));
// 7. Encode PaymentPayloadV2
console.log("\n[7] Encoding PaymentPayloadV2...");
const resourceUrl = paymentRequired.resource?.url || SIGNALS_URL;
const paymentSignature = encodePaymentPayload({
x402Version: 2,
resource: {
url: resourceUrl,
description: paymentRequired.resource?.description || "",
mimeType: paymentRequired.resource?.mimeType || "application/json",
},
accepted: {
scheme: accept.scheme || "exact",
network: accept.network,
asset: accept.asset,
amount: accept.amount,

  1. console.error for retry progress logging — retry attempt counter is operational info, not an error. Should be console.warn or console.log.

Structural note: getNextNonce, advanceNonceCache, sleep, classifyRetryableError, and buildSponsoredSbtcTransfer are now duplicated across inbox.tools.ts and news.tools.ts. The missing duplicate guard (issue #1) is a direct consequence of this divergence. Consider extracting to a shared module.

@whoabuddy whoabuddy marked this pull request as ready for review April 30, 2026 00:19
Copilot AI review requested due to automatic review settings April 30, 2026 00:19
tfireubs-ui and others added 2 commits April 29, 2026 17:20
…el and dead code

- Add "already exists|duplicate" guard in classifyRetryableError so a 409
  response indicating a duplicate signal short-circuits before the
  NONCE_CONFLICT branch, preventing costly re-payment retries (mirrors the
  same guard already present in inbox.tools.ts)
- Change console.error() to console.warn() for retry progress/info logs in
  news_file_signal — these are informational, not errors
- Annotate the unreachable post-loop throw as dead code (the loop always
  exits via return on success or throw on the final failed attempt)
- Add createFungiblePostCondition to test-news-file-signal.ts to enforce
  the exact sBTC transfer amount as a post-condition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Change console.warn → console.error for retryable payment error logging
  (HTTP status + body) to ensure failed payments surface at error severity
- Remove unreachable post-loop throw and its dead-code comment; the for-loop
  always exits via return (success) or throw (non-retryable failure)
@whoabuddy whoabuddy dismissed arc0btc’s stale review April 30, 2026 00:21

Dismissing — original CHANGES_REQUESTED items have been folded into this PR via cherry-picks of #432's commits (4d25e80 + ed60ec6, with tfireubs-ui authorship preserved). #432 closed in favor of this consolidated PR. Per Wave 2 sprint Theme 1 / file-signal stack coordination (agent-news #325 → mcp-server #426 → skills #264).

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40fd948043

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tools/news.tools.ts

const responseData = await finalRes.text();
let parsed: Record<string, unknown>;
try { parsed = JSON.parse(responseData); } catch { parsed = { raw: responseData }; }
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 Badge Feed raw response text into nonce retry classifier

The retry logic is intended to handle plain-text nonce failures (ConflictingNonceInMempool/BadNonce), but this path never triggers here because non-JSON responses are wrapped as { raw: ... } and then passed to classifyRetryableError as an object. In that scenario (e.g., relay/node returns a text nonce error), news_file_signal treats a retryable nonce conflict as terminal and aborts instead of rebuilding with a fresh nonce.

Useful? React with 👍 / 👎.

if (wallets.length === 0) throw new Error("No wallets found");
console.log(" available wallets:", wallets.map(w => `${w.name} (${w.id})`).join(", "));
const target = WALLET_NAME
? wallets.find(w => w.name === WALLET_NAME) || wallets[0]
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 Badge Fail when TEST_WALLET_NAME does not match a wallet

In --pay mode this script silently falls back to wallets[0] when TEST_WALLET_NAME is provided but misspelled/not found, which can charge the wrong wallet with real sBTC. Since this script is explicitly used to execute live payments, a missing named wallet should be treated as an error instead of auto-selecting another account.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds x402 (sBTC sponsored-tx) payment handling to the news_file_signal MCP tool so filing a news signal can complete the full 402 challenge → payment proof → retry flow, aligned with the existing inbox payment pattern.

Changes:

  • Implement x402 payment flow in news_file_signal, including sponsored sBTC transfer building, shared nonce tracking, and retry/dedup behavior.
  • Add a local CLI test script to probe (--dry-run) and execute (--pay) the end-to-end flow.
  • Update CLAUDE.md documentation to reflect the news tools and the new payment behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
tests/scripts/test-news-file-signal.ts Adds a local script to probe and (optionally) execute the x402 + BIP-322 filing flow against a signals endpoint.
src/tools/news.tools.ts Updates news_file_signal to handle 402 responses by constructing a sponsored sBTC tx, encoding x402 payloads, and retrying with payment proof + nonce tracking.
CLAUDE.md Documents AIBTC News tools and notes that news_file_signal handles x402 payment automatically.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tools/news.tools.ts
Comment on lines +812 to +816
const retry = classifyRetryableError(finalRes.status, parsed);

if (retry.retryable && attempt < MAX_ATTEMPTS - 1) {
console.error(
`[news_file_signal] Retryable error on attempt ${attempt + 1}: status=${finalRes.status} relaySide=${retry.relaySideConflict} body=${responseData}`
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

In the retry branch you call advanceNonceCache(account.address, nonce) even though the payment/tx may not have been successfully broadcast/accepted. recordNonceUsed (used by advanceNonceCache) is documented as being called after successful broadcast and only ever advances (never regresses), so recording a nonce here can permanently push the shared nonce tracker ahead of chain state and cause subsequent txs to fail with BadNonce until the user manually resets the nonce-state file. Consider only recording the nonce after a confirmed successful settlement (or when you have a txid you can verify is in mempool/confirmed), and avoid advancing the tracker on generic retryable relay errors.

Copilot uses AI. Check for mistakes.
#432's "remove dead code" change deleted a post-loop throw that was
unreachable at runtime but required for TypeScript to narrow the
return type. Without it, the for-loop's normal exit path makes the
function signature `Promise<{...} | undefined>`, which fails to match
the MCP tool registration parameter type.

Restored the throw with a comment explaining why it must stay even
though it's "dead." Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

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

Cycle 2 re-review after the three follow-up commits (4d25e80, ed60ec6, b31c516).

Cycle 1 feedback — addressed:

  • [blocking] Duplicate-signal guard restoredclassifyRetryableError now exits early on 409 + "already exists/duplicate" body, matching the inbox.tools.ts reference. Payment re-entry risk eliminated.
  • [nit] Retry progress loggingconsole.warn for attempt progress, console.error reserved for actual retryable error conditions. Correct distinction.
  • [nit] Outer throw restored for TS narrowing — commit b31c516 explains why: TypeScript can't statically prove the loop always returns/throws on the last iteration, so the outer throw provides the narrowing hint. Valid. The lastError variable is now consumed there too.
  • Test script post-conditions — confirmed present in the current diff (createFungiblePostConditionpostConditions: [postCondition]).

Remaining items (non-blocking, carry-forward from cycle 1):

[suggestion] Nonce helpers still duplicated from inbox.tools.ts

getNextNonce, advanceNonceCache, buildSponsoredSbtcTransfer, and classifyRetryableError remain copied rather than shared. The duplicate-guard divergence from cycle 1 is now fixed, but the duplication surface is unchanged — next divergence is a matter of when, not if. A shared src/utils/x402-payment.ts would close this permanently. Fine to track as a follow-up given the PR's "DO NOT MERGE — Needs testing" status.

[question] Error path doesn't surface txid if relay broadcast succeeds but settlement fails

On the retry exhaustion path (outer throw at line 495), lastError contains the HTTP status + body string, but there's no way to know whether the relay had already broadcast the tx. If the relay settles the sBTC transfer and then the news API returns a 5xx, the error message has no txid for investigation. The payment-response header could carry a txid even on non-2xx responses — worth checking it before throwing:

// Before throwing on exhaustion:
const settlementHeader = finalRes.headers.get(X402_HEADERS.PAYMENT_RESPONSE);
const settlement = settlementHeader ? decodePaymentResponse(settlementHeader) : null;
if (settlement?.transaction) {
  throw new Error(
    `Signal filing failed after ${MAX_ATTEMPTS} attempts (relay txid: ${settlement.transaction}). Last error: ${lastError}`
  );
}

This isn't a showstopper — the payment-identifier extension + cached tx already handles the retry deduplication case. But when support is needed, having the txid in the error message saves a lot of digging.


Operational context:

We file ~6 signals/day and have seen relay nonce conflicts that resolved within 30 minutes on the next retry cycle. The retry + backoff + relay-vs-sender nonce distinction in this implementation is correct and matches what works for us. The testing blockers (staging DO SQLite migration mismatch, BIP-322 auth hang) look like infra issues separate from this code — worth resolving those in parallel rather than blocking the review cycle on them.

Code is solid for merge once the testing blockers are cleared.

@whoabuddy whoabuddy merged commit 8acaf69 into main Apr 30, 2026
5 checks passed
@whoabuddy whoabuddy deleted the feat/news-signal-x402-payment branch April 30, 2026 00:35
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.

5 participants