From 08ac73cfe53538ae4d79db5296e7d90c7f71e818 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:56:52 +0800 Subject: [PATCH 1/4] fix(polymarket): a withdrawal that may have moved money is never signed twice A retry that resolved the earlier withdrawal now ends the call instead of signing another. The EOA rail signs locally and persists hash, nonce and bytes before the broadcast; only a receipt resolves it, and a retry re-broadcasts the same bytes. No time expiry and no nonce inference for a plain transaction. The relayer withdrawal is recorded before the submit. HTTP 408 is an unknown outcome, not a definite rejection. --- CHANGELOG.md | 44 ++++ VERSION | 2 +- package-lock.json | 4 +- package.json | 2 +- src/utils/polymarket/creds.ts | 13 +- src/utils/polymarket/relayer.ts | 7 + src/utils/polymarket/transactions.ts | 13 +- src/utils/polymarket/withdraw.ts | 232 +++++++++++++---- test/polymarket-definite-rejection.test.ts | 28 ++ test/polymarket-relayer-batch.test.ts | 21 +- test/polymarket-withdraw-eoa.test.ts | 284 +++++++++++++++++++++ test/polymarket-withdraw.test.ts | 38 ++- 12 files changed, 614 insertions(+), 74 deletions(-) create mode 100644 test/polymarket-definite-rejection.test.ts create mode 100644 test/polymarket-withdraw-eoa.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 13cd790..8808da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## 0.54.2 + +### Fixed — a withdrawal that may have moved money is never signed twice + +**A retry that resolved the earlier withdrawal went on to sign another.** The +`pendingWithdraw` guard cleared itself when the earlier transfer turned out +settled (relayer `STATE_MINED`/`STATE_CONFIRMED`, or an EOA receipt) and the +same `confirm:true` call then signed a fresh transfer on top of it. Resolving +the earlier withdrawal now ENDS the call: the outcome is reported, nothing new +is signed, and another withdrawal takes a separate call made after the +balances have been checked. The same holds for a failed batch and for an +expired relayer deadline, which is now reported as "may or may not have +executed" rather than treated as safe. + +**The EOA (sigType 0) rail expired a plain transaction after five minutes.** +A Polygon transaction has no deadline; one that sits in the mempool can be +mined at any later time. The guard used to block for 300s and then clear, +after which a retry signed a second transfer with the next nonce, so both +could land. The transfer is now signed locally first, and its hash, nonce and +signed bytes are written to the state file before the broadcast. Only a +receipt resolves it. Until a receipt exists, a retry re-broadcasts those same +bytes, so the transfer can execute at most once, and no new transaction is +signed. An advanced account nonce plus an RPC that does not know the hash is +not read as "dropped". The guard is released before a receipt only when the +node rejects the bytes outright (insufficient funds, underpriced, intrinsic +gas, invalid sender) and the RPC does not know the hash. "nonce too low" and +"already known" keep the guard. + +**The relayer withdrawal is recorded before the submit, not only in its +error handler.** A process killed mid-POST runs no catch block, and the batch +it posted stays executable until its deadline. A definite 4xx still releases +the guard. + +**HTTP 408 no longer counts as a definite rejection.** `isDefiniteRejection` +treated every 4xx as proof that nothing was accepted. A 408 means a proxy gave +up waiting, and the order or batch behind it may have landed. It is now an +unknown outcome on the CLOB submit, the relayer batch and fund alike, so the +session reservation is kept. + +`test/polymarket-withdraw-eoa.test.ts` and +`test/polymarket-definite-rejection.test.ts` are new. The withdraw and relayer +tests pin the new lifecycle. Each fix was checked by reverting it and +watching its tests fail. + ## 0.54.1 ### Fixed — `blockrun_search` is one flat price, and the tool said otherwise diff --git a/VERSION b/VERSION index 1942d77..71e0bca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.54.1 +0.54.2 diff --git a/package-lock.json b/package-lock.json index e996a06..56b04af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/mcp", - "version": "0.54.1", + "version": "0.54.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.54.1", + "version": "0.54.2", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.123.0", diff --git a/package.json b/package.json index 8dc795a..d7338a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.54.1", + "version": "0.54.2", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Pay per call from a USDC wallet (Solana or Base) or a BlockRun API key.", "type": "module", diff --git a/src/utils/polymarket/creds.ts b/src/utils/polymarket/creds.ts index 9028163..932129f 100644 --- a/src/utils/polymarket/creds.ts +++ b/src/utils/polymarket/creds.ts @@ -53,8 +53,19 @@ export interface PolymarketState { * signature stays executable until `deadline` (unix seconds), so a fresh * withdrawal signed before then can DOUBLE-SEND — withdraw refuses to sign * while this is set and unresolved. Cleared on confirm/failure. + * + * An EOA (sigType 0) withdrawal is recorded as `eoa:` with the signed + * bytes, BEFORE the broadcast. A plain transaction has no deadline — it can + * be mined at any later time — so `deadline` does not apply to it: only a + * receipt resolves it, and until then a retry re-broadcasts the same bytes + * (same nonce, so at most one transfer can ever execute). */ - pendingWithdraw?: { transactionID: string; deadline: number }; + pendingWithdraw?: { + transactionID: string; + deadline: number; + nonce?: number; + serializedTransaction?: string; + }; /** * A funding call whose gateway response was lost, 5xx, or success:false * after the signed EIP-3009 authorization had already been POSTed. The diff --git a/src/utils/polymarket/relayer.ts b/src/utils/polymarket/relayer.ts index e7f446b..abe9c48 100644 --- a/src/utils/polymarket/relayer.ts +++ b/src/utils/polymarket/relayer.ts @@ -229,6 +229,12 @@ export async function sendWalletBatch( // failure that provably moved nothing, wedging the user behind a deadline // for a transfer that was never signed. const relay = await getRelayClient(); + // Armed BEFORE the call, not only in the catch below: a process killed + // mid-POST runs no catch, and the batch it posted stays executable until + // deadlineSec. A failed write here aborts before anything is signed. + if (opts?.trackPendingWithdraw) { + saveState({ pendingWithdraw: { transactionID: "unknown", deadline: deadlineSec } }); + } try { response = await quietStdout(() => relay.executeDepositWalletBatch(calls, depositWallet, String(deadlineSec))); } catch (err) { @@ -258,6 +264,7 @@ export async function sendWalletBatch( `${opts?.guidance ?? 're-run action:"setup" to re-check state'}.`, ); } + if (opts?.trackPendingWithdraw) saveState({ pendingWithdraw: undefined }); // definite 4xx: nothing accepted throw err; } if (opts?.trackPendingWithdraw) { diff --git a/src/utils/polymarket/transactions.ts b/src/utils/polymarket/transactions.ts index dd247eb..d2b7c6d 100644 --- a/src/utils/polymarket/transactions.ts +++ b/src/utils/polymarket/transactions.ts @@ -40,15 +40,18 @@ export function assertTransactionSucceeded( * text (the relayer SDK stringifies `{"error":"request error","status":4xx}`). * Anything else — no status at all (socket hang up, ECONNRESET, a client-side * timeout) or a 5xx (a relay 502/504 after the upstream POST landed) — is - * outcome-unknown: the signed order/batch may already be live. One answer for - * the CLOB submit (orders.ts) and the relayer batch (relayer.ts), so the two - * money paths cannot drift apart on the question again. + * outcome-unknown: the signed order/batch may already be live. So is a 408. + * One answer for the CLOB submit (orders.ts) and the relayer batch + * (relayer.ts), so the two money paths cannot drift apart on the question + * again. */ export function isDefiniteRejection(err: unknown): boolean { const status = (err as { status?: unknown } | undefined)?.status; - if (typeof status === "number") return status >= 400 && status < 500; + // 408 Request Timeout is the one 4xx that proves nothing: a proxy gave up + // waiting, and the request behind it may have landed. + if (typeof status === "number") return status >= 400 && status < 500 && status !== 408; const message = err instanceof Error ? err.message : String(err); - return /"status":4\d\d/.test(message) || /\b(?:HTTP|status(?:\s*code)?)\s*[:=]?\s*4\d\d\b/i.test(message); + return /"status":4(?!08)\d\d/.test(message) || /\b(?:HTTP|status(?:\s*code)?)\s*[:=]?\s*4(?!08)\d\d\b/i.test(message); } /** The shape every Polymarket action returns to the tool handler. */ diff --git a/src/utils/polymarket/withdraw.ts b/src/utils/polymarket/withdraw.ts index 16bdef1..762706f 100644 --- a/src/utils/polymarket/withdraw.ts +++ b/src/utils/polymarket/withdraw.ts @@ -19,7 +19,7 @@ // wrapped to pUSD through the collateral onramp first (sweep design from // @KillerQueen-Z's #59/#66, tracked in #71). import axios from "axios"; -import { encodeFunctionData, formatUnits, http, createWalletClient, isAddress, type Hex } from "viem"; +import { encodeFunctionData, formatUnits, http, createWalletClient, isAddress, keccak256, type Hex } from "viem"; import { polygon } from "viem/chains"; import { BASE_CHAIN_ID, @@ -116,6 +116,141 @@ async function readPusdUntil(owner: Hex, minimum: bigint): Promise { return observed; } +// RPC rejections that prove a node refused (and so never relayed) a raw +// transaction. "already known" is deliberately absent: it means the node HAS +// the transaction. So is "nonce too low": nonce movement is not evidence about +// which transaction used the nonce — ours may be the one that did. +const DEFINITE_BROADCAST_REJECTION = + /insufficient funds|intrinsic gas too low|max fee per gas less than block base fee|transaction underpriced|invalid sender/i; + +/** True when the node refused the raw transaction outright. Exported for tests. */ +export function isDefiniteBroadcastRejection(err: unknown): boolean { + for (let e: unknown = err, depth = 0; e && depth < 5; e = (e as { cause?: unknown }).cause, depth++) { + const msg = e instanceof Error ? `${e.message} ${(e as { details?: string }).details ?? ""}` : String(e); + if (DEFINITE_BROADCAST_REJECTION.test(msg)) return true; + } + return false; +} + +/** True unless the RPC positively reports the hash as unknown. Read errors count as known (fail closed). */ +async function transactionKnown(hash: Hex): Promise { + try { + await getPublicClient().getTransaction({ hash }); + return true; + } catch (err) { + return !(err instanceof Error && err.name === "TransactionNotFoundError"); + } +} + +/** The receipt's status, or null when there is none yet (or it could not be read). */ +async function receiptStatus(hash: Hex): Promise<"success" | "reverted" | null> { + const receipt = await getPublicClient().getTransactionReceipt({ hash }).catch(() => null); + if (!receipt) return null; + return receipt.status === "success" ? "success" : "reverted"; +} + +type PendingWithdraw = NonNullable["pendingWithdraw"]>; + +/** The relayer can mine right at the deadline; don't race it. */ +const PENDING_GRACE_SECS = 60; + +/** + * What became of an earlier withdrawal: either a `resolution` to report (the + * caller clears the guard and ends the call), or a `blocked` message while it + * may still land. + * + * Only a receipt resolves an EOA withdrawal. An advanced account nonce plus an + * RPC that does not know the hash proves nothing — the RPC may lag, or ours + * may be the transaction that used the nonce — so neither is read as + * "dropped", and no deadline applies. While unresolved, the same signed bytes + * are re-broadcast; a fresh signature is never made. + */ +async function resolvePendingWithdraw(pending: PendingWithdraw): Promise<{ resolution?: string; blocked?: string }> { + const now = Math.floor(Date.now() / 1000); + const windowOpen = now < pending.deadline + PENDING_GRACE_SECS; + const id = pending.transactionID; + + if (id.startsWith("eoa:")) { + const hash = id.slice(4) as Hex; + const status = await receiptStatus(hash); + if (status === "success") return { resolution: `The previous withdrawal SETTLED on-chain (tx ${hash}).` }; + if (status === "reverted") return { resolution: `The previous withdrawal REVERTED on-chain (tx ${hash}); no pUSD moved.` }; + if (pending.serializedTransaction) { + const account = getPolymarketAccount(); + const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_WRITE_RPC_URL) }); + await wallet.sendRawTransaction({ serializedTransaction: pending.serializedTransaction as Hex }).catch(() => undefined); + return { + blocked: `A previous withdrawal (tx ${hash}${pending.nonce !== undefined ? `, nonce ${pending.nonce}` : ""}) has no ` + + `receipt yet and may still land — it was re-broadcast as the SAME signed transaction, so it can execute at ` + + `most once. Signing another one now could double-send. Do not retry; check again in a few minutes. ` + + `(If Polygonscan shows that nonce was used by a different transaction, this one can never execute and the ` + + `user can remove "pendingWithdraw" from ~/.blockrun/.polymarket.json.)`, + }; + } + // Recorded by an earlier version, without the signed bytes: nothing to + // re-broadcast. Block while the node still knows the transaction or the + // old window is open; after that, report it as unknown rather than safe. + if (windowOpen || (await transactionKnown(hash))) { + return { + blocked: `A previous withdrawal (tx ${hash}) has no receipt yet and may still land. Signing another one now ` + + `could double-send. Do not retry; check again in a few minutes.`, + }; + } + return { + resolution: `The previous withdrawal (tx ${hash}) never produced a receipt and the RPC no longer knows it. ` + + `It most likely did not execute, but that is not proven.`, + }; + } + + if (id === "eoa" || id === "unknown") { + // No hash and no relayer id: the send never answered (bare "eoa", from an + // earlier version) or the relayer's response was lost ("unknown", see + // relayer.ts sendWalletBatch). There is nothing to look up. + if (windowOpen) { + const waitSecs = pending.deadline + PENDING_GRACE_SECS - now; + return { + blocked: `A previous withdrawal (${id === "unknown" ? "relayer tx unknown — the submit response was lost" : "EOA transfer, send never answered"}) ` + + `may still execute for up to ~${waitSecs}s more. Signing another one now could double-send. Re-run after ` + + `that window, when the balance reads will show what happened.`, + }; + } + return { + resolution: `The previous withdrawal's outcome was never confirmed and its window has passed. ` + + `It may or may not have executed.`, + }; + } + + const state = await getRelayerTransactionState(id); + if (state === "STATE_MINED" || state === "STATE_CONFIRMED") { + return { resolution: `The previous withdrawal SETTLED (relayer tx ${id}, ${state}).` }; + } + if (state === "STATE_FAILED" || state === "STATE_INVALID") { + return { resolution: `The previous withdrawal FAILED (relayer tx ${id}, ${state}); no pUSD moved.` }; + } + if (windowOpen) { + const waitSecs = pending.deadline + PENDING_GRACE_SECS - now; + return { + blocked: `A previous withdrawal (relayer tx ${id}, state: ${state ?? "unreachable"}) may still execute — its ` + + `signed transfer stays valid for up to ~${waitSecs}s more. Signing another one now could double-send. ` + + `Re-run after that window, when the balance reads will show what happened.`, + }; + } + // Deadline long past: the batch can no longer execute, but it may already + // have, so this is not proof that nothing moved. + return { + resolution: `The previous withdrawal's signature expired (relayer tx ${id}, state: ${state ?? "unreachable"}). ` + + `It may or may not have executed before its deadline.`, + }; +} + +function eoaOutcomeUnknown(txHash: string, msg: string): Error { + return new Error( + `Withdraw: the pUSD transfer (tx ${txHash}) did not confirm (${msg}). It may still land — a broadcast ` + + `transaction is not un-sent by a client timeout. Do NOT start a new withdrawal: calling withdraw again ` + + `re-broadcasts this same signed transaction and reports its outcome; it never signs a second one.`, + ); +} + const WITHDRAW_GUIDANCE = 'check the pUSD balance with action:"setup" and the bridge status endpoint before ANY retry — ' + "a resubmitted withdrawal signs a SECOND transfer and can double-send"; @@ -155,47 +290,25 @@ export async function withdrawFunds(input: WithdrawInput): Promise { const isCustom = recipient.toLowerCase() !== agent.toLowerCase(); try { - // Refuse to sign while an earlier withdrawal batch may still land: its - // signature stays executable until its deadline, and a second signed - // transfer on top of it double-sends (issue #72 finding 1). Resolved - // states clear the guard; the balance reads below then reflect reality. + // Refuse to sign while an earlier withdrawal may still land: a second + // signed transfer on top of it double-sends (issue #72 finding 1). + // + // Resolving the earlier withdrawal ENDS this call. The retry that finds + // the first transfer settled is the same call that would otherwise sign a + // second one on top of it — the double-send this guard exists to stop. The + // outcome is reported and the guard cleared; another withdrawal needs a + // fresh, deliberate call made after the balances have been looked at. const pending = loadState().pendingWithdraw; if (pending && input.confirm === true) { - const graceSec = 60; // relayer can mine right at the deadline; don't race it - if (Math.floor(Date.now() / 1000) < pending.deadline + graceSec) { - // "unknown" = the relayer never returned an id (submit response lost, - // see relayer.ts sendWalletBatch). There is nothing to look up, and the - // signed batch may still land — block until the deadline passes. - const idUnknown = pending.transactionID === "unknown"; - // An EOA (sigType 0) withdrawal is a plain Polygon transaction, not a - // relayer batch: "eoa:" is looked up by receipt, a bare "eoa" - // (the send itself never answered) blocks until the deadline. Round 4b: - // this rail had no guard at all, so a receipt timeout after the - // broadcast invited a second full transfer with a fresh nonce. - const eoaHash = pending.transactionID.startsWith("eoa:") ? pending.transactionID.slice(4) : undefined; - let settled = false; - if (eoaHash) { - try { - const receipt = await getPublicClient().getTransactionReceipt({ hash: eoaHash as Hex }); - settled = Boolean(receipt); - } catch { settled = false; } - } - const state = idUnknown || pending.transactionID.startsWith("eoa") ? undefined : await getRelayerTransactionState(pending.transactionID); - if (settled || state === "STATE_MINED" || state === "STATE_CONFIRMED" || state === "STATE_FAILED" || state === "STATE_INVALID") { - saveState({ pendingWithdraw: undefined }); - } else { - const waitSecs = pending.deadline + graceSec - Math.floor(Date.now() / 1000); - const stateLabel = idUnknown ? "unknown — the submit response was lost" : (state ?? "unreachable"); - return { - text: `A previous withdrawal (relayer tx ${pending.transactionID}, state: ${stateLabel}) ` + - `may still execute — its signed transfer stays valid for up to ~${waitSecs}s more. Signing another ` + - `one now could double-send. Re-run after that window, when the balance reads will show what happened.`, - isError: true, - }; - } - } else { - saveState({ pendingWithdraw: undefined }); // deadline long past — expired, safe - } + const resolved = await resolvePendingWithdraw(pending); + if (resolved.blocked) return { text: resolved.blocked, isError: true }; + saveState({ pendingWithdraw: undefined }); + return { + text: `${resolved.resolution} Nothing new was signed. Check the balances (action:"setup") and the bridge ` + + `status before deciding whether ANOTHER withdrawal is wanted; only then call withdraw again.`, + isError: true, + structured: { previousWithdrawal: resolved.resolution }, + }; } // Withdrawable = pUSD + legacy USDC.e (wrapped on demand below). @@ -302,14 +415,33 @@ export async function withdrawFunds(input: WithdrawInput): Promise { } else { const account = getPolymarketAccount(); const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_WRITE_RPC_URL) }); - // The same double-send guard the relayer path keeps: armed before the - // broadcast (a send that never answers may still have reached the - // node), the hash recorded once known, cleared only on a receipt. - const eoaDeadline = Math.floor(Date.now() / 1000) + 300; - saveState({ pendingWithdraw: { transactionID: "eoa", deadline: eoaDeadline } }); + // The same double-send guard the relayer path keeps, armed before the + // broadcast: a send that never answers may still have reached the node. + // The transaction is signed locally first so the record carries its + // hash and exact bytes — a retry re-broadcasts THESE bytes (same nonce, + // so at most one transfer can execute) instead of signing a second one. + // `deadline` is recorded for the shape only; a plain transaction has none. + const nonce = await getPublicClient().getTransactionCount({ address: account.address, blockTag: "pending" }); + const request = await wallet.prepareTransactionRequest({ to: PUSD_COLLATERAL as Hex, data, chain: polygon, account, nonce }); + const serializedTransaction = await wallet.signTransaction(request); + txHash = keccak256(serializedTransaction); + saveState({ + pendingWithdraw: { transactionID: `eoa:${txHash}`, deadline: Math.floor(Date.now() / 1000), nonce, serializedTransaction }, + }); + try { + await wallet.sendRawTransaction({ serializedTransaction }); + } catch (err) { + // Only a node that refused these bytes outright never relayed them, + // so only that case releases the guard — and only if the RPC does not + // know the hash anyway. A timeout or a 5xx keeps it. + const msg = err instanceof Error ? err.message : String(err); + if (isDefiniteBroadcastRejection(err) && !(await transactionKnown(txHash as Hex))) { + saveState({ pendingWithdraw: undefined }); + throw new Error(`Withdraw: the node rejected the pUSD transfer (${msg}). Nothing was sent.`); + } + throw eoaOutcomeUnknown(txHash, msg); + } try { - txHash = await wallet.sendTransaction({ to: PUSD_COLLATERAL as Hex, data, chain: polygon, account }); - saveState({ pendingWithdraw: { transactionID: `eoa:${txHash}`, deadline: eoaDeadline } }); // viem does NOT throw on a reverted tx — it resolves with status:"reverted". // Discarding the receipt meant a REVERTED pUSD transfer still printed // "✅ Withdrawal submitted … the bridge delivers USDC to Base" with a link @@ -321,11 +453,7 @@ export async function withdrawFunds(input: WithdrawInput): Promise { assertTransactionSucceeded(receipt, "pUSD transfer", txHash); } catch (err) { if (err instanceof Error && /reverted/i.test(err.message)) throw err; // a receipt was read: definite - const msg = err instanceof Error ? err.message : String(err); - throw new Error( - `Withdraw: the pUSD transfer ${txHash ? `(tx ${txHash}) ` : ""}did not confirm (${msg}). It may still land — ` + - `a broadcast transaction is not un-sent by a client timeout. Do NOT retry yet: wait for the guard window to pass, then ${WITHDRAW_GUIDANCE}.`, - ); + throw eoaOutcomeUnknown(txHash, err instanceof Error ? err.message : String(err)); } } diff --git a/test/polymarket-definite-rejection.test.ts b/test/polymarket-definite-rejection.test.ts new file mode 100644 index 0000000..e607506 --- /dev/null +++ b/test/polymarket-definite-rejection.test.ts @@ -0,0 +1,28 @@ +// Run with: npm test +// +// isDefiniteRejection decides whether a failed CLOB submit or relayer batch +// releases its budget/guard. Only a 4xx proves nothing was accepted — and a +// 408 is the one 4xx that does not: a proxy timed out, and the request behind +// it may have landed. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isDefiniteRejection } from "../src/utils/polymarket/transactions.js"; + +test("4xx on the status property is a definite rejection, except 408", () => { + for (const status of [400, 401, 403, 404, 422, 429]) { + assert.equal(isDefiniteRejection(Object.assign(new Error("x"), { status })), true, `HTTP ${status}`); + } + assert.equal(isDefiniteRejection(Object.assign(new Error("x"), { status: 408 })), false); + for (const status of [500, 502, 504]) { + assert.equal(isDefiniteRejection(Object.assign(new Error("x"), { status })), false, `HTTP ${status}`); + } +}); + +test("4xx in the message text is a definite rejection, except 408", () => { + assert.equal(isDefiniteRejection(new Error('{"error":"request error","status":400}')), true); + assert.equal(isDefiniteRejection(new Error("HTTP 403 Forbidden")), true); + assert.equal(isDefiniteRejection(new Error('{"error":"request error","status":408}')), false); + assert.equal(isDefiniteRejection(new Error("HTTP 408 Request Timeout")), false); + assert.equal(isDefiniteRejection(new Error("status code 408")), false); + assert.equal(isDefiniteRejection(new Error("socket hang up")), false); +}); diff --git a/test/polymarket-relayer-batch.test.ts b/test/polymarket-relayer-batch.test.ts index d5095a0..755e1e2 100644 --- a/test/polymarket-relayer-batch.test.ts +++ b/test/polymarket-relayer-batch.test.ts @@ -173,7 +173,26 @@ test("a definite 4xx rejection proves nothing was accepted — rethrown raw, gua }, ); assert.equal(stateFile.pendingWithdraw, undefined, "a rejected batch cannot land — must not block for 5 minutes"); - assert.equal(saveStateCalls.length, 0); + // Armed before the submit, released by the definite rejection. + assert.deepEqual(saveStateCalls.map((c) => c.pendingWithdraw && (c.pendingWithdraw as { transactionID: string }).transactionID), ["unknown", undefined]); +}); + +test("the withdraw guard is on disk BEFORE the batch is submitted", async () => { + reset(); + let seenAtSubmit: unknown; + submitThrowsError = undefined; + const original = FakeRelayClient.prototype.executeDepositWalletBatch; + FakeRelayClient.prototype.executeDepositWalletBatch = async function (this: FakeRelayClient) { + seenAtSubmit = stateFile.pendingWithdraw; + throw new Error("process killed mid-POST (test)"); + }; + try { + await assert.rejects(sendWalletBatch(CALLS, DEPOSIT, "Withdraw", { trackPendingWithdraw: true })); + } finally { + FakeRelayClient.prototype.executeDepositWalletBatch = original; + } + assert.equal((seenAtSubmit as { transactionID?: string } | undefined)?.transactionID, "unknown", + "a crash during the POST runs no catch — the record must already exist"); }); test("an untracked batch (approvals/wrap) that loses its submit response writes no state", async () => { diff --git a/test/polymarket-withdraw-eoa.test.ts b/test/polymarket-withdraw-eoa.test.ts new file mode 100644 index 0000000..1a006ae --- /dev/null +++ b/test/polymarket-withdraw-eoa.test.ts @@ -0,0 +1,284 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// The EOA (sigType 0) withdrawal is a plain Polygon transaction: no deadline, +// so only a receipt resolves it. Pins that the signed bytes are on disk before +// the broadcast, that a retry re-broadcasts THOSE bytes instead of signing a +// second transfer, that nonce movement is never read as "dropped", and that a +// retry which resolves the earlier withdrawal ends there. +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; + +const realViem = await import("viem"); +const realConstants = await import("../src/utils/polymarket/constants.js"); + +const DEPOSIT = "0x5d3eaa66AE01F1a907c8e0970D1D021C6Ff8EB26"; +const AGENT = "0xCC8c44AD3dc2A58D841c3EB26131E49b22665EF8"; +const BRIDGE = "0x2222222222222222222222222222222222222222"; +const SIGNED = "0x02f8b1018203e8" as const; +const SIGNED_HASH = realViem.keccak256(SIGNED); +const OTHER_SIGNED = "0x02f8b10182aaaa" as const; +const OTHER_HASH = realViem.keccak256(OTHER_SIGNED); + +let stateFile: Record = {}; +let saveStateThrows = false; +let receipt: { status: "success" | "reverted" } | null = null; +let waitReceipt: () => Promise<{ status: "success" | "reverted" }> = async () => ({ status: "success" }); +let txKnown: boolean | "error" = false; +let pendingNonce = 7; +let sendRawError: Error | undefined; +let bridgeCalls = 0; +const sendRawCalls: string[] = []; +let signCalls = 0; +let stateAtBroadcast: unknown; + +function reset() { + stateFile = {}; + saveStateThrows = false; + receipt = null; + waitReceipt = async () => ({ status: "success" }); + txKnown = false; + pendingNonce = 7; + sendRawError = undefined; + bridgeCalls = 0; + sendRawCalls.length = 0; + signCalls = 0; + stateAtBroadcast = undefined; +} + +class NotFound extends Error { + constructor(name: string) { super(`${name} (test)`); this.name = name; } +} + +mock.module("viem", { + namedExports: { + ...realViem, + createWalletClient: () => ({ + prepareTransactionRequest: async (req: Record) => req, + signTransaction: async () => { signCalls++; return SIGNED; }, + sendRawTransaction: async ({ serializedTransaction }: { serializedTransaction: string }) => { + stateAtBroadcast = stateFile.pendingWithdraw; + sendRawCalls.push(serializedTransaction); + if (sendRawError) throw sendRawError; + return realViem.keccak256(serializedTransaction as `0x${string}`); + }, + }), + }, +}); +mock.module("../src/utils/polymarket/constants.js", { + namedExports: { ...realConstants, getSigType: () => 0 }, +}); +mock.module("../src/utils/polymarket/positions.js", { + namedExports: { getFundsAddress: () => AGENT }, +}); +mock.module("../src/utils/polymarket/setup.js", { + namedExports: { + getPublicClient: () => ({ + readContract: async ({ address }: { address: string }) => + address.toLowerCase() === realConstants.USDCE_COLLATERAL.toLowerCase() ? 0n : 7_500_000n, + getTransactionCount: async () => pendingNonce, + getTransactionReceipt: async () => { + if (!receipt) throw new NotFound("TransactionReceiptNotFoundError"); + return receipt; + }, + waitForTransactionReceipt: async () => waitReceipt(), + getTransaction: async () => { + if (txKnown === "error") throw new Error("rpc 503 (test)"); + if (!txKnown) throw new NotFound("TransactionNotFoundError"); + return {}; + }, + }), + getPusdBalance: async () => 7.5, + }, +}); +mock.module("../src/utils/polymarket/client.js", { + namedExports: { + getPolymarketAccount: () => ({ address: AGENT }), + checkGeoblock: async () => ({ orderPlacement: "permitted", country: "JP", ip: null, raw: {} }), + getClobClient: async () => { throw new Error("not used"); }, + resetClobClient: () => {}, + getClobProxyAgent: () => null, + installUnderscoreHeaderBridge: () => {}, + }, +}); +mock.module("../src/utils/polymarket/creds.js", { + namedExports: { + loadState: () => ({ ...stateFile }), + saveState: (patch: Record) => { + if (saveStateThrows) throw new Error("disk full (test)"); + stateFile = { ...stateFile, ...patch }; + return stateFile; + }, + loadDepositWalletForSigner: () => DEPOSIT, + loadL2Creds: () => null, + saveL2Creds: () => {}, + invalidateL2Creds: () => {}, + loadBuilderCreds: () => null, + saveBuilderCreds: () => {}, + }, +}); +mock.module("../src/utils/polymarket/relayer.js", { + namedExports: { + sendWalletBatch: async () => { throw new Error("relayer not used on the EOA rail"); }, + getRelayerTransactionState: async () => { throw new Error("relayer not used on the EOA rail"); }, + BATCH_DEADLINE_SECS: 300, + }, +}); +mock.module("axios", { + defaultExport: { + post: async () => { bridgeCalls++; return { data: { address: { evm: BRIDGE } } }; }, + get: async () => { throw new Error("not used"); }, + }, +}); + +const { withdrawFunds, isDefiniteBroadcastRejection } = await import("../src/utils/polymarket/withdraw.js"); + +const pendingEoa = (extra: Record = {}) => ({ + pendingWithdraw: { + transactionID: `eoa:${SIGNED_HASH}`, + deadline: Math.floor(Date.now() / 1000) - 3600, // long past: must not matter for an EOA tx + nonce: 7, + serializedTransaction: SIGNED, + ...extra, + }, +}); + +// --- the send --- + +test("the signed bytes, their hash and nonce are on disk BEFORE the broadcast", async () => { + reset(); + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, undefined, res.text); + assert.deepEqual(stateAtBroadcast, { + transactionID: `eoa:${SIGNED_HASH}`, + deadline: (stateAtBroadcast as { deadline: number }).deadline, + nonce: 7, + serializedTransaction: SIGNED, + }); + assert.deepEqual(sendRawCalls, [SIGNED]); + assert.equal(stateFile.pendingWithdraw, undefined, "a success receipt clears the guard"); + assert.match(res.text, /Withdrawal submitted/); +}); + +test("a failed write of the record means nothing is broadcast", async () => { + reset(); + saveStateThrows = true; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.equal(sendRawCalls.length, 0); +}); + +for (const message of ["socket hang up", "nonce too low", "already known", "HTTP request failed. Status: 502"]) { + test(`an ambiguous broadcast error (${message}) keeps the guard and warns against a new withdrawal`, async () => { + reset(); + sendRawError = new Error(message); + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, /may still land/); + assert.match(res.text, /Do NOT start a new withdrawal/); + assert.equal((stateFile.pendingWithdraw as { transactionID?: string })?.transactionID, `eoa:${SIGNED_HASH}`); + }); +} + +test("a definite node rejection the RPC does not know releases the guard", async () => { + reset(); + sendRawError = new Error("insufficient funds for gas * price + value"); + txKnown = false; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, /Nothing was sent/); + assert.equal(stateFile.pendingWithdraw, undefined); +}); + +test("a definite rejection keeps the guard when the RPC knows the hash, or cannot say", async () => { + for (const known of [true, "error"] as const) { + reset(); + sendRawError = new Error("transaction underpriced"); + txKnown = known; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.doesNotMatch(res.text, /Nothing was sent/); + assert.ok(stateFile.pendingWithdraw, `guard kept (txKnown=${known})`); + } +}); + +test("a receipt that never arrives keeps the guard", async () => { + reset(); + waitReceipt = async () => { throw new Error("Timed out while waiting for transaction (test)"); }; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, /may still land/); + assert.ok(stateFile.pendingWithdraw); +}); + +test("isDefiniteBroadcastRejection reads nested causes and excludes the ambiguous phrases", () => { + assert.equal(isDefiniteBroadcastRejection(new Error("outer", { cause: new Error("intrinsic gas too low") })), true); + assert.equal(isDefiniteBroadcastRejection(new Error("nonce too low")), false); + assert.equal(isDefiniteBroadcastRejection(new Error("already known")), false); +}); + +// --- the retry --- + +test("no receipt: the SAME signed bytes are re-broadcast, nothing new is signed, and the call is blocked", async () => { + reset(); + stateFile = pendingEoa(); + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, /re-broadcast as the SAME signed transaction/); + assert.deepEqual(sendRawCalls, [SIGNED]); + assert.equal(signCalls, 0); + assert.equal(bridgeCalls, 0); + assert.ok(stateFile.pendingWithdraw, "a passed deadline does not expire an EOA transaction"); +}); + +test("an advanced nonce plus an unknown hash is NOT read as dropped", async () => { + reset(); + stateFile = pendingEoa(); + pendingNonce = 12; + txKnown = false; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, /may still land/); + assert.equal(signCalls, 0); + assert.ok(stateFile.pendingWithdraw); +}); + +for (const [status, word] of [["success", /SETTLED/], ["reverted", /REVERTED/]] as const) { + test(`a ${status} receipt is reported and the call ends — no second withdrawal`, async () => { + reset(); + stateFile = pendingEoa(); + receipt = { status }; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, word); + assert.match(res.text, /Nothing new was signed/); + assert.equal(signCalls, 0); + assert.equal(bridgeCalls, 0); + assert.equal(sendRawCalls.length, 0); + assert.equal(stateFile.pendingWithdraw, undefined); + }); +} + +// --- records written by earlier versions (no signed bytes) --- + +test("a legacy eoa: record the RPC still knows keeps blocking after its window", async () => { + reset(); + stateFile = { pendingWithdraw: { transactionID: `eoa:${OTHER_HASH}`, deadline: Math.floor(Date.now() / 1000) - 3600 } }; + txKnown = true; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, /may still land/); + assert.ok(stateFile.pendingWithdraw); + assert.equal(bridgeCalls, 0); +}); + +test("a legacy eoa: record past its window and unknown to the RPC is reported as unproven and ends the call", async () => { + reset(); + stateFile = { pendingWithdraw: { transactionID: `eoa:${OTHER_HASH}`, deadline: Math.floor(Date.now() / 1000) - 3600 } }; + txKnown = false; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, /not proven/); + assert.match(res.text, /Nothing new was signed/); + assert.equal(bridgeCalls, 0); + assert.equal(stateFile.pendingWithdraw, undefined); +}); diff --git a/test/polymarket-withdraw.test.ts b/test/polymarket-withdraw.test.ts index 8a42d91..bcd9d4b 100644 --- a/test/polymarket-withdraw.test.ts +++ b/test/polymarket-withdraw.test.ts @@ -195,24 +195,40 @@ test("an unreachable relayer counts as unresolved — conservative side", async assert.match(res.text, /double-send/); }); -test("a resolved (mined) in-flight withdrawal clears the guard and proceeds", async () => { +// Resolving the earlier withdrawal ENDS the call: the retry that finds the +// first transfer settled must not go on to sign a second one on top of it. +for (const [state, word] of [["STATE_MINED", /SETTLED/], ["STATE_CONFIRMED", /SETTLED/], ["STATE_FAILED", /FAILED/], ["STATE_INVALID", /FAILED/]] as const) { + test(`a ${state} earlier withdrawal is reported and the call ends — nothing new is signed`, async () => { + pusdRaw = 7_500_000n; usdceRaw = 0n; + stateFile = { pendingWithdraw: { transactionID: "relayer-tx-1", deadline: futureDeadline() } }; + relayerState = state; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, word); + assert.match(res.text, /Nothing new was signed/); + assert.doesNotMatch(res.text, /bridge offline/, "must not reach the bridge, i.e. must not start a new withdrawal"); + assert.equal(stateFile.pendingWithdraw, undefined, "guard is cleared so a later, deliberate call can proceed"); + }); +} + +test("an expired deadline is reported as unknown (not safe) and the call ends", async () => { pusdRaw = 7_500_000n; usdceRaw = 0n; - stateFile = { pendingWithdraw: { transactionID: "relayer-tx-1", deadline: futureDeadline() } }; - relayerState = "STATE_MINED"; + stateFile = { pendingWithdraw: { transactionID: "relayer-tx-1", deadline: Math.floor(Date.now() / 1000) - 3600 } }; + relayerState = "STATE_NEW"; const res = await withdrawFunds({ amount_usd: 2, confirm: true }); - // Proceeding means reaching the bridge POST, which the axios mock fails loudly. assert.equal(res.isError, true); - assert.match(res.text, /bridge offline \(test\)/); - assert.equal(stateFile.pendingWithdraw, undefined, "guard must be cleared"); + assert.match(res.text, /may or may not have executed/); + assert.doesNotMatch(res.text, /bridge offline/); + assert.equal(stateFile.pendingWithdraw, undefined); }); -test("an expired deadline clears the guard and proceeds", async () => { +test("after the guard is cleared, the next call proceeds normally", async () => { pusdRaw = 7_500_000n; usdceRaw = 0n; - stateFile = { pendingWithdraw: { transactionID: "relayer-tx-1", deadline: Math.floor(Date.now() / 1000) - 3600 } }; - relayerState = "STATE_NEW"; + stateFile = { pendingWithdraw: { transactionID: "relayer-tx-1", deadline: futureDeadline() } }; + relayerState = "STATE_MINED"; + await withdrawFunds({ amount_usd: 2, confirm: true }); const res = await withdrawFunds({ amount_usd: 2, confirm: true }); - assert.match(res.text, /bridge offline \(test\)/); - assert.equal(stateFile.pendingWithdraw, undefined); + assert.match(res.text, /bridge offline \(test\)/, "the second, deliberate call reaches the bridge"); }); test("the guard never blocks dry-runs", async () => { From 3f6bef0dc38911af5b0e5dba7786ab999b3b91c2 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:03:04 +0800 Subject: [PATCH 2/4] fix(polymarket): take the EOA withdrawal nonce from the write endpoint The public reader and the write RPC can disagree on the pending pool; a reader nonce could collide with a transaction only the write node has seen and then wedge the guard behind a 'nonce too low'. prepareTransactionRequest now fills the nonce through the wallet's own transport, and that nonce is what gets persisted. --- src/utils/polymarket/withdraw.ts | 8 ++++++-- test/polymarket-withdraw-eoa.test.ts | 12 +++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/utils/polymarket/withdraw.ts b/src/utils/polymarket/withdraw.ts index 762706f..64297b1 100644 --- a/src/utils/polymarket/withdraw.ts +++ b/src/utils/polymarket/withdraw.ts @@ -421,8 +421,12 @@ export async function withdrawFunds(input: WithdrawInput): Promise { // hash and exact bytes — a retry re-broadcasts THESE bytes (same nonce, // so at most one transfer can execute) instead of signing a second one. // `deadline` is recorded for the shape only; a plain transaction has none. - const nonce = await getPublicClient().getTransactionCount({ address: account.address, blockTag: "pending" }); - const request = await wallet.prepareTransactionRequest({ to: PUSD_COLLATERAL as Hex, data, chain: polygon, account, nonce }); + // The nonce comes from the WRITE endpoint (prepareTransactionRequest + // asks the wallet's own transport for the pending count), not the public + // reader: their pending pools can differ, and a reader nonce could + // collide with a transaction only the write node has seen. + const request = await wallet.prepareTransactionRequest({ to: PUSD_COLLATERAL as Hex, data, chain: polygon, account }); + const nonce = request.nonce; const serializedTransaction = await wallet.signTransaction(request); txHash = keccak256(serializedTransaction); saveState({ diff --git a/test/polymarket-withdraw-eoa.test.ts b/test/polymarket-withdraw-eoa.test.ts index 1a006ae..1001796 100644 --- a/test/polymarket-withdraw-eoa.test.ts +++ b/test/polymarket-withdraw-eoa.test.ts @@ -25,6 +25,8 @@ let receipt: { status: "success" | "reverted" } | null = null; let waitReceipt: () => Promise<{ status: "success" | "reverted" }> = async () => ({ status: "success" }); let txKnown: boolean | "error" = false; let pendingNonce = 7; +let writeNonce = 9; +let readerNonceCalls = 0; let sendRawError: Error | undefined; let bridgeCalls = 0; const sendRawCalls: string[] = []; @@ -38,6 +40,8 @@ function reset() { waitReceipt = async () => ({ status: "success" }); txKnown = false; pendingNonce = 7; + writeNonce = 9; + readerNonceCalls = 0; sendRawError = undefined; bridgeCalls = 0; sendRawCalls.length = 0; @@ -53,7 +57,7 @@ mock.module("viem", { namedExports: { ...realViem, createWalletClient: () => ({ - prepareTransactionRequest: async (req: Record) => req, + prepareTransactionRequest: async (req: Record) => ({ ...req, nonce: req.nonce ?? writeNonce }), signTransaction: async () => { signCalls++; return SIGNED; }, sendRawTransaction: async ({ serializedTransaction }: { serializedTransaction: string }) => { stateAtBroadcast = stateFile.pendingWithdraw; @@ -75,7 +79,7 @@ mock.module("../src/utils/polymarket/setup.js", { getPublicClient: () => ({ readContract: async ({ address }: { address: string }) => address.toLowerCase() === realConstants.USDCE_COLLATERAL.toLowerCase() ? 0n : 7_500_000n, - getTransactionCount: async () => pendingNonce, + getTransactionCount: async () => { readerNonceCalls++; return pendingNonce; }, getTransactionReceipt: async () => { if (!receipt) throw new NotFound("TransactionReceiptNotFoundError"); return receipt; @@ -146,14 +150,16 @@ const pendingEoa = (extra: Record = {}) => ({ test("the signed bytes, their hash and nonce are on disk BEFORE the broadcast", async () => { reset(); + pendingNonce = 3; // the public reader disagrees with the write node const res = await withdrawFunds({ amount_usd: 2, confirm: true }); assert.equal(res.isError, undefined, res.text); assert.deepEqual(stateAtBroadcast, { transactionID: `eoa:${SIGNED_HASH}`, deadline: (stateAtBroadcast as { deadline: number }).deadline, - nonce: 7, + nonce: 9, serializedTransaction: SIGNED, }); + assert.equal(readerNonceCalls, 0, "the nonce comes from the write endpoint, never the public reader"); assert.deepEqual(sendRawCalls, [SIGNED]); assert.equal(stateFile.pendingWithdraw, undefined, "a success receipt clears the guard"); assert.match(res.text, /Withdrawal submitted/); From e88776d8cf9cb435a1e8e18575e1b6a3ceb59838 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:42:14 +0800 Subject: [PATCH 3/4] docs(polymarket): say what the write-endpoint nonce does and does not guarantee --- src/utils/polymarket/withdraw.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/utils/polymarket/withdraw.ts b/src/utils/polymarket/withdraw.ts index 64297b1..4971ae8 100644 --- a/src/utils/polymarket/withdraw.ts +++ b/src/utils/polymarket/withdraw.ts @@ -422,9 +422,11 @@ export async function withdrawFunds(input: WithdrawInput): Promise { // so at most one transfer can execute) instead of signing a second one. // `deadline` is recorded for the shape only; a plain transaction has none. // The nonce comes from the WRITE endpoint (prepareTransactionRequest - // asks the wallet's own transport for the pending count), not the public - // reader: their pending pools can differ, and a reader nonce could - // collide with a transaction only the write node has seen. + // asks the wallet's own transport), not the public reader, which can be + // a separate provider with its own pending pool. A load-balanced write + // URL can still answer from a different backend than the broadcast; + // safety does not rest on this — the bytes are persisted first and a + // retry re-sends only those. const request = await wallet.prepareTransactionRequest({ to: PUSD_COLLATERAL as Hex, data, chain: polygon, account }); const nonce = request.nonce; const serializedTransaction = await wallet.signTransaction(request); From c834b58630924057f641b1caa04e01fb7a9eca6b Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:50:51 +0800 Subject: [PATCH 4/4] test(polymarket): select the EOA rail via env so the test runs on Node 20 --- test/polymarket-withdraw-eoa.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/polymarket-withdraw-eoa.test.ts b/test/polymarket-withdraw-eoa.test.ts index 1001796..5f4f5e2 100644 --- a/test/polymarket-withdraw-eoa.test.ts +++ b/test/polymarket-withdraw-eoa.test.ts @@ -9,7 +9,10 @@ import { test, mock } from "node:test"; import assert from "node:assert/strict"; const realViem = await import("viem"); -const realConstants = await import("../src/utils/polymarket/constants.js"); +// The EOA rail. getSigType() reads this at call time; mocking constants.js +// instead does not take on Node 20 once the module is already loaded. +process.env.POLYMARKET_SIG_TYPE = "0"; +const { USDCE_COLLATERAL } = await import("../src/utils/polymarket/constants.js"); const DEPOSIT = "0x5d3eaa66AE01F1a907c8e0970D1D021C6Ff8EB26"; const AGENT = "0xCC8c44AD3dc2A58D841c3EB26131E49b22665EF8"; @@ -68,9 +71,6 @@ mock.module("viem", { }), }, }); -mock.module("../src/utils/polymarket/constants.js", { - namedExports: { ...realConstants, getSigType: () => 0 }, -}); mock.module("../src/utils/polymarket/positions.js", { namedExports: { getFundsAddress: () => AGENT }, }); @@ -78,7 +78,7 @@ mock.module("../src/utils/polymarket/setup.js", { namedExports: { getPublicClient: () => ({ readContract: async ({ address }: { address: string }) => - address.toLowerCase() === realConstants.USDCE_COLLATERAL.toLowerCase() ? 0n : 7_500_000n, + address.toLowerCase() === USDCE_COLLATERAL.toLowerCase() ? 0n : 7_500_000n, getTransactionCount: async () => { readerNonceCalls++; return pendingNonce; }, getTransactionReceipt: async () => { if (!receipt) throw new NotFound("TransactionReceiptNotFoundError");