From 54d36ace7ec01a2ea1250eb67874c98b3230529d Mon Sep 17 00:00:00 2001 From: tfibtcagent Date: Sun, 29 Mar 2026 20:22:57 +0000 Subject: [PATCH 1/2] fix(news): add duplicate-guard to classifyRetryableError, fix log level and dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/tools/news.tools.ts | 15 +++++++++++++-- tests/scripts/test-news-file-signal.ts | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/tools/news.tools.ts b/src/tools/news.tools.ts index 6b0dba3..fb3c8a7 100644 --- a/src/tools/news.tools.ts +++ b/src/tools/news.tools.ts @@ -157,6 +157,15 @@ interface RetryInfo { function classifyRetryableError(status: number, body: unknown): RetryInfo { const NOT_RETRYABLE: RetryInfo = { retryable: false, delayMs: 0, relaySideConflict: false }; + // Duplicate-signal 409 from the news API must NOT be retried — + // the signal was already delivered and retrying would re-pay. + if (status === 409) { + const bodyStr = typeof body === "string" ? body : JSON.stringify(body); + if (/already exists|duplicate/i.test(bodyStr)) { + return NOT_RETRYABLE; + } + } + if (typeof body === "object" && body !== null) { const b = body as Record; const rawRetryAfter = typeof b["retryAfter"] === "number" ? b["retryAfter"] : 0; @@ -719,7 +728,7 @@ Fields: for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { if (attempt > 0 && nextRetryDelayMs > 0) { - console.error( + console.warn( `[news_file_signal] Retry attempt ${attempt}/${MAX_ATTEMPTS - 1} after ${nextRetryDelayMs}ms` ); await sleep(nextRetryDelayMs); @@ -803,7 +812,7 @@ Fields: const retry = classifyRetryableError(finalRes.status, parsed); if (retry.retryable && attempt < MAX_ATTEMPTS - 1) { - console.error( + console.warn( `[news_file_signal] Retryable error on attempt ${attempt + 1}: status=${finalRes.status} relaySide=${retry.relaySideConflict} body=${responseData}` ); nextRetryDelayMs = retry.delayMs; @@ -824,6 +833,8 @@ Fields: ); } + // Dead code: the loop always exits via return (success) or throw (failure above). + // This path is only reached if MAX_ATTEMPTS is 0, which is not a valid config. throw new Error( `Signal filing failed after ${MAX_ATTEMPTS} attempts. Last error: ${lastError}` ); diff --git a/tests/scripts/test-news-file-signal.ts b/tests/scripts/test-news-file-signal.ts index 9b61dce..66b871a 100644 --- a/tests/scripts/test-news-file-signal.ts +++ b/tests/scripts/test-news-file-signal.ts @@ -29,6 +29,7 @@ import { getWalletManager } from "../../src/services/wallet-manager.js"; import { getAccount, NETWORK, checkSufficientBalance } from "../../src/services/x402.service.js"; import { getStacksNetwork } from "../../src/config/networks.js"; import { getContracts, parseContractId } from "../../src/config/contracts.js"; +import { createFungiblePostCondition } from "../../src/transactions/post-conditions.js"; import { bip322Sign } from "../../src/utils/bip322.js"; const SIGNALS_URL = process.env.SIGNALS_URL || "http://localhost:8787/api/signals"; @@ -180,6 +181,13 @@ async function main() { console.log("\n[6] Building sponsored tx..."); const contracts = getContracts(NETWORK); const { address: contractAddress, name: contractName } = parseContractId(contracts.SBTC_TOKEN); + const postCondition = createFungiblePostCondition( + account.address, + contracts.SBTC_TOKEN, + "sbtc-token", + "eq", + amount + ); const transaction = await makeContractCall({ contractAddress, contractName, @@ -192,6 +200,7 @@ async function main() { ], senderKey: account.privateKey, network: getStacksNetwork(NETWORK), + postConditions: [postCondition], sponsored: true, fee: 0n, }); From 45fb59ca21d761bca57694726354d0cb0b34b11d Mon Sep 17 00:00:00 2001 From: tfibtcagent Date: Sat, 4 Apr 2026 19:43:32 +0000 Subject: [PATCH 2/2] fix: restore console.error for payment failures, remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/tools/news.tools.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/tools/news.tools.ts b/src/tools/news.tools.ts index fb3c8a7..c9c1a2d 100644 --- a/src/tools/news.tools.ts +++ b/src/tools/news.tools.ts @@ -812,7 +812,7 @@ Fields: const retry = classifyRetryableError(finalRes.status, parsed); if (retry.retryable && attempt < MAX_ATTEMPTS - 1) { - console.warn( + console.error( `[news_file_signal] Retryable error on attempt ${attempt + 1}: status=${finalRes.status} relaySide=${retry.relaySideConflict} body=${responseData}` ); nextRetryDelayMs = retry.delayMs; @@ -833,11 +833,7 @@ Fields: ); } - // Dead code: the loop always exits via return (success) or throw (failure above). - // This path is only reached if MAX_ATTEMPTS is 0, which is not a valid config. - throw new Error( - `Signal filing failed after ${MAX_ATTEMPTS} attempts. Last error: ${lastError}` - ); + } catch (error) { return createErrorResponse(error); }