Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.54.1
0.54.2
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 12 additions & 1 deletion src/utils/polymarket/creds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hash>` 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
Expand Down
7 changes: 7 additions & 0 deletions src/utils/polymarket/relayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 8 additions & 5 deletions src/utils/polymarket/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading