From eb3b8d5e26a4c8bc865e3e8bd5c675a34c5dda47 Mon Sep 17 00:00:00 2001 From: 1bcMax <195689928+1bcMax@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:06:44 +0800 Subject: [PATCH 1/2] fix(polymarket): take the EOA withdrawal nonce from the write endpoint The fallback public readers 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 withdraw 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/tools/polymarket/withdraw.ts | 9 +++++++-- test/withdraw.local.mjs | 9 +++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/tools/polymarket/withdraw.ts b/src/tools/polymarket/withdraw.ts index 3965aaf..67fb4fc 100644 --- a/src/tools/polymarket/withdraw.ts +++ b/src/tools/polymarket/withdraw.ts @@ -346,8 +346,13 @@ export async function withdrawFunds(input: WithdrawInput): Promise { const account = getPolymarketAccount(); const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_WRITE_RPC_URL) }); // franklin-local: hash the locally signed bytes and persist before any broadcast. - 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 }); + // franklin-local: the nonce comes from the WRITE endpoint + // (prepareTransactionRequest asks the wallet's own transport for the + // pending count), not the fallback public readers: 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({ pendingWithdraw: { txHash, nonce, from: account.address, serializedTransaction } }); diff --git a/test/withdraw.local.mjs b/test/withdraw.local.mjs index 6bfc4ff..b107b73 100644 --- a/test/withdraw.local.mjs +++ b/test/withdraw.local.mjs @@ -62,12 +62,12 @@ beforeEach(() => { calls: [], publicClient: { readContract: async ({ address }) => { h.calls.push('balance'); return address === PUSD_COLLATERAL ? 10_000_000n : 0n; }, - getTransactionCount: async args => { h.calls.push('nonce'); assert.equal(args.blockTag, 'pending'); return 7; }, + getTransactionCount: async () => { h.calls.push('reader-nonce'); return 3; }, getTransactionReceipt: async () => { h.calls.push('receipt'); throw new Error('receipt unavailable'); }, waitForTransactionReceipt: async () => ({ status: 'success' }), }, wallet: { - prepareTransactionRequest: async request => { h.calls.push('prepare'); assert.equal(request.nonce, 7); return request; }, + prepareTransactionRequest: async request => { h.calls.push('prepare'); assert.equal(request.nonce, undefined, 'the write transport fills the nonce'); return { ...request, nonce: 7 }; }, signTransaction: async request => { h.calls.push('sign'); assert.equal(request.nonce, 7); assert.equal(loadState().pendingWithdraw, undefined); return signed; }, sendRawTransaction: async ({ serializedTransaction }) => { h.calls.push('broadcast'); @@ -200,7 +200,8 @@ test('EOA persists hash, nonce and sender before raw broadcast and clears on rec process.env.POLYMARKET_SIG_TYPE = '0'; const result = await withdrawFunds({ amount_usd: 2, confirm: true }); assert.ok(!result.isError, result.text); - assert.deepEqual(h.calls.slice(-4), ['nonce', 'prepare', 'sign', 'broadcast']); + assert.deepEqual(h.calls.slice(-3), ['prepare', 'sign', 'broadcast']); + assert.ok(!h.calls.includes('reader-nonce'), 'the nonce never comes from the public reader'); assert.equal(result.structured.transactionHash, txHash); assert.equal(loadState().pendingWithdraw, undefined); }); @@ -257,7 +258,7 @@ test('EOA guard never infers "dropped" from an advanced nonce plus an unknown ha }); test('a retry that resolves the earlier withdrawal ends there and signs nothing new', async () => { - const fresh = ['nonce', 'prepare', 'sign', 'broadcast', 'bridge', 'submit', 'batch-sign', 'balance']; + const fresh = ['reader-nonce', 'prepare', 'sign', 'broadcast', 'bridge', 'submit', 'batch-sign', 'balance']; const cases = [ ['eoa success', () => { saveState({ pendingWithdraw: { txHash, nonce: 7, from: agent, serializedTransaction: signed } }); h.publicClient.getTransactionReceipt = async () => ({ status: 'success' }); }, /SETTLED/], From 4f82d1e461bfe4cfd01373f341b33787b3ab879f Mon Sep 17 00:00:00 2001 From: 1bcMax <195689928+1bcMax@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:42:08 +0800 Subject: [PATCH 2/2] docs(polymarket): say what the write-endpoint nonce does and does not guarantee --- src/tools/polymarket/withdraw.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tools/polymarket/withdraw.ts b/src/tools/polymarket/withdraw.ts index 67fb4fc..6ce82bc 100644 --- a/src/tools/polymarket/withdraw.ts +++ b/src/tools/polymarket/withdraw.ts @@ -347,10 +347,11 @@ export async function withdrawFunds(input: WithdrawInput): Promise { const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_WRITE_RPC_URL) }); // franklin-local: hash the locally signed bytes and persist before any broadcast. // franklin-local: the nonce comes from the WRITE endpoint - // (prepareTransactionRequest asks the wallet's own transport for the - // pending count), not the fallback public readers: their pending pools - // can differ, and a reader nonce could collide with a transaction only - // the write node has seen. + // (prepareTransactionRequest asks the wallet's own transport), not the + // fallback public readers, which are separate providers with their own + // pending pools. 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);